Compare commits

..

2 Commits

Author SHA1 Message Date
Dhruwang
a4563819eb fix: build 2025-11-14 11:50:55 +05:30
Dhruwang
31f02aa53c prisma 7 2025-11-13 11:10:29 +05:30
494 changed files with 76771 additions and 7340 deletions

View File

@@ -179,14 +179,14 @@ For endpoints serving client SDKs, coordinate TTLs across layers:
```typescript
// Client SDK cache (expiresAt) - longest TTL for fewer requests
const CLIENT_TTL = 60; // 1 minute (seconds for client)
const CLIENT_TTL = 60 * 60; // 1 hour (seconds for client)
// Server Redis cache - shorter TTL ensures fresh data for clients
const SERVER_TTL = 60 * 1000; // 1 minutes in milliseconds
const SERVER_TTL = 60 * 30 * 1000; // 30 minutes in milliseconds
// HTTP cache headers (seconds)
const BROWSER_TTL = 60; // 1 minute (max-age)
const CDN_TTL = 60; // 1 minute (s-maxage)
const BROWSER_TTL = 60 * 60; // 1 hour (max-age)
const CDN_TTL = 60 * 30; // 30 minutes (s-maxage)
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
```

View File

@@ -89,7 +89,7 @@ jobs:
- check-latest-release
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
docker-build-cloud:
name: Build & push Formbricks Cloud to ECR
@@ -101,7 +101,7 @@ jobs:
with:
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
IS_PRERELEASE: ${{ github.event.release.prerelease }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
needs:
- check-latest-release
- docker-build-community
@@ -154,4 +154,4 @@ jobs:
release_tag: ${{ github.event.release.tag_name }}
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { getTeamsByOrganizationId } from "./onboarding";

View File

@@ -1,8 +1,8 @@
"use server";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationsByUserId } from "./organization";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
import { getProjectsByUserId } from "./project";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TMembership, ZMembership } from "@formbricks/types/memberships";

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -15,6 +16,7 @@ interface AirtableWrapperProps {
airtableArray: TIntegrationItem[];
airtableIntegration?: TIntegrationAirtable;
surveys: TSurvey[];
environment: TEnvironment;
isEnabled: boolean;
webAppUrl: string;
locale: TUserLocale;
@@ -25,6 +27,7 @@ export const AirtableWrapper = ({
airtableArray,
airtableIntegration,
surveys,
environment,
isEnabled,
webAppUrl,
locale,
@@ -45,6 +48,7 @@ export const AirtableWrapper = ({
<ManageIntegration
airtableArray={airtableArray}
environmentId={environmentId}
environment={environment}
airtableIntegration={airtableIntegration}
setIsConnected={setIsConnected}
surveys={surveys}

View File

@@ -4,6 +4,7 @@ import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -14,11 +15,12 @@ import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { IntegrationModalInputs } from "../lib/types";
interface ManageIntegrationProps {
airtableIntegration: TIntegrationAirtable;
environment: TEnvironment;
environmentId: string;
setIsConnected: (data: boolean) => void;
surveys: TSurvey[];
@@ -27,7 +29,7 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
const { t } = useTranslation();
const tableHeaders = [
@@ -130,7 +132,12 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
</div>
) : (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.airtable.no_integrations_yet")} />
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.airtable.no_integrations_yet")}
/>
</div>
)}

View File

@@ -51,6 +51,7 @@ const Page = async (props) => {
airtableArray={airtableArray}
environmentId={environment.id}
surveys={surveys}
environment={environment}
webAppUrl={WEBAPP_URL}
locale={locale}
/>

View File

@@ -60,6 +60,7 @@ export const GoogleSheetWrapper = ({
selectedIntegration={selectedIntegration}
/>
<ManageIntegration
environment={environment}
googleSheetIntegration={googleSheetIntegration}
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}

View File

@@ -4,6 +4,7 @@ import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfigData,
@@ -14,9 +15,10 @@ import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
interface ManageIntegrationProps {
environment: TEnvironment;
googleSheetIntegration: TIntegrationGoogleSheets;
setOpenAddIntegrationModal: (v: boolean) => void;
setIsConnected: (v: boolean) => void;
@@ -25,6 +27,7 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = ({
environment,
googleSheetIntegration,
setOpenAddIntegrationModal,
setIsConnected,
@@ -87,7 +90,12 @@ export const ManageIntegration = ({
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.google_sheets.no_integrations_yet")} />
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.google_sheets.no_integrations_yet")}
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";

View File

@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getWebhookCountBySource } from "./webhook";

View File

@@ -1,6 +1,6 @@
import { Prisma, Webhook } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { Prisma, Webhook } from "@formbricks/database/generated/client";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";

View File

@@ -13,7 +13,6 @@ import {
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import {
ERRORS,
@@ -123,7 +122,7 @@ export const AddIntegrationModal = ({
const questions = selectedSurvey
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
id: q.id,
name: getTextContent(getLocalizedValue(q.headline, "default")),
name: getLocalizedValue(q.headline, "default"),
type: q.type,
}))
: [];

View File

@@ -4,6 +4,7 @@ import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
@@ -11,10 +12,11 @@ import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ManageIntegrationProps {
environment: TEnvironment;
notionIntegration: TIntegrationNotion;
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
@@ -26,6 +28,7 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = ({
environment,
notionIntegration,
setOpenAddIntegrationModal,
setIsConnected,
@@ -98,7 +101,12 @@ export const ManageIntegration = ({
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.notion.no_databases_found")} />
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.notion.no_databases_found")}
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">

View File

@@ -64,6 +64,7 @@ export const NotionWrapper = ({
selectedIntegration={selectedIntegration}
/>
<ManageIntegration
environment={environment}
notionIntegration={notionIntegration}
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}

View File

@@ -4,6 +4,7 @@ import { Trash2Icon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
@@ -11,9 +12,10 @@ import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
interface ManageIntegrationProps {
environment: TEnvironment;
slackIntegration: TIntegrationSlack;
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
@@ -27,6 +29,7 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = ({
environment,
slackIntegration,
setOpenAddIntegrationModal,
setIsConnected,
@@ -103,7 +106,12 @@ export const ManageIntegration = ({
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.slack.connect_your_first_slack_channel")} />
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.slack.connect_your_first_slack_channel")}
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">

View File

@@ -78,6 +78,7 @@ export const SlackWrapper = ({
selectedIntegration={selectedIntegration}
/>
<ManageIntegration
environment={environment}
slackIntegration={slackIntegration}
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}

View File

@@ -215,7 +215,7 @@ export const EditProfileDetailsForm = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-slate-50 text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{appLanguages.map((lang) => (

View File

@@ -1,6 +1,6 @@
import { User } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { User } from "@formbricks/database/generated/client";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";

View File

@@ -26,7 +26,6 @@ interface ResponsePageProps {
isReadOnly: boolean;
isQuotasAllowed: boolean;
quotas: TSurveyQuota[];
initialResponses?: TResponseWithQuotas[];
}
export const ResponsePage = ({
@@ -40,12 +39,11 @@ export const ResponsePage = ({
isReadOnly,
isQuotasAllowed,
quotas,
initialResponses = [],
}: ResponsePageProps) => {
const [responses, setResponses] = useState<TResponseWithQuotas[]>(initialResponses);
const [page, setPage] = useState<number | null>(null);
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
const [responses, setResponses] = useState<TResponseWithQuotas[]>([]);
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
const [isFetchingFirstPage, setFetchingFirstPage] = useState<boolean>(true);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const filters = useMemo(
@@ -58,7 +56,6 @@ export const ResponsePage = ({
const searchParams = useSearchParams();
const fetchNextPage = useCallback(async () => {
if (page === null) return;
const newPage = page + 1;
let newResponses: TResponseWithQuotas[] = [];
@@ -96,22 +93,10 @@ export const ResponsePage = ({
}
}, [searchParams, resetState]);
// Only fetch if filters are applied (not on initial mount with no filters)
const hasFilters =
selectedFilter?.responseStatus !== "all" ||
(selectedFilter?.filter && selectedFilter.filter.length > 0) ||
(dateRange.from && dateRange.to);
useEffect(() => {
const fetchFilteredResponses = async () => {
const fetchInitialResponses = async () => {
try {
// skip call for initial mount
if (page === null && !hasFilters) {
setPage(1);
return;
}
setPage(1);
setIsFetchingFirstPage(true);
setFetchingFirstPage(true);
let responses: TResponseWithQuotas[] = [];
const getResponsesActionResponse = await getResponsesAction({
@@ -125,16 +110,19 @@ export const ResponsePage = ({
if (responses.length < responsesPerPage) {
setHasMore(false);
} else {
setHasMore(true);
}
setResponses(responses);
} finally {
setIsFetchingFirstPage(false);
setFetchingFirstPage(false);
}
};
fetchFilteredResponses();
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
fetchInitialResponses();
}, [surveyId, filters, responsesPerPage]);
useEffect(() => {
setPage(1);
setHasMore(true);
}, [filters]);
return (
<>

View File

@@ -2,8 +2,9 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
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 { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
@@ -13,6 +14,7 @@ import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -21,43 +23,44 @@ const Page = async (props) => {
const params = await props.params;
const t = await getTranslate();
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
getSurvey(params.surveyId),
getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(),
getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]);
const survey = await getSurvey(params.surveyId);
if (!survey) {
throw new Error(t("common.survey_not_found"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const tags = await getTagsByEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled();
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
// Get response count for the CTA component
const responseCount = await getResponseCountBySurveyId(params.surveyId);
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
const locale = await findMatchingLocale();
const publicDomain = getPublicDomain();
const organizationBilling = await getOrganizationBilling(organization.id);
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
if (!organizationId) {
throw new Error(t("common.organization_not_found"));
}
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error(t("common.organization_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
// Fetch initial responses on the server to prevent duplicate client-side fetch
const initialResponses = await getResponses(params.surveyId, RESPONSES_PER_PAGE, 0);
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
return (
<PageContentWrapper>
@@ -71,6 +74,7 @@ const Page = async (props) => {
user={user}
publicDomain={publicDomain}
responseCount={responseCount}
displayCount={displayCount}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
@@ -90,7 +94,6 @@ const Page = async (props) => {
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
initialResponses={initialResponses}
/>
</PageContentWrapper>
);

View File

@@ -1,32 +0,0 @@
"use client";
import { CSSProperties, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ClickableBarSegmentProps {
children: ReactNode;
onClick: () => void;
className?: string;
style?: CSSProperties;
}
export const ClickableBarSegment = ({
children,
onClick,
className = "",
style,
}: ClickableBarSegmentProps) => {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<button className={className} style={style} onClick={onClick}>
{children}
</button>
</TooltipTrigger>
<TooltipContent>{t("common.click_to_filter")}</TooltipContent>
</Tooltip>
);
};

View File

@@ -1,7 +1,5 @@
"use client";
import { BarChart, BarChartHorizontal } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
TI18nString,
@@ -11,12 +9,8 @@ import {
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { convertFloatToNDecimal } from "../lib/utils";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
import { SatisfactionIndicator } from "./SatisfactionIndicator";
interface NPSSummaryProps {
questionSummary: TSurveyQuestionSummaryNps;
@@ -30,20 +24,8 @@ interface NPSSummaryProps {
) => void;
}
const calculateNPSOpacity = (rating: number): number => {
if (rating <= 6) {
return 0.3 + (rating / 6) * 0.3;
}
if (rating <= 8) {
return 0.6 + ((rating - 6) / 2) * 0.2;
}
return 0.8 + ((rating - 8) / 2) * 0.2;
};
export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
const applyFilter = (group: string) => {
const filters = {
promoters: {
@@ -79,110 +61,38 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
additionalInfo={
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={questionSummary.promoters.percentage} />
<div>
{t("environments.surveys.summary.promoters")}:{" "}
{convertFloatToNDecimal(questionSummary.promoters.percentage, 2)}%
</div>
</div>
}
/>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("environments.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("environments.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="aggregated" className="mt-4">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary[group]?.count}{" "}
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
</p>
</div>
<ProgressBar
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
))}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<TooltipProvider delayDuration={200}>
<div className="grid grid-cols-11 gap-2 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((choice) => {
const opacity = calculateNPSOpacity(choice.rating);
return (
<ClickableBarSegment
key={choice.rating}
className="group flex cursor-pointer flex-col items-center"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
choice.rating.toString()
)
}>
<div className="flex h-32 w-full flex-col items-center justify-end">
<div
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
style={{
height: `${Math.max(choice.percentage, 2)}%`,
opacity,
}}
/>
</div>
<div className="flex w-full flex-col items-center rounded-b-lg border border-t-0 border-slate-200 bg-slate-50 px-1 py-2">
<div className="mb-1.5 text-xs font-medium text-slate-500">{choice.rating}</div>
<div className="mb-1 flex items-center space-x-1">
<div className="text-base font-semibold text-slate-700">{choice.count}</div>
<div className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600">
{convertFloatToNDecimal(choice.percentage, 1)}%
</div>
</div>
</div>
</ClickableBarSegment>
);
})}
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary[group]?.count}{" "}
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
</TooltipProvider>
</TabsContent>
</Tabs>
<ProgressBar
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
))}
</div>
<div className="flex justify-center pb-4 pt-4">
<HalfCircle value={questionSummary.score} />

View File

@@ -57,8 +57,8 @@ export const QuestionSummaryHeader = ({
{t("environments.surveys.edit.optional")}
</div>
)}
<IdBadge id={questionSummary.question.id} />
</div>
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
</div>
);
};

View File

@@ -1,24 +0,0 @@
"use client";
import { TSurveyRatingQuestion } from "@formbricks/types/surveys/types";
import { RatingResponse } from "@/modules/ui/components/rating-response";
interface RatingScaleLegendProps {
scale: TSurveyRatingQuestion["scale"];
range: number;
}
export const RatingScaleLegend = ({ scale, range }: RatingScaleLegendProps) => {
return (
<div className="mt-3 flex w-full items-start justify-between px-1">
<div className="flex items-center space-x-1">
<RatingResponse scale={scale} answer={1} range={range} addColors={false} variant="scale" />
<span className="text-xs text-slate-500">1</span>
</div>
<div className="flex items-center space-x-1">
<span className="text-xs text-slate-500">{range}</span>
<RatingResponse scale={scale} answer={range} range={range} addColors={false} variant="scale" />
</div>
</div>
);
};

View File

@@ -1,7 +1,7 @@
"use client";
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
TI18nString,
@@ -13,12 +13,7 @@ import {
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
import { RatingScaleLegend } from "./RatingScaleLegend";
import { SatisfactionIndicator } from "./SatisfactionIndicator";
interface RatingSummaryProps {
questionSummary: TSurveyQuestionSummaryRating;
@@ -34,8 +29,6 @@ interface RatingSummaryProps {
export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
const getIconBasedOnScale = useMemo(() => {
const scale = questionSummary.question.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
@@ -49,174 +42,52 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
questionSummary={questionSummary}
survey={survey}
additionalInfo={
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
</div>
</div>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={questionSummary.csat.satisfiedPercentage} />
<div>
CSAT: {questionSummary.csat.satisfiedPercentage}%{" "}
{t("environments.surveys.summary.satisfied")}
</div>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
</div>
</div>
}
/>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("environments.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("environments.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
{questionSummary.responseCount === 0 ? (
<>
<div className="rounded-lg border border-slate-200 bg-slate-50 p-8 text-center">
<p className="text-sm text-slate-500">
{t("environments.surveys.summary.no_responses_found")}
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((result) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={result.rating}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.rating}
range={questionSummary.question.range}
addColors={questionSummary.question.isColorCodingEnabled}
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
<RatingScaleLegend
scale={questionSummary.question.scale}
range={questionSummary.question.range}
/>
</>
) : (
<>
<TooltipProvider delayDuration={200}>
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
{questionSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
const range = questionSummary.question.range;
const opacity = 0.3 + (result.rating / range) * 0.8;
const isFirst = index === 0;
const isLast = index === questionSummary.choices.length - 1;
return (
<ClickableBarSegment
key={result.rating}
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
style={{
width: `${result.percentage}%`,
borderRight: isLast ? "none" : "1px solid rgb(226, 232, 240)",
}}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }}
/>
</ClickableBarSegment>
);
})}
</div>
</TooltipProvider>
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
{questionSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
return (
<div
key={result.rating}
className="flex flex-col items-center justify-center py-2"
style={{
width: `${result.percentage}%`,
borderRight:
index < questionSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)"
: "none",
}}>
<div className="mb-1 flex items-center justify-center">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.rating}
range={questionSummary.question.range}
addColors={false}
variant="aggregated"
/>
</div>
<div className="text-xs font-medium text-slate-600">
{convertFloatToNDecimal(result.percentage, 1)}%
</div>
</div>
);
})}
</div>
<RatingScaleLegend
scale={questionSummary.question.scale}
range={questionSummary.question.range}
/>
</>
)}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base">
{questionSummary.choices.map((result) => (
<div key={result.rating}>
<button
className="w-full cursor-pointer hover:opacity-80"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.rating}
range={questionSummary.question.range}
addColors={questionSummary.question.isColorCodingEnabled}
variant="individual"
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
</div>
))}
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
</div>
</TabsContent>
</Tabs>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
))}
</div>
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 py-4">
<div key="dismissed">

View File

@@ -1,17 +0,0 @@
interface SatisfactionIndicatorProps {
percentage: number;
}
export const SatisfactionIndicator = ({ percentage }: SatisfactionIndicatorProps) => {
let colorClass = "";
if (percentage > 80) {
colorClass = "bg-emerald-500";
} else if (percentage >= 55) {
colorClass = "bg-orange-500";
} else {
colorClass = "bg-rose-500";
}
return <div className={`h-3 w-3 rounded-full ${colorClass}`} />;
};

View File

@@ -3,14 +3,9 @@
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import {
TI18nString,
TSurvey,
TSurveyQuestionId,
TSurveyQuestionTypeEnum,
TSurveySummary,
} from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TI18nString, TSurveyQuestionId, TSurveySummary } from "@formbricks/types/surveys/types";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import {
SelectedFilterValue,
@@ -34,7 +29,7 @@ import { RatingSummary } from "@/app/(app)/environments/[environmentId]/surveys/
import { constructToastMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
import { AddressSummary } from "./AddressSummary";
@@ -59,7 +54,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
const filterObject: SelectedFilterValue = { ...selectedFilter };
const value = {
id: questionId,
label: getTextContent(getLocalizedValue(label, "default")),
label: getLocalizedValue(label, "default"),
questionType: questionType,
type: OptionsType.QUESTIONS,
};
@@ -108,7 +103,12 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
) : summary.length === 0 ? (
<SkeletonLoader type="summary" />
) : responseCount === 0 ? (
<EmptyState text={t("environments.surveys.summary.no_responses_found")} />
<EmptySpaceFiller
type="response"
environment={environment}
noWidgetRequired={survey.type === "link"}
emptyMessage={t("environments.surveys.summary.no_responses_found")}
/>
) : (
summary.map((questionSummary) => {
if (questionSummary.type === TSurveyQuestionTypeEnum.OpenText) {

View File

@@ -29,6 +29,7 @@ interface SurveyAnalysisCTAProps {
user: TUser;
publicDomain: string;
responseCount: number;
displayCount: number;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
@@ -47,6 +48,7 @@ export const SurveyAnalysisCTA = ({
user,
publicDomain,
responseCount,
displayCount,
segments,
isContactsEnabled,
isFormbricksCloud,
@@ -167,7 +169,7 @@ export const SurveyAnalysisCTA = ({
icon: ListRestart,
tooltip: t("environments.surveys.summary.reset_survey"),
onClick: () => setIsResetModalOpen(true),
isVisible: !isReadOnly,
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
},
{
icon: SquarePenIcon,

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError } from "@formbricks/types/errors";
import { deleteResponsesAndDisplaysForSurvey, getQuotasSummary } from "./survey";

View File

@@ -1,6 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { convertFloatTo2Decimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TLanguage } from "@formbricks/types/project";
import { TResponseFilterCriteria } from "@formbricks/types/responses";
@@ -2334,147 +2334,6 @@ describe("NPS question type tests", () => {
// Score should be -100 since all valid responses are detractors
expect(summary[0].score).toBe(-100);
});
test("getQuestionSummary includes individual score breakdown in choices array for NPS", async () => {
const question = {
id: "nps-q1",
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "How likely are you to recommend us?" },
required: true,
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "r1",
data: { "nps-q1": 0 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r2",
data: { "nps-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r3",
data: { "nps-q1": 7 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r4",
data: { "nps-q1": 9 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r5",
data: { "nps-q1": 10 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ questionId: "nps-q1", impressions: 5, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
expect(summary[0].choices).toBeDefined();
expect(summary[0].choices).toHaveLength(11); // Scores 0-10
// Verify specific scores
const score0 = summary[0].choices.find((c: any) => c.rating === 0);
expect(score0.count).toBe(1);
expect(score0.percentage).toBe(20); // 1/5 * 100
const score5 = summary[0].choices.find((c: any) => c.rating === 5);
expect(score5.count).toBe(1);
expect(score5.percentage).toBe(20);
const score7 = summary[0].choices.find((c: any) => c.rating === 7);
expect(score7.count).toBe(1);
expect(score7.percentage).toBe(20);
const score9 = summary[0].choices.find((c: any) => c.rating === 9);
expect(score9.count).toBe(1);
expect(score9.percentage).toBe(20);
const score10 = summary[0].choices.find((c: any) => c.rating === 10);
expect(score10.count).toBe(1);
expect(score10.percentage).toBe(20);
// Verify scores with no responses have 0 count
const score1 = summary[0].choices.find((c: any) => c.rating === 1);
expect(score1.count).toBe(0);
expect(score1.percentage).toBe(0);
});
test("getQuestionSummary handles NPS individual score breakdown with no responses", async () => {
const question = {
id: "nps-q1",
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "How likely are you to recommend us?" },
required: true,
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses: any[] = [];
const dropOff = [
{ questionId: "nps-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
expect(summary[0].choices).toBeDefined();
expect(summary[0].choices).toHaveLength(11); // Scores 0-10
// All scores should have 0 count and percentage
summary[0].choices.forEach((choice: any) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
});
});
});
describe("Rating question type tests", () => {
@@ -2698,549 +2557,6 @@ describe("Rating question type tests", () => {
// Verify dismissed is 0
expect(summary[0].dismissed.count).toBe(0);
});
test("getQuestionSummary calculates CSAT for Rating question with range 3", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 3,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "r1",
data: { "rating-q1": 3 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r2",
data: { "rating-q1": 2 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r3",
data: { "rating-q1": 3 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 3: satisfied = score 3
// 2 out of 3 responses are satisfied (score 3)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67); // Math.round((2/3) * 100)
});
test("getQuestionSummary calculates CSAT for Rating question with range 4", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 4,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "r1",
data: { "rating-q1": 3 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r2",
data: { "rating-q1": 4 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r3",
data: { "rating-q1": 2 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 4: satisfied = scores 3-4
// 2 out of 3 responses are satisfied (scores 3 and 4)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67);
});
test("getQuestionSummary calculates CSAT for Rating question with range 5", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "r1",
data: { "rating-q1": 4 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r2",
data: { "rating-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r3",
data: { "rating-q1": 3 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 5: satisfied = scores 4-5
// 2 out of 3 responses are satisfied (scores 4 and 5)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67);
});
test("getQuestionSummary calculates CSAT for Rating question with range 6", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 6,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "r1",
data: { "rating-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r2",
data: { "rating-q1": 6 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r3",
data: { "rating-q1": 4 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 6: satisfied = scores 5-6
// 2 out of 3 responses are satisfied (scores 5 and 6)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67);
});
test("getQuestionSummary calculates CSAT for Rating question with range 7", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 7,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "r1",
data: { "rating-q1": 6 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r2",
data: { "rating-q1": 7 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r3",
data: { "rating-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 7: satisfied = scores 6-7
// 2 out of 3 responses are satisfied (scores 6 and 7)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67);
});
test("getQuestionSummary calculates CSAT for Rating question with range 10", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 10,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "r1",
data: { "rating-q1": 8 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r2",
data: { "rating-q1": 9 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r3",
data: { "rating-q1": 10 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r4",
data: { "rating-q1": 7 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ questionId: "rating-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 10: satisfied = scores 8-10
// 3 out of 4 responses are satisfied (scores 8, 9, 10)
expect(summary[0].csat.satisfiedCount).toBe(3);
expect(summary[0].csat.satisfiedPercentage).toBe(75);
});
test("getQuestionSummary calculates CSAT for Rating question with all satisfied", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "r1",
data: { "rating-q1": 4 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r2",
data: { "rating-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ questionId: "rating-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 5: satisfied = scores 4-5
// All 2 responses are satisfied
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(100);
});
test("getQuestionSummary calculates CSAT for Rating question with none satisfied", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "r1",
data: { "rating-q1": 1 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r2",
data: { "rating-q1": 2 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r3",
data: { "rating-q1": 3 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 5: satisfied = scores 4-5
// None of the responses are satisfied (all are 1, 2, or 3)
expect(summary[0].csat.satisfiedCount).toBe(0);
expect(summary[0].csat.satisfiedPercentage).toBe(0);
});
test("getQuestionSummary calculates CSAT for Rating question with no responses", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses: any[] = [];
const dropOff = [
{ questionId: "rating-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
expect(summary[0].csat.satisfiedCount).toBe(0);
expect(summary[0].csat.satisfiedPercentage).toBe(0);
});
});
describe("PictureSelection question type tests", () => {

View File

@@ -1,8 +1,8 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
@@ -532,31 +532,13 @@ export const getQuestionSummary = async (
Object.entries(choiceCountMap).forEach(([label, count]) => {
values.push({
rating: Number.parseInt(label),
rating: parseInt(label),
count,
percentage:
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
});
});
// Calculate CSAT based on range
let satisfiedCount = 0;
if (range === 3) {
satisfiedCount = choiceCountMap[3] || 0;
} else if (range === 4) {
satisfiedCount = (choiceCountMap[3] || 0) + (choiceCountMap[4] || 0);
} else if (range === 5) {
satisfiedCount = (choiceCountMap[4] || 0) + (choiceCountMap[5] || 0);
} else if (range === 6) {
satisfiedCount = (choiceCountMap[5] || 0) + (choiceCountMap[6] || 0);
} else if (range === 7) {
satisfiedCount = (choiceCountMap[6] || 0) + (choiceCountMap[7] || 0);
} else if (range === 10) {
satisfiedCount = (choiceCountMap[8] || 0) + (choiceCountMap[9] || 0) + (choiceCountMap[10] || 0);
}
const satisfiedPercentage =
totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0;
summary.push({
type: question.type,
question,
@@ -566,10 +548,6 @@ export const getQuestionSummary = async (
dismissed: {
count: dismissed,
},
csat: {
satisfiedCount,
satisfiedPercentage,
},
});
values = [];
@@ -585,17 +563,10 @@ export const getQuestionSummary = async (
score: 0,
};
// Track individual score counts (0-10)
const scoreCountMap: Record<number, number> = {};
for (let i = 0; i <= 10; i++) {
scoreCountMap[i] = 0;
}
responses.forEach((response) => {
const value = response.data[question.id];
if (typeof value === "number") {
data.total++;
scoreCountMap[value]++;
if (value >= 9) {
data.promoters++;
} else if (value >= 7) {
@@ -614,13 +585,6 @@ export const getQuestionSummary = async (
? convertFloatTo2Decimal(((data.promoters - data.detractors) / data.total) * 100)
: 0;
// Build choices array with individual score breakdown
const choices = Object.entries(scoreCountMap).map(([rating, count]) => ({
rating: Number.parseInt(rating),
count,
percentage: data.total > 0 ? convertFloatTo2Decimal((count / data.total) * 100) : 0,
}));
summary.push({
type: question.type,
question,
@@ -643,7 +607,6 @@ export const getQuestionSummary = async (
count: data.dismissed,
percentage: data.total > 0 ? convertFloatTo2Decimal((data.dismissed / data.total) * 100) : 0,
},
choices,
});
break;
}

View File

@@ -70,6 +70,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
user={user}
publicDomain={publicDomain}
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
displayCount={initialSurveySummary?.meta.displayCount ?? 0}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}

View File

@@ -4,7 +4,7 @@ import clsx from "clsx";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { TI18nString, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
@@ -26,8 +26,8 @@ import {
import { Input } from "@/modules/ui/components/input";
type QuestionFilterComboBoxProps = {
filterOptions: (string | TI18nString)[] | undefined;
filterComboBoxOptions: (string | TI18nString)[] | undefined;
filterOptions: string[] | undefined;
filterComboBoxOptions: string[] | undefined;
filterValue: string | undefined;
filterComboBoxValue: string | string[] | undefined;
onChangeFilterValue: (o: string) => void;
@@ -74,7 +74,7 @@ export const QuestionFilterComboBox = ({
if (!isMultiple) return filterComboBoxOptions;
return filterComboBoxOptions?.filter((o) => {
const optionValue = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
return !filterComboBoxValue?.includes(optionValue);
});
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue, defaultLanguageCode]);
@@ -91,15 +91,14 @@ export const QuestionFilterComboBox = ({
const filteredOptions = useMemo(
() =>
options?.filter((o) => {
const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
}),
[options, searchQuery, defaultLanguageCode]
);
const handleCommandItemSelect = (o: string | TI18nString) => {
const value = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
const handleCommandItemSelect = (o: string) => {
const value = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
if (isMultiple) {
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
@@ -201,18 +200,14 @@ export const QuestionFilterComboBox = ({
)}
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-white">
{filterOptions?.map((o, index) => {
const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
return (
<DropdownMenuItem
key={`${optionValue}-${index}`}
className="cursor-pointer"
onClick={() => onChangeFilterValue(optionValue)}>
{optionValue}
</DropdownMenuItem>
);
})}
{filterOptions?.map((o, index) => (
<DropdownMenuItem
key={`${o}-${index}`}
className="cursor-pointer"
onClick={() => onChangeFilterValue(o)}>
{o}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -274,8 +269,7 @@ export const QuestionFilterComboBox = ({
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions?.map((o) => {
const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
return (
<CommandItem
key={optionValue}

View File

@@ -4,7 +4,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TI18nString, TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
SelectedFilterValue,
TResponseStatus,
@@ -13,7 +13,6 @@ import {
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { Button } from "@/modules/ui/components/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import {
@@ -27,8 +26,8 @@ import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsCombo
export type QuestionFilterOptions = {
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
filterOptions: (string | TI18nString)[];
filterComboBoxOptions: (string | TI18nString)[];
filterOptions: string[];
filterComboBoxOptions: string[];
id: string;
};
@@ -70,12 +69,6 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [filterValue, setFilterValue] = useState<SelectedFilterValue>(selectedFilter);
const getDefaultFilterValue = (option?: QuestionFilterOptions): string | undefined => {
if (!option || option.filterOptions.length === 0) return undefined;
const firstOption = option.filterOptions[0];
return typeof firstOption === "object" ? getLocalizedValue(firstOption, "default") : firstOption;
};
useEffect(() => {
// Fetch the initial data for the filter and load it into the state
const handleInitialData = async () => {
@@ -101,18 +94,15 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
}, [isOpen, setSelectedOptions, survey]);
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
const matchingFilterOption = selectedOptions.questionFilterOptions.find(
(q) => q.type === value.type || q.type === value.questionType
);
const defaultFilterValue = getDefaultFilterValue(matchingFilterOption);
if (filterValue.filter[index].questionType) {
// Create a new array and copy existing values from SelectedFilter
filterValue.filter[index] = {
questionType: value,
filterType: {
filterComboBoxValue: undefined,
filterValue: defaultFilterValue,
filterValue: selectedOptions.questionFilterOptions.find(
(q) => q.type === value.type || q.type === value.questionType
)?.filterOptions[0],
},
};
setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus });
@@ -121,7 +111,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
filterValue.filter[index].questionType = value;
filterValue.filter[index].filterType = {
filterComboBoxValue: undefined,
filterValue: defaultFilterValue,
filterValue: selectedOptions.questionFilterOptions.find(
(q) => q.type === value.type || q.type === value.questionType
)?.filterOptions[0],
};
setFilterValue({ ...filterValue });
}

View File

@@ -1,6 +1,6 @@
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
import { PipelineTriggers, Webhook } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";

View File

@@ -1,4 +1,4 @@
import { Organization } from "@prisma/client";
import { Organization } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TProject } from "@formbricks/types/project";

View File

@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TDisplayCreateInput } from "@formbricks/types/displays";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";

View File

@@ -1,5 +1,5 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TDisplayCreateInput, ZDisplayCreateInput } from "@formbricks/types/displays";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";

View File

@@ -1,6 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";

View File

@@ -1,6 +1,5 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
import { responses } from "@/app/lib/api/response";
@@ -29,38 +28,15 @@ export const GET = withV1ApiWrapper({
const params = await props.params;
try {
// Basic type check for environmentId
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
if (typeof params.environmentId !== "string") {
return {
response: responses.badRequestResponse("Environment ID is required", undefined, true),
};
}
const environmentId = params.environmentId.trim();
// Validate CUID v1 format using Zod (matches Prisma schema @default(cuid()))
// This catches all invalid formats including:
// - null/undefined passed as string "null" or "undefined"
// - HTML-encoded placeholders like <environmentId> or %3C...%3E
// - Empty or whitespace-only IDs
// - Any other invalid CUID v1 format
const cuidValidation = ZEnvironmentId.safeParse(environmentId);
if (!cuidValidation.success) {
logger.warn(
{
environmentId: params.environmentId,
url: req.url,
validationError: cuidValidation.error.errors[0]?.message,
},
"Invalid CUID v1 format detected"
);
return {
response: responses.badRequestResponse("Invalid environment ID format", undefined, true),
};
}
// Use optimized environment state fetcher with new caching approach
const environmentState = await getEnvironmentState(environmentId);
const environmentState = await getEnvironmentState(params.environmentId);
const { data } = environmentState;
return {
@@ -70,12 +46,12 @@ export const GET = withV1ApiWrapper({
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
},
true,
// Cache headers aligned with Redis cache TTL (1 minute)
// max-age=60: 1min browser cache
// s-maxage=60: 1min Cloudflare CDN cache
// stale-while-revalidate=60: 1min stale serving during revalidation
// stale-if-error=60: 1min stale serving on origin errors
"public, s-maxage=60, max-age=60, stale-while-revalidate=60, stale-if-error=60"
// Optimized cache headers for Cloudflare CDN and browser caching
// max-age=3600: 1hr browser cache (per guidelines)
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
// stale-while-revalidate=1800: 30min stale serving during revalidation
// stale-if-error=3600: 1hr stale serving on origin errors
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
),
};
} catch (err) {

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { getContact, getContactByUserId } from "./contact";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError } from "@formbricks/types/errors";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota";

View File

@@ -1,6 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
@@ -78,10 +78,7 @@ export const createResponseWithQuotaEvaluation = async (
return txResponse;
};
export const createResponse = async (
responseInput: TResponseInput,
tx: Prisma.TransactionClient
): Promise<TResponse> => {
export const createResponse = async (responseInput: TResponseInput, tx: any): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");

View File

@@ -2,7 +2,7 @@ import { headers } from "next/headers";
import { NextRequest } from "next/server";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
@@ -51,7 +51,7 @@ export const POST = withV1ApiWrapper({
}
const { environmentId } = params;
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const environmentIdValidation = ZId.safeParse(environmentId);
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {

View File

@@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client";
import { Prisma } from "@formbricks/database/generated/client";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { TResponseInput } from "@formbricks/types/responses";

View File

@@ -1,9 +1,9 @@
"use server";
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TActionClass } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";

View File

@@ -16,7 +16,7 @@ export const GET = withV1ApiWrapper({
(permission) => permission.environmentId
);
const actionClasses = await getActionClasses(environmentIds);
const actionClasses = await getActionClasses(environmentIds as string[]);
return {
response: responses.successResponse(actionClasses),

View File

@@ -1,6 +1,6 @@
import { Organization, Prisma, Response as ResponsePrisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Organization, Prisma, Response as ResponsePrisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput } from "@formbricks/types/responses";

View File

@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
@@ -88,10 +88,9 @@ export const createResponseWithQuotaEvaluation = async (
return txResponse;
};
export const createResponse = async (
responseInput: TResponseInput,
tx?: Prisma.TransactionClient
): Promise<TResponse> => {
// Use any for transaction client to avoid dist/src type mismatch in TypeScript
// Runtime behavior is correct, this is purely a type resolution issue
export const createResponse = async (responseInput: TResponseInput, tx?: any): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");

View File

@@ -49,7 +49,11 @@ export const GET = withV1ApiWrapper({
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
const environmentResponses = await getResponsesByEnvironmentIds(
environmentIds as string[],
limit,
offset
);
allResponses.push(...environmentResponses);
}
return {

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";

View File

@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";

View File

@@ -27,7 +27,7 @@ export const GET = withV1ApiWrapper({
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const surveys = await getSurveys(environmentIds, limit, offset);
const surveys = await getSurveys(environmentIds as string[], limit, offset);
return {
response: responses.successResponse(surveys),

View File

@@ -1,7 +1,7 @@
import { Prisma, Webhook } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma, Webhook } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { deleteWebhook, getWebhook } from "./webhook";

View File

@@ -1,5 +1,5 @@
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma, Webhook } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";

View File

@@ -1,7 +1,7 @@
import { Prisma, WebhookSource } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma, WebhookSource } from "@formbricks/database/generated/client";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook";
import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";

View File

@@ -1,5 +1,5 @@
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma, Webhook } from "@formbricks/database/generated/client";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";

View File

@@ -13,7 +13,7 @@ export const GET = withV1ApiWrapper({
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const webhooks = await getWebhooks(environmentIds);
const webhooks = await getWebhooks(environmentIds as string[]);
return {
response: responses.successResponse(webhooks),
};

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { TDisplayCreateInputV2 } from "../types/display";

View File

@@ -1,5 +1,5 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
TDisplayCreateInputV2,

View File

@@ -1,6 +1,6 @@
import { Organization } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Organization } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { getOrganizationBillingByEnvironmentId } from "./organization";

View File

@@ -1,6 +1,6 @@
import { Organization } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Organization } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
export const getOrganizationBillingByEnvironmentId = reactCache(

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";

View File

@@ -1,6 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
@@ -86,10 +86,9 @@ const buildPrismaResponseData = (
};
};
export const createResponse = async (
responseInput: TResponseInputV2,
tx?: Prisma.TransactionClient
): Promise<TResponse> => {
// Use any for transaction client to avoid dist/src type mismatch in TypeScript
// Runtime behavior is correct, this is purely a type resolution issue
export const createResponse = async (responseInput: TResponseInputV2, tx?: any): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");

View File

@@ -1,5 +1,5 @@
import { Organization } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { Organization } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization";

View File

@@ -1,7 +1,7 @@
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
@@ -43,7 +43,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
}
const { environmentId } = params;
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const environmentIdValidation = ZId.safeParse(environmentId);
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {

View File

@@ -25,6 +25,10 @@ export type TApiV1Authentication = TAuthenticationApiKey | Session | null;
export type TApiKeyAuthentication = TAuthenticationApiKey | null;
export type TSessionAuthentication = Session | null;
// Helper type to properly narrow NonNullable<TApiKeyAuthentication> to TAuthenticationApiKey
// This ensures TypeScript properly infers nested properties like environmentPermissions
export type TNonNullableApiKeyAuthentication = NonNullable<TApiKeyAuthentication> & TAuthenticationApiKey;
// Interface for handler function parameters
export interface THandlerParams<TProps = unknown> {
req?: NextRequest;
@@ -272,6 +276,15 @@ const getRouteType = (
*
*/
export const withV1ApiWrapper: {
// More specific overload for TAuthenticationApiKey (non-null) - must come first for proper type inference
<TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps> & {
handler: (
params: THandlerParams<TProps> & { authentication: TAuthenticationApiKey }
) => Promise<TResult>;
}
): (req: NextRequest, props: TProps) => Promise<Response>;
<TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps> & {
handler: (

View File

@@ -1,5 +1,5 @@
import { PipelineTriggers } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { PipelineTriggers } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { TResponse } from "@formbricks/types/responses";
import { TPipelineInput } from "@/app/lib/types/pipelines";

View File

@@ -213,8 +213,8 @@ describe("surveys", () => {
id: "q8",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix" },
rows: [{ id: "r1", label: { default: "Row 1" } }],
columns: [{ id: "c1", label: { default: "Column 1" } }],
rows: [{ id: "r1", label: "Row 1" }],
columns: [{ id: "c1", label: "Column 1" }],
} as unknown as TSurveyQuestion,
],
createdAt: new Date(),

View File

@@ -121,8 +121,8 @@ export const generateQuestionAndFilterOptions = (
} else if (q.type === TSurveyQuestionTypeEnum.Matrix) {
questionFilterOptions.push({
type: q.type,
filterOptions: q.rows.map((row) => getLocalizedValue(row.label, "default")),
filterComboBoxOptions: q.columns.map((column) => getLocalizedValue(column.label, "default")),
filterOptions: q.rows.flatMap((row) => Object.values(row)),
filterComboBoxOptions: q.columns.flatMap((column) => Object.values(column)),
id: q.id,
});
} else {

View File

@@ -1504,7 +1504,7 @@ const docsFeedback = (t: TFunction): TTemplate => {
buildOpenTextQuestion({
headline: t("templates.docs_feedback_question_2_headline"),
required: false,
inputType: "url",
inputType: "text",
t,
}),
buildOpenTextQuestion({

View File

@@ -1,4 +1,4 @@
import { PipelineTriggers } from "@prisma/client";
import { PipelineTriggers } from "@formbricks/database/generated/client";
import { TResponse } from "@formbricks/types/responses";
export interface TPipelineInput {

View File

@@ -7,18 +7,7 @@
},
"locale": {
"source": "en-US",
"targets": [
"de-DE",
"fr-FR",
"ja-JP",
"pt-BR",
"pt-PT",
"ro-RO",
"zh-Hans-CN",
"zh-Hant-TW",
"nl-NL",
"es-ES"
]
"targets": ["de-DE", "fr-FR", "ja-JP", "pt-BR", "pt-PT", "ro-RO", "zh-Hans-CN", "zh-Hant-TW", "nl-NL"]
},
"version": 1.8
}

View File

@@ -126,7 +126,6 @@ checksums:
common/clear_filters: 8f40ab5af527e4b190da94e7b6221379
common/clear_selection: af5d720527735d4253e289400d29ec9e
common/click: 9c2744de6b5ac7333d9dae1d5cf4a76d
common/click_to_filter: 527714113ca5fd3504e7d0bd31bca303
common/clicks: f9e154545f87d8ede27b529e5fdf2015
common/close: 2c2e22f8424a1031de89063bd0022e16
common/code: 343bc5386149b97cece2b093c39034b2
@@ -184,7 +183,6 @@ checksums:
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
common/failed_to_load_projects: 0bba9f9b2b38c189706a486a1bb134c3
common/finish: ffa7a10f71182b48fefed7135bee24fa
@@ -193,7 +191,6 @@ checksums:
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
common/gathering_responses: c5914490ed81bd77f13d411739f0c9ef
common/general: b891e8f15579fc5d97bcaf3637f5ae59
common/generate: 0345bf322c191e70d01fd6607ec5c2f8
common/go_back: b917ea82facb90c88c523b255d29f84b
common/go_to_dashboard: a6efa97d25e36fedc0af794f6ba610f2
common/hidden: fa290c6ada5869d744ed35e9cca64699
@@ -401,7 +398,6 @@ checksums:
common/user_id: 37f5ba37f71cb50607af32a6a203b1d4
common/user_not_found: 5903581136ac6c1c1351a482a6d8fdf7
common/variable: c13db5775ba9791b1522cc55c9c7acce
common/variable_ids: 44bf93b70703b7699fa9f21bc6c8eed4
common/variables: ffd3eec5497af36d7b4e4185bad1313a
common/verified_email: d4a9e5e47d622c6ef2fede44233076c7
common/video: 8050c90e4289b105a0780f0fdda6ff66
@@ -495,7 +491,6 @@ checksums:
environments/actions/add_css_class_or_id: cfc4d88412c5b9ef1157e28db4afdcc5
environments/actions/add_regular_expression_here: 797fde3681996b85bc63c3550dec1fd4
environments/actions/add_url: 8eba7972136a42da78a8fa4798da8e87
environments/actions/and: 53e8eb67a396fcb5e419bb4cbf0008df
environments/actions/click: 9c2744de6b5ac7333d9dae1d5cf4a76d
environments/actions/contains: 41c8c25407527a5336404313f4c8d650
environments/actions/create_action: 3abcc6dbbca18d3218ba49f90c4a66fd
@@ -526,7 +521,6 @@ checksums:
environments/actions/limit_to_specific_pages: f8ba95b2fc68d965689594b8a545417c
environments/actions/matches_regex: 208b4d02b38714b4523923239e4a66b0
environments/actions/on_all_pages: ccb8ee531a55e21eb8157c36fa75ad9a
environments/actions/or: 0208d355f231c386b19390f0bea41b95
environments/actions/page_filter: fe98a0bcbedb938e58cc3730589caa95
environments/actions/page_view: 019c12b6739f6f7b1500f96ee275d47c
environments/actions/select_match_type: b555dce1cb5c61538d3fbd792b2c71a2
@@ -563,18 +557,9 @@ checksums:
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
environments/contacts/delete_contact_confirmation: 4304d36277daa205b4aa09f5e0d494ab
environments/contacts/delete_contact_confirmation_with_quotas: 7c0e2e223ca55101270ac2988c53e616
environments/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
environments/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe
environments/contacts/no_published_surveys: bd945b0e2e2328c17615c94143bdd62b
environments/contacts/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
environments/contacts/not_provided: a09e4d61bbeb04b927406a50116445e2
environments/contacts/personal_link_generated: efb7a0420bd459847eb57bca41a4ab0d
environments/contacts/personal_link_generated_but_clipboard_failed: 4eb1e208e729bd5ac00c33f72fc38d53
environments/contacts/personal_survey_link: 5b3f1afc53733718c4ed5b1443b6a604
environments/contacts/please_select_a_survey: 465aa7048773079c8ffdde8b333b78eb
environments/contacts/search_contact: 020205a93846ab3e12c203ac4fa97c12
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
@@ -737,8 +722,8 @@ checksums:
environments/project/api_keys/unable_to_delete_api_key: 1fd76d9a22c5f5f8c241c4891fca8295
environments/project/app-connection/app_connection: 778d2305e1a9c8efe91c2c7b4af37ae4
environments/project/app-connection/app_connection_description: dde226414bd2265cbd0daf6635efcfdd
environments/project/app-connection/cache_update_delay_description: 3368e4a8090b7684117a16c94f0c409c
environments/project/app-connection/cache_update_delay_title: 60e4a0fcfbd8850bddf29b5c3f59550c
environments/project/app-connection/cache_update_delay_description: 1cb2c46fdb6762ccb348d21086063a4f
environments/project/app-connection/cache_update_delay_title: fef7f99f0228f9e30093574ac7770e7e
environments/project/app-connection/environment_id: 3dba898b081c18cd4cae131765ef411f
environments/project/app-connection/environment_id_description: 8b4a763d069b000cfa1a2025a13df80c
environments/project/app-connection/formbricks_sdk_connected: 29e8a40ad6a7fdb5af5ee9451a70a9aa
@@ -823,6 +808,7 @@ checksums:
environments/project/tags/add_tag: 2cfa04ceea966149f2b5d40d9c131141
environments/project/tags/count: 9c5848662eb8024ddf360f7e4001a968
environments/project/tags/delete_tag_confirmation: a9fb98064cd156242899643f3d2ef032
environments/project/tags/empty_message: da71bd7c7b5bf634469d20e010d25503
environments/project/tags/manage_tags: 2761d558b82b6104befbc240ae2379c6
environments/project/tags/manage_tags_description: ce7cc42da3646fba960502d7e4e49cd2
environments/project/tags/merge: 95051c859b8778be51226b43be6f1075
@@ -1252,7 +1238,7 @@ checksums:
environments/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
environments/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
environments/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3
environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: c70466147d49dcbb3686452f35c46428
environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: 71977f91ec151b61ee3528ac2618afed
environments/surveys/edit/enable_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13
environments/surveys/edit/enable_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
environments/surveys/edit/end_screen_card: 6146c2bcb87291e25ecb03abd2d9a479
@@ -1611,7 +1597,7 @@ checksums:
environments/surveys/responses/last_name: 2c9a7de7738ca007ba9023c385149c26
environments/surveys/responses/not_completed: df34eab65a6291f2c5e15a0e349c4eba
environments/surveys/responses/os: a4c753bb2c004a58d02faeed6b4da476
environments/surveys/responses/person_attributes: 07ae67ae73d7a2a7c67008694a83f0a3
environments/surveys/responses/person_attributes: 8f7f8a9040ce8efb3cb54ce33b590866
environments/surveys/responses/phone: b9537ee90fc5b0116942e0af29d926cc
environments/surveys/responses/respondent_skipped_questions: d85daf579ade534dc7e639689156fcd5
environments/surveys/responses/response_deleted_successfully: 6cec5427c271800619fee8c812d7db18
@@ -1706,7 +1692,6 @@ checksums:
environments/surveys/share/social_media/title: 1bf4899b063ee8f02f7188576555828b
environments/surveys/summary/added_filter_for_responses_where_answer_to_question: 5bddf0d4f771efd06d58441d11fa5091
environments/surveys/summary/added_filter_for_responses_where_answer_to_question_is_skipped: 74ca713c491cfc33751a5db3de972821
environments/surveys/summary/aggregated: 9d4e77225d5952abed414fffd828c078
environments/surveys/summary/all_responses_csv: 16c0c211853f0839a79f1127ec679ca2
environments/surveys/summary/all_responses_excel: 8bf18916ab127f16bfcf9f38956710b0
environments/surveys/summary/all_time: 62258944e7c2e83f3ebf69074b2c2156
@@ -1730,6 +1715,7 @@ checksums:
environments/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf
environments/surveys/summary/filtered_responses_excel: 06e57bae9e41979fd7fc4b8bfe3466f9
environments/surveys/summary/generating_qr_code: 5026d4a76f995db458195e5215d9bbd9
environments/surveys/summary/go_to_setup_checklist: d70bd018d651d01c41ae10370e71d0be
environments/surveys/summary/impressions: 7fe38d42d68a64d3fd8436a063751584
environments/surveys/summary/impressions_tooltip: 4d0823cbf360304770c7c5913e33fdc8
environments/surveys/summary/in_app/connection_description: 9710bbf8048a8a5c3b2b56db9d946b73
@@ -1761,7 +1747,7 @@ checksums:
environments/surveys/summary/in_app/title: a2d1b633244d0e0504ec6f8f561c7a6b
environments/surveys/summary/includes_all: b0e3679282417c62d511c258362f860e
environments/surveys/summary/includes_either: 186d6923c1693e80d7b664b8367d4221
environments/surveys/summary/individual: 52ebce389ed97a13b6089802055ed667
environments/surveys/summary/install_widget: 55d403de32e3d0da7513ab199f1d1934
environments/surveys/summary/is_equal_to: f4aab30ef188eb25dcc0e392cf8e86bb
environments/surveys/summary/is_less_than: 6109d595ba21497c59b1c91d7fd09a13
environments/surveys/summary/last_30_days: a738894cfc5e592052f1e16787744568
@@ -1774,7 +1760,6 @@ checksums:
environments/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
environments/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
environments/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
environments/surveys/summary/promoters: 41fbb8d0439227661253a82fda39f521
environments/surveys/summary/qr_code: 48cb2a8c07a3d1647f766f93bb9e9382
environments/surveys/summary/qr_code_description: 19f48dcf473809f178abf4212657ef14
environments/surveys/summary/qr_code_download_failed: 2764b5615112800da27eecafc21e3472
@@ -1784,7 +1769,6 @@ checksums:
environments/surveys/summary/quotas_completed_tooltip: ec5c4dc67eda27c06764354f695db613
environments/surveys/summary/reset_survey: 8c88ddb81f5f787d183d2e7cb43e7c64
environments/surveys/summary/reset_survey_warning: 6b44be171d7e2716f234387b100b173d
environments/surveys/summary/satisfied: 4d542ba354b85e644acbca5691d2ce45
environments/surveys/summary/selected_responses_csv: 9cef3faccd54d4f24647791e6359db90
environments/surveys/summary/selected_responses_excel: a0ade8b2658e887a4a3f2ad3bdb0c686
environments/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53
@@ -1800,6 +1784,7 @@ checksums:
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
environments/surveys/summary/waiting_for_response: 0194a84e0850b8e98435632d5331a916
environments/surveys/summary/whats_next: d920145bfa2147014062f6f2d1d451a4
environments/surveys/summary/your_survey_is_public: 3f5cb5949a5f4020a3d4d74fdfc95e83
environments/surveys/summary/youre_not_plugged_in_yet: 9217467742cdcf7edf8d59cc1472ede6

View File

@@ -1,6 +1,6 @@
import { PrismaClient } from "@prisma/client";
import { beforeEach, vi } from "vitest";
import { mockDeep, mockReset } from "vitest-mock-extended";
import { PrismaClient } from "@formbricks/database/generated/client";
export const prisma = mockDeep<PrismaClient>();

View File

@@ -1,5 +1,5 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TAccount, TAccountInput, ZAccountInput } from "@formbricks/types/account";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";

View File

@@ -1,9 +1,9 @@
"use server";
import "server-only";
import { ActionClass, Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ActionClass, Prisma } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";

View File

@@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegrationItem } from "@formbricks/types/integration";

View File

@@ -175,7 +175,6 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"ro-RO",
"ja-JP",
"zh-Hans-CN",
"es-ES",
];
// Billing constants

View File

@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZId } from "@formbricks/types/common";
import { TDisplay, TDisplayFilters } from "@formbricks/types/displays";
import { DatabaseError } from "@formbricks/types/errors";
@@ -42,7 +42,9 @@ export const getDisplayCountBySurveyId = reactCache(
}
);
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
// Use any for transaction client to avoid dist/src type mismatch in TypeScript
// Runtime behavior is correct, this is purely a type resolution issue
export const deleteDisplay = async (displayId: string, tx?: any): Promise<TDisplay> => {
validateInputs([displayId, ZId]);
try {
const prismaClient = tx ?? prisma;

View File

@@ -9,9 +9,9 @@ import {
mockSurveyId,
} from "./__mocks__/data.mock";
import { prisma } from "@/lib/__mocks__/database";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { testInputValidation } from "vitestSetup";
import { Prisma } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError } from "@formbricks/types/errors";
import { createDisplay } from "@/app/api/v1/client/[environmentId]/displays/lib/display";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "./auth";

View File

@@ -1,5 +1,5 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";

View File

@@ -1,6 +1,6 @@
import { EnvironmentType, Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { EnvironmentType, Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment, getEnvironments, updateEnvironment } from "./service";

View File

@@ -1,8 +1,8 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import type {

View File

@@ -1,6 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { Prisma } from "@formbricks/database/generated/client";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import {

View File

@@ -138,7 +138,6 @@ export const appLanguages = [
"ja-JP": "英語(米国)",
"zh-Hans-CN": "英语(美国)",
"nl-NL": "Engels (VS)",
"es-ES": "Inglés (EE.UU.)",
},
},
{
@@ -154,7 +153,6 @@ export const appLanguages = [
"ja-JP": "ドイツ語",
"zh-Hans-CN": "德语",
"nl-NL": "Duits",
"es-ES": "Alemán",
},
},
{
@@ -170,7 +168,6 @@ export const appLanguages = [
"ja-JP": "ポルトガル語(ブラジル)",
"zh-Hans-CN": "葡萄牙语(巴西)",
"nl-NL": "Portugees (Brazilië)",
"es-ES": "Portugués (Brasil)",
},
},
{
@@ -186,7 +183,6 @@ export const appLanguages = [
"ja-JP": "フランス語",
"zh-Hans-CN": "法语",
"nl-NL": "Frans",
"es-ES": "Francés",
},
},
{
@@ -202,7 +198,6 @@ export const appLanguages = [
"ja-JP": "中国語(繁体字)",
"zh-Hans-CN": "繁体中文",
"nl-NL": "Chinees (Traditioneel)",
"es-ES": "Chino (Tradicional)",
},
},
{
@@ -218,7 +213,6 @@ export const appLanguages = [
"ja-JP": "ポルトガル語(ポルトガル)",
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
"nl-NL": "Portugees (Portugal)",
"es-ES": "Portugués (Portugal)",
},
},
{
@@ -234,7 +228,6 @@ export const appLanguages = [
"ja-JP": "ルーマニア語",
"zh-Hans-CN": "罗马尼亚语",
"nl-NL": "Roemeens",
"es-ES": "Rumano",
},
},
{
@@ -250,7 +243,6 @@ export const appLanguages = [
"ja-JP": "日本語",
"zh-Hans-CN": "日语",
"nl-NL": "Japans",
"es-ES": "Japonés",
},
},
{
@@ -266,7 +258,6 @@ export const appLanguages = [
"ja-JP": "中国語(簡体字)",
"zh-Hans-CN": "简体中文",
"nl-NL": "Chinees (Vereenvoudigd)",
"es-ES": "Chino (Simplificado)",
},
},
{
@@ -282,23 +273,6 @@ export const appLanguages = [
"ja-JP": "オランダ語",
"zh-Hans-CN": "荷兰语",
"nl-NL": "Nederlands",
"es-ES": "Neerlandés",
},
},
{
code: "es-ES",
label: {
"en-US": "Spanish",
"de-DE": "Spanisch",
"pt-BR": "Espanhol",
"fr-FR": "Espagnol",
"zh-Hant-TW": "西班牙語",
"pt-PT": "Espanhol",
"ro-RO": "Spaniol",
"ja-JP": "スペイン語",
"zh-Hans-CN": "西班牙语",
"nl-NL": "Spaans",
"es-ES": "Español",
},
},
];

View File

@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
// Function to check if there are any users in the database

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