mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 11:20:56 -05:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bbdc6da634 | |||
| db80025ebc | |||
| a274c444ad | |||
| 5fae207cd7 | |||
| 654539d320 | |||
| 44aac89d41 | |||
| e0250b2a58 | |||
| a8d6cd8a9f | |||
| f79fe1490e | |||
| 5cfbc671c5 | |||
| e6e9419b93 | |||
| 90eb78e571 | |||
| 991866f549 | |||
| 6d1b3475d4 | |||
| 5e967a2b67 | |||
| 23a8fd6a47 | |||
| 0686eb3cbb | |||
| 6d9ab315c2 |
@@ -11,15 +11,15 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@chromatic-com/storybook": "5.0.2",
|
||||
"@storybook/addon-a11y": "10.3.5",
|
||||
"@storybook/addon-docs": "10.3.5",
|
||||
"@storybook/addon-links": "10.3.5",
|
||||
"@storybook/addon-onboarding": "10.3.5",
|
||||
"@storybook/react-vite": "10.3.5",
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.3.5",
|
||||
|
||||
+2
-4
@@ -6,11 +6,9 @@ import {
|
||||
TUserUpdateInput,
|
||||
ZUserPersonalInfoUpdateInput,
|
||||
} from "@formbricks/types/user";
|
||||
import {
|
||||
getIsEmailUnique,
|
||||
verifyUserPassword,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
import { getIsEmailUnique } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { verifyUserPassword } from "@/lib/user/password";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifyUserPassword } from "@/lib/user/password";
|
||||
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
|
||||
import { getIsEmailUnique, verifyUserPassword } from "./user";
|
||||
import { getIsEmailUnique } from "./user";
|
||||
|
||||
vi.mock("@/modules/auth/lib/utils", () => ({
|
||||
verifyPassword: vi.fn(),
|
||||
|
||||
-37
@@ -1,42 +1,5 @@
|
||||
import { User } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifyPassword } from "@/modules/auth/lib/utils";
|
||||
|
||||
export const getUserById = reactCache(
|
||||
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new ResourceNotFoundError("user", userId);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
);
|
||||
|
||||
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (user.identityProvider !== "email" || !user.password) {
|
||||
throw new InvalidInputError("Password is not set for this user");
|
||||
}
|
||||
|
||||
const isCorrectPassword = await verifyPassword(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
|
||||
const user = await prisma.user.findUnique({
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import {
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
PASSWORD_RESET_DISABLED,
|
||||
} from "@/lib/constants";
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -65,7 +70,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
: t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${params.environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
: ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { EnterpriseLicenseStatus } from "@/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -173,7 +173,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link
|
||||
href="https://app.formbricks.com/s/clvupq3y205i5yrm3sm9v1xt5"
|
||||
href={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
referrerPolicy="no-referrer">
|
||||
|
||||
+7
-1
@@ -1,6 +1,11 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
||||
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import {
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
FB_LOGO_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
} from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -80,6 +85,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
fbLogoUrl={FB_LOGO_URL}
|
||||
user={user}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
/>
|
||||
{isMultiOrgEnabled && (
|
||||
<SettingsCard
|
||||
|
||||
+41
-15
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useCallback, useContext, useState } from "react";
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ElementOption,
|
||||
ElementOptions,
|
||||
@@ -30,7 +30,7 @@ interface SelectedFilterOptions {
|
||||
|
||||
export interface DateRange {
|
||||
from: Date | undefined;
|
||||
to?: Date | undefined;
|
||||
to?: Date;
|
||||
}
|
||||
|
||||
interface FilterDateContextProps {
|
||||
@@ -41,6 +41,8 @@ interface FilterDateContextProps {
|
||||
dateRange: DateRange;
|
||||
setDateRange: React.Dispatch<React.SetStateAction<DateRange>>;
|
||||
resetState: () => void;
|
||||
refreshAnalysisData: () => Promise<void>;
|
||||
registerAnalysisRefreshHandler: (handler: () => Promise<void>) => () => void;
|
||||
}
|
||||
|
||||
const ResponseFilterContext = createContext<FilterDateContextProps | undefined>(undefined);
|
||||
@@ -61,6 +63,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
from: undefined,
|
||||
to: getTodayDate(),
|
||||
});
|
||||
const refreshHandlerRef = useRef<(() => Promise<void>) | null>(null);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setDateRange({
|
||||
@@ -73,20 +76,43 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ResponseFilterContext.Provider
|
||||
value={{
|
||||
setSelectedFilter,
|
||||
selectedFilter,
|
||||
selectedOptions,
|
||||
setSelectedOptions,
|
||||
dateRange,
|
||||
setDateRange,
|
||||
resetState,
|
||||
}}>
|
||||
{children}
|
||||
</ResponseFilterContext.Provider>
|
||||
const refreshAnalysisData = useCallback(async () => {
|
||||
await refreshHandlerRef.current?.();
|
||||
}, []);
|
||||
|
||||
const registerAnalysisRefreshHandler = useCallback((handler: () => Promise<void>) => {
|
||||
refreshHandlerRef.current = handler;
|
||||
|
||||
return () => {
|
||||
if (refreshHandlerRef.current === handler) {
|
||||
refreshHandlerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
setSelectedFilter,
|
||||
selectedFilter,
|
||||
selectedOptions,
|
||||
setSelectedOptions,
|
||||
dateRange,
|
||||
setDateRange,
|
||||
resetState,
|
||||
refreshAnalysisData,
|
||||
registerAnalysisRefreshHandler,
|
||||
}),
|
||||
[
|
||||
dateRange,
|
||||
refreshAnalysisData,
|
||||
registerAnalysisRefreshHandler,
|
||||
resetState,
|
||||
selectedFilter,
|
||||
selectedOptions,
|
||||
]
|
||||
);
|
||||
|
||||
return <ResponseFilterContext.Provider value={contextValue}>{children}</ResponseFilterContext.Provider>;
|
||||
};
|
||||
|
||||
const useResponseFilter = () => {
|
||||
|
||||
+35
-2
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
@@ -13,6 +15,7 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surv
|
||||
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
|
||||
interface ResponsePageProps {
|
||||
@@ -46,8 +49,8 @@ export const ResponsePage = ({
|
||||
const [page, setPage] = useState<number | null>(null);
|
||||
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
|
||||
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
|
||||
const { t } = useTranslation();
|
||||
const filters = useMemo(
|
||||
() => getFormattedFilters(survey, selectedFilter, dateRange),
|
||||
|
||||
@@ -86,6 +89,34 @@ export const ResponsePage = ({
|
||||
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
|
||||
};
|
||||
|
||||
const refetchResponses = useCallback(async () => {
|
||||
setIsFetchingFirstPage(true);
|
||||
|
||||
try {
|
||||
const getResponsesActionResponse = await getResponsesAction({
|
||||
surveyId,
|
||||
limit: responsesPerPage,
|
||||
offset: 0,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
|
||||
if (getResponsesActionResponse?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(getResponsesActionResponse) ?? t("common.something_went_wrong"));
|
||||
}
|
||||
|
||||
const freshResponses = getResponsesActionResponse?.data ?? [];
|
||||
setResponses(freshResponses);
|
||||
setPage(1);
|
||||
setHasMore(freshResponses.length >= responsesPerPage);
|
||||
} finally {
|
||||
setIsFetchingFirstPage(false);
|
||||
}
|
||||
}, [filters, responsesPerPage, surveyId]);
|
||||
|
||||
useEffect(() => {
|
||||
return registerAnalysisRefreshHandler(refetchResponses);
|
||||
}, [refetchResponses, registerAnalysisRefreshHandler]);
|
||||
|
||||
const surveyMemoized = useMemo(() => {
|
||||
return replaceHeadlineRecall(survey, "default");
|
||||
}, [survey]);
|
||||
@@ -134,6 +165,8 @@ export const ResponsePage = ({
|
||||
}
|
||||
};
|
||||
fetchFilteredResponses();
|
||||
// page is intentionally omitted to avoid refetching after the initial page setup.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
|
||||
|
||||
return (
|
||||
|
||||
+7
-1
@@ -2,7 +2,12 @@ import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/er
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import {
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
RESPONSES_PER_PAGE,
|
||||
} from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -72,6 +77,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation activeId="responses" />
|
||||
|
||||
+35
-19
@@ -71,7 +71,7 @@ export const SummaryPage = ({
|
||||
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
|
||||
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
|
||||
|
||||
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
|
||||
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
|
||||
@@ -111,7 +111,7 @@ export const SummaryPage = ({
|
||||
} finally {
|
||||
setIsDisplaysLoading(false);
|
||||
}
|
||||
}, [fetchDisplays, t]);
|
||||
}, [fetchDisplays]);
|
||||
|
||||
const handleLoadMoreDisplays = useCallback(async () => {
|
||||
try {
|
||||
@@ -131,13 +131,39 @@ export const SummaryPage = ({
|
||||
}
|
||||
}, [tab, loadInitialDisplays]);
|
||||
|
||||
const fetchSummary = useCallback(async () => {
|
||||
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
const updatedSurveySummary = await getSurveySummaryAction({
|
||||
surveyId,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
|
||||
if (updatedSurveySummary?.serverError) {
|
||||
throw new Error(getFormattedErrorMessage(updatedSurveySummary));
|
||||
}
|
||||
|
||||
setSurveySummary(updatedSurveySummary?.data ?? defaultSurveySummary);
|
||||
}, [dateRange, selectedFilter, survey, surveyId]);
|
||||
|
||||
const refreshSummary = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await Promise.all([fetchSummary(), tab === "impressions" ? loadInitialDisplays() : Promise.resolve()]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [fetchSummary, loadInitialDisplays, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
return registerAnalysisRefreshHandler(refreshSummary);
|
||||
}, [refreshSummary, registerAnalysisRefreshHandler]);
|
||||
|
||||
// Only fetch data when filters change or when there's no initial data
|
||||
useEffect(() => {
|
||||
// If we have initial data and no filters are applied, don't fetch
|
||||
const hasNoFilters =
|
||||
(!selectedFilter ||
|
||||
Object.keys(selectedFilter).length === 0 ||
|
||||
(selectedFilter.filter && selectedFilter.filter.length === 0)) &&
|
||||
(!selectedFilter || Object.keys(selectedFilter).length === 0 || selectedFilter.filter?.length === 0) &&
|
||||
(!dateRange || (!dateRange.from && !dateRange.to));
|
||||
|
||||
if (initialSurveySummary && hasNoFilters) {
|
||||
@@ -145,21 +171,11 @@ export const SummaryPage = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchSummary = async () => {
|
||||
const fetchFilteredSummary = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Recalculate filters inside the effect to ensure we have the latest values
|
||||
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
let updatedSurveySummary;
|
||||
|
||||
updatedSurveySummary = await getSurveySummaryAction({
|
||||
surveyId,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
|
||||
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
|
||||
setSurveySummary(surveySummary);
|
||||
await fetchSummary();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
@@ -167,8 +183,8 @@ export const SummaryPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
fetchSummary();
|
||||
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
|
||||
fetchFilteredSummary();
|
||||
}, [selectedFilter, dateRange, initialSurveySummary, fetchSummary]);
|
||||
|
||||
const surveyMemoized = useMemo(() => {
|
||||
return replaceHeadlineRecall(survey, "default");
|
||||
|
||||
+27
-2
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
|
||||
import { BellRing, Eye, ListRestart, RefreshCcwIcon, SquarePenIcon } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
|
||||
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
@@ -30,6 +31,7 @@ interface SurveyAnalysisCTAProps {
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
isStorageConfigured: boolean;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
interface ModalState {
|
||||
@@ -46,6 +48,7 @@ export const SurveyAnalysisCTA = ({
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
isStorageConfigured,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: SurveyAnalysisCTAProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -58,10 +61,12 @@ export const SurveyAnalysisCTA = ({
|
||||
});
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const { environment, project } = useEnvironment();
|
||||
const { survey } = useSurvey();
|
||||
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||
const { refreshAnalysisData } = useResponseFilter();
|
||||
|
||||
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
|
||||
@@ -73,7 +78,7 @@ export const SurveyAnalysisCTA = ({
|
||||
}, [searchParams]);
|
||||
|
||||
const handleShareModalToggle = (open: boolean) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const params = new URLSearchParams(globalThis.location.search);
|
||||
const currentShareParam = params.get("share") === "true";
|
||||
|
||||
if (open && !currentShareParam) {
|
||||
@@ -143,6 +148,25 @@ export const SurveyAnalysisCTA = ({
|
||||
};
|
||||
|
||||
const iconActions = [
|
||||
{
|
||||
icon: RefreshCcwIcon,
|
||||
tooltip: t("common.refresh"),
|
||||
onClick: async () => {
|
||||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await refreshAnalysisData();
|
||||
toast.success(t("common.data_refreshed_successfully"));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
},
|
||||
disabled: isRefreshing,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
icon: BellRing,
|
||||
tooltip: t("environments.surveys.summary.configure_alerts"),
|
||||
@@ -209,6 +233,7 @@ export const SurveyAnalysisCTA = ({
|
||||
isReadOnly={isReadOnly}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
projectCustomScripts={project.customHeadScripts}
|
||||
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
|
||||
/>
|
||||
)}
|
||||
<SuccessMessage />
|
||||
|
||||
+3
@@ -54,6 +54,7 @@ interface ShareSurveyModalProps {
|
||||
isReadOnly: boolean;
|
||||
isStorageConfigured: boolean;
|
||||
projectCustomScripts?: string | null;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const ShareSurveyModal = ({
|
||||
@@ -69,6 +70,7 @@ export const ShareSurveyModal = ({
|
||||
isReadOnly,
|
||||
isStorageConfigured,
|
||||
projectCustomScripts,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: ShareSurveyModalProps) => {
|
||||
const environmentId = survey.environmentId;
|
||||
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
||||
@@ -108,6 +110,7 @@ export const ShareSurveyModal = ({
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
},
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
|
||||
+3
-1
@@ -34,6 +34,7 @@ interface PersonalLinksTabProps {
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
interface PersonalLinksFormData {
|
||||
@@ -74,6 +75,7 @@ export const PersonalLinksTab = ({
|
||||
surveyId,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: PersonalLinksTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -169,7 +171,7 @@ export const PersonalLinksTab = ({
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
: enterpriseLicenseRequestFormUrl,
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
+7
-1
@@ -4,7 +4,12 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
} from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
@@ -74,6 +79,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation activeId="summary" />
|
||||
|
||||
@@ -8,7 +8,7 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
|
||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
|
||||
import { CRON_SECRET, DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS, POSTHOG_KEY } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
@@ -91,10 +91,15 @@ export const POST = async (request: Request) => {
|
||||
const webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
|
||||
// Prepare webhook and email promises
|
||||
|
||||
// Fetch with timeout of 5 seconds to prevent hanging
|
||||
// Fetch with timeout of 5 seconds to prevent hanging.
|
||||
// `redirect: "manual"` blocks SSRF via redirect — webhook URLs are validated against private/internal
|
||||
// ranges before delivery, but redirect targets would otherwise bypass that check. Gated on the same
|
||||
// env var as `validateWebhookUrl`: self-hosters who opted into trusting internal URLs also get the
|
||||
// pre-patch redirect-follow behavior for consistency.
|
||||
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
|
||||
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
|
||||
return Promise.race([
|
||||
fetch(url, options),
|
||||
fetch(url, { ...options, redirect: redirectMode }),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { authenticateRequest } from "./auth";
|
||||
import { authenticateRequest, handleErrorResponse } from "./auth";
|
||||
|
||||
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
|
||||
getApiKeyWithPermissions: vi.fn(),
|
||||
@@ -193,3 +199,53 @@ describe("authenticateRequest", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleErrorResponse", () => {
|
||||
test("returns 401 notAuthenticated for 'NotAuthenticated' message", async () => {
|
||||
const response = handleErrorResponse(new Error("NotAuthenticated"));
|
||||
expect(response.status).toBe(401);
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("not_authenticated");
|
||||
});
|
||||
|
||||
test("returns 401 unauthorized for 'Unauthorized' message", async () => {
|
||||
const response = handleErrorResponse(new Error("Unauthorized"));
|
||||
expect(response.status).toBe(401);
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("unauthorized");
|
||||
});
|
||||
|
||||
test("returns 409 conflict for UniqueConstraintError", async () => {
|
||||
const response = handleErrorResponse(new UniqueConstraintError("Action with name foo already exists"));
|
||||
expect(response.status).toBe(409);
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("conflict");
|
||||
expect(body.message).toBe("Action with name foo already exists");
|
||||
});
|
||||
|
||||
test("returns 400 badRequest for DatabaseError", async () => {
|
||||
const response = handleErrorResponse(new DatabaseError("db boom"));
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("db boom");
|
||||
});
|
||||
|
||||
test("returns 400 badRequest for InvalidInputError", async () => {
|
||||
const response = handleErrorResponse(new InvalidInputError("bad input"));
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("bad input");
|
||||
});
|
||||
|
||||
test("returns 400 badRequest for ResourceNotFoundError", async () => {
|
||||
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("returns 500 internalServerError for unknown errors", async () => {
|
||||
const response = handleErrorResponse(new Error("something else"));
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Some error occurred");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
|
||||
@@ -40,6 +45,9 @@ export const handleErrorResponse = (error: any): Response => {
|
||||
case "Unauthorized":
|
||||
return responses.unauthorizedResponse();
|
||||
default:
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
return responses.conflictResponse(error.message);
|
||||
}
|
||||
if (
|
||||
error instanceof DatabaseError ||
|
||||
error instanceof InvalidInputError ||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -80,6 +80,11 @@ export const POST = withV1ApiWrapper({
|
||||
response: responses.successResponse(actionClass),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
return {
|
||||
response: responses.conflictResponse(error.message),
|
||||
};
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("parseV3SurveysListQuery", () => {
|
||||
expect(r.invalid_params[0]).toEqual({
|
||||
name: "foo",
|
||||
reason:
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("parseV3SurveysListQuery", () => {
|
||||
expect(r.invalid_params[0]).toEqual({
|
||||
name: "after",
|
||||
reason:
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -57,7 +57,7 @@ describe("parseV3SurveysListQuery", () => {
|
||||
expect(r.invalid_params[0]).toEqual({
|
||||
name: "name",
|
||||
reason:
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -68,11 +68,20 @@ describe("parseV3SurveysListQuery", () => {
|
||||
if (r.ok) {
|
||||
expect(r.limit).toBe(20);
|
||||
expect(r.cursor).toBeNull();
|
||||
expect(r.includeTotalCount).toBe(true);
|
||||
expect(r.sortBy).toBe("updatedAt");
|
||||
expect(r.filterCriteria).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("parses includeTotalCount=false", () => {
|
||||
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&includeTotalCount=false`));
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.includeTotalCount).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("builds filter from explicit operator params", () => {
|
||||
const r = parseV3SurveysListQuery(
|
||||
params(
|
||||
@@ -102,7 +111,7 @@ describe("parseV3SurveysListQuery", () => {
|
||||
expect(r.invalid_params[0]).toEqual({
|
||||
name: "filter[createdBy][in]",
|
||||
reason:
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ const SUPPORTED_QUERY_PARAMS = [
|
||||
"workspaceId",
|
||||
"limit",
|
||||
"cursor",
|
||||
"includeTotalCount",
|
||||
FILTER_NAME_CONTAINS_QUERY_PARAM,
|
||||
FILTER_STATUS_IN_QUERY_PARAM,
|
||||
FILTER_TYPE_IN_QUERY_PARAM,
|
||||
@@ -53,6 +54,11 @@ const ZV3SurveysListQuery = z.object({
|
||||
workspaceId: ZId,
|
||||
limit: z.coerce.number().int().min(1).max(V3_SURVEYS_MAX_LIMIT).default(V3_SURVEYS_DEFAULT_LIMIT),
|
||||
cursor: z.string().min(1).optional(),
|
||||
includeTotalCount: z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
.transform((value) => value !== "false")
|
||||
.default(true),
|
||||
[FILTER_NAME_CONTAINS_QUERY_PARAM]: z
|
||||
.string()
|
||||
.max(512)
|
||||
@@ -71,6 +77,7 @@ export type TV3SurveysListQueryParseResult =
|
||||
workspaceId: string;
|
||||
limit: number;
|
||||
cursor: TSurveyListPageCursor | null;
|
||||
includeTotalCount: boolean;
|
||||
sortBy: TSurveyListSort;
|
||||
filterCriteria: TSurveyFilterCriteria | undefined;
|
||||
}
|
||||
@@ -111,6 +118,7 @@ export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3Surve
|
||||
workspaceId: searchParams.get("workspaceId"),
|
||||
limit: searchParams.get("limit") ?? undefined,
|
||||
cursor: searchParams.get("cursor")?.trim() || undefined,
|
||||
includeTotalCount: searchParams.get("includeTotalCount")?.trim() || undefined,
|
||||
[FILTER_NAME_CONTAINS_QUERY_PARAM]: searchParams.get(FILTER_NAME_CONTAINS_QUERY_PARAM) ?? undefined,
|
||||
[FILTER_STATUS_IN_QUERY_PARAM]: statusVals.length > 0 ? statusVals : undefined,
|
||||
[FILTER_TYPE_IN_QUERY_PARAM]: typeVals.length > 0 ? typeVals : undefined,
|
||||
@@ -153,6 +161,7 @@ export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3Surve
|
||||
workspaceId: q.workspaceId,
|
||||
limit: q.limit,
|
||||
cursor,
|
||||
includeTotalCount: q.includeTotalCount,
|
||||
sortBy,
|
||||
filterCriteria: buildFilterCriteria(q),
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
|
||||
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
|
||||
import { encodeSurveyListPageCursor, getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
|
||||
import { GET } from "./route";
|
||||
|
||||
const { mockAuthenticateRequest } = vi.hoisted(() => ({
|
||||
@@ -257,6 +257,29 @@ describe("GET /api/v3/surveys", () => {
|
||||
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
|
||||
});
|
||||
|
||||
test("skips totalCount when includeTotalCount=false", async () => {
|
||||
vi.mocked(getSurveyListPage).mockResolvedValue({
|
||||
surveys: [],
|
||||
nextCursor: null,
|
||||
});
|
||||
const cursor = encodeSurveyListPageCursor({
|
||||
version: 1,
|
||||
sortBy: "updatedAt",
|
||||
value: "2026-04-15T10:00:00.000Z",
|
||||
id: "survey_1",
|
||||
});
|
||||
|
||||
const req = createRequest(
|
||||
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&cursor=${cursor}&includeTotalCount=false`
|
||||
);
|
||||
const res = await GET(req, {} as any);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toEqual({ limit: 20, nextCursor: null, totalCount: null });
|
||||
expect(getSurveyCount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("passes filter query to getSurveyListPage", async () => {
|
||||
const filterCriteria = { status: ["inProgress"] };
|
||||
const req = createRequest(
|
||||
|
||||
@@ -46,21 +46,22 @@ export const GET = withV3ApiWrapper({
|
||||
|
||||
const { environmentId } = authResult;
|
||||
|
||||
const [{ surveys, nextCursor }, totalCount] = await Promise.all([
|
||||
getSurveyListPage(environmentId, {
|
||||
limit: parsed.limit,
|
||||
cursor: parsed.cursor,
|
||||
sortBy: parsed.sortBy,
|
||||
filterCriteria: parsed.filterCriteria,
|
||||
}),
|
||||
getSurveyCount(environmentId, parsed.filterCriteria),
|
||||
]);
|
||||
const surveyPagePromise = getSurveyListPage(environmentId, {
|
||||
limit: parsed.limit,
|
||||
cursor: parsed.cursor,
|
||||
sortBy: parsed.sortBy,
|
||||
filterCriteria: parsed.filterCriteria,
|
||||
});
|
||||
const totalCountPromise = parsed.includeTotalCount
|
||||
? getSurveyCount(environmentId, parsed.filterCriteria)
|
||||
: Promise.resolve(null);
|
||||
const [surveyPage, totalCount] = await Promise.all([surveyPagePromise, totalCountPromise]);
|
||||
|
||||
return successListResponse(
|
||||
surveys.map(serializeV3SurveyListItem),
|
||||
surveyPage.surveys.map(serializeV3SurveyListItem),
|
||||
{
|
||||
limit: parsed.limit,
|
||||
nextCursor,
|
||||
nextCursor: surveyPage.nextCursor,
|
||||
totalCount,
|
||||
},
|
||||
{ requestId, cache: "private, no-store" }
|
||||
|
||||
+5
-4
@@ -170,6 +170,7 @@ checksums:
|
||||
common/created_by: 6775c2fa7d495fea48f1ad816daea93b
|
||||
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
|
||||
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
|
||||
common/data_refreshed_successfully: 85728c61a9e1a16e46af69ddf0dbcda6
|
||||
common/date: 56f41c5d30a76295bb087b20b7bee4c3
|
||||
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
|
||||
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
|
||||
@@ -346,6 +347,7 @@ checksums:
|
||||
common/quotas_description: a2caa44fa74664b3b6007e813f31a754
|
||||
common/read_docs: d06513c266fdd9056e0500eab838ebac
|
||||
common/recipients: f90e7f266be3f5a724858f21a9fd855e
|
||||
common/refresh: c0aec3f31be4c984bae9a482572d2857
|
||||
common/remove: dba2fe5fe9f83f8078c687f28cba4b52
|
||||
common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
|
||||
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
|
||||
@@ -638,8 +640,6 @@ checksums:
|
||||
environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
|
||||
environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
|
||||
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
|
||||
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
|
||||
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
||||
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
|
||||
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
|
||||
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
|
||||
@@ -1137,7 +1137,7 @@ checksums:
|
||||
environments/settings/general/organization_invite_link_ready: e54b37c4ec2e5a9ea9f6bc6e5b512b0b
|
||||
environments/settings/general/organization_name: 73c9b31c9032a22bd84a07881942bb04
|
||||
environments/settings/general/organization_name_description: ff517b4749a332b94a26110d7c7e771f
|
||||
environments/settings/general/organization_name_placeholder: fc91de3ddc89ab77f30d555778312380
|
||||
environments/settings/general/organization_name_placeholder: abcee7d91a848e573b63a763cfaf6a08
|
||||
environments/settings/general/organization_name_updated_successfully: d36ba3a4f614d30b10e696d25da22432
|
||||
environments/settings/general/organization_settings: d31952131ad5f0ec72ad96f1ed11bef6
|
||||
environments/settings/general/please_add_a_logo: 66d6f97a2e7b27efc04bd653240ee813
|
||||
@@ -1192,6 +1192,7 @@ checksums:
|
||||
environments/settings/profile/update_personal_info: 5806f9bae0248a604cf85a2d8790a606
|
||||
environments/settings/profile/warning_cannot_delete_account: 07c25c3829149cd7171e7ad88229deac
|
||||
environments/settings/profile/warning_cannot_undo: dd1b2a59ff244b362d1d0d4eb1dbf7c6
|
||||
environments/settings/profile/wrong_password: e3523f78b302d11b33af6cc40d8df9da
|
||||
environments/settings/teams/add_members_description: 96e1e7125a0dfeaecc2c238eda3a216f
|
||||
environments/settings/teams/add_workspaces_description: f0f0cdd4d1032fbcb83d34a780bdfa52
|
||||
environments/settings/teams/all_members_added: 0541be1777b5c838f2e039035488506c
|
||||
@@ -1481,7 +1482,7 @@ checksums:
|
||||
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef
|
||||
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
|
||||
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
|
||||
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 31c18a8c7c578db2ba49eed663d1739f
|
||||
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 5abd8b702f9fb0e3815c3413d6f8aef6
|
||||
environments/surveys/edit/ignore_global_waiting_time: e08db543ace4935625e0961cc6e60489
|
||||
environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859
|
||||
environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import {
|
||||
createActionClass,
|
||||
deleteActionClass,
|
||||
getActionClass,
|
||||
getActionClassByEnvironmentIdAndName,
|
||||
getActionClasses,
|
||||
updateActionClass,
|
||||
} from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -16,6 +20,8 @@ vi.mock("@formbricks/database", () => ({
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -178,4 +184,147 @@ describe("ActionClass Service", () => {
|
||||
await expect(deleteActionClass("id4")).rejects.toThrow("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createActionClass", () => {
|
||||
const codeInput: TActionClassInput = {
|
||||
name: "Code Action",
|
||||
description: "desc",
|
||||
type: "code",
|
||||
key: "code-action-key",
|
||||
environmentId: "env-create",
|
||||
};
|
||||
|
||||
const buildPrismaUniqueError = (target: string[]) =>
|
||||
Object.assign(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "test",
|
||||
}),
|
||||
{ meta: { target } }
|
||||
);
|
||||
|
||||
test("should create and return the action class", async () => {
|
||||
const created: TActionClass = {
|
||||
id: "id-create",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: codeInput.name,
|
||||
description: codeInput.description ?? null,
|
||||
type: "code",
|
||||
key: codeInput.type === "code" ? codeInput.key : null,
|
||||
noCodeConfig: null,
|
||||
environmentId: codeInput.environmentId,
|
||||
};
|
||||
vi.mocked(prisma.actionClass.create).mockResolvedValue(created as never);
|
||||
|
||||
const result = await createActionClass(codeInput.environmentId, codeInput);
|
||||
expect(result).toEqual(created);
|
||||
});
|
||||
|
||||
test("should throw UniqueConstraintError on P2002 with target field", async () => {
|
||||
vi.mocked(prisma.actionClass.create).mockRejectedValue(buildPrismaUniqueError(["name"]));
|
||||
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
|
||||
UniqueConstraintError
|
||||
);
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
|
||||
`Action with name ${codeInput.name} already exists`
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw UniqueConstraintError on P2002 even when target is missing", async () => {
|
||||
vi.mocked(prisma.actionClass.create).mockRejectedValue(
|
||||
Object.assign(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "test",
|
||||
}),
|
||||
{ meta: undefined }
|
||||
)
|
||||
);
|
||||
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
|
||||
UniqueConstraintError
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError for non-P2002 errors", async () => {
|
||||
vi.mocked(prisma.actionClass.create).mockRejectedValue(new Error("boom"));
|
||||
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(DatabaseError);
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
|
||||
`Database error when creating an action for environment ${codeInput.environmentId}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateActionClass", () => {
|
||||
const updateInput: Partial<TActionClassInput> = {
|
||||
name: "Renamed Action",
|
||||
description: "updated desc",
|
||||
type: "code",
|
||||
key: "renamed-key",
|
||||
environmentId: "env-update",
|
||||
};
|
||||
|
||||
const buildPrismaUniqueError = (target: string[]) =>
|
||||
Object.assign(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "test",
|
||||
}),
|
||||
{ meta: { target } }
|
||||
);
|
||||
|
||||
test("should update and return the action class", async () => {
|
||||
const updated = {
|
||||
id: "id-update",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: updateInput.name,
|
||||
description: updateInput.description ?? null,
|
||||
type: "code" as const,
|
||||
key: "renamed-key",
|
||||
noCodeConfig: null,
|
||||
environmentId: updateInput.environmentId,
|
||||
surveyTriggers: [],
|
||||
};
|
||||
vi.mocked(prisma.actionClass.update).mockResolvedValue(updated as never);
|
||||
|
||||
const result = await updateActionClass(updateInput.environmentId!, "id-update", updateInput);
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
test("should throw UniqueConstraintError on P2002 with target field", async () => {
|
||||
vi.mocked(prisma.actionClass.update).mockRejectedValue(buildPrismaUniqueError(["name"]));
|
||||
|
||||
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
|
||||
UniqueConstraintError
|
||||
);
|
||||
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
|
||||
`Action with name ${updateInput.name} already exists`
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError for other PrismaClientKnownRequestError codes", async () => {
|
||||
vi.mocked(prisma.actionClass.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "test",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
|
||||
test("should rethrow unknown errors", async () => {
|
||||
vi.mocked(prisma.actionClass.update).mockRejectedValue(new Error("boom"));
|
||||
|
||||
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
|
||||
"boom"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
@@ -135,7 +135,7 @@ export const createActionClass = async (
|
||||
error.code === PrismaErrorType.UniqueConstraintViolation
|
||||
) {
|
||||
const targetField = (error.meta?.target as string[] | undefined)?.[0];
|
||||
throw new DatabaseError(
|
||||
throw new UniqueConstraintError(
|
||||
`Action with ${targetField} ${targetField ? (actionClass as Record<string, unknown>)[targetField] : ""} already exists`
|
||||
);
|
||||
}
|
||||
@@ -185,7 +185,7 @@ export const updateActionClass = async (
|
||||
error.code === PrismaErrorType.UniqueConstraintViolation
|
||||
) {
|
||||
const targetField = (error.meta?.target as string[] | undefined)?.[0];
|
||||
throw new DatabaseError(
|
||||
throw new UniqueConstraintError(
|
||||
`Action with ${targetField} ${targetField ? (inputActionClass as Record<string, unknown>)[targetField] : ""} already exists`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,6 +153,9 @@ export const DEBUG = env.DEBUG === "1";
|
||||
// Enterprise License constant
|
||||
export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
|
||||
|
||||
export const ENTERPRISE_LICENSE_REQUEST_FORM_URL =
|
||||
"https://app.formbricks.com/s/trvp8tzy5uvsps9rc9qi9l9w?delivery=onpremise&source=ce";
|
||||
|
||||
export const REDIS_URL = env.REDIS_URL;
|
||||
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
|
||||
export const TELEMETRY_DISABLED = env.TELEMETRY_DISABLED === "1";
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getFeatureFlag: vi.fn(),
|
||||
posthog: {
|
||||
__loaded: false,
|
||||
getFeatureFlag: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("posthog-js", () => ({
|
||||
default: mocks.posthog,
|
||||
}));
|
||||
|
||||
describe("getPostHogClientFeatureFlag", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.posthog.__loaded = false;
|
||||
mocks.posthog.getFeatureFlag = mocks.getFeatureFlag;
|
||||
});
|
||||
|
||||
test("returns false before PostHog is initialized", async () => {
|
||||
const { getPostHogClientFeatureFlag } = await import("./client");
|
||||
|
||||
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
|
||||
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns true from posthog.getFeatureFlag", async () => {
|
||||
mocks.posthog.__loaded = true;
|
||||
mocks.getFeatureFlag.mockReturnValue(true);
|
||||
|
||||
const { getPostHogClientFeatureFlag } = await import("./client");
|
||||
|
||||
expect(getPostHogClientFeatureFlag("test-flag")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false from posthog.getFeatureFlag", async () => {
|
||||
mocks.posthog.__loaded = true;
|
||||
mocks.getFeatureFlag.mockReturnValue(false);
|
||||
|
||||
const { getPostHogClientFeatureFlag } = await import("./client");
|
||||
|
||||
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns variant string from posthog.getFeatureFlag", async () => {
|
||||
mocks.posthog.__loaded = true;
|
||||
mocks.getFeatureFlag.mockReturnValue("variant-a");
|
||||
|
||||
const { getPostHogClientFeatureFlag } = await import("./client");
|
||||
|
||||
expect(getPostHogClientFeatureFlag("test-flag")).toBe("variant-a");
|
||||
});
|
||||
|
||||
test("coerces undefined to false", async () => {
|
||||
mocks.posthog.__loaded = true;
|
||||
mocks.getFeatureFlag.mockReturnValue(undefined);
|
||||
|
||||
const { getPostHogClientFeatureFlag } = await import("./client");
|
||||
|
||||
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import type { TPostHogFeatureFlagValue } from "./types";
|
||||
|
||||
export const getPostHogClientFeatureFlag = (flagKey: string): TPostHogFeatureFlagValue => {
|
||||
if (!posthog.__loaded) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const featureFlagValue = posthog.getFeatureFlag(flagKey);
|
||||
return featureFlagValue ?? false;
|
||||
};
|
||||
|
||||
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getFeatureFlag: vi.fn(),
|
||||
loggerWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getPostHogFeatureFlag", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("returns false when PostHog is not configured", async () => {
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: undefined }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(getPostHogFeatureFlag("user123", "test-flag")).resolves.toBe(false);
|
||||
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
|
||||
expect(mocks.loggerWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns false when posthogServerClient is null", async () => {
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: null,
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(getPostHogFeatureFlag("user123", "test-flag")).resolves.toBe(false);
|
||||
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
|
||||
expect(mocks.loggerWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("forwards distinctId, flagKey, and mapped groups to PostHog", async () => {
|
||||
mocks.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(
|
||||
getPostHogFeatureFlag("user123", "experiment-flag", {
|
||||
organizationId: "org_123",
|
||||
workspaceId: "ws_456",
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(mocks.getFeatureFlag).toHaveBeenCalledWith("experiment-flag", "user123", {
|
||||
groups: {
|
||||
organization: "org_123",
|
||||
workspace: "ws_456",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves variant string responses", async () => {
|
||||
mocks.getFeatureFlag.mockResolvedValue("variant-a");
|
||||
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe("variant-a");
|
||||
});
|
||||
|
||||
test("coerces undefined to false", async () => {
|
||||
mocks.getFeatureFlag.mockResolvedValue(undefined);
|
||||
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test("logs and returns false when PostHog throws", async () => {
|
||||
mocks.getFeatureFlag.mockRejectedValue(new Error("network error"));
|
||||
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe(false);
|
||||
expect(mocks.loggerWarn).toHaveBeenCalledWith(
|
||||
{ error: expect.any(Error), flagKey: "experiment-flag" },
|
||||
"Failed to evaluate PostHog feature flag"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import "server-only";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { POSTHOG_KEY } from "@/lib/constants";
|
||||
import { posthogServerClient } from "./server";
|
||||
import type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
|
||||
|
||||
const buildPostHogGroups = (context?: TPostHogFeatureFlagContext): Record<string, string> | undefined => {
|
||||
const groups = {
|
||||
...(context?.organizationId ? { organization: context.organizationId } : {}),
|
||||
...(context?.workspaceId ? { workspace: context.workspaceId } : {}),
|
||||
};
|
||||
|
||||
return Object.keys(groups).length > 0 ? groups : undefined;
|
||||
};
|
||||
|
||||
export const getPostHogFeatureFlag = async (
|
||||
distinctId: string,
|
||||
flagKey: string,
|
||||
context?: TPostHogFeatureFlagContext
|
||||
): Promise<TPostHogFeatureFlagValue> => {
|
||||
if (!POSTHOG_KEY || !posthogServerClient) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const featureFlagValue = await posthogServerClient.getFeatureFlag(flagKey, distinctId, {
|
||||
groups: buildPostHogGroups(context),
|
||||
});
|
||||
|
||||
return featureFlagValue ?? false;
|
||||
} catch (error) {
|
||||
logger.warn({ error, flagKey }, "Failed to evaluate PostHog feature flag");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1 +1,5 @@
|
||||
import "server-only";
|
||||
|
||||
export { capturePostHogEvent } from "./capture";
|
||||
export { getPostHogFeatureFlag } from "./get-feature-flag";
|
||||
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export type TPostHogFeatureFlagValue = boolean | string;
|
||||
|
||||
export type TPostHogFeatureFlagContext = {
|
||||
organizationId?: string;
|
||||
workspaceId?: string;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import "server-only";
|
||||
import { User } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifyPassword } from "@/modules/auth/lib/utils";
|
||||
|
||||
const getUserAuthenticationData = reactCache(
|
||||
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new ResourceNotFoundError("user", userId);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
);
|
||||
|
||||
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
|
||||
const user = await getUserAuthenticationData(userId);
|
||||
|
||||
if (user.identityProvider !== "email" || !user.password) {
|
||||
throw new InvalidInputError("Password is not set for this user");
|
||||
}
|
||||
|
||||
return await verifyPassword(password, user.password);
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
OperationNotAllowedError,
|
||||
ResourceNotFoundError,
|
||||
TooManyRequestsError,
|
||||
UniqueConstraintError,
|
||||
UnknownError,
|
||||
ValidationError,
|
||||
isExpectedError,
|
||||
@@ -74,6 +75,7 @@ describe("isExpectedError (shared helper)", () => {
|
||||
"OperationNotAllowedError",
|
||||
"TooManyRequestsError",
|
||||
"InvalidPasswordResetTokenError",
|
||||
"UniqueConstraintError",
|
||||
];
|
||||
|
||||
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
|
||||
@@ -91,6 +93,7 @@ describe("isExpectedError (shared helper)", () => {
|
||||
{ ErrorClass: ValidationError, args: ["Invalid data"] },
|
||||
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
|
||||
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
|
||||
{ ErrorClass: UniqueConstraintError, args: ["Already exists"] },
|
||||
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
|
||||
const error = new (ErrorClass as any)(...args);
|
||||
expect(isExpectedError(error)).toBe(true);
|
||||
@@ -186,6 +189,14 @@ describe("actionClient handleServerError", () => {
|
||||
expect(result?.serverError).toBe(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE);
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("UniqueConstraintError returns its message and is not sent to Sentry", async () => {
|
||||
const result = await executeThrowingAction(
|
||||
new UniqueConstraintError("Action with name foo already exists")
|
||||
);
|
||||
expect(result?.serverError).toBe("Action with name foo already exists");
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unexpected errors SHOULD be reported to Sentry", () => {
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"created_by": "Erstellt von",
|
||||
"customer_success": "Kundenerfolg",
|
||||
"dark_overlay": "Dunkle Überlagerung",
|
||||
"data_refreshed_successfully": "Daten erfolgreich aktualisiert",
|
||||
"date": "Datum",
|
||||
"days": "Tage",
|
||||
"default": "Standard",
|
||||
@@ -374,6 +375,7 @@
|
||||
"quotas_description": "Begrenze die Anzahl der Antworten, die du von Teilnehmern erhältst, die bestimmte Kriterien erfüllen.",
|
||||
"read_docs": "Dokumentation lesen",
|
||||
"recipients": "Empfänger",
|
||||
"refresh": "Aktualisieren",
|
||||
"remove": "Entfernen",
|
||||
"remove_from_team": "Aus Team entfernen",
|
||||
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
|
||||
@@ -675,8 +677,6 @@
|
||||
"attributes_msg_new_attribute_created": "Neues Attribut “{key}” mit Typ “{dataType}” erstellt",
|
||||
"attributes_msg_userid_already_exists": "Die Benutzer-ID existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
|
||||
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
|
||||
"contacts_table_refresh": "Kontakte aktualisieren",
|
||||
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
|
||||
"create_attribute": "Attribut erstellen",
|
||||
"create_new_attribute": "Neues Attribut erstellen",
|
||||
"create_new_attribute_description": "Erstellen Sie ein neues Attribut für Segmentierungszwecke.",
|
||||
@@ -1199,7 +1199,7 @@
|
||||
"organization_invite_link_ready": "Dein Einladungslink für die Organisation ist fertig!",
|
||||
"organization_name": "Organisationsname",
|
||||
"organization_name_description": "Gib deiner Organisation einen Namen.",
|
||||
"organization_name_placeholder": "z. B. Powerpuff Girls",
|
||||
"organization_name_placeholder": "z. B. Acme Inc.",
|
||||
"organization_name_updated_successfully": "Organisationsname erfolgreich aktualisiert",
|
||||
"organization_settings": "Organisationseinstellungen",
|
||||
"please_add_a_logo": "Bitte füge ein Logo hinzu",
|
||||
@@ -1257,7 +1257,8 @@
|
||||
"unlock_two_factor_authentication": "Zwei-Faktor-Authentifizierung mit einem höheren Plan freischalten",
|
||||
"update_personal_info": "Persönliche Daten aktualisieren",
|
||||
"warning_cannot_delete_account": "Du bist der einzige Besitzer dieser Organisation. Bitte übertrage das Eigentum zuerst an ein anderes Mitglied.",
|
||||
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden"
|
||||
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden",
|
||||
"wrong_password": "Falsches Passwort"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Füge Mitglieder zum Team hinzu und bestimme ihre Rolle.",
|
||||
@@ -1553,7 +1554,7 @@
|
||||
"hide_question_settings": "Frageeinstellungen ausblenden",
|
||||
"hostname": "Hostname",
|
||||
"if_you_need_more_please": "Wenn Sie mehr benötigen, bitte",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Weiterhin anzeigen, wenn ausgelöst, bis eine Antwort abgegeben wird.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Immer anzeigen, wenn ausgelöst, bis eine Antwort oder Teilantwort übermittelt wurde.",
|
||||
"ignore_global_waiting_time": "Abkühlphase ignorieren",
|
||||
"ignore_global_waiting_time_description": "Diese Umfrage kann angezeigt werden, wenn ihre Bedingungen erfüllt sind, auch wenn kürzlich eine andere Umfrage angezeigt wurde.",
|
||||
"image": "Bild",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"created_by": "Created by",
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Dark overlay",
|
||||
"data_refreshed_successfully": "Data refreshed successfully",
|
||||
"date": "Date",
|
||||
"days": "days",
|
||||
"default": "Default",
|
||||
@@ -374,6 +375,7 @@
|
||||
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
|
||||
"read_docs": "Read docs",
|
||||
"recipients": "Recipients",
|
||||
"refresh": "Refresh",
|
||||
"remove": "Remove",
|
||||
"remove_from_team": "Remove from team",
|
||||
"reorder_and_hide_columns": "Reorder and hide columns",
|
||||
@@ -675,8 +677,6 @@
|
||||
"attributes_msg_new_attribute_created": "Created new attribute “{key}” with type “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "The user ID already exists for this environment and was not updated.",
|
||||
"contact_deleted_successfully": "Contact deleted successfully",
|
||||
"contacts_table_refresh": "Refresh contacts",
|
||||
"contacts_table_refresh_success": "Contacts refreshed successfully",
|
||||
"create_attribute": "Create attribute",
|
||||
"create_new_attribute": "Create new attribute",
|
||||
"create_new_attribute_description": "Create a new attribute for segmentation purposes.",
|
||||
@@ -1199,7 +1199,7 @@
|
||||
"organization_invite_link_ready": "Your organization invite link is ready!",
|
||||
"organization_name": "Organization Name",
|
||||
"organization_name_description": "Give your organization a descriptive name.",
|
||||
"organization_name_placeholder": "e.g. Power Puff Girls",
|
||||
"organization_name_placeholder": "e.g. Acme Inc.",
|
||||
"organization_name_updated_successfully": "Organization name updated successfully",
|
||||
"organization_settings": "Organization Settings",
|
||||
"please_add_a_logo": "Please add a logo",
|
||||
@@ -1257,7 +1257,8 @@
|
||||
"unlock_two_factor_authentication": "Unlock two-factor authentication with a higher plan",
|
||||
"update_personal_info": "Update your personal information",
|
||||
"warning_cannot_delete_account": "You are the only owner of this organization. Please transfer ownership to another member first.",
|
||||
"warning_cannot_undo": "This cannot be undone"
|
||||
"warning_cannot_undo": "This cannot be undone",
|
||||
"wrong_password": "Wrong password"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Add members to the team and determine their role.",
|
||||
@@ -1553,7 +1554,7 @@
|
||||
"hide_question_settings": "Hide Question settings",
|
||||
"hostname": "Hostname",
|
||||
"if_you_need_more_please": "If you need more, please",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response is submitted.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response or partial response is submitted.",
|
||||
"ignore_global_waiting_time": "Ignore Cooldown Period",
|
||||
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
|
||||
"image": "Image",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"created_by": "Creado por",
|
||||
"customer_success": "Éxito del cliente",
|
||||
"dark_overlay": "Superposición oscura",
|
||||
"data_refreshed_successfully": "Datos actualizados correctamente",
|
||||
"date": "Fecha",
|
||||
"days": "días",
|
||||
"default": "Predeterminado",
|
||||
@@ -374,6 +375,7 @@
|
||||
"quotas_description": "Limita la cantidad de respuestas que recibes de participantes que cumplen ciertos criterios.",
|
||||
"read_docs": "Leer documentación",
|
||||
"recipients": "Destinatarios",
|
||||
"refresh": "Actualizar",
|
||||
"remove": "Eliminar",
|
||||
"remove_from_team": "Eliminar del equipo",
|
||||
"reorder_and_hide_columns": "Reordenar y ocultar columnas",
|
||||
@@ -675,8 +677,6 @@
|
||||
"attributes_msg_new_attribute_created": "Se creó el atributo nuevo “{key}” con el tipo “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "El ID de usuario ya existe para este entorno y no se actualizó.",
|
||||
"contact_deleted_successfully": "Contacto eliminado correctamente",
|
||||
"contacts_table_refresh": "Actualizar contactos",
|
||||
"contacts_table_refresh_success": "Contactos actualizados correctamente",
|
||||
"create_attribute": "Crear atributo",
|
||||
"create_new_attribute": "Crear atributo nuevo",
|
||||
"create_new_attribute_description": "Crea un atributo nuevo para fines de segmentación.",
|
||||
@@ -1199,7 +1199,7 @@
|
||||
"organization_invite_link_ready": "¡Tu enlace de invitación a la organización está listo!",
|
||||
"organization_name": "Nombre de la organización",
|
||||
"organization_name_description": "Dale a tu organización un nombre descriptivo.",
|
||||
"organization_name_placeholder": "p. ej. Power Puff Girls",
|
||||
"organization_name_placeholder": "p. ej. Acme Inc.",
|
||||
"organization_name_updated_successfully": "Nombre de la organización actualizado correctamente",
|
||||
"organization_settings": "Configuración de la organización",
|
||||
"please_add_a_logo": "Por favor, añade un logotipo",
|
||||
@@ -1257,7 +1257,8 @@
|
||||
"unlock_two_factor_authentication": "Desbloquea la autenticación de dos factores con un plan superior",
|
||||
"update_personal_info": "Actualiza tu información personal",
|
||||
"warning_cannot_delete_account": "Eres el único propietario de esta organización. Por favor, transfiere la propiedad a otro miembro primero.",
|
||||
"warning_cannot_undo": "Esto no se puede deshacer"
|
||||
"warning_cannot_undo": "Esto no se puede deshacer",
|
||||
"wrong_password": "Contraseña incorrecta"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Añade miembros al equipo y determina su rol.",
|
||||
@@ -1553,7 +1554,7 @@
|
||||
"hide_question_settings": "Ocultar ajustes de la pregunta",
|
||||
"hostname": "Nombre de host",
|
||||
"if_you_need_more_please": "Si necesitas más, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Seguir mostrando cuando se active hasta que se envíe una respuesta.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Seguir mostrando cada vez que se active hasta que se envíe una respuesta o respuesta parcial.",
|
||||
"ignore_global_waiting_time": "Ignorar periodo de espera",
|
||||
"ignore_global_waiting_time_description": "Esta encuesta puede mostrarse siempre que se cumplan sus condiciones, incluso si otra encuesta se mostró recientemente.",
|
||||
"image": "Imagen",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"created_by": "Créé par",
|
||||
"customer_success": "Succès Client",
|
||||
"dark_overlay": "Foncée",
|
||||
"data_refreshed_successfully": "Données actualisées avec succès",
|
||||
"date": "Date",
|
||||
"days": "jours",
|
||||
"default": "Par défaut",
|
||||
@@ -374,6 +375,7 @@
|
||||
"quotas_description": "Limitez le nombre de réponses que vous recevez de la part des participants répondant à certains critères.",
|
||||
"read_docs": "Lire la documentation",
|
||||
"recipients": "Destinataires",
|
||||
"refresh": "Actualiser",
|
||||
"remove": "Retirer",
|
||||
"remove_from_team": "Retirer de l'équipe",
|
||||
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
|
||||
@@ -675,8 +677,6 @@
|
||||
"attributes_msg_new_attribute_created": "Nouvel attribut “{key}” créé avec le type “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "L'identifiant utilisateur existe déjà pour cet environnement et n'a pas été mis à jour.",
|
||||
"contact_deleted_successfully": "Contact supprimé avec succès",
|
||||
"contacts_table_refresh": "Actualiser les contacts",
|
||||
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
|
||||
"create_attribute": "Créer un attribut",
|
||||
"create_new_attribute": "Créer un nouvel attribut",
|
||||
"create_new_attribute_description": "Créez un nouvel attribut à des fins de segmentation.",
|
||||
@@ -1199,7 +1199,7 @@
|
||||
"organization_invite_link_ready": "Le lien d'invitation de votre organisation est prêt !",
|
||||
"organization_name": "Nom de l'organisation",
|
||||
"organization_name_description": "Attribuez un nom descriptif à votre organisation.",
|
||||
"organization_name_placeholder": "e.g. Power Puff Girls",
|
||||
"organization_name_placeholder": "e.g. Acme Inc.",
|
||||
"organization_name_updated_successfully": "Nom de l'organisation mis à jour avec succès",
|
||||
"organization_settings": "Paramètres de l'organisation",
|
||||
"please_add_a_logo": "Veuillez ajouter un logo",
|
||||
@@ -1257,7 +1257,8 @@
|
||||
"unlock_two_factor_authentication": "Débloquez l'authentification à deux facteurs avec une offre supérieure",
|
||||
"update_personal_info": "Mettez à jour vos informations personnelles",
|
||||
"warning_cannot_delete_account": "Tu es le seul propriétaire de cette organisation. Transfère la propriété à un autre membre d'abord.",
|
||||
"warning_cannot_undo": "Cette opération est irréversible."
|
||||
"warning_cannot_undo": "Cette opération est irréversible.",
|
||||
"wrong_password": "Mot de passe incorrect"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Ajoutez des membres à l'équipe et déterminez leur rôle.",
|
||||
@@ -1553,7 +1554,7 @@
|
||||
"hide_question_settings": "Masquer les paramètres de la question",
|
||||
"hostname": "Nom d'hôte",
|
||||
"if_you_need_more_please": "Si vous avez besoin de plus, veuillez",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse soit soumise.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse ou une réponse partielle soit soumise.",
|
||||
"ignore_global_waiting_time": "Ignorer la période de refroidissement",
|
||||
"ignore_global_waiting_time_description": "Cette enquête peut s'afficher chaque fois que ses conditions sont remplies, même si une autre enquête a été affichée récemment.",
|
||||
"image": "Image",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"created_by": "Létrehozta",
|
||||
"customer_success": "Ügyfélsiker",
|
||||
"dark_overlay": "Sötét rávetítés",
|
||||
"data_refreshed_successfully": "Az adatok sikeresen frissítve lettek",
|
||||
"date": "Dátum",
|
||||
"days": "nap",
|
||||
"default": "Alapértelmezett",
|
||||
@@ -374,6 +375,7 @@
|
||||
"quotas_description": "A bizonyos feltételeknek megfelelő résztvevőktől kapott válaszok számának korlátozása.",
|
||||
"read_docs": "Dokumentáció elolvasása",
|
||||
"recipients": "Címzettek",
|
||||
"refresh": "Frissítés",
|
||||
"remove": "Eltávolítás",
|
||||
"remove_from_team": "Eltávolítás a csapatból",
|
||||
"reorder_and_hide_columns": "Oszlopok átrendezése és elrejtése",
|
||||
@@ -675,8 +677,6 @@
|
||||
"attributes_msg_new_attribute_created": "Az új „{dataType}” típusú „{key}” attribútum létrehozva",
|
||||
"attributes_msg_userid_already_exists": "A felhasználó-azonosító már létezik ennél a környezetnél, és nem lett frissítve.",
|
||||
"contact_deleted_successfully": "A partner sikeresen törölve",
|
||||
"contacts_table_refresh": "Partnerek frissítése",
|
||||
"contacts_table_refresh_success": "A partnerek sikeresen frissítve",
|
||||
"create_attribute": "Attribútum létrehozása",
|
||||
"create_new_attribute": "Új attribútum létrehozása",
|
||||
"create_new_attribute_description": "Új attribútum létrehozása szakaszolási célokhoz.",
|
||||
@@ -1199,7 +1199,7 @@
|
||||
"organization_invite_link_ready": "A szervezete meghívási hivatkozása készen áll!",
|
||||
"organization_name": "Szervezet neve",
|
||||
"organization_name_description": "Adjon a szervezetének egy leíró nevet.",
|
||||
"organization_name_placeholder": "például Pindúr pandúrok",
|
||||
"organization_name_placeholder": "például Acme Inc.",
|
||||
"organization_name_updated_successfully": "A szervezet neve sikeresen frissítve",
|
||||
"organization_settings": "Szervezet beállításai",
|
||||
"please_add_a_logo": "Adjon hozzá egy logót",
|
||||
@@ -1257,7 +1257,8 @@
|
||||
"unlock_two_factor_authentication": "A kétfaktoros hitelesítés feloldása egy magasabb csomaggal",
|
||||
"update_personal_info": "Személyes információk frissítése",
|
||||
"warning_cannot_delete_account": "Ön az egyetlen tulajdonosa ennek a szervezetnek. Először adja át a tulajdonjogot egy másik tagnak.",
|
||||
"warning_cannot_undo": "Ezt nem lehet visszavonni"
|
||||
"warning_cannot_undo": "Ezt nem lehet visszavonni",
|
||||
"wrong_password": "Hibás jelszó"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Tagok hozzáadása a csapathoz és a szerepük meghatározása.",
|
||||
@@ -1553,7 +1554,7 @@
|
||||
"hide_question_settings": "Kérdésbeállítások elrejtése",
|
||||
"hostname": "Gépnév",
|
||||
"if_you_need_more_please": "Ha többre van szüksége, akkor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Maradjon megjelenítve bármikor is aktiválódott, amíg egy választ el nem küldenek.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Továbbra is megjelenítés minden egyes aktiváláskor, amíg választ vagy részleges választ nem küldenek be.",
|
||||
"ignore_global_waiting_time": "Várakozási időszak figyelmen kívül hagyása",
|
||||
"ignore_global_waiting_time_description": "Ez a kérdőív akkor jelenhet meg, ha a feltételei teljesülnek, még akkor is, ha egy másik kérdőív jelent meg nemrég.",
|
||||
"image": "Kép",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"created_by": "作成者",
|
||||
"customer_success": "カスタマーサクセス",
|
||||
"dark_overlay": "暗いオーバーレイ",
|
||||
"data_refreshed_successfully": "データが正常に更新されました",
|
||||
"date": "日付",
|
||||
"days": "日",
|
||||
"default": "デフォルト",
|
||||
@@ -374,6 +375,7 @@
|
||||
"quotas_description": "特定の基準を満たす参加者からの回答数を制限する",
|
||||
"read_docs": "ドキュメントを読む",
|
||||
"recipients": "受信者",
|
||||
"refresh": "更新",
|
||||
"remove": "削除",
|
||||
"remove_from_team": "チームから削除",
|
||||
"reorder_and_hide_columns": "列の並び替えと非表示",
|
||||
@@ -675,8 +677,6 @@
|
||||
"attributes_msg_new_attribute_created": "新しい属性“{key}”を型“{dataType}”で作成しました",
|
||||
"attributes_msg_userid_already_exists": "この環境にはすでにユーザーIDが存在するため、更新されませんでした。",
|
||||
"contact_deleted_successfully": "連絡先を正常に削除しました",
|
||||
"contacts_table_refresh": "連絡先を更新",
|
||||
"contacts_table_refresh_success": "連絡先を正常に更新しました",
|
||||
"create_attribute": "属性を作成",
|
||||
"create_new_attribute": "新しい属性を作成",
|
||||
"create_new_attribute_description": "セグメンテーション用の新しい属性を作成します。",
|
||||
@@ -1199,7 +1199,7 @@
|
||||
"organization_invite_link_ready": "組織の招待リンクが準備できました!",
|
||||
"organization_name": "組織名",
|
||||
"organization_name_description": "組織に分かりやすい名前を付けます。",
|
||||
"organization_name_placeholder": "例: パワーパフガールズ",
|
||||
"organization_name_placeholder": "例: Acme Inc.",
|
||||
"organization_name_updated_successfully": "組織名を正常に更新しました",
|
||||
"organization_settings": "組織設定",
|
||||
"please_add_a_logo": "ロゴを追加してください",
|
||||
@@ -1257,7 +1257,8 @@
|
||||
"unlock_two_factor_authentication": "上位プランで二段階認証をアンロック",
|
||||
"update_personal_info": "個人情報を更新",
|
||||
"warning_cannot_delete_account": "あなたは、この組織の唯一のオーナーです。まず、別のメンバーにオーナーシップを譲渡してください。",
|
||||
"warning_cannot_undo": "この操作は元に戻せません"
|
||||
"warning_cannot_undo": "この操作は元に戻せません",
|
||||
"wrong_password": "パスワードが間違っています"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "チームにメンバーを追加し、役割を決定します。",
|
||||
@@ -1553,7 +1554,7 @@
|
||||
"hide_question_settings": "質問設定を非表示",
|
||||
"hostname": "ホスト名",
|
||||
"if_you_need_more_please": "さらに必要な場合は、",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "回答が提出されるまで、トリガーされるたびに表示し続けます。",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "回答または一部の回答が送信されるまで、トリガーされるたびに表示し続けます。",
|
||||
"ignore_global_waiting_time": "クールダウン期間を無視",
|
||||
"ignore_global_waiting_time_description": "このフォームは、最近別のフォームが表示されていても、条件が満たされればいつでも表示できます。",
|
||||
"image": "画像",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"created_by": "Gemaakt door",
|
||||
"customer_success": "Klant succes",
|
||||
"dark_overlay": "Donkere overlay",
|
||||
"data_refreshed_successfully": "Gegevens succesvol vernieuwd",
|
||||
"date": "Datum",
|
||||
"days": "dagen",
|
||||
"default": "Standaard",
|
||||
@@ -374,6 +375,7 @@
|
||||
"quotas_description": "Beperk het aantal reacties dat u ontvangt van deelnemers die aan bepaalde criteria voldoen.",
|
||||
"read_docs": "Documentatie lezen",
|
||||
"recipients": "Ontvangers",
|
||||
"refresh": "Vernieuwen",
|
||||
"remove": "Verwijderen",
|
||||
"remove_from_team": "Verwijderen uit team",
|
||||
"reorder_and_hide_columns": "Kolommen opnieuw rangschikken en verbergen",
|
||||
@@ -675,8 +677,6 @@
|
||||
"attributes_msg_new_attribute_created": "Nieuw attribuut “{key}” aangemaakt met type “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "De gebruikers-ID bestaat al voor deze omgeving en is niet bijgewerkt.",
|
||||
"contact_deleted_successfully": "Contact succesvol verwijderd",
|
||||
"contacts_table_refresh": "Vernieuw contacten",
|
||||
"contacts_table_refresh_success": "Contacten zijn vernieuwd",
|
||||
"create_attribute": "Attribuut aanmaken",
|
||||
"create_new_attribute": "Nieuw attribuut aanmaken",
|
||||
"create_new_attribute_description": "Maak een nieuw attribuut aan voor segmentatiedoeleinden.",
|
||||
@@ -1199,7 +1199,7 @@
|
||||
"organization_invite_link_ready": "De uitnodigingslink voor uw organisatie is gereed!",
|
||||
"organization_name": "Organisatienaam",
|
||||
"organization_name_description": "Geef uw organisatie een beschrijvende naam.",
|
||||
"organization_name_placeholder": "bijv. Power Puff-meisjes",
|
||||
"organization_name_placeholder": "bijv. Acme Inc.",
|
||||
"organization_name_updated_successfully": "Organisatienaam is succesvol bijgewerkt",
|
||||
"organization_settings": "Organisatie-instellingen",
|
||||
"please_add_a_logo": "Voeg een logo toe",
|
||||
@@ -1257,7 +1257,8 @@
|
||||
"unlock_two_factor_authentication": "Ontgrendel tweefactorauthenticatie met een hoger abonnement",
|
||||
"update_personal_info": "Update uw persoonlijke gegevens",
|
||||
"warning_cannot_delete_account": "U bent de enige eigenaar van deze organisatie. Draag het eigendom eerst over aan een ander lid.",
|
||||
"warning_cannot_undo": "Dit kan niet ongedaan worden gemaakt"
|
||||
"warning_cannot_undo": "Dit kan niet ongedaan worden gemaakt",
|
||||
"wrong_password": "Verkeerd wachtwoord"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Voeg leden toe aan het team en bepaal hun rol.",
|
||||
@@ -1553,7 +1554,7 @@
|
||||
"hide_question_settings": "Vraaginstellingen verbergen",
|
||||
"hostname": "Hostnaam",
|
||||
"if_you_need_more_please": "Als je meer nodig hebt,",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een reactie is ingediend.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een antwoord of gedeeltelijk antwoord is ingediend.",
|
||||
"ignore_global_waiting_time": "Afkoelperiode negeren",
|
||||
"ignore_global_waiting_time_description": "Deze enquête kan worden getoond wanneer aan de voorwaarden wordt voldaan, zelfs als er onlangs een andere enquête is getoond.",
|
||||
"image": "Afbeelding",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"created_by": "Criado por",
|
||||
"customer_success": "Sucesso do Cliente",
|
||||
"dark_overlay": "sobreposição escura",
|
||||
"data_refreshed_successfully": "Dados atualizados com sucesso",
|
||||
"date": "Encontro",
|
||||
"days": "dias",
|
||||
"default": "Padrão",
|
||||
@@ -374,6 +375,7 @@
|
||||
"quotas_description": "Limite a quantidade de respostas que você recebe de participantes que atendem a determinados critérios.",
|
||||
"read_docs": "Ler documentação",
|
||||
"recipients": "Destinatários",
|
||||
"refresh": "Atualizar",
|
||||
"remove": "remover",
|
||||
"remove_from_team": "Remover da equipe",
|
||||
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
|
||||
@@ -675,8 +677,6 @@
|
||||
"attributes_msg_new_attribute_created": "Novo atributo “{key}” criado com tipo “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "O ID de usuário já existe para este ambiente e não foi atualizado.",
|
||||
"contact_deleted_successfully": "Contato excluído com sucesso",
|
||||
"contacts_table_refresh": "Atualizar contatos",
|
||||
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
|
||||
"create_attribute": "Criar atributo",
|
||||
"create_new_attribute": "Criar novo atributo",
|
||||
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
|
||||
@@ -1199,7 +1199,7 @@
|
||||
"organization_invite_link_ready": "O link de convite da sua organização está pronto!",
|
||||
"organization_name": "Nome da Organização",
|
||||
"organization_name_description": "Dê um nome descritivo pra sua organização.",
|
||||
"organization_name_placeholder": "por exemplo, Meninas Superpoderosas",
|
||||
"organization_name_placeholder": "por exemplo, Acme Inc.",
|
||||
"organization_name_updated_successfully": "Nome da organização atualizado com sucesso",
|
||||
"organization_settings": "Configurações da Organização",
|
||||
"please_add_a_logo": "Por favor, adicione um logo",
|
||||
@@ -1257,7 +1257,8 @@
|
||||
"unlock_two_factor_authentication": "Desbloqueia a autenticação de dois fatores com um plano melhor",
|
||||
"update_personal_info": "Atualize suas informações pessoais",
|
||||
"warning_cannot_delete_account": "Você é o único dono desta organização. Transfere a propriedade para outra pessoa primeiro.",
|
||||
"warning_cannot_undo": "Isso não pode ser desfeito"
|
||||
"warning_cannot_undo": "Isso não pode ser desfeito",
|
||||
"wrong_password": "Senha incorreta"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Adicione membros à equipe e determine sua função.",
|
||||
@@ -1553,7 +1554,7 @@
|
||||
"hide_question_settings": "Ocultar configurações da pergunta",
|
||||
"hostname": "nome do host",
|
||||
"if_you_need_more_please": "Se você precisar de mais, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar mostrando sempre que acionada até que uma resposta seja enviada.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continue mostrando sempre que acionado até que uma resposta ou resposta parcial seja enviada.",
|
||||
"ignore_global_waiting_time": "Ignorar período de espera",
|
||||
"ignore_global_waiting_time_description": "Esta pesquisa pode ser mostrada sempre que suas condições forem atendidas, mesmo que outra pesquisa tenha sido mostrada recentemente.",
|
||||
"image": "imagem",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"created_by": "Criado por",
|
||||
"customer_success": "Sucesso do Cliente",
|
||||
"dark_overlay": "Sobreposição escura",
|
||||
"data_refreshed_successfully": "Dados atualizados com sucesso",
|
||||
"date": "Data",
|
||||
"days": "dias",
|
||||
"default": "Padrão",
|
||||
@@ -374,6 +375,7 @@
|
||||
"quotas_description": "Limitar a quantidade de respostas recebidas de participantes que atendem a certos critérios.",
|
||||
"read_docs": "Ler documentação",
|
||||
"recipients": "Destinatários",
|
||||
"refresh": "Atualizar",
|
||||
"remove": "Remover",
|
||||
"remove_from_team": "Remover da equipa",
|
||||
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
|
||||
@@ -675,8 +677,6 @@
|
||||
"attributes_msg_new_attribute_created": "Criado novo atributo “{key}” com tipo “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "O ID de utilizador já existe para este ambiente e não foi atualizado.",
|
||||
"contact_deleted_successfully": "Contacto eliminado com sucesso",
|
||||
"contacts_table_refresh": "Atualizar contactos",
|
||||
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
|
||||
"create_attribute": "Criar atributo",
|
||||
"create_new_attribute": "Criar novo atributo",
|
||||
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
|
||||
@@ -1199,7 +1199,7 @@
|
||||
"organization_invite_link_ready": "O link de convite da sua organização está pronto!",
|
||||
"organization_name": "Nome da Organização",
|
||||
"organization_name_description": "Dê à sua organização um nome descritivo.",
|
||||
"organization_name_placeholder": "por exemplo, Power Puff Girls",
|
||||
"organization_name_placeholder": "por exemplo, Acme Inc.",
|
||||
"organization_name_updated_successfully": "Nome da organização atualizado com sucesso",
|
||||
"organization_settings": "Configurações da organização",
|
||||
"please_add_a_logo": "Por favor, adicione um logótipo",
|
||||
@@ -1257,7 +1257,8 @@
|
||||
"unlock_two_factor_authentication": "Desbloqueie a autenticação de dois fatores com um plano superior",
|
||||
"update_personal_info": "Atualize as suas informações pessoais",
|
||||
"warning_cannot_delete_account": "É o único proprietário desta organização. Transfira a propriedade para outro membro primeiro.",
|
||||
"warning_cannot_undo": "Isto não pode ser desfeito"
|
||||
"warning_cannot_undo": "Isto não pode ser desfeito",
|
||||
"wrong_password": "Palavra-passe incorreta"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Adicionar membros à equipa e determinar o seu papel.",
|
||||
@@ -1553,7 +1554,7 @@
|
||||
"hide_question_settings": "Ocultar definições da pergunta",
|
||||
"hostname": "Nome do host",
|
||||
"if_you_need_more_please": "Se precisar de mais, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar a mostrar sempre que acionado até que uma resposta seja submetida.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar a mostrar sempre que acionado até que uma resposta ou resposta parcial seja submetida.",
|
||||
"ignore_global_waiting_time": "Ignorar período de espera",
|
||||
"ignore_global_waiting_time_description": "Este inquérito pode ser mostrado sempre que as suas condições forem cumpridas, mesmo que outro inquérito tenha sido mostrado recentemente.",
|
||||
"image": "Imagem",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"created_by": "Creat de",
|
||||
"customer_success": "Succesul Clientului",
|
||||
"dark_overlay": "Suprapunere întunecată",
|
||||
"data_refreshed_successfully": "Datele au fost actualizate cu succes",
|
||||
"date": "Dată",
|
||||
"days": "zile",
|
||||
"default": "Implicit",
|
||||
@@ -374,6 +375,7 @@
|
||||
"quotas_description": "Limitați numărul de răspunsuri primite de la participanții care îndeplinesc anumite criterii.",
|
||||
"read_docs": "Citește documentația",
|
||||
"recipients": "Destinatari",
|
||||
"refresh": "Actualizează",
|
||||
"remove": "Șterge",
|
||||
"remove_from_team": "Elimină din echipă",
|
||||
"reorder_and_hide_columns": "Reordonați și ascundeți coloanele",
|
||||
@@ -675,8 +677,6 @@
|
||||
"attributes_msg_new_attribute_created": "A fost creat un nou atribut „{key}” cu tipul „{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "ID-ul de utilizator există deja pentru acest mediu și nu a fost actualizat.",
|
||||
"contact_deleted_successfully": "Contact șters cu succes",
|
||||
"contacts_table_refresh": "Reîmprospătare contacte",
|
||||
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
|
||||
"create_attribute": "Creează atribut",
|
||||
"create_new_attribute": "Creează atribut nou",
|
||||
"create_new_attribute_description": "Creează un atribut nou pentru segmentare.",
|
||||
@@ -1199,7 +1199,7 @@
|
||||
"organization_invite_link_ready": "Linkul de invitație al organizației tale este gata!",
|
||||
"organization_name": "Nume Organizație",
|
||||
"organization_name_description": "Oferiți organizației dumneavoastră un nume descriptiv.",
|
||||
"organization_name_placeholder": "ex. Power Puff Girls",
|
||||
"organization_name_placeholder": "ex. Acme Inc.",
|
||||
"organization_name_updated_successfully": "Numele organizației actualizat cu succes",
|
||||
"organization_settings": "Setări Organizație",
|
||||
"please_add_a_logo": "Adaugă un logo",
|
||||
@@ -1257,7 +1257,8 @@
|
||||
"unlock_two_factor_authentication": "Deblocați autentificarea în doi pași cu un plan superior",
|
||||
"update_personal_info": "Actualizează informațiile tale personale",
|
||||
"warning_cannot_delete_account": "Ești singurul proprietar al acestei organizații. Te rugăm să transferi proprietatea către un alt membru mai întâi.",
|
||||
"warning_cannot_undo": "Aceasta nu poate fi anulată"
|
||||
"warning_cannot_undo": "Aceasta nu poate fi anulată",
|
||||
"wrong_password": "Parolă greșită"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Adaugă membri în echipă și stabilește rolul lor.",
|
||||
@@ -1553,7 +1554,7 @@
|
||||
"hide_question_settings": "Ascunde setările întrebării",
|
||||
"hostname": "Nume gazdă",
|
||||
"if_you_need_more_please": "Dacă aveți nevoie de mai mult, vă rugăm",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă afișarea ori de câte ori este declanșat până când se trimite un răspuns.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă să afișezi de fiecare dată când este declanșat, până când se trimite un răspuns sau un răspuns parțial.",
|
||||
"ignore_global_waiting_time": "Ignoră perioada de răcire",
|
||||
"ignore_global_waiting_time_description": "Acest sondaj poate fi afișat ori de câte ori condițiile sale sunt îndeplinite, chiar dacă un alt sondaj a fost afișat recent.",
|
||||
"image": "Imagine",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"created_by": "Создано пользователем",
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Тёмный оверлей",
|
||||
"data_refreshed_successfully": "Данные успешно обновлены",
|
||||
"date": "Дата",
|
||||
"days": "дни",
|
||||
"default": "По умолчанию",
|
||||
@@ -374,6 +375,7 @@
|
||||
"quotas_description": "Ограничьте количество ответов, которые вы получаете от участников, соответствующих определённым критериям.",
|
||||
"read_docs": "Читать документацию",
|
||||
"recipients": "Получатели",
|
||||
"refresh": "Обновить",
|
||||
"remove": "Удалить",
|
||||
"remove_from_team": "Удалить из команды",
|
||||
"reorder_and_hide_columns": "Изменить порядок и скрыть столбцы",
|
||||
@@ -675,8 +677,6 @@
|
||||
"attributes_msg_new_attribute_created": "Создан новый атрибут «{key}» с типом «{dataType}»",
|
||||
"attributes_msg_userid_already_exists": "Этот user ID уже существует в данной среде и не был обновлён.",
|
||||
"contact_deleted_successfully": "Контакт успешно удалён",
|
||||
"contacts_table_refresh": "Обновить контакты",
|
||||
"contacts_table_refresh_success": "Контакты успешно обновлены",
|
||||
"create_attribute": "Создать атрибут",
|
||||
"create_new_attribute": "Создать новый атрибут",
|
||||
"create_new_attribute_description": "Создайте новый атрибут для целей сегментации.",
|
||||
@@ -1199,7 +1199,7 @@
|
||||
"organization_invite_link_ready": "Ссылка для приглашения в организацию готова!",
|
||||
"organization_name": "Название организации",
|
||||
"organization_name_description": "Дайте вашей организации понятное название.",
|
||||
"organization_name_placeholder": "например, Power Puff Girls",
|
||||
"organization_name_placeholder": "например, Acme Inc.",
|
||||
"organization_name_updated_successfully": "Название организации успешно обновлено",
|
||||
"organization_settings": "Настройки организации",
|
||||
"please_add_a_logo": "Пожалуйста, добавьте логотип",
|
||||
@@ -1257,7 +1257,8 @@
|
||||
"unlock_two_factor_authentication": "Откройте двухфакторную аутентификацию с более высоким тарифом",
|
||||
"update_personal_info": "Обновить личную информацию",
|
||||
"warning_cannot_delete_account": "Вы являетесь единственным владельцем этой организации. Пожалуйста, сначала передайте права другому участнику.",
|
||||
"warning_cannot_undo": "Это действие необратимо"
|
||||
"warning_cannot_undo": "Это действие необратимо",
|
||||
"wrong_password": "Неверный пароль"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Добавьте участников в команду и определите их роль.",
|
||||
@@ -1553,7 +1554,7 @@
|
||||
"hide_question_settings": "Скрыть настройки вопроса",
|
||||
"hostname": "Имя хоста",
|
||||
"if_you_need_more_please": "Если вам нужно больше, пожалуйста",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Показывать каждый раз при срабатывании, пока не будет получен ответ.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Продолжай показывать при каждом срабатывании триггера, пока не будет отправлен ответ или частичный ответ.",
|
||||
"ignore_global_waiting_time": "Игнорировать период ожидания",
|
||||
"ignore_global_waiting_time_description": "Этот опрос может отображаться при выполнении условий, даже если недавно уже был показан другой опрос.",
|
||||
"image": "Изображение",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"created_by": "Skapad av",
|
||||
"customer_success": "Kundframgång",
|
||||
"dark_overlay": "Mörkt överlägg",
|
||||
"data_refreshed_successfully": "Data uppdaterades",
|
||||
"date": "Datum",
|
||||
"days": "dagar",
|
||||
"default": "Standard",
|
||||
@@ -374,6 +375,7 @@
|
||||
"quotas_description": "Begränsa antalet svar du får från deltagare som uppfyller vissa kriterier.",
|
||||
"read_docs": "Läs dokumentation",
|
||||
"recipients": "Mottagare",
|
||||
"refresh": "Uppdatera",
|
||||
"remove": "Ta bort",
|
||||
"remove_from_team": "Ta bort från teamet",
|
||||
"reorder_and_hide_columns": "Ordna om och dölj kolumner",
|
||||
@@ -675,8 +677,6 @@
|
||||
"attributes_msg_new_attribute_created": "Nytt attribut ”{key}” med typen ”{dataType}” har skapats",
|
||||
"attributes_msg_userid_already_exists": "Användar-ID finns redan för denna miljö och uppdaterades inte.",
|
||||
"contact_deleted_successfully": "Kontakt borttagen",
|
||||
"contacts_table_refresh": "Uppdatera kontakter",
|
||||
"contacts_table_refresh_success": "Kontakter uppdaterade",
|
||||
"create_attribute": "Skapa attribut",
|
||||
"create_new_attribute": "Skapa nytt attribut",
|
||||
"create_new_attribute_description": "Skapa ett nytt attribut för segmenteringsändamål.",
|
||||
@@ -1199,7 +1199,7 @@
|
||||
"organization_invite_link_ready": "Din organisationsinbjudningslänk är redo!",
|
||||
"organization_name": "Organisationsnamn",
|
||||
"organization_name_description": "Ge din organisation ett beskrivande namn.",
|
||||
"organization_name_placeholder": "t.ex. Power Puff Girls",
|
||||
"organization_name_placeholder": "t.ex. Acme Inc.",
|
||||
"organization_name_updated_successfully": "Organisationsnamn uppdaterat",
|
||||
"organization_settings": "Organisationsinställningar",
|
||||
"please_add_a_logo": "Vänligen lägg till en logotyp",
|
||||
@@ -1257,7 +1257,8 @@
|
||||
"unlock_two_factor_authentication": "Lås upp tvåfaktorsautentisering med en högre plan",
|
||||
"update_personal_info": "Uppdatera din personliga information",
|
||||
"warning_cannot_delete_account": "Du är den enda ägaren av denna organisation. Vänligen överför ägarskapet till en annan medlem först.",
|
||||
"warning_cannot_undo": "Detta kan inte ångras"
|
||||
"warning_cannot_undo": "Detta kan inte ångras",
|
||||
"wrong_password": "Fel lösenord"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Lägg till medlemmar i teamet och bestäm deras roll.",
|
||||
@@ -1553,7 +1554,7 @@
|
||||
"hide_question_settings": "Dölj frågeinställningar",
|
||||
"hostname": "Värdnamn",
|
||||
"if_you_need_more_please": "Om du behöver mer, vänligen",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa när villkoren är uppfyllda tills ett svar skickas in.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa varje gång det utlöses tills ett svar eller ett delsvar skickas in.",
|
||||
"ignore_global_waiting_time": "Ignorera väntetid",
|
||||
"ignore_global_waiting_time_description": "Denna enkät kan visas när dess villkor är uppfyllda, även om en annan enkät nyligen visats.",
|
||||
"image": "Bild",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"created_by": "Oluşturan",
|
||||
"customer_success": "Müşteri Başarısı",
|
||||
"dark_overlay": "Koyu kaplama",
|
||||
"data_refreshed_successfully": "Veriler başarıyla yenilendi",
|
||||
"date": "Tarih",
|
||||
"days": "gün",
|
||||
"default": "Varsayılan",
|
||||
@@ -374,6 +375,7 @@
|
||||
"quotas_description": "Belirli kriterleri karşılayan katılımcılardan aldığınız yanıt miktarını sınırlayın.",
|
||||
"read_docs": "Dokümanları oku",
|
||||
"recipients": "Alıcılar",
|
||||
"refresh": "Yenile",
|
||||
"remove": "Kaldır",
|
||||
"remove_from_team": "Takımdan çıkar",
|
||||
"reorder_and_hide_columns": "Sütunları yeniden sırala ve gizle",
|
||||
@@ -675,8 +677,6 @@
|
||||
"attributes_msg_new_attribute_created": "\"{key}\" adında \"{dataType}\" türünde yeni özellik oluşturuldu",
|
||||
"attributes_msg_userid_already_exists": "Kullanıcı kimliği bu ortamda zaten mevcut ve güncellenmedi.",
|
||||
"contact_deleted_successfully": "Contact başarıyla silindi",
|
||||
"contacts_table_refresh": "Kişileri yenile",
|
||||
"contacts_table_refresh_success": "Kişiler başarıyla yenilendi",
|
||||
"create_attribute": "Özellik oluştur",
|
||||
"create_new_attribute": "Yeni özellik oluştur",
|
||||
"create_new_attribute_description": "Segmentasyon amacıyla yeni bir özellik oluşturun.",
|
||||
@@ -1199,7 +1199,7 @@
|
||||
"organization_invite_link_ready": "Kuruluş davet bağlantınız hazır!",
|
||||
"organization_name": "Kuruluş Adı",
|
||||
"organization_name_description": "Kuruluşunuza açıklayıcı bir ad verin.",
|
||||
"organization_name_placeholder": "ör. Power Puff Girls",
|
||||
"organization_name_placeholder": "ör. Acme Inc.",
|
||||
"organization_name_updated_successfully": "Organization name başarıyla güncellendi",
|
||||
"organization_settings": "Kuruluş Ayarları",
|
||||
"please_add_a_logo": "Lütfen bir logo ekleyin",
|
||||
@@ -1257,7 +1257,8 @@
|
||||
"unlock_two_factor_authentication": "Daha üst bir planla iki faktörlü kimlik doğrulamayı açın",
|
||||
"update_personal_info": "Kişisel bilgilerinizi güncelleyin",
|
||||
"warning_cannot_delete_account": "Bu organizasyonun tek sahibi sizsiniz. Lütfen önce sahipliği başka bir üyeye devredin.",
|
||||
"warning_cannot_undo": "Bu işlem geri alınamaz"
|
||||
"warning_cannot_undo": "Bu işlem geri alınamaz",
|
||||
"wrong_password": "Yanlış parola"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Takıma üyeler ekleyin ve rollerini belirleyin.",
|
||||
@@ -1553,7 +1554,7 @@
|
||||
"hide_question_settings": "Soru ayarlarını gizle",
|
||||
"hostname": "Ana bilgisayar adı",
|
||||
"if_you_need_more_please": "Daha fazlasına ihtiyacınız varsa lütfen",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Yanıt gönderilene kadar tetiklendiğinde göstermeye devam et.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Bir yanıt veya kısmi yanıt gönderilene kadar her tetiklendiğinde göstermeye devam et.",
|
||||
"ignore_global_waiting_time": "Bekleme Süresini Yoksay",
|
||||
"ignore_global_waiting_time_description": "Bu survey, yakın zamanda başka bir survey gösterilmiş olsa bile koşulları sağlandığında gösterilebilir.",
|
||||
"image": "Görsel",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"created_by": "由 创建",
|
||||
"customer_success": "客户成功",
|
||||
"dark_overlay": "深色遮罩层",
|
||||
"data_refreshed_successfully": "数据刷新成功",
|
||||
"date": "日期",
|
||||
"days": "天",
|
||||
"default": "默认",
|
||||
@@ -374,6 +375,7 @@
|
||||
"quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。",
|
||||
"read_docs": "阅读文档",
|
||||
"recipients": "收件人",
|
||||
"refresh": "刷新",
|
||||
"remove": "移除",
|
||||
"remove_from_team": "从团队中移除",
|
||||
"reorder_and_hide_columns": "重新排序和隐藏列",
|
||||
@@ -675,8 +677,6 @@
|
||||
"attributes_msg_new_attribute_created": "已创建新属性“{key}”,类型为“{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "该环境下的用户ID已存在,未进行更新。",
|
||||
"contact_deleted_successfully": "联系人 删除 成功",
|
||||
"contacts_table_refresh": "刷新 联系人",
|
||||
"contacts_table_refresh_success": "联系人 已成功刷新",
|
||||
"create_attribute": "创建属性",
|
||||
"create_new_attribute": "创建新属性",
|
||||
"create_new_attribute_description": "为细分目的创建新属性。",
|
||||
@@ -1199,7 +1199,7 @@
|
||||
"organization_invite_link_ready": "您的组织邀请链接已准备好!",
|
||||
"organization_name": "组织 名称",
|
||||
"organization_name_description": "为您的组织 提供一个描述性的名称",
|
||||
"organization_name_placeholder": "例如 Power Puff Girls",
|
||||
"organization_name_placeholder": "例如 Acme Inc.",
|
||||
"organization_name_updated_successfully": "组织 名称 更新 成功",
|
||||
"organization_settings": "组织 设置",
|
||||
"please_add_a_logo": "请添加 徽标",
|
||||
@@ -1257,7 +1257,8 @@
|
||||
"unlock_two_factor_authentication": "使用 更高 级 方案 解锁 双 重 因素 验证",
|
||||
"update_personal_info": "更新你的个人信息",
|
||||
"warning_cannot_delete_account": "您 是 该 组织 的 唯一 拥有者 。 请 先 将 所有权 转移 给 其他 成员 。",
|
||||
"warning_cannot_undo": "此 无法 撤销。"
|
||||
"warning_cannot_undo": "此 无法 撤销。",
|
||||
"wrong_password": "密码错误"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "将 成员 添加到 团队 ,并 确定 他们 的 角色",
|
||||
@@ -1553,7 +1554,7 @@
|
||||
"hide_question_settings": "隐藏问题设置",
|
||||
"hostname": "主 机 名",
|
||||
"if_you_need_more_please": "如果您需要更多,请",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "每次触发时都会显示,直到提交回应为止。",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "持续在触发时显示,直到提交响应或部分响应。",
|
||||
"ignore_global_waiting_time": "忽略冷却期",
|
||||
"ignore_global_waiting_time_description": "只要满足条件,此调查即可显示,即使最近刚显示过其他调查。",
|
||||
"image": "图片",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"created_by": "建立者",
|
||||
"customer_success": "客戶成功",
|
||||
"dark_overlay": "深色覆蓋",
|
||||
"data_refreshed_successfully": "資料已成功重新整理",
|
||||
"date": "日期",
|
||||
"days": "天",
|
||||
"default": "預設",
|
||||
@@ -374,6 +375,7 @@
|
||||
"quotas_description": "限制 擁有 特定 條件 的 參與者 所 提供 的 回應 數量。",
|
||||
"read_docs": "閱讀文件",
|
||||
"recipients": "收件者",
|
||||
"refresh": "重新整理",
|
||||
"remove": "移除",
|
||||
"remove_from_team": "從團隊中移除",
|
||||
"reorder_and_hide_columns": "重新排序和隱藏欄位",
|
||||
@@ -675,8 +677,6 @@
|
||||
"attributes_msg_new_attribute_created": "已建立新屬性「{key}」,型別為「{dataType}」",
|
||||
"attributes_msg_userid_already_exists": "此環境已存在該使用者 ID,未進行更新。",
|
||||
"contact_deleted_successfully": "聯絡人已成功刪除",
|
||||
"contacts_table_refresh": "重新整理聯絡人",
|
||||
"contacts_table_refresh_success": "聯絡人已成功重新整理",
|
||||
"create_attribute": "建立屬性",
|
||||
"create_new_attribute": "建立新屬性",
|
||||
"create_new_attribute_description": "建立新屬性以進行分群用途。",
|
||||
@@ -1199,7 +1199,7 @@
|
||||
"organization_invite_link_ready": "您的組織邀請連結已準備就緒!",
|
||||
"organization_name": "組織名稱",
|
||||
"organization_name_description": "為您的組織提供描述性名稱。",
|
||||
"organization_name_placeholder": "例如:飛天小女警",
|
||||
"organization_name_placeholder": "例如:Acme Inc.",
|
||||
"organization_name_updated_successfully": "組織名稱已成功更新",
|
||||
"organization_settings": "組織設定",
|
||||
"please_add_a_logo": "請新增標誌",
|
||||
@@ -1257,7 +1257,8 @@
|
||||
"unlock_two_factor_authentication": "使用更高等級的方案解鎖雙重驗證",
|
||||
"update_personal_info": "更新您的個人資訊",
|
||||
"warning_cannot_delete_account": "您是此組織的唯一擁有者。請先將所有權轉讓給其他成員。",
|
||||
"warning_cannot_undo": "此操作無法復原"
|
||||
"warning_cannot_undo": "此操作無法復原",
|
||||
"wrong_password": "密碼錯誤"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "將成員新增至團隊並確定其角色。",
|
||||
@@ -1553,7 +1554,7 @@
|
||||
"hide_question_settings": "隱藏問題設定",
|
||||
"hostname": "主機名稱",
|
||||
"if_you_need_more_please": "如果您需要更多,請",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "每次觸發時都顯示,直到提交回應為止。",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "持續顯示,直到使用者提交回應或部分回應為止。",
|
||||
"ignore_global_waiting_time": "忽略冷卻期",
|
||||
"ignore_global_waiting_time_description": "此問卷在符合條件時即可顯示,即使最近已顯示過其他問卷。",
|
||||
"image": "圖片",
|
||||
|
||||
@@ -1,24 +1,89 @@
|
||||
"use server";
|
||||
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { verifyUserPassword } from "@/lib/user/password";
|
||||
import { deleteUser, getUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants";
|
||||
|
||||
export const deleteUserAction = authenticatedActionClient.action(
|
||||
withAuditLogging("deleted", "user", async ({ ctx }) => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
|
||||
if (!isMultiOrgEnabled && organizationsWithSingleOwner.length > 0) {
|
||||
throw new OperationNotAllowedError(
|
||||
"You are the only owner of this organization. Please transfer ownership to another member first."
|
||||
);
|
||||
}
|
||||
const DELETE_USER_CONFIRMATION_REQUIRED_ERROR =
|
||||
"Password and email confirmation are required to delete your account.";
|
||||
|
||||
const ZDeleteUserConfirmation = z
|
||||
.object({
|
||||
confirmationEmail: z.string().trim().min(1).max(255),
|
||||
password: z.string().max(128).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const parseDeleteUserConfirmation = (input: unknown) => {
|
||||
const parsedInput = ZDeleteUserConfirmation.safeParse(input);
|
||||
|
||||
if (!parsedInput.success) {
|
||||
throw new InvalidInputError(DELETE_USER_CONFIRMATION_REQUIRED_ERROR);
|
||||
}
|
||||
|
||||
return parsedInput.data;
|
||||
};
|
||||
|
||||
const getPasswordOrThrow = (password?: string) => {
|
||||
if (!password) {
|
||||
throw new InvalidInputError(DELETE_USER_CONFIRMATION_REQUIRED_ERROR);
|
||||
}
|
||||
|
||||
return password;
|
||||
};
|
||||
|
||||
const logAccountDeletionError = (userId: string, error: unknown) => {
|
||||
logger.error({ error, userId }, "Account deletion failed");
|
||||
};
|
||||
|
||||
export const deleteUserAction = authenticatedActionClient.inputSchema(z.unknown()).action(
|
||||
withAuditLogging("deleted", "user", async ({ ctx, parsedInput }) => {
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = await getUser(ctx.user.id);
|
||||
const result = await deleteUser(ctx.user.id);
|
||||
return result;
|
||||
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
|
||||
|
||||
const isPasswordBackedAccount = ctx.user.identityProvider === "email";
|
||||
const { confirmationEmail, password } = parseDeleteUserConfirmation(parsedInput);
|
||||
|
||||
if (confirmationEmail.toLowerCase() !== ctx.user.email.toLowerCase()) {
|
||||
throw new AuthorizationError("Email confirmation does not match");
|
||||
}
|
||||
|
||||
if (isPasswordBackedAccount) {
|
||||
const isCorrectPassword = await verifyUserPassword(ctx.user.id, getPasswordOrThrow(password));
|
||||
if (!isCorrectPassword) {
|
||||
throw new AuthorizationError(DELETE_ACCOUNT_WRONG_PASSWORD_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (!isMultiOrgEnabled) {
|
||||
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
|
||||
if (organizationsWithSingleOwner.length > 0) {
|
||||
throw new OperationNotAllowedError(
|
||||
"You are the only owner of this organization. Please transfer ownership to another member first."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.oldObject = await getUser(ctx.user.id);
|
||||
|
||||
await deleteUser(ctx.user.id);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logAccountDeletionError(ctx.user.id, error);
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const DELETE_ACCOUNT_WRONG_PASSWORD_ERROR = "Wrong password";
|
||||
@@ -3,12 +3,16 @@
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { deleteUserAction } from "./actions";
|
||||
import { DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants";
|
||||
|
||||
interface DeleteAccountModalProps {
|
||||
open: boolean;
|
||||
@@ -28,15 +32,57 @@ export const DeleteAccountModal = ({
|
||||
const { t } = useTranslation();
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
const isPasswordBackedAccount = user.identityProvider === "email";
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
setInputValue("");
|
||||
setPassword("");
|
||||
}
|
||||
setOpen(nextOpen);
|
||||
};
|
||||
|
||||
const hasValidEmailConfirmation = inputValue.trim().toLowerCase() === user.email.toLowerCase();
|
||||
const hasValidConfirmation = hasValidEmailConfirmation && (!isPasswordBackedAccount || password.length > 0);
|
||||
const isDeleteDisabled = !hasValidConfirmation;
|
||||
|
||||
const deleteAccount = async () => {
|
||||
try {
|
||||
if (!hasValidConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleting(true);
|
||||
await deleteUserAction();
|
||||
const result = await deleteUserAction(
|
||||
isPasswordBackedAccount
|
||||
? {
|
||||
confirmationEmail: inputValue,
|
||||
password,
|
||||
}
|
||||
: {
|
||||
confirmationEmail: inputValue,
|
||||
}
|
||||
);
|
||||
|
||||
if (!result?.data?.success) {
|
||||
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
|
||||
let errorMessage = fallbackErrorMessage;
|
||||
|
||||
if (result?.serverError === DELETE_ACCOUNT_WRONG_PASSWORD_ERROR) {
|
||||
errorMessage = t("environments.settings.profile.wrong_password");
|
||||
} else if (result) {
|
||||
errorMessage = getFormattedErrorMessage(result);
|
||||
}
|
||||
|
||||
logger.error({ errorMessage }, "Account deletion action failed");
|
||||
toast.error(errorMessage || fallbackErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sign out with account deletion reason (no automatic redirect)
|
||||
await signOutWithAudit({
|
||||
@@ -52,22 +98,22 @@ export const DeleteAccountModal = ({
|
||||
window.location.replace("/auth/login");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Something went wrong");
|
||||
logger.error({ error }, "Account deletion failed");
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DeleteDialog
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
setOpen={handleOpenChange}
|
||||
deleteWhat={t("common.account")}
|
||||
onDelete={() => deleteAccount()}
|
||||
text={t("environments.settings.profile.account_deletion_consequences_warning")}
|
||||
isDeleting={deleting}
|
||||
disabled={inputValue !== user.email}>
|
||||
disabled={isDeleteDisabled}>
|
||||
<div className="py-5">
|
||||
<ul className="list-disc pb-6 pl-6">
|
||||
<li>
|
||||
@@ -110,11 +156,29 @@ export const DeleteAccountModal = ({
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
placeholder={user.email}
|
||||
className="mt-5"
|
||||
className="mt-2"
|
||||
type="text"
|
||||
id="deleteAccountConfirmation"
|
||||
name="deleteAccountConfirmation"
|
||||
/>
|
||||
{isPasswordBackedAccount && (
|
||||
<>
|
||||
<label htmlFor="deleteAccountPassword" className="mt-4 block">
|
||||
{t("common.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
data-testid="deleteAccountPassword"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="pr-10"
|
||||
containerClassName="mt-2"
|
||||
id="deleteAccountPassword"
|
||||
name="deleteAccountPassword"
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</DeleteDialog>
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("authOptions", () => {
|
||||
);
|
||||
}, 15000);
|
||||
|
||||
test("should throw error if user has no password stored", async () => {
|
||||
test("should throw generic invalid credentials error if user has no password stored", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUser.id,
|
||||
@@ -186,7 +186,7 @@ describe("authOptions", () => {
|
||||
const credentials = { email: mockUser.email, password: mockPassword };
|
||||
|
||||
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"User has no password stored"
|
||||
"Invalid credentials"
|
||||
);
|
||||
}, 15000);
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ export const authOptions: NextAuthOptions = {
|
||||
|
||||
if (!user.password) {
|
||||
logAuthAttempt("no_password_set", "credentials", "password_validation", user.id, user.email);
|
||||
throw new Error("User has no password stored");
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
if (user.isActive === false) {
|
||||
|
||||
@@ -75,6 +75,7 @@ describe("rateLimitConfigs", () => {
|
||||
const actionConfigs = Object.keys(rateLimitConfigs.actions);
|
||||
expect(actionConfigs).toEqual([
|
||||
"emailUpdate",
|
||||
"accountDeletion",
|
||||
"surveyFollowUp",
|
||||
"sendLinkSurveyEmail",
|
||||
"licenseRecheck",
|
||||
@@ -139,6 +140,7 @@ describe("rateLimitConfigs", () => {
|
||||
{ config: rateLimitConfigs.api.v3, identifier: "api-v3-key" },
|
||||
{ config: rateLimitConfigs.api.client, identifier: "client-api-key" },
|
||||
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
|
||||
{ config: rateLimitConfigs.actions.accountDeletion, identifier: "user-account-delete" },
|
||||
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
|
||||
{ config: rateLimitConfigs.storage.delete, identifier: "storage-delete" },
|
||||
];
|
||||
|
||||
@@ -18,6 +18,7 @@ export const rateLimitConfigs = {
|
||||
// Server actions - varies by action type
|
||||
actions: {
|
||||
emailUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:email" }, // 3 per hour
|
||||
accountDeletion: { interval: 3600, allowedPerInterval: 5, namespace: "action:account-delete" }, // 5 per hour
|
||||
surveyFollowUp: { interval: 3600, allowedPerInterval: 50, namespace: "action:followup" }, // 50 per hour
|
||||
sendLinkSurveyEmail: {
|
||||
interval: 3600,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ReactNode } from "react";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -52,7 +52,7 @@ export const ContactsPageLayout = async ({
|
||||
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
: ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
@@ -29,6 +29,7 @@ interface QuotasCardProps {
|
||||
isFormbricksCloud?: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
hasResponses: boolean;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
const AddQuotaButton = ({
|
||||
@@ -67,6 +68,7 @@ export const QuotasCard = ({
|
||||
isFormbricksCloud,
|
||||
quotas,
|
||||
hasResponses,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: QuotasCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -177,7 +179,7 @@ export const QuotasCard = ({
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
: enterpriseLicenseRequestFormUrl,
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
@@ -264,6 +264,14 @@ const provisionNewSsoUser = async ({
|
||||
"License and instance configuration checked"
|
||||
);
|
||||
|
||||
if (!isFirstUser && !isMultiOrgEnabled && SKIP_INVITE_FOR_SSO && !DEFAULT_TEAM_ID) {
|
||||
contextLogger.error(
|
||||
{ reason: "missing_default_team_id" },
|
||||
"SSO callback rejected: AUTH_SKIP_INVITE_FOR_SSO is enabled but AUTH_SSO_DEFAULT_TEAM_ID is not configured. Refusing to auto-provision new SSO user into an arbitrary organization."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) {
|
||||
if (!callbackUrl) {
|
||||
contextLogger.debug(
|
||||
|
||||
@@ -120,12 +120,21 @@ vi.mock("@formbricks/logger", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const constantsOverrides = vi.hoisted(() => ({
|
||||
SKIP_INVITE_FOR_SSO: false as boolean,
|
||||
DEFAULT_TEAM_ID: "team-123" as string | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
SKIP_INVITE_FOR_SSO: 0,
|
||||
DEFAULT_TEAM_ID: "team-123",
|
||||
get SKIP_INVITE_FOR_SSO() {
|
||||
return constantsOverrides.SKIP_INVITE_FOR_SSO;
|
||||
},
|
||||
get DEFAULT_TEAM_ID() {
|
||||
return constantsOverrides.DEFAULT_TEAM_ID;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -147,6 +156,8 @@ const transactionUser = {
|
||||
describe("handleSsoCallback", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
constantsOverrides.SKIP_INVITE_FOR_SSO = false;
|
||||
constantsOverrides.DEFAULT_TEAM_ID = "team-123";
|
||||
|
||||
vi.mocked(prisma.$transaction).mockImplementation(
|
||||
async (callback: (tx: any) => unknown) =>
|
||||
@@ -705,6 +716,80 @@ describe("handleSsoCallback", () => {
|
||||
expect(createUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects auto-provisioning when SKIP_INVITE_FOR_SSO is enabled but DEFAULT_TEAM_ID is missing", async () => {
|
||||
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
|
||||
constantsOverrides.SKIP_INVITE_FOR_SSO = true;
|
||||
constantsOverrides.DEFAULT_TEAM_ID = undefined;
|
||||
|
||||
const result = await handleSsoCallback({
|
||||
user: mockUser,
|
||||
account: mockAccount,
|
||||
callbackUrl: "http://localhost:3000",
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(getFirstOrganization).not.toHaveBeenCalled();
|
||||
expect(getOrganizationByTeamId).not.toHaveBeenCalled();
|
||||
expect(createUser).not.toHaveBeenCalled();
|
||||
expect(createMembership).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects auto-provisioning when DEFAULT_TEAM_ID is empty string", async () => {
|
||||
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
|
||||
constantsOverrides.SKIP_INVITE_FOR_SSO = true;
|
||||
constantsOverrides.DEFAULT_TEAM_ID = "";
|
||||
|
||||
const result = await handleSsoCallback({
|
||||
user: mockUser,
|
||||
account: mockAccount,
|
||||
callbackUrl: "http://localhost:3000",
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(getFirstOrganization).not.toHaveBeenCalled();
|
||||
expect(createUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("allows auto-provisioning when SKIP_INVITE_FOR_SSO and DEFAULT_TEAM_ID are both set", async () => {
|
||||
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
|
||||
constantsOverrides.SKIP_INVITE_FOR_SSO = true;
|
||||
constantsOverrides.DEFAULT_TEAM_ID = "team-123";
|
||||
vi.mocked(createUser).mockResolvedValue(
|
||||
mockCreatedUser("Auto Provisioned User") as typeof mockUser & {
|
||||
notificationSettings: { alert: Record<string, never>; unsubscribedOrganizationIds: string[] };
|
||||
}
|
||||
);
|
||||
transactionAccount.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await handleSsoCallback({
|
||||
user: mockUser,
|
||||
account: mockAccount,
|
||||
callbackUrl: "http://localhost:3000",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(getOrganizationByTeamId).toHaveBeenCalledWith("team-123");
|
||||
expect(getFirstOrganization).not.toHaveBeenCalled();
|
||||
expect(createMembership).toHaveBeenCalledWith(
|
||||
mockOrganization.id,
|
||||
mockUser.id,
|
||||
{ role: "member", accepted: true },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
test("assigns invited SSO users into the resolved organization and syncs notification settings", async () => {
|
||||
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { TeamsTable } from "@/modules/ee/teams/team-list/components/teams-table";
|
||||
import { getProjectsByOrganizationId } from "@/modules/ee/teams/team-list/lib/project";
|
||||
@@ -41,7 +41,7 @@ export const TeamsView = async ({
|
||||
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/docs/self-hosting/license#30-day-trial-license-request",
|
||||
: ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
+3
-1
@@ -36,6 +36,7 @@ interface EmailCustomizationSettingsProps {
|
||||
user: TUser | null;
|
||||
fbLogoUrl: string;
|
||||
isStorageConfigured: boolean;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const EmailCustomizationSettings = ({
|
||||
@@ -47,6 +48,7 @@ export const EmailCustomizationSettings = ({
|
||||
user,
|
||||
fbLogoUrl,
|
||||
isStorageConfigured,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: EmailCustomizationSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -184,7 +186,7 @@ export const EmailCustomizationSettings = ({
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
: enterpriseLicenseRequestFormUrl,
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { Project } from "@prisma/client";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { EditBranding } from "@/modules/ee/whitelabel/remove-branding/components/edit-branding";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
@@ -26,7 +26,7 @@ export const BrandingSettingsCard = async ({
|
||||
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
: ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
@@ -76,6 +76,7 @@ export const AddWebhookModal = ({
|
||||
url: testEndpointInput,
|
||||
secret: webhookSecret,
|
||||
});
|
||||
|
||||
if (!testEndpointActionResult?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(testEndpointActionResult);
|
||||
throw new Error(errorMessage);
|
||||
|
||||
@@ -17,6 +17,14 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const constantsMock = vi.hoisted(() => ({ dangerouslyAllow: false }));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS() {
|
||||
return constantsMock.dangerouslyAllow;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
generateStandardWebhookSignature: vi.fn(() => "signed-payload"),
|
||||
generateWebhookSecret: vi.fn(() => "generated-secret"),
|
||||
@@ -41,6 +49,7 @@ vi.mock("uuid", () => ({
|
||||
describe("testEndpoint", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
constantsMock.dangerouslyAllow = false;
|
||||
vi.mocked(generateStandardWebhookSignature).mockReturnValue("signed-payload");
|
||||
vi.mocked(validateWebhookUrl).mockResolvedValue(undefined);
|
||||
vi.mocked(getTranslate).mockResolvedValue((key: string) => key);
|
||||
@@ -76,6 +85,36 @@ describe("testEndpoint", () => {
|
||||
expect(getTranslate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([301, 302, 303, 307, 308])(
|
||||
"rejects %s redirects to prevent SSRF via redirect",
|
||||
async (statusCode) => {
|
||||
const fetchMock = vi.fn(async () => ({ status: statusCode }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(testEndpoint("https://example.com/webhook")).rejects.toThrow(
|
||||
"Webhook endpoint returned a redirect, which is not allowed"
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook",
|
||||
expect.objectContaining({ redirect: "manual" })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test("follows redirects when DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS is enabled", async () => {
|
||||
constantsMock.dangerouslyAllow = true;
|
||||
const fetchMock = vi.fn(async () => ({ status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(testEndpoint("https://example.com/webhook")).resolves.toBe(true);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook",
|
||||
expect.objectContaining({ redirect: "follow" })
|
||||
);
|
||||
});
|
||||
|
||||
test("allows non-blocked non-2xx statuses", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ResourceNotFoundError,
|
||||
UnknownError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
@@ -191,13 +192,29 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
|
||||
);
|
||||
}
|
||||
|
||||
// `redirect: "manual"` prevents SSRF via redirect — validateWebhookUrl only checks the
|
||||
// initial URL, so following 30x to a private/internal host (e.g. cloud metadata) would bypass it.
|
||||
// Gated on the same env var as validateWebhookUrl: self-hosters who opted into trusting internal
|
||||
// URLs also get the pre-patch redirect-follow behavior for consistency.
|
||||
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: requestHeaders,
|
||||
signal: controller.signal,
|
||||
redirect: redirectMode,
|
||||
});
|
||||
|
||||
const statusCode = response.status;
|
||||
|
||||
// With `redirect: "manual"`, Node's undici returns the actual 30x response (not the spec's
|
||||
// opaqueredirect filter). Treat any 30x as a redirect rejection so users get a clear error
|
||||
// instead of a misleading success. With `redirect: "follow"`, fetch returns the final 2xx/4xx/5xx
|
||||
// and this branch is unreachable.
|
||||
if (statusCode >= 300 && statusCode < 400) {
|
||||
throw new InvalidInputError("Webhook endpoint returned a redirect, which is not allowed");
|
||||
}
|
||||
|
||||
const errorMessage = await getWebhookTestErrorMessage(statusCode);
|
||||
|
||||
if (errorMessage) {
|
||||
|
||||
+3
@@ -39,6 +39,7 @@ interface OrganizationActionsProps {
|
||||
isStorageConfigured: boolean;
|
||||
isTeamAdmin: boolean;
|
||||
userAdminTeamIds?: string[];
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const OrganizationActions = ({
|
||||
@@ -56,6 +57,7 @@ export const OrganizationActions = ({
|
||||
isStorageConfigured,
|
||||
isTeamAdmin,
|
||||
userAdminTeamIds,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: OrganizationActionsProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
@@ -174,6 +176,7 @@ export const OrganizationActions = ({
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isTeamAdmin={isTeamAdmin}
|
||||
userAdminTeamIds={userAdminTeamIds}
|
||||
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
|
||||
/>
|
||||
|
||||
<Dialog open={isLeaveOrganizationModalOpen} onOpenChange={setIsLeaveOrganizationModalOpen}>
|
||||
|
||||
+3
-1
@@ -29,6 +29,7 @@ interface IndividualInviteTabProps {
|
||||
environmentId: string;
|
||||
membershipRole?: TOrganizationRole;
|
||||
showTeamAdminRestrictions: boolean;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const IndividualInviteTab = ({
|
||||
@@ -40,6 +41,7 @@ export const IndividualInviteTab = ({
|
||||
environmentId,
|
||||
membershipRole,
|
||||
showTeamAdminRestrictions,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: IndividualInviteTabProps) => {
|
||||
const ZFormSchema = z.object({
|
||||
name: ZUserName,
|
||||
@@ -191,7 +193,7 @@ export const IndividualInviteTab = ({
|
||||
href={
|
||||
isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license"
|
||||
: enterpriseLicenseRequestFormUrl
|
||||
}>
|
||||
{t("common.upgrade_plan")}
|
||||
</Link>
|
||||
|
||||
+3
@@ -30,6 +30,7 @@ interface InviteMemberModalProps {
|
||||
isOwnerOrManager: boolean;
|
||||
isTeamAdmin: boolean;
|
||||
userAdminTeamIds?: string[];
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const InviteMemberModal = ({
|
||||
@@ -45,6 +46,7 @@ export const InviteMemberModal = ({
|
||||
isOwnerOrManager,
|
||||
isTeamAdmin,
|
||||
userAdminTeamIds,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: InviteMemberModalProps) => {
|
||||
const [type, setType] = useState<"individual" | "bulk">("individual");
|
||||
|
||||
@@ -68,6 +70,7 @@ export const InviteMemberModal = ({
|
||||
teams={filteredTeams}
|
||||
membershipRole={membershipRole}
|
||||
showTeamAdminRestrictions={showTeamAdminRestrictions}
|
||||
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
|
||||
/>
|
||||
),
|
||||
bulk: (
|
||||
|
||||
@@ -2,7 +2,12 @@ import { Suspense } from "react";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import {
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
INVITE_DISABLED,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
} from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
|
||||
@@ -70,6 +75,7 @@ export const MembersView = async ({
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
environmentId={environmentId}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
teams={teams}
|
||||
|
||||
@@ -29,6 +29,7 @@ interface SettingsViewProps {
|
||||
isFormbricksCloud: boolean;
|
||||
isQuotasAllowed: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const SettingsView = ({
|
||||
@@ -46,6 +47,7 @@ export const SettingsView = ({
|
||||
projectPermission,
|
||||
isFormbricksCloud,
|
||||
quotas,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: SettingsViewProps) => {
|
||||
const isAppSurvey = localSurvey.type === "app";
|
||||
|
||||
@@ -70,7 +72,11 @@ export const SettingsView = ({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TargetingLockedCard isFormbricksCloud={isFormbricksCloud} environmentId={environment.id} />
|
||||
<TargetingLockedCard
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
environmentId={environment.id}
|
||||
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -89,6 +95,7 @@ export const SettingsView = ({
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
quotas={quotas}
|
||||
hasResponses={responseCount > 0}
|
||||
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
|
||||
/>
|
||||
|
||||
<ResponseOptionsCard
|
||||
|
||||
@@ -51,6 +51,7 @@ interface SurveyEditorProps {
|
||||
quotas: TSurveyQuota[];
|
||||
isExternalUrlsAllowed: boolean;
|
||||
publicDomain: string;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const SurveyEditor = ({
|
||||
@@ -80,6 +81,7 @@ export const SurveyEditor = ({
|
||||
quotas,
|
||||
isExternalUrlsAllowed,
|
||||
publicDomain,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: SurveyEditorProps) => {
|
||||
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("elements");
|
||||
const [activeElementId, setActiveElementId] = useState<string | null>(null);
|
||||
@@ -266,6 +268,7 @@ export const SurveyEditor = ({
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -280,6 +283,7 @@ export const SurveyEditor = ({
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberDetails}
|
||||
locale={locale}
|
||||
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -9,9 +9,14 @@ import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
interface TargetingLockedCardProps {
|
||||
isFormbricksCloud: boolean;
|
||||
environmentId: string;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const TargetingLockedCard = ({ isFormbricksCloud, environmentId }: TargetingLockedCardProps) => {
|
||||
export const TargetingLockedCard = ({
|
||||
isFormbricksCloud,
|
||||
environmentId,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: TargetingLockedCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -47,7 +52,7 @@ export const TargetingLockedCard = ({ isFormbricksCloud, environmentId }: Target
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
: enterpriseLicenseRequestFormUrl,
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ActionClass } from "@prisma/client";
|
||||
import { ActionClass, Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { createActionClass } from "./action-class";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -99,14 +99,19 @@ describe("createActionClass", () => {
|
||||
expect(result).toEqual(createdAction);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError for unique constraint violation", async () => {
|
||||
const prismaError = {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
meta: { target: ["name"] },
|
||||
};
|
||||
test("should throw UniqueConstraintError for unique constraint violation", async () => {
|
||||
const prismaError = Object.assign(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "test",
|
||||
}),
|
||||
{ meta: { target: ["name"] } }
|
||||
);
|
||||
vi.mocked(prisma.actionClass.create).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(createActionClass(mockEnvironmentId, mockCodeActionInput)).rejects.toThrow(DatabaseError);
|
||||
await expect(createActionClass(mockEnvironmentId, mockCodeActionInput)).rejects.toThrow(
|
||||
UniqueConstraintError
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError for other database errors", async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ActionClass, Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
|
||||
export const createActionClass = async (
|
||||
environmentId: string,
|
||||
@@ -32,7 +32,7 @@ export const createActionClass = async (
|
||||
error.code === PrismaErrorType.UniqueConstraintViolation
|
||||
) {
|
||||
const targetField = (error.meta?.target as string[] | undefined)?.[0];
|
||||
throw new DatabaseError(
|
||||
throw new UniqueConstraintError(
|
||||
`Action with ${targetField} ${targetField ? (actionClass as Record<string, unknown>)[targetField] : ""} already exists`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
MAIL_FROM,
|
||||
@@ -138,6 +139,7 @@ export const SurveyEditorPage = async (props: {
|
||||
quotas={quotas}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
publicDomain={publicDomain}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ interface FollowUpsViewProps {
|
||||
userEmail: string;
|
||||
teamMemberDetails: TFollowUpEmailToUser[];
|
||||
locale: TUserLocale;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const FollowUpsView = ({
|
||||
@@ -34,6 +35,7 @@ export const FollowUpsView = ({
|
||||
userEmail,
|
||||
teamMemberDetails,
|
||||
locale,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: FollowUpsViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [addFollowUpModalOpen, setAddFollowUpModalOpen] = useState(false);
|
||||
@@ -54,7 +56,7 @@ export const FollowUpsView = ({
|
||||
: t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${localSurvey.environmentId}/settings/billing`
|
||||
: "https://formbricks.com/docs/self-hosting/license",
|
||||
: enterpriseLicenseRequestFormUrl,
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("useSurveys", () => {
|
||||
meta: {
|
||||
limit: 20,
|
||||
nextCursor: null,
|
||||
totalCount: 2,
|
||||
totalCount: null,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
@@ -120,7 +120,7 @@ describe("useSurveys", () => {
|
||||
expect(result.current.hasNextPage).toBe(false);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/api/v3/surveys?workspaceId=env_1&limit=20&sortBy=relevance&cursor=cursor_1",
|
||||
"/api/v3/surveys?workspaceId=env_1&limit=20&sortBy=relevance&cursor=cursor_1&includeTotalCount=false",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
|
||||
@@ -32,6 +32,7 @@ export const useSurveys = ({
|
||||
workspaceId,
|
||||
limit,
|
||||
cursor: pageParam,
|
||||
includeTotalCount: pageParam === null,
|
||||
filters,
|
||||
signal,
|
||||
}),
|
||||
|
||||
@@ -63,4 +63,25 @@ describe("removeSurveyFromInfiniteData", () => {
|
||||
test("returns the original cache when the survey is not present", () => {
|
||||
expect(removeSurveyFromInfiniteData(baseData, "missing_survey")).toBe(baseData);
|
||||
});
|
||||
|
||||
test("preserves null totalCount for pages that skipped the count query", () => {
|
||||
const dataWithNullTotalCount: InfiniteData<TSurveyListPage> = {
|
||||
...baseData,
|
||||
pages: [
|
||||
baseData.pages[0],
|
||||
{
|
||||
...baseData.pages[1],
|
||||
meta: {
|
||||
...baseData.pages[1].meta,
|
||||
totalCount: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextData = removeSurveyFromInfiniteData(dataWithNullTotalCount, "survey_a");
|
||||
|
||||
expect(nextData?.pages[0]?.meta.totalCount).toBe(1);
|
||||
expect(nextData?.pages[1]?.meta.totalCount).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ export function removeSurveyFromInfiniteData(
|
||||
...page,
|
||||
meta: {
|
||||
...page.meta,
|
||||
totalCount: Math.max(0, page.meta.totalCount - 1),
|
||||
totalCount: page.meta.totalCount === null ? null : Math.max(0, page.meta.totalCount - 1),
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { buildWhereClause } from "@/modules/survey/lib/utils";
|
||||
import { decodeSurveyListPageCursor, encodeSurveyListPageCursor, getSurveyListPage } from "./survey-page";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/utils", () => ({
|
||||
buildWhereClause: vi.fn(() => ({ AND: [] })),
|
||||
}));
|
||||
@@ -15,6 +17,9 @@ vi.mock("@formbricks/database", () => ({
|
||||
survey: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
response: {
|
||||
groupBy: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -37,7 +42,6 @@ function makeSurveyRow(overrides: Record<string, unknown> = {}) {
|
||||
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
|
||||
creator: { name: "Alice" },
|
||||
singleUse: null,
|
||||
_count: { responses: 3 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -74,6 +78,8 @@ describe("survey-page cursor helpers", () => {
|
||||
describe("getSurveyListPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.survey.findMany).mockReset();
|
||||
vi.mocked(prisma.response.groupBy).mockReset();
|
||||
});
|
||||
|
||||
test("uses a stable updatedAt order with a next cursor", async () => {
|
||||
@@ -81,6 +87,9 @@ describe("getSurveyListPage", () => {
|
||||
makeSurveyRow({ id: "survey_2", updatedAt: new Date("2025-01-03T00:00:00.000Z") }),
|
||||
makeSurveyRow({ id: "survey_1", updatedAt: new Date("2025-01-02T00:00:00.000Z") }),
|
||||
] as never);
|
||||
vi.mocked(prisma.response.groupBy).mockResolvedValue([
|
||||
{ surveyId: "survey_2", _count: { _all: 3 } },
|
||||
] as never);
|
||||
|
||||
const page = await getSurveyListPage(environmentId, {
|
||||
limit: 1,
|
||||
@@ -121,6 +130,9 @@ describe("getSurveyListPage", () => {
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([
|
||||
makeSurveyRow({ id: "survey_c", name: "Charlie" }),
|
||||
] as never);
|
||||
vi.mocked(prisma.response.groupBy).mockResolvedValue([
|
||||
{ surveyId: "survey_c", _count: { _all: 3 } },
|
||||
] as never);
|
||||
|
||||
await getSurveyListPage(environmentId, {
|
||||
limit: 2,
|
||||
@@ -161,6 +173,10 @@ describe("getSurveyListPage", () => {
|
||||
updatedAt: new Date("2025-01-01T00:00:00.000Z"),
|
||||
}),
|
||||
] as never);
|
||||
vi.mocked(prisma.response.groupBy).mockResolvedValue([
|
||||
{ surveyId: "survey_in_progress", _count: { _all: 3 } },
|
||||
{ surveyId: "survey_other_1", _count: { _all: 2 } },
|
||||
] as never);
|
||||
|
||||
const page = await getSurveyListPage(environmentId, {
|
||||
limit: 2,
|
||||
@@ -214,6 +230,9 @@ describe("getSurveyListPage", () => {
|
||||
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
|
||||
}),
|
||||
] as never);
|
||||
vi.mocked(prisma.response.groupBy).mockResolvedValue([
|
||||
{ surveyId: "survey_in_progress", _count: { _all: 3 } },
|
||||
] as never);
|
||||
|
||||
const page = await getSurveyListPage(environmentId, {
|
||||
limit: 1,
|
||||
@@ -250,6 +269,9 @@ describe("getSurveyListPage", () => {
|
||||
updatedAt: new Date("2025-01-01T00:00:00.000Z"),
|
||||
}),
|
||||
] as never);
|
||||
vi.mocked(prisma.response.groupBy).mockResolvedValue([
|
||||
{ surveyId: "survey_other_2", _count: { _all: 3 } },
|
||||
] as never);
|
||||
|
||||
const page = await getSurveyListPage(environmentId, {
|
||||
limit: 2,
|
||||
|
||||
@@ -7,7 +7,12 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import type { TSurveyFilterCriteria } from "@formbricks/types/surveys/types";
|
||||
import { buildWhereClause } from "@/modules/survey/lib/utils";
|
||||
import type { TSurvey } from "../types/surveys";
|
||||
import { type TSurveyRow, mapSurveyRowsToSurveys, surveySelect } from "./survey-record";
|
||||
import {
|
||||
type TSurveyRow,
|
||||
getResponseCountsBySurveyIds,
|
||||
mapSurveyRowsToSurveys,
|
||||
surveySelect,
|
||||
} from "./survey-record";
|
||||
|
||||
const SURVEY_LIST_CURSOR_VERSION = 1 as const;
|
||||
const IN_PROGRESS_BUCKET = "inProgress" as const;
|
||||
@@ -229,9 +234,14 @@ function getPageRows<T>(rows: T[], limit: number): { pageRows: T[]; hasMore: boo
|
||||
};
|
||||
}
|
||||
|
||||
function buildSurveyListPage(rows: TSurveyRow[], cursor: TSurveyListPageCursor | null): TSurveyListPage {
|
||||
async function buildSurveyListPage(
|
||||
rows: TSurveyRow[],
|
||||
cursor: TSurveyListPageCursor | null
|
||||
): Promise<TSurveyListPage> {
|
||||
const responseCountsBySurveyId = await getResponseCountsBySurveyIds(rows.map((survey) => survey.id));
|
||||
|
||||
return {
|
||||
surveys: mapSurveyRowsToSurveys(rows),
|
||||
surveys: mapSurveyRowsToSurveys(rows, responseCountsBySurveyId),
|
||||
nextCursor: cursor ? encodeSurveyListPageCursor(cursor) : null,
|
||||
};
|
||||
}
|
||||
@@ -251,7 +261,7 @@ async function getStandardSurveyListPage(
|
||||
const { pageRows, hasMore } = getPageRows(surveyRows, options.limit);
|
||||
const lastRow = getLastSurveyRow(pageRows);
|
||||
|
||||
return buildSurveyListPage(
|
||||
return await buildSurveyListPage(
|
||||
pageRows,
|
||||
hasMore && lastRow ? getStandardNextCursor(lastRow, options.sortBy) : null
|
||||
);
|
||||
@@ -308,10 +318,13 @@ function shouldReadInProgressBucket(cursor: TRelevanceSurveyListCursor | null):
|
||||
return !cursor || cursor.bucket === IN_PROGRESS_BUCKET;
|
||||
}
|
||||
|
||||
function buildRelevancePage(rows: TSurveyRow[], bucket: TRelevanceBucket | null): TSurveyListPage {
|
||||
async function buildRelevancePage(
|
||||
rows: TSurveyRow[],
|
||||
bucket: TRelevanceBucket | null
|
||||
): Promise<TSurveyListPage> {
|
||||
const lastRow = getLastSurveyRow(rows);
|
||||
|
||||
return buildSurveyListPage(rows, bucket && lastRow ? getRelevanceNextCursor(lastRow, bucket) : null);
|
||||
return await buildSurveyListPage(rows, bucket && lastRow ? getRelevanceNextCursor(lastRow, bucket) : null);
|
||||
}
|
||||
|
||||
async function getInProgressRelevanceStep(
|
||||
@@ -332,7 +345,7 @@ async function getInProgressRelevanceStep(
|
||||
return {
|
||||
pageRows,
|
||||
remaining: limit - pageRows.length,
|
||||
response: hasMore ? buildRelevancePage(pageRows, IN_PROGRESS_BUCKET) : null,
|
||||
response: hasMore ? await buildRelevancePage(pageRows, IN_PROGRESS_BUCKET) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -347,7 +360,7 @@ async function buildInProgressOnlyRelevancePage(
|
||||
shouldReadInProgressBucket(cursor) &&
|
||||
(await hasMoreRelevanceRowsInOtherBucket(environmentId, filterCriteria));
|
||||
|
||||
return buildRelevancePage(rows, hasOtherRows ? IN_PROGRESS_BUCKET : null);
|
||||
return await buildRelevancePage(rows, hasOtherRows ? IN_PROGRESS_BUCKET : null);
|
||||
}
|
||||
|
||||
async function getRelevanceSurveyListPage(
|
||||
@@ -393,7 +406,7 @@ async function getRelevanceSurveyListPage(
|
||||
const { pageRows: otherPageRows, hasMore: hasMoreOther } = getPageRows(otherRows, remaining);
|
||||
pageRows.push(...otherPageRows);
|
||||
|
||||
return buildRelevancePage(pageRows, hasMoreOther ? OTHER_BUCKET : null);
|
||||
return await buildRelevancePage(pageRows, hasMoreOther ? OTHER_BUCKET : null);
|
||||
}
|
||||
|
||||
export async function getSurveyListPage(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { TSurvey } from "@/modules/survey/list/types/surveys";
|
||||
|
||||
export const surveySelect = {
|
||||
@@ -15,22 +16,40 @@ export const surveySelect = {
|
||||
status: true,
|
||||
singleUse: true,
|
||||
environmentId: true,
|
||||
_count: {
|
||||
select: { responses: true },
|
||||
},
|
||||
} satisfies Prisma.SurveySelect;
|
||||
|
||||
export type TSurveyRow = Prisma.SurveyGetPayload<{ select: typeof surveySelect }>;
|
||||
|
||||
export function mapSurveyRowToSurvey(row: TSurveyRow): TSurvey {
|
||||
const { _count, ...survey } = row;
|
||||
export async function getResponseCountsBySurveyIds(surveyIds: string[]): Promise<Map<string, number>> {
|
||||
if (surveyIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const responseCounts = await prisma.response.groupBy({
|
||||
by: ["surveyId"],
|
||||
where: {
|
||||
surveyId: {
|
||||
in: surveyIds,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
});
|
||||
|
||||
return new Map(responseCounts.map(({ surveyId, _count }) => [surveyId, _count._all]));
|
||||
}
|
||||
|
||||
export function mapSurveyRowToSurvey(row: TSurveyRow, responseCount = 0): TSurvey {
|
||||
return {
|
||||
...survey,
|
||||
responseCount: _count.responses,
|
||||
...row,
|
||||
responseCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapSurveyRowsToSurveys(rows: TSurveyRow[]): TSurvey[] {
|
||||
return rows.map(mapSurveyRowToSurvey);
|
||||
export function mapSurveyRowsToSurveys(
|
||||
rows: TSurveyRow[],
|
||||
responseCountsBySurveyId: Map<string, number> = new Map()
|
||||
): TSurvey[] {
|
||||
return rows.map((row) => mapSurveyRowToSurvey(row, responseCountsBySurveyId.get(row.id) ?? 0));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { checkForInvalidMediaInBlocks } from "@/lib/survey/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils";
|
||||
import { doesEnvironmentExist } from "@/modules/survey/list/lib/environment";
|
||||
import { getProjectWithLanguagesByEnvironmentId } from "@/modules/survey/list/lib/project";
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
} from "./survey";
|
||||
import { surveySelect } from "./survey-record";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react")>();
|
||||
return {
|
||||
@@ -65,6 +68,10 @@ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsQuotasEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/quotas/lib/quotas", () => ({
|
||||
getQuotas: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lingodotdev/server", () => ({
|
||||
getTranslate: async () => (key: string, params?: Record<string, unknown>) => {
|
||||
if (key === "common.duplicate_copy") return "(copy)";
|
||||
@@ -86,6 +93,9 @@ vi.mock("@formbricks/database", () => ({
|
||||
delete: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
response: {
|
||||
groupBy: vi.fn(),
|
||||
},
|
||||
language: {
|
||||
// Added for language connectOrCreate in copySurvey
|
||||
findUnique: vi.fn(),
|
||||
@@ -127,7 +137,9 @@ const resetMocks = () => {
|
||||
vi.mocked(prisma.survey.create).mockReset();
|
||||
vi.mocked(prisma.segment.delete).mockReset();
|
||||
vi.mocked(prisma.segment.findFirst).mockReset();
|
||||
vi.mocked(prisma.response.groupBy).mockReset();
|
||||
vi.mocked(prisma.actionClass.findMany).mockReset();
|
||||
vi.mocked(getQuotas).mockReset();
|
||||
vi.mocked(logger.error).mockClear();
|
||||
};
|
||||
|
||||
@@ -153,7 +165,6 @@ const mockSurveyPrisma = {
|
||||
status: "draft" as any,
|
||||
singleUse: null,
|
||||
environmentId,
|
||||
_count: { responses: 10 },
|
||||
};
|
||||
|
||||
describe("getSurveyCount", () => {
|
||||
@@ -191,8 +202,9 @@ describe("getSurvey", () => {
|
||||
});
|
||||
|
||||
test("should return a survey if found", async () => {
|
||||
const prismaSurvey = { ...mockSurveyPrisma, _count: { responses: 5 } };
|
||||
const prismaSurvey = { ...mockSurveyPrisma };
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValue(prismaSurvey as any);
|
||||
vi.mocked(prisma.response.groupBy).mockResolvedValue([{ surveyId, _count: { _all: 5 } }] as any);
|
||||
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
@@ -228,6 +240,15 @@ describe("getSurvey", () => {
|
||||
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting survey");
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when response count lookup fails", async () => {
|
||||
const prismaError = makePrismaKnownError();
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurveyPrisma } as any);
|
||||
vi.mocked(prisma.response.groupBy).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getSurvey(surveyId)).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting survey");
|
||||
});
|
||||
|
||||
test("should rethrow unknown error", async () => {
|
||||
const unknownError = new Error("Unknown error");
|
||||
vi.mocked(prisma.survey.findUnique).mockRejectedValue(unknownError);
|
||||
@@ -241,24 +262,42 @@ describe("getSurveys", () => {
|
||||
});
|
||||
|
||||
const mockPrismaSurveys = [
|
||||
{ ...mockSurveyPrisma, id: "s1", name: "Survey 1", _count: { responses: 1 } },
|
||||
{ ...mockSurveyPrisma, id: "s2", name: "Survey 2", _count: { responses: 2 } },
|
||||
{ ...mockSurveyPrisma, id: "s1", name: "Survey 1" },
|
||||
{ ...mockSurveyPrisma, id: "s2", name: "Survey 2" },
|
||||
];
|
||||
const expectedSurveys: TSurvey[] = [
|
||||
{
|
||||
id: "s1",
|
||||
createdAt: mockPrismaSurveys[0].createdAt,
|
||||
updatedAt: mockPrismaSurveys[0].updatedAt,
|
||||
name: "Survey 1",
|
||||
type: mockPrismaSurveys[0].type,
|
||||
creator: mockPrismaSurveys[0].creator,
|
||||
status: mockPrismaSurveys[0].status,
|
||||
singleUse: mockPrismaSurveys[0].singleUse,
|
||||
environmentId: mockPrismaSurveys[0].environmentId,
|
||||
responseCount: 1,
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
createdAt: mockPrismaSurveys[1].createdAt,
|
||||
updatedAt: mockPrismaSurveys[1].updatedAt,
|
||||
name: "Survey 2",
|
||||
type: mockPrismaSurveys[1].type,
|
||||
creator: mockPrismaSurveys[1].creator,
|
||||
status: mockPrismaSurveys[1].status,
|
||||
singleUse: mockPrismaSurveys[1].singleUse,
|
||||
environmentId: mockPrismaSurveys[1].environmentId,
|
||||
responseCount: 2,
|
||||
},
|
||||
];
|
||||
const expectedSurveys: TSurvey[] = mockPrismaSurveys.map((s) => ({
|
||||
id: s.id,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
name: s.name,
|
||||
type: s.type,
|
||||
creator: s.creator,
|
||||
status: s.status,
|
||||
singleUse: s.singleUse,
|
||||
environmentId: s.environmentId,
|
||||
responseCount: s._count.responses,
|
||||
}));
|
||||
|
||||
test("should return surveys with default parameters", async () => {
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys as any);
|
||||
vi.mocked(prisma.response.groupBy).mockResolvedValue([
|
||||
{ surveyId: "s1", _count: { _all: 1 } },
|
||||
{ surveyId: "s2", _count: { _all: 2 } },
|
||||
] as any);
|
||||
const surveys = await getSurveys(environmentId);
|
||||
|
||||
expect(surveys).toEqual(expectedSurveys);
|
||||
@@ -274,6 +313,7 @@ describe("getSurveys", () => {
|
||||
|
||||
test("should return surveys with limit and offset", async () => {
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([mockPrismaSurveys[0]] as any);
|
||||
vi.mocked(prisma.response.groupBy).mockResolvedValue([{ surveyId: "s1", _count: { _all: 1 } }] as any);
|
||||
const surveys = await getSurveys(environmentId, 1, 1);
|
||||
|
||||
expect(surveys).toEqual([expectedSurveys[0]]);
|
||||
@@ -291,6 +331,10 @@ describe("getSurveys", () => {
|
||||
vi.mocked(buildWhereClause).mockReturnValue({ AND: [{ name: { contains: "Test" } }] }); // Mock correct return type
|
||||
vi.mocked(buildOrderByClause).mockReturnValue([{ createdAt: "desc" }]); // Mock specific return
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys as any);
|
||||
vi.mocked(prisma.response.groupBy).mockResolvedValue([
|
||||
{ surveyId: "s1", _count: { _all: 1 } },
|
||||
{ surveyId: "s2", _count: { _all: 2 } },
|
||||
] as any);
|
||||
|
||||
const surveys = await getSurveys(environmentId, undefined, undefined, filterCriteria);
|
||||
|
||||
@@ -328,13 +372,11 @@ describe("getSurveysSortedByRelevance", () => {
|
||||
...mockSurveyPrisma,
|
||||
id: "s_inprog",
|
||||
status: "inProgress" as any,
|
||||
_count: { responses: 3 },
|
||||
};
|
||||
const mockOtherPrisma = {
|
||||
...mockSurveyPrisma,
|
||||
id: "s_other",
|
||||
status: "completed" as any,
|
||||
_count: { responses: 5 },
|
||||
};
|
||||
|
||||
const expectedInProgressSurvey: TSurvey = {
|
||||
@@ -367,6 +409,10 @@ describe("getSurveysSortedByRelevance", () => {
|
||||
vi.mocked(prisma.survey.findMany)
|
||||
.mockResolvedValueOnce([mockInProgressPrisma] as any) // In-progress surveys
|
||||
.mockResolvedValueOnce([mockOtherPrisma] as any); // Additional surveys
|
||||
vi.mocked(prisma.response.groupBy).mockResolvedValue([
|
||||
{ surveyId: "s_inprog", _count: { _all: 3 } },
|
||||
{ surveyId: "s_other", _count: { _all: 5 } },
|
||||
] as any);
|
||||
|
||||
const surveys = await getSurveysSortedByRelevance(environmentId, 2, 0);
|
||||
|
||||
@@ -394,6 +440,9 @@ describe("getSurveysSortedByRelevance", () => {
|
||||
test("should only fetch inProgress surveys if limit is met", async () => {
|
||||
vi.mocked(prisma.survey.count).mockResolvedValue(1);
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValueOnce([mockInProgressPrisma] as any);
|
||||
vi.mocked(prisma.response.groupBy).mockResolvedValue([
|
||||
{ surveyId: "s_inprog", _count: { _all: 3 } },
|
||||
] as any);
|
||||
|
||||
const surveys = await getSurveysSortedByRelevance(environmentId, 1, 0);
|
||||
expect(surveys).toEqual([expectedInProgressSurvey]);
|
||||
@@ -507,7 +556,7 @@ describe("copySurveyToOtherEnvironment", () => {
|
||||
vi.mocked(prisma.survey.create).mockResolvedValue(mockNewSurveyResult as any);
|
||||
vi.mocked(prisma.segment.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.actionClass.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.surveyQuota.findMany).mockResolvedValue([]);
|
||||
vi.mocked(getQuotas).mockResolvedValue([]);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({
|
||||
billing: {},
|
||||
id: "org_123",
|
||||
@@ -645,6 +694,12 @@ describe("copySurveyToOtherEnvironment", () => {
|
||||
vi.mocked(prisma.survey.create).mockResolvedValue(mockNewSurveyResult as any);
|
||||
vi.mocked(prisma.segment.findFirst).mockResolvedValue(null); // No existing public segment with same title in target
|
||||
vi.mocked(prisma.actionClass.findMany).mockResolvedValue([]);
|
||||
vi.mocked(getQuotas).mockResolvedValue([]);
|
||||
vi.mocked(getIsQuotasEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({
|
||||
billing: {},
|
||||
id: "org_123",
|
||||
} as any);
|
||||
|
||||
// Case 2: Different environment, segment with same title does not exist in target
|
||||
await copySurveyToOtherEnvironment(environmentId, surveyId, targetEnvironmentId, userId);
|
||||
@@ -679,6 +734,12 @@ describe("copySurveyToOtherEnvironment", () => {
|
||||
vi.mocked(prisma.survey.create).mockResolvedValue(mockNewSurveyResult as any);
|
||||
vi.mocked(prisma.segment.findFirst).mockResolvedValue({ id: "existing_target_seg" } as any); // Segment with same title EXISTS
|
||||
vi.mocked(prisma.actionClass.findMany).mockResolvedValue([]);
|
||||
vi.mocked(getQuotas).mockResolvedValue([]);
|
||||
vi.mocked(getIsQuotasEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({
|
||||
billing: {},
|
||||
id: "org_123",
|
||||
} as any);
|
||||
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(1234567890);
|
||||
|
||||
await copySurveyToOtherEnvironment(environmentId, surveyId, targetEnvironmentId, userId);
|
||||
|
||||
@@ -18,7 +18,13 @@ import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils
|
||||
import { doesEnvironmentExist } from "@/modules/survey/list/lib/environment";
|
||||
import { getProjectWithLanguagesByEnvironmentId } from "@/modules/survey/list/lib/project";
|
||||
import { TProjectWithLanguages, TSurvey } from "@/modules/survey/list/types/surveys";
|
||||
import { mapSurveyRowToSurvey, mapSurveyRowsToSurveys, surveySelect } from "./survey-record";
|
||||
import {
|
||||
type TSurveyRow,
|
||||
getResponseCountsBySurveyIds,
|
||||
mapSurveyRowToSurvey,
|
||||
mapSurveyRowsToSurveys,
|
||||
surveySelect,
|
||||
} from "./survey-record";
|
||||
|
||||
export const getSurveys = reactCache(
|
||||
async (
|
||||
@@ -45,7 +51,11 @@ export const getSurveys = reactCache(
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
return mapSurveyRowsToSurveys(surveysPrisma);
|
||||
const responseCountsBySurveyId = await getResponseCountsBySurveyIds(
|
||||
surveysPrisma.map((survey) => survey.id)
|
||||
);
|
||||
|
||||
return mapSurveyRowsToSurveys(surveysPrisma, responseCountsBySurveyId);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting surveys");
|
||||
@@ -64,7 +74,7 @@ export const getSurveysSortedByRelevance = reactCache(
|
||||
filterCriteria?: TSurveyFilterCriteria
|
||||
): Promise<TSurvey[]> => {
|
||||
try {
|
||||
let surveys: TSurvey[] = [];
|
||||
let surveyRows: TSurveyRow[] = [];
|
||||
|
||||
const inProgressSurveyCount = await prisma.survey.count({
|
||||
where: {
|
||||
@@ -90,7 +100,7 @@ export const getSurveysSortedByRelevance = reactCache(
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
surveys = mapSurveyRowsToSurveys(inProgressSurveys);
|
||||
surveyRows = inProgressSurveys;
|
||||
|
||||
// Determine if additional surveys are needed
|
||||
if (offset !== undefined && limit && inProgressSurveys.length < limit) {
|
||||
@@ -108,10 +118,14 @@ export const getSurveysSortedByRelevance = reactCache(
|
||||
skip: newOffset,
|
||||
});
|
||||
|
||||
surveys = [...surveys, ...mapSurveyRowsToSurveys(additionalSurveys)];
|
||||
surveyRows = [...surveyRows, ...additionalSurveys];
|
||||
}
|
||||
|
||||
return surveys;
|
||||
const responseCountsBySurveyId = await getResponseCountsBySurveyIds(
|
||||
surveyRows.map((survey) => survey.id)
|
||||
);
|
||||
|
||||
return mapSurveyRowsToSurveys(surveyRows, responseCountsBySurveyId);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting surveys sorted by relevance");
|
||||
@@ -123,14 +137,21 @@ export const getSurveysSortedByRelevance = reactCache(
|
||||
);
|
||||
|
||||
export const getSurvey = reactCache(async (surveyId: string): Promise<TSurvey | null> => {
|
||||
let surveyPrisma;
|
||||
try {
|
||||
surveyPrisma = await prisma.survey.findUnique({
|
||||
const surveyPrisma = await prisma.survey.findUnique({
|
||||
where: {
|
||||
id: surveyId,
|
||||
},
|
||||
select: surveySelect,
|
||||
});
|
||||
|
||||
if (!surveyPrisma) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const responseCountsBySurveyId = await getResponseCountsBySurveyIds([surveyPrisma.id]);
|
||||
|
||||
return mapSurveyRowToSurvey(surveyPrisma, responseCountsBySurveyId.get(surveyPrisma.id) ?? 0);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting survey");
|
||||
@@ -138,12 +159,6 @@ export const getSurvey = reactCache(async (surveyId: string): Promise<TSurvey |
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!surveyPrisma) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapSurveyRowToSurvey(surveyPrisma);
|
||||
});
|
||||
|
||||
const getExistingSurvey = async (surveyId: string) => {
|
||||
|
||||
@@ -19,4 +19,23 @@ describe("buildSurveyListSearchParams", () => {
|
||||
"workspaceId=env_1&limit=20&sortBy=relevance&cursor=cursor_1&filter%5Bname%5D%5Bcontains%5D=Product+feedback&filter%5Bstatus%5D%5Bin%5D=draft&filter%5Bstatus%5D%5Bin%5D=paused&filter%5Btype%5D%5Bin%5D=app&filter%5Btype%5D%5Bin%5D=link"
|
||||
);
|
||||
});
|
||||
|
||||
test("emits includeTotalCount=false when requested", () => {
|
||||
const searchParams = buildSurveyListSearchParams({
|
||||
workspaceId: "env_1",
|
||||
limit: 20,
|
||||
cursor: "cursor_1",
|
||||
includeTotalCount: false,
|
||||
filters: {
|
||||
name: "",
|
||||
status: [],
|
||||
type: [],
|
||||
sortBy: "relevance",
|
||||
},
|
||||
});
|
||||
|
||||
expect(searchParams.toString()).toBe(
|
||||
"workspaceId=env_1&limit=20&sortBy=relevance&cursor=cursor_1&includeTotalCount=false"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ export type TSurveyListPage = {
|
||||
meta: {
|
||||
limit: number;
|
||||
nextCursor: string | null;
|
||||
totalCount: number;
|
||||
totalCount: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -39,11 +39,13 @@ export function buildSurveyListSearchParams({
|
||||
workspaceId,
|
||||
limit,
|
||||
cursor,
|
||||
includeTotalCount,
|
||||
filters,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
limit: number;
|
||||
cursor?: string | null;
|
||||
includeTotalCount?: boolean;
|
||||
filters: TSurveyOverviewFilters;
|
||||
}): URLSearchParams {
|
||||
const normalizedFilters = normalizeSurveyFilters(filters);
|
||||
@@ -57,6 +59,10 @@ export function buildSurveyListSearchParams({
|
||||
searchParams.set("cursor", cursor);
|
||||
}
|
||||
|
||||
if (includeTotalCount === false) {
|
||||
searchParams.set("includeTotalCount", "false");
|
||||
}
|
||||
|
||||
if (normalizedFilters.name) {
|
||||
searchParams.set("filter[name][contains]", normalizedFilters.name);
|
||||
}
|
||||
@@ -76,17 +82,25 @@ export async function listSurveys({
|
||||
workspaceId,
|
||||
limit,
|
||||
cursor,
|
||||
includeTotalCount,
|
||||
filters,
|
||||
signal,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
limit: number;
|
||||
cursor?: string | null;
|
||||
includeTotalCount?: boolean;
|
||||
filters: TSurveyOverviewFilters;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<TSurveyListPage> {
|
||||
const response = await fetch(
|
||||
`/api/v3/surveys?${buildSurveyListSearchParams({ workspaceId, limit, cursor, filters }).toString()}`,
|
||||
`/api/v3/surveys?${buildSurveyListSearchParams({
|
||||
workspaceId,
|
||||
limit,
|
||||
cursor,
|
||||
includeTotalCount,
|
||||
filters,
|
||||
}).toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
|
||||
@@ -52,14 +52,12 @@ export const DataTableToolbar = <T,>({
|
||||
<div>{leftContent}</div>
|
||||
)}
|
||||
<div className="flex space-x-2">
|
||||
{type === "contact" && onRefresh ? (
|
||||
<TooltipRenderer
|
||||
tooltipContent={t("environments.contacts.contacts_table_refresh")}
|
||||
shouldRender={true}>
|
||||
{onRefresh ? (
|
||||
<TooltipRenderer tooltipContent={t("common.refresh")} shouldRender={true}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await onRefresh();
|
||||
toast.success(t("environments.contacts.contacts_table_refresh_success"));
|
||||
toast.success(t("common.data_refreshed_successfully"));
|
||||
}}
|
||||
className="cursor-pointer rounded-md border bg-white hover:border-slate-400">
|
||||
<RefreshCcwIcon strokeWidth={1.5} className={cn("m-1 h-6 w-6 p-0.5")} />
|
||||
|
||||
@@ -7,6 +7,7 @@ interface IconAction {
|
||||
tooltip: string;
|
||||
onClick?: () => void;
|
||||
isVisible?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface IconBarProps {
|
||||
@@ -32,6 +33,7 @@ export const IconBar = ({ actions }: IconBarProps) => {
|
||||
className="border-none hover:bg-slate-50"
|
||||
size="icon"
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
aria-label={action.tooltip}>
|
||||
<action.icon />
|
||||
</Button>
|
||||
|
||||
+19
-19
@@ -48,14 +48,14 @@
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "0.213.0",
|
||||
"@opentelemetry/exporter-prometheus": "0.213.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.213.0",
|
||||
"@opentelemetry/resources": "2.6.0",
|
||||
"@opentelemetry/sdk-metrics": "2.6.0",
|
||||
"@opentelemetry/resources": "2.6.1",
|
||||
"@opentelemetry/sdk-metrics": "2.6.1",
|
||||
"@opentelemetry/sdk-node": "0.213.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.6.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||
"@opentelemetry/semantic-conventions": "1.40.0",
|
||||
"@paralleldrive/cuid2": "2.3.1",
|
||||
"@prisma/client": "6.19.2",
|
||||
"@prisma/instrumentation": "6.19.2",
|
||||
"@prisma/client": "6.19.3",
|
||||
"@prisma/instrumentation": "6.19.3",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
@@ -71,10 +71,10 @@
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@sentry/nextjs": "10.43.0",
|
||||
"@t3-oss/env-nextjs": "0.13.10",
|
||||
"@t3-oss/env-nextjs": "0.13.11",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/typography": "0.5.19",
|
||||
"@tanstack/react-query": "5.99.0",
|
||||
"@tanstack/react-query": "5.99.2",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"bcryptjs": "3.0.3",
|
||||
"boring-avatars": "2.0.4",
|
||||
@@ -86,7 +86,7 @@
|
||||
"framer-motion": "12.35.2",
|
||||
"googleapis": "171.4.0",
|
||||
"heic-convert": "2.1.0",
|
||||
"i18next": "25.8.18",
|
||||
"i18next": "25.8.20",
|
||||
"i18next-icu": "2.4.3",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"isomorphic-dompurify": "3.9.0",
|
||||
@@ -97,34 +97,34 @@
|
||||
"markdown-it": "14.1.1",
|
||||
"next": "16.2.4",
|
||||
"next-auth": "4.24.13",
|
||||
"next-safe-action": "8.1.8",
|
||||
"nodemailer": "8.0.5",
|
||||
"next-safe-action": "8.1.10",
|
||||
"nodemailer": "8.0.7",
|
||||
"otplib": "12.0.1",
|
||||
"papaparse": "5.5.3",
|
||||
"posthog-js": "1.369.5",
|
||||
"posthog-node": "5.28.9",
|
||||
"posthog-node": "5.28.11",
|
||||
"prismjs": "1.30.0",
|
||||
"qr-code-styling": "1.9.2",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.2.4",
|
||||
"react-calendar": "6.0.0",
|
||||
"react": "19.2.5",
|
||||
"react-calendar": "6.0.1",
|
||||
"react-colorful": "5.6.1",
|
||||
"react-confetti": "6.4.0",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-dom": "19.2.5",
|
||||
"react-hook-form": "7.71.2",
|
||||
"react-hot-toast": "2.6.0",
|
||||
"react-i18next": "16.5.8",
|
||||
"react-turnstile": "1.1.5",
|
||||
"sanitize-html": "2.17.1",
|
||||
"sanitize-html": "2.17.3",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "0.34.5",
|
||||
"stripe": "20.4.1",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"tailwindcss": "3.4.19",
|
||||
"ua-parser-js": "2.0.9",
|
||||
"undici": "7.24.0",
|
||||
"uuid": "13.0.0",
|
||||
"undici": "7.24.8",
|
||||
"uuid": "14.0.0",
|
||||
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
|
||||
"zod": "4.3.6",
|
||||
"zod-openapi": "5.4.6"
|
||||
@@ -145,11 +145,11 @@
|
||||
"autoprefixer": "10.4.27",
|
||||
"cross-env": "10.1.0",
|
||||
"dotenv": "17.3.1",
|
||||
"postcss": "8.5.8",
|
||||
"postcss": "8.5.12",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"vite": "7.3.2",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.5",
|
||||
"vitest-mock-extended": "3.1.0"
|
||||
"vitest-mock-extended": "3.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8143,7 +8143,7 @@
|
||||
"url": "https://{baseurl}",
|
||||
"variables": {
|
||||
"baseurl": {
|
||||
"default": "localhost:3000"
|
||||
"default": "app.formbricks.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8174,7 +8174,7 @@
|
||||
"name": "Client API - User"
|
||||
},
|
||||
{
|
||||
"description": "The Management API provides access to all data and settings that are visible in the Formbricks App. This API requires a personal API Key for authentication, which can be generated in the Settings section of the Formbricks App. With the Management API, you can manage your Formbricks account programmatically, accessing and modifying data and settings as needed.\n\n> **For Auth:** we use the `x-api-key` header \n \n\nAPI requests made to the Management API are authorized using a personal API key. This key grants the same rights and access as if you were logged in at formbricks.com. It's essential to keep your API key secure and not share it with others.\n\nTo generate, store, or delete an API key, follow the instructions provided on the following page [API Key](https://formbricks.com/docs/api/management/api-key-setup).",
|
||||
"description": "The Management API provides access to all data and settings that are visible in the Formbricks App. This API requires a personal API Key for authentication, which can be generated in the Settings section of the Formbricks App. With the Management API, you can manage your Formbricks account programmatically, accessing and modifying data and settings as needed.\n\n> **For Auth:** we use the `x-api-key` header \n \n\nAPI requests made to the Management API are authorized using a personal API key. This key grants the same rights and access as if you were logged in at app.formbricks.com. It's essential to keep your API key secure and not share it with others.\n\nTo generate, store, or delete an API key, follow the instructions provided on the following page [API Key](https://formbricks.com/docs/api/management/api-key-setup).",
|
||||
"name": "Management API"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -73,6 +73,13 @@ paths:
|
||||
type: string
|
||||
description: |
|
||||
Opaque cursor returned as `meta.nextCursor` from the previous page. Omit on the first request.
|
||||
- in: query
|
||||
name: includeTotalCount
|
||||
schema:
|
||||
type: boolean
|
||||
default: true
|
||||
description: |
|
||||
Whether to calculate `meta.totalCount` for this request. Set to `false` on cursor-pagination follow-up requests to skip the extra count query; in that case `meta.totalCount` is `null`.
|
||||
- in: query
|
||||
name: filter[name][contains]
|
||||
schema:
|
||||
@@ -137,8 +144,9 @@ paths:
|
||||
description: Opaque cursor for the next page. `null` when there are no more results.
|
||||
totalCount:
|
||||
type: integer
|
||||
nullable: true
|
||||
minimum: 0
|
||||
description: Total number of surveys matching the current filters across all pages.
|
||||
description: Total number of surveys matching the current filters across all pages. `null` when `includeTotalCount=false`.
|
||||
"400":
|
||||
description: Bad Request
|
||||
content:
|
||||
|
||||
@@ -277,7 +277,6 @@
|
||||
"self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
|
||||
"self-hosting/advanced/enterprise-features/team-access",
|
||||
"self-hosting/advanced/enterprise-features/contact-management-segments",
|
||||
"self-hosting/advanced/enterprise-features/multi-language-surveys",
|
||||
"self-hosting/advanced/enterprise-features/oidc-sso",
|
||||
"self-hosting/advanced/enterprise-features/saml-sso",
|
||||
"self-hosting/advanced/enterprise-features/audit-logging"
|
||||
|
||||
@@ -5,8 +5,6 @@ description: Enable comprehensive audit logs for your Formbricks instance.
|
||||
icon: file-shield
|
||||
---
|
||||
|
||||
import Hint from "@theme/Hint";
|
||||
|
||||
Audit logs record **who** did **what**, **when**, **from where**, and **with what outcome** across your Formbricks instance.
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ The Formbricks core source code is licensed under AGPLv3 and available on GitHub
|
||||
|
||||
<Note>
|
||||
Want to get your hands on the Enterprise Edition? [Request a free Enterprise Edition
|
||||
Trial](https://formbricks.com/enterprise-license?source=docs) License to build a fully functioning Proof of
|
||||
Trial](https://app.formbricks.com/s/trvp8tzy5uvsps9rc9qi9l9w?delivery=onpremise&source=docs) License to build a fully functioning Proof of
|
||||
Concept.
|
||||
</Note>
|
||||
|
||||
@@ -38,11 +38,11 @@ The Formbricks core application is licensed under the [AGPLv3 Open Source Licens
|
||||
|
||||
### The Enterprise Edition
|
||||
|
||||
Additional to the AGPL licensed Formbricks core, this repository contains code licensed under an Enterprise license. The [code](https://github.com/formbricks/formbricks/tree/main/apps/web/modules/ee) and [license](https://github.com/formbricks/formbricks/blob/main/apps/web/modules/ee/LICENSE) for the enterprise functionality can be found in the `/apps/web/modules/ee` folder of this repository. This additional functionality is not part of the AGPLv3 licensed Formbricks core and is designed to meet the needs of larger teams and enterprises. This advanced functionality is already included in the Docker images, but you need an [Enterprise License Key](https://formbricks.com/enterprise-license?source=docs) to unlock it.
|
||||
Additional to the AGPL licensed Formbricks core, this repository contains code licensed under an Enterprise license. The [code](https://github.com/formbricks/formbricks/tree/main/apps/web/modules/ee) and [license](https://github.com/formbricks/formbricks/blob/main/apps/web/modules/ee/LICENSE) for the enterprise functionality can be found in the `/apps/web/modules/ee` folder of this repository. This additional functionality is not part of the AGPLv3 licensed Formbricks core and is designed to meet the needs of larger teams and enterprises. This advanced functionality is already included in the Docker images, but you need an [Enterprise License Key](https://app.formbricks.com/s/trvp8tzy5uvsps9rc9qi9l9w?delivery=onpremise&source=docs) to unlock it.
|
||||
|
||||
<Note>
|
||||
Want to get your hands on the Enterprise Edition? [Request a free Enterprise Edition
|
||||
Trial](https://formbricks.com/enterprise-license?source=docs) License to build a fully functioning Proof of
|
||||
Trial](https://app.formbricks.com/s/trvp8tzy5uvsps9rc9qi9l9w?delivery=onpremise&source=docs) License to build a fully functioning Proof of
|
||||
Concept.
|
||||
</Note>
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ https://app.formbricks.com/s/clin3dxja02k8l80hpwmx4bjy?question_id_1=answer_1&qu
|
||||
|
||||
## How it works
|
||||
|
||||
1. To prefill survey questions, add query parameters to the survey URL using the format `questionId=answer`.
|
||||
2. The answer must match the question’s expected type to pass [validation](/xm-and-surveys/surveys/link-surveys/data-prefilling#validation).
|
||||
1. To prefill survey questions, add query parameters to the survey URL using the format `questionId=answer`.
|
||||
2. The answer must match the question’s expected type to pass [validation](/xm-and-surveys/surveys/link-surveys/data-prefilling#validation). For Single Select and Multi Select questions, `answer` can be either the option label text or the option ID.
|
||||
3. The answer needs to be [URL encoded](https://www.urlencoder.org/) (if it contains spaces or special characters)
|
||||
|
||||
|
||||
@@ -108,15 +108,28 @@ https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?pictureSelection_question
|
||||
```
|
||||
|
||||
|
||||
## Using Option IDs for Choice-Based Questions
|
||||
## Using Option IDs for Single Select and Multi Select Questions
|
||||
|
||||
All choice-based question types (Single Select, Multi Select, Picture Selection) now support prefilling with **option IDs** in addition to option labels. This is the recommended approach as it's more reliable and doesn't break when you update option text.
|
||||
Single Select and Multi Select questions support prefilling with **option IDs** in addition to option labels. This is the most robust approach and the recommended default, especially for multilingual surveys.
|
||||
|
||||
### Benefits of Using Option IDs
|
||||
### Why Option IDs are recommended
|
||||
|
||||
- **Stable:** Option IDs don't change when you edit the option label text
|
||||
- **Language-independent:** Works across all survey languages without modification
|
||||
- **Reliable:** No issues with special characters or URL encoding of complex text
|
||||
- **Stable:** Option IDs don't change when you edit option labels.
|
||||
- **Language-independent:** The same URL works across all survey languages.
|
||||
- **Reliable:** No brittle string matching and fewer URL-encoding issues.
|
||||
|
||||
### One link per option
|
||||
|
||||
When you create one CTA link per answer option (for example in emails), use the option ID as the parameter value:
|
||||
|
||||
```sh Option-ID prefill
|
||||
https://app.formbricks.com/s/cmoh0584taxoxu501jyvk8tzn?tuk2i9ktui1lee0mfos82czb=e2r697fou23n7ba4vmt7you6
|
||||
```
|
||||
|
||||
In this example:
|
||||
|
||||
- `tuk2i9ktui1lee0mfos82czb` is the question ID.
|
||||
- `e2r697fou23n7ba4vmt7you6` is the selected option ID.
|
||||
|
||||
### How to Find Option IDs
|
||||
|
||||
@@ -160,7 +173,7 @@ The URL validation works as follows:
|
||||
- **Rating or NPS questions:** The response is parsed as a number and verified to be within the valid range.
|
||||
- **Consent type questions:** Valid values are "accepted" (consent given) and "dismissed" (consent not given).
|
||||
- **Picture Selection questions:** The response is parsed as comma-separated numbers (1-based indices) and verified against available choices.
|
||||
- **Single/Multi Select questions:** Values can be either option IDs or exact label text. The system tries to match by option ID first, then falls back to label matching.
|
||||
- **Single/Multi Select questions:** Values can be either option IDs or exact label text. The system tries option ID matching first, then falls back to label matching.
|
||||
- **Open Text questions:** Any string value is accepted.
|
||||
|
||||
<Note>
|
||||
|
||||
+4
-4
@@ -46,11 +46,11 @@
|
||||
"dev:setup": "bash scripts/setup-dev-env.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@azure/playwright": "1.1.2",
|
||||
"@azure/playwright": "1.1.5",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@playwright/test": "1.58.2",
|
||||
"eslint": "8.57.1",
|
||||
@@ -58,7 +58,7 @@
|
||||
"lint-staged": "16.4.0",
|
||||
"rimraf": "6.1.3",
|
||||
"tsx": "4.21.0",
|
||||
"turbo": "2.8.16"
|
||||
"turbo": "2.8.21"
|
||||
},
|
||||
"lint-staged": {
|
||||
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [
|
||||
|
||||
@@ -36,19 +36,19 @@
|
||||
},
|
||||
"author": "Formbricks <hola@formbricks.com>",
|
||||
"dependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "4.0.83",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/google-vertex": "4.0.95",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/azure": "3.0.54",
|
||||
"@ai-sdk/google-vertex": "4.0.112",
|
||||
"@aws-sdk/credential-providers": "3.1017.0",
|
||||
"@formbricks/logger": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"ai": "6.0.138"
|
||||
"ai": "6.0.168"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"vite": "8.0.5",
|
||||
"vite": "8.0.10",
|
||||
"vitest": "4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user