chore: remove old AI classification feature (#5529)

Co-authored-by: Victor Santos <victor@formbricks.com>
This commit is contained in:
Matti Nannt
2025-04-28 21:18:07 +02:00
committed by GitHub
parent a9eedd3c7a
commit 51001d07b6
82 changed files with 68 additions and 5051 deletions

View File

@@ -58,11 +58,6 @@ const Page = async (props) => {
comingSoon: false,
onRequest: false,
},
{
title: t("environments.settings.enterprise.ai"),
comingSoon: false,
onRequest: true,
},
{
title: t("environments.settings.enterprise.audit_logs"),
comingSoon: false,

View File

@@ -1,96 +0,0 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateOrganizationAIEnabledAction } from "@/modules/ee/insights/actions";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { TOrganization } from "@formbricks/types/organizations";
interface AIToggleProps {
environmentId: string;
organization: TOrganization;
isOwnerOrManager: boolean;
}
export const AIToggle = ({ organization, isOwnerOrManager }: AIToggleProps) => {
const { t } = useTranslate();
const [isAIEnabled, setIsAIEnabled] = useState(organization.isAIEnabled);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleUpdateOrganization = async (data) => {
try {
setIsAIEnabled(data.enabled);
setIsSubmitting(true);
const updatedOrganizationResponse = await updateOrganizationAIEnabledAction({
organizationId: organization.id,
data: {
isAIEnabled: data.enabled,
},
});
if (updatedOrganizationResponse?.data) {
if (data.enabled) {
toast.success(t("environments.settings.general.formbricks_ai_enable_success_message"));
} else {
toast.success(t("environments.settings.general.formbricks_ai_disable_success_message"));
}
} else {
const errorMessage = getFormattedErrorMessage(updatedOrganizationResponse);
toast.error(errorMessage);
}
} catch (err) {
toast.error(`Error: ${err.message}`);
} finally {
setIsSubmitting(false);
if (typeof window !== "undefined") {
setTimeout(() => {
window.location.reload();
}, 500);
}
}
};
return (
<>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Label htmlFor="formbricks-ai-toggle" className="cursor-pointer">
{t("environments.settings.general.enable_formbricks_ai")}
</Label>
<Switch
id="formbricks-ai-toggle"
disabled={!isOwnerOrManager || isSubmitting}
checked={isAIEnabled}
onClick={(e) => {
e.stopPropagation();
handleUpdateOrganization({ enabled: !organization.isAIEnabled });
}}
/>
</div>
<div className="mt-3 text-xs text-slate-600">
{t("environments.settings.general.formbricks_ai_privacy_policy_text")}{" "}
<Link
className="underline"
href={"https://formbricks.com/privacy-policy"}
rel="noreferrer"
target="_blank">
{t("common.privacy_policy")}
</Link>
.
</div>
</div>
{!isOwnerOrManager && (
<Alert variant="warning" className="mt-4">
<AlertDescription>
{t("environments.settings.general.only_org_owner_can_perform_action")}
</AlertDescription>
</Alert>
)}
</>
);
};

View File

@@ -1,9 +1,5 @@
import { getUser } from "@/lib/user/service";
import {
getIsMultiOrgEnabled,
getIsOrganizationAIReady,
getWhiteLabelPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { getTranslate } from "@/tolgee/server";
@@ -33,12 +29,6 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name",
AI_AZURE_LLM_API_KEY: "mock-ai",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id",
}));
vi.mock("next-auth", () => ({
@@ -59,7 +49,6 @@ vi.mock("@/modules/environments/lib/utils", () => ({
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: vi.fn(),
getIsOrganizationAIReady: vi.fn(),
getWhiteLabelPermission: vi.fn(),
}));
@@ -80,7 +69,6 @@ describe("Page", () => {
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getIsOrganizationAIReady).mockResolvedValue(true);
vi.mocked(getWhiteLabelPermission).mockResolvedValue(true);
});

View File

@@ -1,12 +1,7 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { AIToggle } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import {
getIsMultiOrgEnabled,
getIsOrganizationAIReady,
getWhiteLabelPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
@@ -35,8 +30,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const isOwnerOrManager = isManager || isOwner;
const isOrganizationAIReady = await getIsOrganizationAIReady(organization.billing.plan);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
@@ -56,17 +49,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
membershipRole={currentUserMembership?.role}
/>
</SettingsCard>
{isOrganizationAIReady && (
<SettingsCard
title={t("environments.settings.general.formbricks_ai")}
description={t("environments.settings.general.formbricks_ai_description")}>
<AIToggle
environmentId={params.environmentId}
organization={organization}
isOwnerOrManager={isOwnerOrManager}
/>
</SettingsCard>
)}
<EmailCustomizationSettings
organization={organization}
hasWhiteLabelPermission={hasWhiteLabelPermission}

View File

@@ -1,6 +1,5 @@
"use server";
import { generateInsightsForSurvey } from "@/app/api/(internal)/insights/lib/utils";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
@@ -108,31 +107,3 @@ export const getResponseCountAction = authenticatedActionClient
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
});
const ZGenerateInsightsForSurveyAction = z.object({
surveyId: ZId,
});
export const generateInsightsForSurveyAction = authenticatedActionClient
.schema(ZGenerateInsightsForSurveyAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
schema: ZGenerateInsightsForSurveyAction,
data: parsedInput,
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
generateInsightsForSurvey(parsedInput.surveyId);
});

View File

@@ -1,16 +1,13 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { MAX_RESPONSES_FOR_INSIGHT_GENERATION, RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -20,7 +17,7 @@ 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 = await getSurvey(params.surveyId);
@@ -38,11 +35,6 @@ const Page = async (props) => {
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
const isAIEnabled = await getIsAIEnabled({
isAIEnabled: organization.isAIEnabled,
billing: organization.billing,
});
const shouldGenerateInsights = needsInsightsGeneration(survey);
const locale = await findMatchingLocale();
const surveyDomain = getSurveyDomain();
@@ -60,14 +52,6 @@ const Page = async (props) => {
responseCount={totalResponseCount}
/>
}>
{isAIEnabled && shouldGenerateInsights && (
<EnableInsightsBanner
surveyId={survey.id}
surveyResponseCount={totalResponseCount}
maxResponseCount={MAX_RESPONSES_FOR_INSIGHT_GENERATION}
/>
)}
<SurveyAnalysisNavigation
environmentId={environment.id}
survey={survey}

View File

@@ -1,153 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { EnableInsightsBanner } from "./EnableInsightsBanner";
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
},
}));
vi.mock("@/modules/ee/insights/actions", () => ({
generateInsightsForSurveyAction: vi.fn(),
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children, className }: { children: React.ReactNode; className: string }) => (
<div data-testid="alert" className={className}>
{children}
</div>
),
AlertTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="alert-title">{children}</div>
),
AlertDescription: ({ children, className }: { children: React.ReactNode; className: string }) => (
<div data-testid="alert-description" className={className}>
{children}
</div>
),
}));
vi.mock("@/modules/ui/components/badge", () => ({
Badge: ({ type, size, text }: { type: string; size: string; text: string }) => (
<span data-testid="badge" data-type={type} data-size={size}>
{text}
</span>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({
size,
className,
onClick,
loading,
disabled,
children,
}: {
size: string;
className: string;
onClick: () => void;
loading: boolean;
disabled: boolean;
children: React.ReactNode;
}) => (
<button
data-testid="button"
data-size={size}
className={className}
onClick={onClick}
disabled={disabled || loading}
aria-busy={loading}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: ({
tooltipContent,
children,
}: {
tooltipContent: string | undefined;
children: React.ReactNode;
}) => (
<div data-testid="tooltip" data-content={tooltipContent}>
{children}
</div>
),
}));
vi.mock("lucide-react", () => ({
SparklesIcon: ({ className, strokeWidth }: { className: string; strokeWidth: number }) => (
<div data-testid="sparkles-icon" className={className} data-stroke-width={strokeWidth} />
),
}));
describe("EnableInsightsBanner", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const surveyId = "survey-123";
test("renders banner with correct content", () => {
render(<EnableInsightsBanner surveyId={surveyId} maxResponseCount={100} surveyResponseCount={50} />);
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(screen.getByTestId("alert-title")).toBeInTheDocument();
expect(screen.getByTestId("badge")).toHaveTextContent("Beta");
expect(screen.getByTestId("alert-description")).toBeInTheDocument();
expect(screen.getByTestId("sparkles-icon")).toBeInTheDocument();
expect(screen.getByTestId("button")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.enable_ai_insights_banner_button")
).toBeInTheDocument();
});
test("disables button when response count exceeds maximum", () => {
render(<EnableInsightsBanner surveyId={surveyId} maxResponseCount={50} surveyResponseCount={100} />);
const button = screen.getByTestId("button");
expect(button).toBeDisabled();
// Tooltip should have content when button is disabled
const tooltip = screen.getByTestId("tooltip");
expect(tooltip).toHaveAttribute(
"data-content",
"environments.surveys.summary.enable_ai_insights_banner_tooltip"
);
});
test("enables button when response count is within maximum", () => {
render(<EnableInsightsBanner surveyId={surveyId} maxResponseCount={100} surveyResponseCount={50} />);
const button = screen.getByTestId("button");
expect(button).not.toBeDisabled();
// Tooltip should not have content when button is enabled
const tooltip = screen.getByTestId("tooltip");
expect(tooltip).not.toHaveAttribute(
"data-content",
"environments.surveys.summary.enable_ai_insights_banner_tooltip"
);
});
test("generates insights when button is clicked", async () => {
const { generateInsightsForSurveyAction } = await import("@/modules/ee/insights/actions");
render(<EnableInsightsBanner surveyId={surveyId} maxResponseCount={100} surveyResponseCount={50} />);
const button = screen.getByTestId("button");
await userEvent.click(button);
expect(toast.success).toHaveBeenCalledTimes(2);
expect(generateInsightsForSurveyAction).toHaveBeenCalledWith({ surveyId });
// Banner should disappear after generating insights
expect(screen.queryByTestId("alert")).not.toBeInTheDocument();
});
});

View File

@@ -1,71 +0,0 @@
"use client";
import { generateInsightsForSurveyAction } from "@/modules/ee/insights/actions";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { SparklesIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
interface EnableInsightsBannerProps {
surveyId: string;
maxResponseCount: number;
surveyResponseCount: number;
}
export const EnableInsightsBanner = ({
surveyId,
surveyResponseCount,
maxResponseCount,
}: EnableInsightsBannerProps) => {
const { t } = useTranslate();
const [isGeneratingInsights, setIsGeneratingInsights] = useState(false);
const handleInsightGeneration = async () => {
toast.success("Generating insights for this survey. Please check back in a few minutes.", {
duration: 3000,
});
setIsGeneratingInsights(true);
toast.success(t("environments.surveys.summary.enable_ai_insights_banner_success"));
generateInsightsForSurveyAction({ surveyId });
};
if (isGeneratingInsights) {
return null;
}
return (
<Alert className="mb-6 mt-4 flex items-center gap-4 border-slate-400 bg-white">
<div>
<SparklesIcon strokeWidth={1.5} className="size-7 text-slate-700" />
</div>
<div className="flex-1">
<AlertTitle>
<span className="mr-2">{t("environments.surveys.summary.enable_ai_insights_banner_title")}</span>
<Badge type="gray" size="normal" text="Beta" />
</AlertTitle>
<AlertDescription className="flex items-start justify-between gap-4">
{t("environments.surveys.summary.enable_ai_insights_banner_description")}
</AlertDescription>
</div>
<TooltipRenderer
tooltipContent={
surveyResponseCount > maxResponseCount
? t("environments.surveys.summary.enable_ai_insights_banner_tooltip")
: undefined
}>
<Button
size="sm"
className="shrink-0"
onClick={handleInsightGeneration}
loading={isGeneratingInsights}
disabled={surveyResponseCount > maxResponseCount}>
{t("environments.surveys.summary.enable_ai_insights_banner_button")}
</Button>
</TooltipRenderer>
</Alert>
);
};

View File

@@ -74,8 +74,6 @@ describe("OpenTextSummary", () => {
test("renders response mode by default when insights not enabled", () => {
const questionSummary = {
question: { id: "q1", headline: "Open Text Question" },
insightsEnabled: false,
insights: [],
samples: [
{
id: "response1",
@@ -92,7 +90,6 @@ describe("OpenTextSummary", () => {
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
isAIEnabled={true}
locale={locale}
/>
);
@@ -108,93 +105,9 @@ describe("OpenTextSummary", () => {
expect(screen.queryByTestId("secondary-navigation")).not.toBeInTheDocument();
});
test("shows insights disabled message when AI is enabled but insights are disabled", () => {
const questionSummary = {
question: { id: "q1", headline: "Open Text Question" },
insightsEnabled: false,
insights: [],
samples: [],
} as unknown as TSurveyQuestionSummaryOpenText;
render(
<OpenTextSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
isAIEnabled={true}
locale={locale}
/>
);
expect(screen.getByText("environments.surveys.summary.insights_disabled")).toBeInTheDocument();
});
test("shows insights tab by default when insights are available", () => {
const questionSummary = {
question: { id: "q1", headline: "Open Text Question" },
insightsEnabled: true,
insights: [{ id: "insight1", text: "Insight text" }],
samples: [],
} as unknown as TSurveyQuestionSummaryOpenText;
render(
<OpenTextSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
isAIEnabled={true}
locale={locale}
/>
);
expect(screen.getByTestId("secondary-navigation")).toBeInTheDocument();
expect(screen.getByTestId("insight-view")).toBeInTheDocument();
expect(screen.queryByTestId("table")).not.toBeInTheDocument();
});
test("allows switching between insights and responses tabs", async () => {
const questionSummary = {
question: { id: "q1", headline: "Open Text Question" },
insightsEnabled: true,
insights: [{ id: "insight1", text: "Insight text" }],
samples: [
{
id: "response1",
value: "Sample response text",
updatedAt: new Date().toISOString(),
contact: { id: "contact1" },
contactAttributes: {},
},
],
} as unknown as TSurveyQuestionSummaryOpenText;
render(
<OpenTextSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
isAIEnabled={true}
locale={locale}
/>
);
// Initially showing insights
expect(screen.getByTestId("insight-view")).toBeInTheDocument();
expect(screen.queryByTestId("table")).not.toBeInTheDocument();
// Click on responses tab
await userEvent.click(screen.getByText("common.responses"));
// Now showing responses
expect(screen.queryByTestId("insight-view")).not.toBeInTheDocument();
expect(screen.getByTestId("table")).toBeInTheDocument();
});
test("renders anonymous user when no contact is provided", () => {
const questionSummary = {
question: { id: "q1", headline: "Open Text Question" },
insightsEnabled: false,
insights: [],
samples: [
{
id: "response1",
@@ -211,7 +124,6 @@ describe("OpenTextSummary", () => {
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
isAIEnabled={false}
locale={locale}
/>
);
@@ -231,8 +143,6 @@ describe("OpenTextSummary", () => {
const questionSummary = {
question: { id: "q1", headline: "Open Text Question" },
insightsEnabled: false,
insights: [],
samples,
} as unknown as TSurveyQuestionSummaryOpenText;
@@ -241,7 +151,6 @@ describe("OpenTextSummary", () => {
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
isAIEnabled={false}
locale={locale}
/>
);

View File

@@ -3,10 +3,8 @@
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { InsightView } from "@/modules/ee/insights/components/insights-view";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
@@ -19,25 +17,12 @@ interface OpenTextSummaryProps {
questionSummary: TSurveyQuestionSummaryOpenText;
environmentId: string;
survey: TSurvey;
isAIEnabled: boolean;
documentsPerPage?: number;
locale: TUserLocale;
}
export const OpenTextSummary = ({
questionSummary,
environmentId,
survey,
isAIEnabled,
documentsPerPage,
locale,
}: OpenTextSummaryProps) => {
export const OpenTextSummary = ({ questionSummary, environmentId, survey, locale }: OpenTextSummaryProps) => {
const { t } = useTranslate();
const isInsightsEnabled = isAIEnabled && questionSummary.insightsEnabled;
const [visibleResponses, setVisibleResponses] = useState(10);
const [activeTab, setActiveTab] = useState<"insights" | "responses">(
isInsightsEnabled && questionSummary.insights.length ? "insights" : "responses"
);
const handleLoadMore = () => {
// Increase the number of visible responses by 10, not exceeding the total number of responses
@@ -46,104 +31,62 @@ export const OpenTextSummary = ({
);
};
const tabNavigation = [
{
id: "insights",
label: t("common.insights"),
onClick: () => setActiveTab("insights"),
},
{
id: "responses",
label: t("common.responses"),
onClick: () => setActiveTab("responses"),
},
];
return (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
additionalInfo={
isAIEnabled && questionSummary.insightsEnabled === false ? (
<div className="flex items-center space-x-2">
<div className="flex items-center rounded-lg bg-slate-100 p-2">
{t("environments.surveys.summary.insights_disabled")}
</div>
</div>
) : undefined
}
/>
{isInsightsEnabled && (
<div className="ml-4">
<SecondaryNavigation activeId={activeTab} navigation={tabNavigation} />
</div>
)}
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="border-t border-slate-200"></div>
<div className="max-h-[40vh] overflow-y-auto">
{activeTab === "insights" ? (
<InsightView
insights={questionSummary.insights}
questionId={questionSummary.question.id}
surveyId={survey.id}
documentsPerPage={documentsPerPage}
locale={locale}
/>
) : activeTab === "responses" ? (
<>
<Table>
<TableHeader className="bg-slate-100">
<TableRow>
<TableHead>{t("common.user")}</TableHead>
<TableHead>{t("common.response")}</TableHead>
<TableHead>{t("common.time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
<TableRow key={response.id}>
<TableCell>
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</TableCell>
<TableCell className="font-medium">
{typeof response.value === "string"
? renderHyperlinkedContent(response.value)
: response.value}
</TableCell>
<TableCell width={120}>
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{visibleResponses < questionSummary.samples.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</>
) : null}
<Table>
<TableHeader className="bg-slate-100">
<TableRow>
<TableHead>{t("common.user")}</TableHead>
<TableHead>{t("common.response")}</TableHead>
<TableHead>{t("common.time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
<TableRow key={response.id}>
<TableCell>
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</TableCell>
<TableCell className="font-medium">
{typeof response.value === "string"
? renderHyperlinkedContent(response.value)
: response.value}
</TableCell>
<TableCell width={120}>
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{visibleResponses < questionSummary.samples.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</div>
</div>
);

View File

@@ -39,8 +39,6 @@ interface SummaryListProps {
environment: TEnvironment;
survey: TSurvey;
totalResponseCount: number;
isAIEnabled: boolean;
documentsPerPage?: number;
locale: TUserLocale;
}
@@ -50,8 +48,6 @@ export const SummaryList = ({
responseCount,
survey,
totalResponseCount,
isAIEnabled,
documentsPerPage,
locale,
}: SummaryListProps) => {
const { setSelectedFilter, selectedFilter } = useResponseFilter();
@@ -134,8 +130,6 @@ export const SummaryList = ({
questionSummary={questionSummary}
environmentId={environment.id}
survey={survey}
isAIEnabled={isAIEnabled}
documentsPerPage={documentsPerPage}
locale={locale}
/>
);

View File

@@ -171,7 +171,6 @@ describe("SummaryPage", () => {
surveyId: "survey-123",
webAppUrl: "https://app.example.com",
totalResponseCount: 50,
isAIEnabled: true,
locale,
isReadOnly: false,
};

View File

@@ -46,7 +46,6 @@ interface SummaryPageProps {
webAppUrl: string;
user?: TUser;
totalResponseCount: number;
isAIEnabled: boolean;
documentsPerPage?: number;
locale: TUserLocale;
isReadOnly: boolean;
@@ -58,8 +57,6 @@ export const SummaryPage = ({
surveyId,
webAppUrl,
totalResponseCount,
isAIEnabled,
documentsPerPage,
locale,
isReadOnly,
}: SummaryPageProps) => {
@@ -184,8 +181,6 @@ export const SummaryPage = ({
survey={surveyMemoized}
environment={environment}
totalResponseCount={totalResponseCount}
isAIEnabled={isAIEnabled}
documentsPerPage={documentsPerPage}
locale={locale}
/>
</>

View File

@@ -25,12 +25,6 @@ vi.mock("@/lib/constants", () => ({
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
WEBAPP_URL: "mock-webapp-url",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
IS_PRODUCTION: true,
FB_LOGO_URL: "https://example.com/mock-logo.png",
SMTP_HOST: "mock-smtp-host",

View File

@@ -1,88 +0,0 @@
import { cache } from "@/lib/cache";
import { documentCache } from "@/lib/cache/document";
import { INSIGHTS_PER_PAGE } from "@/lib/constants";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import {
TSurveyQuestionId,
TSurveyQuestionSummaryOpenText,
ZSurveyQuestionId,
} from "@formbricks/types/surveys/types";
export const getInsightsBySurveyIdQuestionId = reactCache(
async (
surveyId: string,
questionId: TSurveyQuestionId,
insightResponsesIds: string[],
limit?: number,
offset?: number
): Promise<TSurveyQuestionSummaryOpenText["insights"]> =>
cache(
async () => {
validateInputs([surveyId, ZId], [questionId, ZSurveyQuestionId]);
limit = limit ?? INSIGHTS_PER_PAGE;
try {
const insights = await prisma.insight.findMany({
where: {
documentInsights: {
some: {
document: {
surveyId,
questionId,
...(insightResponsesIds.length > 0 && {
responseId: {
in: insightResponsesIds,
},
}),
},
},
},
},
include: {
_count: {
select: {
documentInsights: {
where: {
document: {
surveyId,
questionId,
},
},
},
},
},
},
orderBy: [
{
documentInsights: {
_count: "desc",
},
},
{
createdAt: "desc",
},
],
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
});
return insights;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getInsightsBySurveyIdQuestionId-${surveyId}-${questionId}-${limit}-${offset}`],
{
tags: [documentCache.tag.bySurveyId(surveyId)],
}
)()
);

View File

@@ -1,5 +1,4 @@
import "server-only";
import { getInsightsBySurveyIdQuestionId } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/insights";
import { cache } from "@/lib/cache";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { displayCache } from "@/lib/display/cache";
@@ -317,11 +316,9 @@ export const getQuestionSummary = async (
switch (question.type) {
case TSurveyQuestionTypeEnum.OpenText: {
let values: TSurveyQuestionSummaryOpenText["samples"] = [];
const insightResponsesIds: string[] = [];
responses.forEach((response) => {
const answer = response.data[question.id];
if (answer && typeof answer === "string") {
insightResponsesIds.push(response.id);
values.push({
id: response.id,
updatedAt: response.updatedAt,
@@ -331,20 +328,12 @@ export const getQuestionSummary = async (
});
}
});
const insights = await getInsightsBySurveyIdQuestionId(
survey.id,
question.id,
insightResponsesIds,
50
);
summary.push({
type: question.type,
question,
responseCount: values.length,
samples: values.slice(0, VALUES_LIMIT),
insights,
insightsEnabled: question.insightsEnabled,
});
values = [];

View File

@@ -38,12 +38,3 @@ export const constructToastMessage = (
});
}
};
export const needsInsightsGeneration = (survey: TSurvey): boolean => {
const openTextQuestions = survey.questions.filter((question) => question.type === "openText");
const questionWithoutInsightsEnabled = openTextQuestions.some(
(question) => question.type === "openText" && typeof question.insightsEnabled === "undefined"
);
return openTextQuestions.length > 0 && questionWithoutInsightsEnabled;
};

View File

@@ -1,19 +1,11 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import {
DEFAULT_LOCALE,
DOCUMENTS_PER_PAGE,
MAX_RESPONSES_FOR_INSIGHT_GENERATION,
WEBAPP_URL,
} from "@/lib/constants";
import { DEFAULT_LOCALE, DOCUMENTS_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -25,7 +17,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
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 surveyId = params.surveyId;
@@ -50,11 +42,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
// I took this out cause it's cloud only right?
// const { active: isEnterpriseEdition } = await getEnterpriseLicense();
const isAIEnabled = await getIsAIEnabled({
isAIEnabled: organization.isAIEnabled,
billing: organization.billing,
});
const shouldGenerateInsights = needsInsightsGeneration(survey);
const surveyDomain = getSurveyDomain();
return (
@@ -71,13 +58,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
responseCount={totalResponseCount}
/>
}>
{isAIEnabled && shouldGenerateInsights && (
<EnableInsightsBanner
surveyId={survey.id}
surveyResponseCount={totalResponseCount}
maxResponseCount={MAX_RESPONSES_FOR_INSIGHT_GENERATION}
/>
)}
<SurveyAnalysisNavigation
environmentId={environment.id}
survey={survey}
@@ -92,7 +72,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
webAppUrl={WEBAPP_URL}
user={user}
totalResponseCount={totalResponseCount}
isAIEnabled={isAIEnabled}
documentsPerPage={DOCUMENTS_PER_PAGE}
isReadOnly={isReadOnly}
locale={user.locale ?? DEFAULT_LOCALE}

View File

@@ -1,84 +0,0 @@
import { embeddingsModel, llmModel } from "@/lib/aiModels";
import { documentCache } from "@/lib/cache/document";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { embed, generateObject } from "ai";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import {
TDocument,
TDocumentCreateInput,
TGenerateDocumentObjectSchema,
ZDocumentCreateInput,
ZGenerateDocumentObjectSchema,
} from "@formbricks/types/documents";
import { DatabaseError } from "@formbricks/types/errors";
export type TCreatedDocument = TDocument & {
isSpam: boolean;
insights: TGenerateDocumentObjectSchema["insights"];
};
export const createDocument = async (
surveyName: string,
documentInput: TDocumentCreateInput
): Promise<TCreatedDocument> => {
validateInputs([surveyName, z.string()], [documentInput, ZDocumentCreateInput]);
try {
// Generate text embedding
const { embedding } = await embed({
model: embeddingsModel,
value: documentInput.text,
experimental_telemetry: { isEnabled: true },
});
// generate sentiment and insights
const { object } = await generateObject({
model: llmModel,
schema: ZGenerateDocumentObjectSchema,
system: `You are an XM researcher. You analyse a survey response (survey name, question headline & user answer) and generate insights from it. The insight title (1-3 words) should concisely answer the question, e.g., "What type of people do you think would most benefit" -> "Developers". You are very objective. For the insights, split the feedback into the smallest parts possible and only use the feedback itself to draw conclusions. You must output at least one insight. Always generate insights and titles in English, regardless of the input language.`,
prompt: `Survey: ${surveyName}\n${documentInput.text}`,
temperature: 0,
experimental_telemetry: { isEnabled: true },
});
const sentiment = object.sentiment;
const isSpam = object.isSpam;
// create document
const prismaDocument = await prisma.document.create({
data: {
...documentInput,
sentiment,
isSpam,
},
});
const document = {
...prismaDocument,
vector: embedding,
};
// update document vector with the embedding
const vectorString = `[${embedding.join(",")}]`;
await prisma.$executeRaw`
UPDATE "Document"
SET "vector" = ${vectorString}::vector(512)
WHERE "id" = ${document.id};
`;
documentCache.revalidate({
id: document.id,
responseId: document.responseId,
questionId: document.questionId,
});
return { ...document, insights: object.insights, isSpam };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -1,430 +0,0 @@
import { createDocument } from "@/app/api/(internal)/insights/lib/document";
import { doesResponseHasAnyOpenTextAnswer } from "@/app/api/(internal)/insights/lib/utils";
import { embeddingsModel } from "@/lib/aiModels";
import { documentCache } from "@/lib/cache/document";
import { insightCache } from "@/lib/cache/insight";
import { getPromptText } from "@/lib/utils/ai";
import { parseRecallInfo } from "@/lib/utils/recall";
import { validateInputs } from "@/lib/utils/validate";
import { Insight, InsightCategory, Prisma } from "@prisma/client";
import { embed } from "ai";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { TCreatedDocument } from "@formbricks/types/documents";
import { DatabaseError } from "@formbricks/types/errors";
import {
TSurvey,
TSurveyQuestionId,
TSurveyQuestionTypeEnum,
ZSurveyQuestions,
} from "@formbricks/types/surveys/types";
import { TInsightCreateInput, TNearestInsights, ZInsightCreateInput } from "./types";
export const generateInsightsForSurveyResponsesConcept = async (
survey: Pick<TSurvey, "id" | "name" | "environmentId" | "questions">
): Promise<void> => {
const { id: surveyId, name, environmentId, questions } = survey;
validateInputs([surveyId, ZId], [environmentId, ZId], [questions, ZSurveyQuestions]);
try {
const openTextQuestionsWithInsights = questions.filter(
(question) => question.type === TSurveyQuestionTypeEnum.OpenText && question.insightsEnabled
);
const openTextQuestionIds = openTextQuestionsWithInsights.map((question) => question.id);
if (openTextQuestionIds.length === 0) {
return;
}
// Fetching responses
const batchSize = 200;
let skip = 0;
let rateLimit: number | undefined;
const spillover: { responseId: string; questionId: string; text: string }[] = [];
let allResponsesProcessed = false;
// Fetch the rate limit once, if not already set
if (rateLimit === undefined) {
const { rawResponse } = await embed({
model: embeddingsModel,
value: "Test",
experimental_telemetry: { isEnabled: true },
});
const rateLimitHeader = rawResponse?.headers?.["x-ratelimit-remaining-requests"];
rateLimit = rateLimitHeader ? parseInt(rateLimitHeader, 10) : undefined;
}
while (!allResponsesProcessed || spillover.length > 0) {
// If there are any spillover documents from the previous iteration, prioritize them
let answersForDocumentCreation = [...spillover];
spillover.length = 0; // Empty the spillover array after moving contents
// Fetch new responses only if spillover is empty
if (answersForDocumentCreation.length === 0 && !allResponsesProcessed) {
const responses = await prisma.response.findMany({
where: {
surveyId,
documents: {
none: {},
},
finished: true,
},
select: {
id: true,
data: true,
variables: true,
contactId: true,
language: true,
},
take: batchSize,
skip,
});
if (
responses.length === 0 ||
(responses.length < batchSize && rateLimit && responses.length < rateLimit)
) {
allResponsesProcessed = true; // Mark as finished when no more responses are found
}
const responsesWithOpenTextAnswers = responses.filter((response) =>
doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response.data)
);
skip += batchSize - responsesWithOpenTextAnswers.length;
const answersForDocumentCreationPromises = await Promise.all(
responsesWithOpenTextAnswers.map(async (response) => {
const responseEntries = openTextQuestionsWithInsights.map((question) => {
const responseText = response.data[question.id] as string;
if (!responseText) {
return;
}
const headline = parseRecallInfo(
question.headline[response.language ?? "default"],
response.data,
response.variables
);
const text = getPromptText(headline, responseText);
return {
responseId: response.id,
questionId: question.id,
text,
};
});
return responseEntries;
})
);
const answersForDocumentCreationResult = answersForDocumentCreationPromises.flat();
answersForDocumentCreationResult.forEach((answer) => {
if (answer) {
answersForDocumentCreation.push(answer);
}
});
}
// Process documents only up to the rate limit
if (rateLimit !== undefined && rateLimit < answersForDocumentCreation.length) {
// Push excess documents to the spillover array
spillover.push(...answersForDocumentCreation.slice(rateLimit));
answersForDocumentCreation = answersForDocumentCreation.slice(0, rateLimit);
}
const createDocumentPromises = answersForDocumentCreation.map((answer) => {
return createDocument(name, {
environmentId,
surveyId,
responseId: answer.responseId,
questionId: answer.questionId,
text: answer.text,
});
});
const createDocumentResults = await Promise.allSettled(createDocumentPromises);
const fullfilledCreateDocumentResults = createDocumentResults.filter(
(result) => result.status === "fulfilled"
) as PromiseFulfilledResult<TCreatedDocument>[];
const createdDocuments = fullfilledCreateDocumentResults.filter(Boolean).map((result) => result.value);
for (const document of createdDocuments) {
if (document) {
const insightPromises: Promise<void>[] = [];
const { insights, isSpam, id, environmentId } = document;
if (!isSpam) {
for (const insight of insights) {
if (typeof insight.title !== "string" || typeof insight.description !== "string") {
throw new Error("Insight title and description must be a string");
}
// Create or connect the insight
insightPromises.push(handleInsightAssignments(environmentId, id, insight));
}
await Promise.allSettled(insightPromises);
}
}
}
documentCache.revalidate({
environmentId: environmentId,
surveyId: surveyId,
});
}
return;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const generateInsightsForSurveyResponses = async (
survey: Pick<TSurvey, "id" | "name" | "environmentId" | "questions">
): Promise<void> => {
const { id: surveyId, name, environmentId, questions } = survey;
validateInputs([surveyId, ZId], [environmentId, ZId], [questions, ZSurveyQuestions]);
try {
const openTextQuestionsWithInsights = questions.filter(
(question) => question.type === TSurveyQuestionTypeEnum.OpenText && question.insightsEnabled
);
const openTextQuestionIds = openTextQuestionsWithInsights.map((question) => question.id);
if (openTextQuestionIds.length === 0) {
return;
}
// Fetching responses
const batchSize = 200;
let skip = 0;
const totalResponseCount = await prisma.response.count({
where: {
surveyId,
documents: {
none: {},
},
finished: true,
},
});
const pages = Math.ceil(totalResponseCount / batchSize);
for (let i = 0; i < pages; i++) {
const responses = await prisma.response.findMany({
where: {
surveyId,
documents: {
none: {},
},
finished: true,
},
select: {
id: true,
data: true,
variables: true,
contactId: true,
language: true,
},
take: batchSize,
skip,
});
const responsesWithOpenTextAnswers = responses.filter((response) =>
doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response.data)
);
skip += batchSize - responsesWithOpenTextAnswers.length;
const createDocumentPromises: Promise<TCreatedDocument | undefined>[] = [];
for (const response of responsesWithOpenTextAnswers) {
for (const question of openTextQuestionsWithInsights) {
const responseText = response.data[question.id] as string;
if (!responseText) {
continue;
}
const headline = parseRecallInfo(
question.headline[response.language ?? "default"],
response.data,
response.variables
);
const text = getPromptText(headline, responseText);
const createDocumentPromise = createDocument(name, {
environmentId,
surveyId,
responseId: response.id,
questionId: question.id,
text,
});
createDocumentPromises.push(createDocumentPromise);
}
}
const createdDocuments = (await Promise.all(createDocumentPromises)).filter(
Boolean
) as TCreatedDocument[];
for (const document of createdDocuments) {
if (document) {
const insightPromises: Promise<void>[] = [];
const { insights, isSpam, id, environmentId } = document;
if (!isSpam) {
for (const insight of insights) {
if (typeof insight.title !== "string" || typeof insight.description !== "string") {
throw new Error("Insight title and description must be a string");
}
// create or connect the insight
insightPromises.push(handleInsightAssignments(environmentId, id, insight));
}
await Promise.all(insightPromises);
}
}
}
documentCache.revalidate({
environmentId: environmentId,
surveyId: surveyId,
});
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getQuestionResponseReferenceId = (surveyId: string, questionId: TSurveyQuestionId) => {
return `${surveyId}-${questionId}`;
};
export const createInsight = async (insightGroupInput: TInsightCreateInput): Promise<Insight> => {
validateInputs([insightGroupInput, ZInsightCreateInput]);
try {
// create document
const { vector, ...data } = insightGroupInput;
const insight = await prisma.insight.create({
data,
});
// update document vector with the embedding
const vectorString = `[${insightGroupInput.vector.join(",")}]`;
await prisma.$executeRaw`
UPDATE "Insight"
SET "vector" = ${vectorString}::vector(512)
WHERE "id" = ${insight.id};
`;
insightCache.revalidate({
id: insight.id,
environmentId: insight.environmentId,
});
return insight;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const handleInsightAssignments = async (
environmentId: string,
documentId: string,
insight: {
title: string;
description: string;
category: InsightCategory;
}
) => {
try {
// create embedding for insight
const { embedding } = await embed({
model: embeddingsModel,
value: getInsightVectorText(insight.title, insight.description),
experimental_telemetry: { isEnabled: true },
});
// find close insight to merge it with
const nearestInsights = await findNearestInsights(environmentId, embedding, 1, 0.2);
if (nearestInsights.length > 0) {
// create a documentInsight with this insight
await prisma.documentInsight.create({
data: {
documentId,
insightId: nearestInsights[0].id,
},
});
documentCache.revalidate({
insightId: nearestInsights[0].id,
});
} else {
// create new insight and documentInsight
const newInsight = await createInsight({
environmentId: environmentId,
title: insight.title,
description: insight.description,
category: insight.category ?? "other",
vector: embedding,
});
// create a documentInsight with this insight
await prisma.documentInsight.create({
data: {
documentId,
insightId: newInsight.id,
},
});
documentCache.revalidate({
insightId: newInsight.id,
});
}
} catch (error) {
throw error;
}
};
export const findNearestInsights = async (
environmentId: string,
vector: number[],
limit: number = 5,
threshold: number = 0.5
): Promise<TNearestInsights[]> => {
validateInputs([environmentId, ZId]);
// Convert the embedding array to a JSON-like string representation
const vectorString = `[${vector.join(",")}]`;
// Execute raw SQL query to find nearest neighbors and exclude the vector column
const insights: TNearestInsights[] = await prisma.$queryRaw`
SELECT
id
FROM "Insight" d
WHERE d."environmentId" = ${environmentId}
AND d."vector" <=> ${vectorString}::vector(512) <= ${threshold}
ORDER BY d."vector" <=> ${vectorString}::vector(512)
LIMIT ${limit};
`;
return insights;
};
export const getInsightVectorText = (title: string, description: string): string =>
`${title}: ${description}`;

View File

@@ -1,16 +0,0 @@
import { Insight } from "@prisma/client";
import { z } from "zod";
import { ZInsight } from "@formbricks/database/zod/insights";
export const ZInsightCreateInput = ZInsight.pick({
environmentId: true,
title: true,
description: true,
category: true,
}).extend({
vector: z.array(z.number()).length(512),
});
export type TInsightCreateInput = z.infer<typeof ZInsightCreateInput>;
export type TNearestInsights = Pick<Insight, "id">;

View File

@@ -1,390 +0,0 @@
import { CRON_SECRET, WEBAPP_URL } from "@/lib/constants";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { mockSurveyOutput } from "@/lib/survey/tests/__mock__/survey.mock";
import { doesSurveyHasOpenTextQuestion } from "@/lib/survey/utils";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
doesResponseHasAnyOpenTextAnswer,
generateInsightsEnabledForSurveyQuestions,
generateInsightsForSurvey,
} from "./utils";
// Mock all dependencies
vi.mock("@/lib/constants", () => ({
CRON_SECRET: vi.fn(() => "mocked-cron-secret"),
WEBAPP_URL: "https://mocked-webapp-url.com",
}));
vi.mock("@/lib/survey/cache", () => ({
surveyCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
updateSurvey: vi.fn(),
}));
vi.mock("@/lib/survey/utils", () => ({
doesSurveyHasOpenTextQuestion: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe("Insights Utils", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("generateInsightsForSurvey", () => {
test("should call fetch with correct parameters", () => {
const surveyId = "survey-123";
mockFetch.mockResolvedValueOnce({ ok: true });
generateInsightsForSurvey(surveyId);
expect(mockFetch).toHaveBeenCalledWith(`${WEBAPP_URL}/api/insights`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": CRON_SECRET,
},
body: JSON.stringify({
surveyId,
}),
});
});
test("should handle errors and return error object", () => {
const surveyId = "survey-123";
mockFetch.mockImplementationOnce(() => {
throw new Error("Network error");
});
const result = generateInsightsForSurvey(surveyId);
expect(result).toEqual({
ok: false,
error: new Error("Error while generating insights for survey: Network error"),
});
});
test("should throw error if CRON_SECRET is not set", async () => {
// Reset modules to ensure clean state
vi.resetModules();
// Mock CRON_SECRET as undefined
vi.doMock("@/lib/constants", () => ({
CRON_SECRET: undefined,
WEBAPP_URL: "https://mocked-webapp-url.com",
}));
// Re-import the utils module to get the mocked CRON_SECRET
const { generateInsightsForSurvey } = await import("./utils");
expect(() => generateInsightsForSurvey("survey-123")).toThrow("CRON_SECRET is not set");
// Reset modules after test
vi.resetModules();
});
});
describe("generateInsightsEnabledForSurveyQuestions", () => {
test("should return success=false when survey has no open text questions", async () => {
// Mock data
const surveyId = "survey-123";
const mockSurvey: TSurvey = {
...mockSurveyOutput,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 1" },
required: true,
choices: [
{
id: "cm8cjnse3000009jxf20v91ic",
label: { default: "Choice 1" },
},
],
},
{
id: "cm8cjo19c000109jx6znygc0u",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Question 2" },
required: true,
scale: "number",
range: 5,
isColorCodingEnabled: false,
},
],
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(false);
// Execute function
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
// Verify results
expect(result).toEqual({ success: false });
expect(updateSurvey).not.toHaveBeenCalled();
});
test("should return success=true when survey is updated with insights enabled", async () => {
vi.clearAllMocks();
// Mock data
const surveyId = "cm8ckvchx000008lb710n0gdn";
// Mock survey with open text questions that have no insightsEnabled property
const mockSurveyWithOpenTextQuestions: TSurvey = {
...mockSurveyOutput,
id: surveyId,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: {},
},
{
id: "cm8cjo19c000109jx6znygc0u",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 2" },
required: true,
inputType: "text",
charLimit: {},
},
],
};
// Define the updated survey that should be returned after updateSurvey
const mockUpdatedSurveyWithOpenTextQuestions: TSurvey = {
...mockSurveyWithOpenTextQuestions,
questions: mockSurveyWithOpenTextQuestions.questions.map((q) => ({
...q,
insightsEnabled: true, // Updated property
})),
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurveyWithOpenTextQuestions);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
vi.mocked(updateSurvey).mockResolvedValueOnce(mockUpdatedSurveyWithOpenTextQuestions);
// Execute function
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
expect(result).toEqual({
success: true,
survey: mockUpdatedSurveyWithOpenTextQuestions,
});
});
test("should return success=false when all open text questions already have insightsEnabled defined", async () => {
// Mock data
const surveyId = "survey-123";
const mockSurvey: TSurvey = {
...mockSurveyOutput,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: {},
insightsEnabled: true,
},
{
id: "cm8cjo19c000109jx6znygc0u",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 2" },
required: true,
choices: [
{
id: "cm8cjnse3000009jxf20v91ic",
label: { default: "Choice 1" },
},
],
},
],
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
// Execute function
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
// Verify results
expect(result).toEqual({ success: false });
expect(updateSurvey).not.toHaveBeenCalled();
});
test("should throw ResourceNotFoundError if survey is not found", async () => {
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(null);
// Execute and verify function
await expect(generateInsightsEnabledForSurveyQuestions("survey-123")).rejects.toThrow(
new ResourceNotFoundError("Survey", "survey-123")
);
});
test("should throw ResourceNotFoundError if updateSurvey returns null", async () => {
// Mock data
const surveyId = "survey-123";
const mockSurvey: TSurvey = {
...mockSurveyOutput,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: {},
},
],
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
// Type assertion to handle the null case
vi.mocked(updateSurvey).mockResolvedValueOnce(null as unknown as TSurvey);
// Execute and verify function
await expect(generateInsightsEnabledForSurveyQuestions(surveyId)).rejects.toThrow(
new ResourceNotFoundError("Survey", surveyId)
);
});
test("should return success=false when no questions have insights enabled after update", async () => {
// Mock data
const surveyId = "survey-123";
const mockSurvey: TSurvey = {
...mockSurveyOutput,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: {},
insightsEnabled: false,
},
],
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
vi.mocked(updateSurvey).mockResolvedValueOnce(mockSurvey);
// Execute function
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
// Verify results
expect(result).toEqual({ success: false });
});
test("should propagate any errors that occur", async () => {
// Setup mocks
const testError = new Error("Test error");
vi.mocked(getSurvey).mockRejectedValueOnce(testError);
// Execute and verify function
await expect(generateInsightsEnabledForSurveyQuestions("survey-123")).rejects.toThrow(testError);
});
});
describe("doesResponseHasAnyOpenTextAnswer", () => {
test("should return true when at least one open text question has an answer", () => {
const openTextQuestionIds = ["q1", "q2", "q3"];
const response = {
q1: "",
q2: "This is an answer",
q3: "",
q4: "This is not an open text answer",
};
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
expect(result).toBe(true);
});
test("should return false when no open text questions have answers", () => {
const openTextQuestionIds = ["q1", "q2", "q3"];
const response = {
q1: "",
q2: "",
q3: "",
q4: "This is not an open text answer",
};
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
expect(result).toBe(false);
});
test("should return false when response does not contain any open text question IDs", () => {
const openTextQuestionIds = ["q1", "q2", "q3"];
const response = {
q4: "This is not an open text answer",
q5: "Another answer",
};
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
expect(result).toBe(false);
});
test("should return false for non-string answers", () => {
const openTextQuestionIds = ["q1", "q2", "q3"];
const response = {
q1: "",
q2: 123,
q3: true,
} as any; // Use type assertion to handle mixed types in the test
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
expect(result).toBe(false);
});
});
});

View File

@@ -1,101 +0,0 @@
import "server-only";
import { CRON_SECRET, WEBAPP_URL } from "@/lib/constants";
import { surveyCache } from "@/lib/survey/cache";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { doesSurveyHasOpenTextQuestion } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
export const generateInsightsForSurvey = (surveyId: string) => {
if (!CRON_SECRET) {
throw new Error("CRON_SECRET is not set");
}
try {
return fetch(`${WEBAPP_URL}/api/insights`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": CRON_SECRET,
},
body: JSON.stringify({
surveyId,
}),
});
} catch (error) {
return {
ok: false,
error: new Error(`Error while generating insights for survey: ${error.message}`),
};
}
};
export const generateInsightsEnabledForSurveyQuestions = async (
surveyId: string
): Promise<
| {
success: false;
}
| {
success: true;
survey: Pick<TSurvey, "id" | "name" | "environmentId" | "questions">;
}
> => {
validateInputs([surveyId, ZId]);
try {
const survey = await getSurvey(surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
if (!doesSurveyHasOpenTextQuestion(survey.questions)) {
return { success: false };
}
const openTextQuestions = survey.questions.filter((question) => question.type === "openText");
const openTextQuestionsWithoutInsightsEnabled = openTextQuestions.filter(
(question) => question.type === "openText" && typeof question.insightsEnabled === "undefined"
);
if (openTextQuestionsWithoutInsightsEnabled.length === 0) {
return { success: false };
}
const updatedSurvey = await updateSurvey(survey);
if (!updatedSurvey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
const doesSurveyHasInsightsEnabledQuestion = updatedSurvey.questions.some(
(question) => question.type === "openText" && question.insightsEnabled === true
);
surveyCache.revalidate({ id: surveyId, environmentId: survey.environmentId });
if (doesSurveyHasInsightsEnabledQuestion) {
return { success: true, survey: updatedSurvey };
}
return { success: false };
} catch (error) {
logger.error(error, "Error generating insights for surveys");
throw error;
}
};
export const doesResponseHasAnyOpenTextAnswer = (
openTextQuestionIds: string[],
response: TResponse["data"]
): boolean => {
return openTextQuestionIds.some((questionId) => {
const answer = response[questionId];
return typeof answer === "string" && answer.length > 0;
});
};

View File

@@ -1,51 +0,0 @@
// This function can run for a maximum of 300 seconds
import { generateInsightsForSurveyResponsesConcept } from "@/app/api/(internal)/insights/lib/insights";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { CRON_SECRET } from "@/lib/constants";
import { headers } from "next/headers";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { generateInsightsEnabledForSurveyQuestions } from "./lib/utils";
export const maxDuration = 300; // This function can run for a maximum of 300 seconds
const ZGenerateInsightsInput = z.object({
surveyId: z.string(),
});
export const POST = async (request: Request) => {
try {
const requestHeaders = await headers();
// Check authentication
if (requestHeaders.get("x-api-key") !== CRON_SECRET) {
return responses.notAuthenticatedResponse();
}
const jsonInput = await request.json();
const inputValidation = ZGenerateInsightsInput.safeParse(jsonInput);
if (!inputValidation.success) {
logger.error({ error: inputValidation.error, url: request.url }, "Error in POST /api/insights");
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { surveyId } = inputValidation.data;
const data = await generateInsightsEnabledForSurveyQuestions(surveyId);
if (!data.success) {
return responses.successResponse({ message: "No insights enabled questions found" });
}
await generateInsightsForSurveyResponsesConcept(data.survey);
return responses.successResponse({ message: "Insights generated successfully" });
} catch (error) {
throw error;
}
};

View File

@@ -1,107 +0,0 @@
import { handleInsightAssignments } from "@/app/api/(internal)/insights/lib/insights";
import { embeddingsModel, llmModel } from "@/lib/aiModels";
import { documentCache } from "@/lib/cache/document";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { embed, generateObject } from "ai";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZInsight } from "@formbricks/database/zod/insights";
import {
TDocument,
TDocumentCreateInput,
ZDocumentCreateInput,
ZDocumentSentiment,
} from "@formbricks/types/documents";
import { DatabaseError } from "@formbricks/types/errors";
export const createDocumentAndAssignInsight = async (
surveyName: string,
documentInput: TDocumentCreateInput
): Promise<TDocument> => {
validateInputs([surveyName, z.string()], [documentInput, ZDocumentCreateInput]);
try {
// Generate text embedding
const { embedding } = await embed({
model: embeddingsModel,
value: documentInput.text,
experimental_telemetry: { isEnabled: true },
});
// generate sentiment and insights
const { object } = await generateObject({
model: llmModel,
schema: z.object({
sentiment: ZDocumentSentiment,
insights: z.array(
z.object({
title: z.string().describe("insight title, very specific"),
description: z.string().describe("very brief insight description"),
category: ZInsight.shape.category,
})
),
isSpam: z.boolean(),
}),
system: `You are an XM researcher. You analyse a survey response (survey name, question headline & user answer) and generate insights from it. The insight title (1-3 words) should concisely answer the question, e.g., "What type of people do you think would most benefit" -> "Developers". You are very objective. For the insights, split the feedback into the smallest parts possible and only use the feedback itself to draw conclusions. You must output at least one insight. Always generate insights and titles in English, regardless of the input language.`,
prompt: `Survey: ${surveyName}\n${documentInput.text}`,
temperature: 0,
experimental_telemetry: { isEnabled: true },
});
const sentiment = object.sentiment;
const isSpam = object.isSpam;
const insights = object.insights;
// create document
const prismaDocument = await prisma.document.create({
data: {
...documentInput,
sentiment,
isSpam,
},
});
const document = {
...prismaDocument,
vector: embedding,
};
// update document vector with the embedding
const vectorString = `[${embedding.join(",")}]`;
await prisma.$executeRaw`
UPDATE "Document"
SET "vector" = ${vectorString}::vector(512)
WHERE "id" = ${document.id};
`;
// connect or create the insights
const insightPromises: Promise<void>[] = [];
if (!isSpam) {
for (const insight of insights) {
if (typeof insight.title !== "string" || typeof insight.description !== "string") {
throw new Error("Insight title and description must be a string");
}
// create or connect the insight
insightPromises.push(handleInsightAssignments(documentInput.environmentId, document.id, insight));
}
await Promise.allSettled(insightPromises);
}
documentCache.revalidate({
id: document.id,
environmentId: document.environmentId,
surveyId: document.surveyId,
responseId: document.responseId,
questionId: document.questionId,
});
return document;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -1,19 +1,15 @@
import { createDocumentAndAssignInsight } from "@/app/api/(internal)/pipeline/lib/documents";
import { sendSurveyFollowUps } from "@/app/api/(internal)/pipeline/lib/survey-follow-up";
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { cache } from "@/lib/cache";
import { webhookCache } from "@/lib/cache/webhook";
import { CRON_SECRET, IS_AI_CONFIGURED } from "@/lib/constants";
import { CRON_SECRET } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { getPromptText } from "@/lib/utils/ai";
import { parseRecallInfo } from "@/lib/utils/recall";
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
import { sendResponseFinishedEmail } from "@/modules/email";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { PipelineTriggers, Webhook } from "@prisma/client";
@@ -199,50 +195,6 @@ export const POST = async (request: Request) => {
logger.error({ error: result.reason, url: request.url }, "Promise rejected");
}
});
// generate embeddings for all open text question responses for all paid plans
const hasSurveyOpenTextQuestions = survey.questions.some((question) => question.type === "openText");
if (hasSurveyOpenTextQuestions) {
const isAICofigured = IS_AI_CONFIGURED;
if (hasSurveyOpenTextQuestions && isAICofigured) {
const isAIEnabled = await getIsAIEnabled({
isAIEnabled: organization.isAIEnabled,
billing: organization.billing,
});
if (isAIEnabled) {
for (const question of survey.questions) {
if (question.type === "openText" && question.insightsEnabled) {
const isQuestionAnswered =
response.data[question.id] !== undefined && response.data[question.id] !== "";
if (!isQuestionAnswered) {
continue;
}
const headline = parseRecallInfo(
question.headline[response.language ?? "default"],
response.data,
response.variables
);
const text = getPromptText(headline, response.data[question.id] as string);
// TODO: check if subheadline gives more context and better embeddings
try {
await createDocumentAndAssignInsight(survey.name, {
environmentId,
surveyId,
responseId: response.id,
questionId: question.id,
text,
});
} catch (e) {
logger.error({ error: e, url: request.url }, "Error creating document and assigning insight");
}
}
}
}
}
}
} else {
// Await webhook promises if no emails are sent (with allSettled to prevent early rejection)
const results = await Promise.allSettled(webhookPromises);

View File

@@ -66,7 +66,6 @@ const Page = async (props: SummaryPageProps) => {
surveyId={survey.id}
webAppUrl={WEBAPP_URL}
totalResponseCount={totalResponseCount}
isAIEnabled={false} // Disable AI for sharing page for now
isReadOnly={true}
locale={DEFAULT_LOCALE}
/>

View File

@@ -1,21 +0,0 @@
import { createAzure } from "@ai-sdk/azure";
import {
AI_AZURE_EMBEDDINGS_API_KEY,
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID,
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME,
AI_AZURE_LLM_API_KEY,
AI_AZURE_LLM_DEPLOYMENT_ID,
AI_AZURE_LLM_RESSOURCE_NAME,
} from "./constants";
export const llmModel = createAzure({
resourceName: AI_AZURE_LLM_RESSOURCE_NAME, // Azure resource name
apiKey: AI_AZURE_LLM_API_KEY, // Azure API key
})(AI_AZURE_LLM_DEPLOYMENT_ID || "llm");
export const embeddingsModel = createAzure({
resourceName: AI_AZURE_EMBEDDINGS_RESSOURCE_NAME, // Azure resource name
apiKey: AI_AZURE_EMBEDDINGS_API_KEY, // Azure API key
}).embedding(AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID || "embeddings", {
dimensions: 512,
});

View File

@@ -96,7 +96,6 @@ export const RESPONSES_PER_PAGE = 25;
export const TEXT_RESPONSES_PER_PAGE = 5;
export const INSIGHTS_PER_PAGE = 10;
export const DOCUMENTS_PER_PAGE = 10;
export const MAX_RESPONSES_FOR_INSIGHT_GENERATION = 500;
export const DEFAULT_ORGANIZATION_ID = env.DEFAULT_ORGANIZATION_ID;
export const DEFAULT_ORGANIZATION_ROLE = env.DEFAULT_ORGANIZATION_ROLE;
@@ -262,21 +261,6 @@ export const BILLING_LIMITS = {
},
} as const;
export const AI_AZURE_LLM_RESSOURCE_NAME = env.AI_AZURE_LLM_RESSOURCE_NAME;
export const AI_AZURE_LLM_API_KEY = env.AI_AZURE_LLM_API_KEY;
export const AI_AZURE_LLM_DEPLOYMENT_ID = env.AI_AZURE_LLM_DEPLOYMENT_ID;
export const AI_AZURE_EMBEDDINGS_RESSOURCE_NAME = env.AI_AZURE_EMBEDDINGS_RESSOURCE_NAME;
export const AI_AZURE_EMBEDDINGS_API_KEY = env.AI_AZURE_EMBEDDINGS_API_KEY;
export const AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID = env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID;
export const IS_AI_CONFIGURED = Boolean(
env.AI_AZURE_EMBEDDINGS_API_KEY &&
env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID &&
env.AI_AZURE_EMBEDDINGS_RESSOURCE_NAME &&
env.AI_AZURE_LLM_API_KEY &&
env.AI_AZURE_LLM_DEPLOYMENT_ID &&
env.AI_AZURE_LLM_RESSOURCE_NAME
);
export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY);

View File

@@ -7,12 +7,6 @@ export const env = createEnv({
* Will throw if you access these variables on the client.
*/
server: {
AI_AZURE_EMBEDDINGS_API_KEY: z.string().optional(),
AI_AZURE_LLM_API_KEY: z.string().optional(),
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: z.string().optional(),
AI_AZURE_LLM_DEPLOYMENT_ID: z.string().optional(),
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: z.string().optional(),
AI_AZURE_LLM_RESSOURCE_NAME: z.string().optional(),
AIRTABLE_CLIENT_ID: z.string().optional(),
AZUREAD_CLIENT_ID: z.string().optional(),
AZUREAD_CLIENT_SECRET: z.string().optional(),
@@ -128,12 +122,6 @@ export const env = createEnv({
* 💡 You'll get type errors if not all variables from `server` & `client` are included here.
*/
runtimeEnv: {
AI_AZURE_EMBEDDINGS_API_KEY: process.env.AI_AZURE_EMBEDDINGS_API_KEY,
AI_AZURE_LLM_API_KEY: process.env.AI_AZURE_LLM_API_KEY,
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: process.env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID,
AI_AZURE_LLM_DEPLOYMENT_ID: process.env.AI_AZURE_LLM_DEPLOYMENT_ID,
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: process.env.AI_AZURE_EMBEDDINGS_RESSOURCE_NAME,
AI_AZURE_LLM_RESSOURCE_NAME: process.env.AI_AZURE_LLM_RESSOURCE_NAME,
LANGFUSE_SECRET_KEY: process.env.LANGFUSE_SECRET_KEY,
LANGFUSE_PUBLIC_KEY: process.env.LANGFUSE_PUBLIC_KEY,
LANGFUSE_BASEURL: process.env.LANGFUSE_BASEURL,

View File

@@ -1064,7 +1064,6 @@
"website_surveys": "Website-Umfragen"
},
"enterprise": {
"ai": "KI-Analyse",
"audit_logs": "Audit Logs",
"coming_soon": "Kommt bald",
"contacts_and_segments": "Kontaktverwaltung & Segmente",
@@ -1102,13 +1101,7 @@
"eliminate_branding_with_whitelabel": "Entferne Formbricks Branding und aktiviere zusätzliche White-Label-Anpassungsoptionen.",
"email_customization_preview_email_heading": "Hey {userName}",
"email_customization_preview_email_text": "Dies ist eine E-Mail-Vorschau, um dir zu zeigen, welches Logo in den E-Mails gerendert wird.",
"enable_formbricks_ai": "Formbricks KI aktivieren",
"error_deleting_organization_please_try_again": "Fehler beim Löschen der Organisation. Bitte versuche es erneut.",
"formbricks_ai": "Formbricks KI",
"formbricks_ai_description": "Erhalte personalisierte Einblicke aus deinen Umfrageantworten mit Formbricks KI",
"formbricks_ai_disable_success_message": "Formbricks KI wurde erfolgreich deaktiviert.",
"formbricks_ai_enable_success_message": "Formbricks KI erfolgreich aktiviert.",
"formbricks_ai_privacy_policy_text": "Durch die Aktivierung von Formbricks KI stimmst Du den aktualisierten",
"from_your_organization": "von deiner Organisation",
"invitation_sent_once_more": "Einladung nochmal gesendet.",
"invite_deleted_successfully": "Einladung erfolgreich gelöscht",
@@ -1454,9 +1447,6 @@
"follow_ups_new": "Neues Follow-up",
"follow_ups_upgrade_button_text": "Upgrade, um Follow-ups zu aktivieren",
"form_styling": "Umfrage Styling",
"formbricks_ai_description": "Beschreibe deine Umfrage und lass Formbricks KI die Umfrage für Dich erstellen",
"formbricks_ai_generate": "erzeugen",
"formbricks_ai_prompt_placeholder": "Gib Umfrageinformationen ein (z.B. wichtige Themen, die abgedeckt werden sollen)",
"formbricks_sdk_is_not_connected": "Formbricks SDK ist nicht verbunden",
"four_points": "4 Punkte",
"heading": "Überschrift",
@@ -1740,11 +1730,6 @@
"embed_on_website": "Auf Website einbetten",
"embed_pop_up_survey_title": "Wie man eine Pop-up-Umfrage auf seiner Website einbindet",
"embed_survey": "Umfrage einbetten",
"enable_ai_insights_banner_button": "Insights aktivieren",
"enable_ai_insights_banner_description": "Du kannst die neue Insights-Funktion für die Umfrage aktivieren, um KI-basierte Insights für deine Freitextantworten zu erhalten.",
"enable_ai_insights_banner_success": "Erzeuge Insights für diese Umfrage. Bitte in ein paar Minuten die Seite neu laden.",
"enable_ai_insights_banner_title": "Bereit, KI-Insights zu testen?",
"enable_ai_insights_banner_tooltip": "Das sind ganz schön viele Freitextantworten! Kontaktiere uns bitte unter hola@formbricks.com, um Insights für diese Umfrage zu erhalten.",
"failed_to_copy_link": "Kopieren des Links fehlgeschlagen",
"filter_added_successfully": "Filter erfolgreich hinzugefügt",
"filter_updated_successfully": "Filter erfolgreich aktualisiert",

View File

@@ -1064,7 +1064,6 @@
"website_surveys": "Sondages de site web"
},
"enterprise": {
"ai": "Analyse IA",
"audit_logs": "Journaux d'audit",
"coming_soon": "À venir bientôt",
"contacts_and_segments": "Gestion des contacts et des segments",
@@ -1102,13 +1101,7 @@
"eliminate_branding_with_whitelabel": "Éliminez la marque Formbricks et activez des options de personnalisation supplémentaires.",
"email_customization_preview_email_heading": "Salut {userName}",
"email_customization_preview_email_text": "Cette est une prévisualisation d'e-mail pour vous montrer quel logo sera rendu dans les e-mails.",
"enable_formbricks_ai": "Activer Formbricks IA",
"error_deleting_organization_please_try_again": "Erreur lors de la suppression de l'organisation. Veuillez réessayer.",
"formbricks_ai": "Formbricks IA",
"formbricks_ai_description": "Obtenez des insights personnalisés à partir de vos réponses au sondage avec Formbricks AI.",
"formbricks_ai_disable_success_message": "Formbricks AI désactivé avec succès.",
"formbricks_ai_enable_success_message": "Formbricks AI activé avec succès.",
"formbricks_ai_privacy_policy_text": "En activant Formbricks AI, vous acceptez les mises à jour",
"from_your_organization": "de votre organisation",
"invitation_sent_once_more": "Invitation envoyée une fois de plus.",
"invite_deleted_successfully": "Invitation supprimée avec succès",
@@ -1454,9 +1447,6 @@
"follow_ups_new": "Nouveau suivi",
"follow_ups_upgrade_button_text": "Passez à la version supérieure pour activer les relances",
"form_styling": "Style de formulaire",
"formbricks_ai_description": "Décrivez votre enquête et laissez l'IA de Formbricks créer l'enquête pour vous.",
"formbricks_ai_generate": "Générer",
"formbricks_ai_prompt_placeholder": "Saisissez les informations de l'enquête (par exemple, les sujets clés à aborder)",
"formbricks_sdk_is_not_connected": "Le SDK Formbricks n'est pas connecté",
"four_points": "4 points",
"heading": "En-tête",
@@ -1740,11 +1730,6 @@
"embed_on_website": "Incorporer sur le site web",
"embed_pop_up_survey_title": "Comment intégrer une enquête pop-up sur votre site web",
"embed_survey": "Intégrer l'enquête",
"enable_ai_insights_banner_button": "Activer les insights",
"enable_ai_insights_banner_description": "Vous pouvez activer la nouvelle fonctionnalité d'aperçus pour l'enquête afin d'obtenir des aperçus basés sur l'IA pour vos réponses en texte libre.",
"enable_ai_insights_banner_success": "Génération d'analyses pour cette enquête. Veuillez revenir dans quelques minutes.",
"enable_ai_insights_banner_title": "Prêt à tester les insights de l'IA ?",
"enable_ai_insights_banner_tooltip": "Veuillez nous contacter à hola@formbricks.com pour générer des insights pour cette enquête.",
"failed_to_copy_link": "Échec de la copie du lien",
"filter_added_successfully": "Filtre ajouté avec succès",
"filter_updated_successfully": "Filtre mis à jour avec succès",

View File

@@ -1064,7 +1064,6 @@
"website_surveys": "Pesquisas de Site"
},
"enterprise": {
"ai": "Análise de IA",
"audit_logs": "Registros de Auditoria",
"coming_soon": "Em breve",
"contacts_and_segments": "Gerenciamento de contatos e segmentos",
@@ -1102,13 +1101,7 @@
"eliminate_branding_with_whitelabel": "Elimine a marca Formbricks e ative opções adicionais de personalização de marca branca.",
"email_customization_preview_email_heading": "Oi {userName}",
"email_customization_preview_email_text": "Esta é uma pré-visualização de e-mail para mostrar qual logo será renderizado nos e-mails.",
"enable_formbricks_ai": "Ativar Formbricks IA",
"error_deleting_organization_please_try_again": "Erro ao deletar a organização. Por favor, tente novamente.",
"formbricks_ai": "Formbricks IA",
"formbricks_ai_description": "Obtenha insights personalizados das suas respostas de pesquisa com o Formbricks AI",
"formbricks_ai_disable_success_message": "Formbricks AI desativado com sucesso.",
"formbricks_ai_enable_success_message": "Formbricks AI ativado com sucesso.",
"formbricks_ai_privacy_policy_text": "Ao ativar o Formbricks AI, você concorda com a versão atualizada",
"from_your_organization": "da sua organização",
"invitation_sent_once_more": "Convite enviado de novo.",
"invite_deleted_successfully": "Convite deletado com sucesso",
@@ -1454,9 +1447,6 @@
"follow_ups_new": "Novo acompanhamento",
"follow_ups_upgrade_button_text": "Atualize para habilitar os Acompanhamentos",
"form_styling": "Estilização de Formulários",
"formbricks_ai_description": "Descreva sua pesquisa e deixe a Formbricks AI criar a pesquisa pra você",
"formbricks_ai_generate": "gerar",
"formbricks_ai_prompt_placeholder": "Insira as informações da pesquisa (ex.: tópicos principais a serem abordados)",
"formbricks_sdk_is_not_connected": "O SDK do Formbricks não está conectado",
"four_points": "4 pontos",
"heading": "Título",
@@ -1740,11 +1730,6 @@
"embed_on_website": "Incorporar no site",
"embed_pop_up_survey_title": "Como incorporar uma pesquisa pop-up no seu site",
"embed_survey": "Incorporar pesquisa",
"enable_ai_insights_banner_button": "Ativar insights",
"enable_ai_insights_banner_description": "Você pode ativar o novo recurso de insights para a pesquisa e obter insights baseados em IA para suas respostas em texto aberto.",
"enable_ai_insights_banner_success": "Gerando insights para essa pesquisa. Por favor, volte em alguns minutos.",
"enable_ai_insights_banner_title": "Pronto pra testar as ideias da IA?",
"enable_ai_insights_banner_tooltip": "Por favor, entre em contato conosco pelo e-mail hola@formbricks.com para gerar insights para esta pesquisa",
"failed_to_copy_link": "Falha ao copiar link",
"filter_added_successfully": "Filtro adicionado com sucesso",
"filter_updated_successfully": "Filtro atualizado com sucesso",

View File

@@ -1064,7 +1064,6 @@
"website_surveys": "Inquéritos do Website"
},
"enterprise": {
"ai": "Análise de IA",
"audit_logs": "Registos de Auditoria",
"coming_soon": "Em breve",
"contacts_and_segments": "Gestão de contactos e segmentos",
@@ -1102,13 +1101,7 @@
"eliminate_branding_with_whitelabel": "Elimine a marca Formbricks e ative opções adicionais de personalização de marca branca.",
"email_customization_preview_email_heading": "Olá {userName}",
"email_customization_preview_email_text": "Esta é uma pré-visualização de email para mostrar qual logotipo será exibido nos emails.",
"enable_formbricks_ai": "Ativar Formbricks IA",
"error_deleting_organization_please_try_again": "Erro ao eliminar a organização. Por favor, tente novamente.",
"formbricks_ai": "Formbricks IA",
"formbricks_ai_description": "Obtenha informações personalizadas das suas respostas aos inquéritos com o Formbricks IA",
"formbricks_ai_disable_success_message": "Formbricks AI desativado com sucesso.",
"formbricks_ai_enable_success_message": "Formbricks IA ativado com sucesso.",
"formbricks_ai_privacy_policy_text": "Ao ativar o Formbricks AI, você concorda com a atualização",
"from_your_organization": "da sua organização",
"invitation_sent_once_more": "Convite enviado mais uma vez.",
"invite_deleted_successfully": "Convite eliminado com sucesso",
@@ -1454,9 +1447,6 @@
"follow_ups_new": "Novo acompanhamento",
"follow_ups_upgrade_button_text": "Atualize para ativar os acompanhamentos",
"form_styling": "Estilo do formulário",
"formbricks_ai_description": "Descreva o seu inquérito e deixe a Formbricks AI criar o inquérito para si",
"formbricks_ai_generate": "Gerar",
"formbricks_ai_prompt_placeholder": "Introduza as informações do inquérito (por exemplo, tópicos principais a abordar)",
"formbricks_sdk_is_not_connected": "O SDK do Formbricks não está conectado",
"four_points": "4 pontos",
"heading": "Cabeçalho",
@@ -1740,11 +1730,6 @@
"embed_on_website": "Incorporar no site",
"embed_pop_up_survey_title": "Como incorporar um questionário pop-up no seu site",
"embed_survey": "Incorporar inquérito",
"enable_ai_insights_banner_button": "Ativar insights",
"enable_ai_insights_banner_description": "Pode ativar a nova funcionalidade de insights para o inquérito para obter insights baseados em IA para as suas respostas de texto aberto.",
"enable_ai_insights_banner_success": "A gerar insights para este inquérito. Por favor, volte a verificar dentro de alguns minutos.",
"enable_ai_insights_banner_title": "Pronto para testar as perceções de IA?",
"enable_ai_insights_banner_tooltip": "Por favor, contacte-nos em hola@formbricks.com para gerar insights para este inquérito",
"failed_to_copy_link": "Falha ao copiar link",
"filter_added_successfully": "Filtro adicionado com sucesso",
"filter_updated_successfully": "Filtro atualizado com sucesso",

View File

@@ -1064,7 +1064,6 @@
"website_surveys": "網站問卷"
},
"enterprise": {
"ai": "AI 分析",
"audit_logs": "稽核記錄",
"coming_soon": "即將推出",
"contacts_and_segments": "聯絡人管理和區隔",
@@ -1102,13 +1101,7 @@
"eliminate_branding_with_whitelabel": "消除 Formbricks 品牌並啟用其他白標自訂選項。",
"email_customization_preview_email_heading": "嗨,'{'userName'}'",
"email_customization_preview_email_text": "這是電子郵件預覽,向您展示電子郵件中將呈現哪個標誌。",
"enable_formbricks_ai": "啟用 Formbricks AI",
"error_deleting_organization_please_try_again": "刪除組織時發生錯誤。請再試一次。",
"formbricks_ai": "Formbricks AI",
"formbricks_ai_description": "使用 Formbricks AI 從您的問卷回應中取得個人化洞察",
"formbricks_ai_disable_success_message": "已成功停用 Formbricks AI。",
"formbricks_ai_enable_success_message": "已成功啟用 Formbricks AI。",
"formbricks_ai_privacy_policy_text": "藉由啟用 Formbricks AI您同意更新後的",
"from_your_organization": "來自您的組織",
"invitation_sent_once_more": "已再次發送邀請。",
"invite_deleted_successfully": "邀請已成功刪除",
@@ -1454,9 +1447,6 @@
"follow_ups_new": "新增後續追蹤",
"follow_ups_upgrade_button_text": "升級以啟用後續追蹤",
"form_styling": "表單樣式設定",
"formbricks_ai_description": "描述您的問卷並讓 Formbricks AI 為您建立問卷",
"formbricks_ai_generate": "產生",
"formbricks_ai_prompt_placeholder": "輸入問卷資訊(例如,要涵蓋的關鍵主題)",
"formbricks_sdk_is_not_connected": "Formbricks SDK 未連線",
"four_points": "4 分",
"heading": "標題",
@@ -1740,11 +1730,6 @@
"embed_on_website": "嵌入網站",
"embed_pop_up_survey_title": "如何在您的網站上嵌入彈出式問卷",
"embed_survey": "嵌入問卷",
"enable_ai_insights_banner_button": "啟用洞察",
"enable_ai_insights_banner_description": "您可以為問卷啟用新的洞察功能,以取得針對您開放文字回應的 AI 洞察。",
"enable_ai_insights_banner_success": "正在為此問卷產生洞察。請稍後再查看。",
"enable_ai_insights_banner_title": "準備好測試 AI 洞察了嗎?",
"enable_ai_insights_banner_tooltip": "請透過 hola@formbricks.com 與我們聯絡,以產生此問卷的洞察",
"failed_to_copy_link": "無法複製連結",
"filter_added_successfully": "篩選器已成功新增",
"filter_updated_successfully": "篩選器已成功更新",

View File

@@ -392,8 +392,6 @@ export const mockSurveySummaryOutput = {
},
summary: [
{
insights: undefined,
insightsEnabled: undefined,
question: {
headline: { default: "Question Text", de: "Fragetext" },
id: "ars2tjk8hsi8oqk1uac00mo8",

View File

@@ -9,25 +9,16 @@ import { ActionClass, Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZOptionalNumber } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/common";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import {
TSurvey,
TSurveyCreateInput,
TSurveyOpenTextQuestion,
TSurveyQuestions,
ZSurvey,
ZSurveyCreateInput,
} from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyCreateInput, ZSurvey, ZSurveyCreateInput } from "@formbricks/types/surveys/types";
import { getActionClasses } from "../actionClass/service";
import { ITEMS_PER_PAGE } from "../constants";
import { capturePosthogEnvironmentEvent } from "../posthogServer";
import { getIsAIEnabled } from "../utils/ai";
import { validateInputs } from "../utils/validate";
import { surveyCache } from "./cache";
import { doesSurveyHasOpenTextQuestion, getInsightsEnabled, transformPrismaSurvey } from "./utils";
import { transformPrismaSurvey } from "./utils";
interface TriggerUpdate {
create?: Array<{ actionClassId: string }>;
@@ -570,71 +561,6 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
throw new ResourceNotFoundError("Organization", null);
}
//AI Insights
const isAIEnabled = await getIsAIEnabled(organization);
if (isAIEnabled) {
if (doesSurveyHasOpenTextQuestion(data.questions ?? [])) {
const openTextQuestions = data.questions?.filter((question) => question.type === "openText") ?? [];
const currentSurveyOpenTextQuestions = currentSurvey.questions?.filter(
(question) => question.type === "openText"
);
// find the questions that have been updated or added
const questionsToCheckForInsights: TSurveyQuestions = [];
for (const question of openTextQuestions) {
const existingQuestion = currentSurveyOpenTextQuestions?.find((ques) => ques.id === question.id) as
| TSurveyOpenTextQuestion
| undefined;
const isExistingQuestion = !!existingQuestion;
if (
isExistingQuestion &&
question.headline.default === existingQuestion.headline.default &&
existingQuestion.insightsEnabled !== undefined
) {
continue;
} else {
questionsToCheckForInsights.push(question);
}
}
if (questionsToCheckForInsights.length > 0) {
const insightsEnabledValues = await Promise.all(
questionsToCheckForInsights.map(async (question) => {
const insightsEnabled = await getInsightsEnabled(question);
return { id: question.id, insightsEnabled };
})
);
data.questions = data.questions?.map((question) => {
const index = insightsEnabledValues.findIndex((item) => item.id === question.id);
if (index !== -1) {
return {
...question,
insightsEnabled: insightsEnabledValues[index].insightsEnabled,
};
}
return question;
});
}
}
} else {
// check if an existing question got changed that had insights enabled
const insightsEnabledOpenTextQuestions = currentSurvey.questions?.filter(
(question) => question.type === "openText" && question.insightsEnabled !== undefined
);
// if question headline changed, remove insightsEnabled
for (const question of insightsEnabledOpenTextQuestions) {
const updatedQuestion = data.questions?.find((q) => q.id === question.id);
if (updatedQuestion && updatedQuestion.headline.default !== question.headline.default) {
updatedQuestion.insightsEnabled = undefined;
}
}
}
surveyData.updatedAt = new Date();
data = {
@@ -739,33 +665,6 @@ export const createSurvey = async (
throw new ResourceNotFoundError("Organization", null);
}
//AI Insights
const isAIEnabled = await getIsAIEnabled(organization);
if (isAIEnabled) {
if (doesSurveyHasOpenTextQuestion(data.questions ?? [])) {
const openTextQuestions = data.questions?.filter((question) => question.type === "openText") ?? [];
const insightsEnabledValues = await Promise.all(
openTextQuestions.map(async (question) => {
const insightsEnabled = await getInsightsEnabled(question);
return { id: question.id, insightsEnabled };
})
);
data.questions = data.questions?.map((question) => {
const index = insightsEnabledValues.findIndex((item) => item.id === question.id);
if (index !== -1) {
return {
...question,
insightsEnabled: insightsEnabledValues[index].insightsEnabled,
};
}
return question;
});
}
}
// Survey follow-ups
if (restSurveyBody.followUps?.length) {
data.followUps = {

View File

@@ -1,10 +1,7 @@
import "server-only";
import { generateObject } from "ai";
import { z } from "zod";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyQuestion, TSurveyQuestions } from "@formbricks/types/surveys/types";
import { llmModel } from "../aiModels";
import { TSurvey } from "@formbricks/types/surveys/types";
export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSurvey>(
surveyPrisma: any
@@ -35,24 +32,3 @@ export const anySurveyHasFilters = (surveys: TSurvey[]): boolean => {
return false;
});
};
export const doesSurveyHasOpenTextQuestion = (questions: TSurveyQuestions): boolean => {
return questions.some((question) => question.type === "openText");
};
export const getInsightsEnabled = async (question: TSurveyQuestion): Promise<boolean> => {
try {
const { object } = await generateObject({
model: llmModel,
schema: z.object({
insightsEnabled: z.boolean(),
}),
prompt: `We extract insights (e.g. feature requests, complaints, other) from survey questions. Can we find them in this question?: ${question.headline.default}`,
experimental_telemetry: { isEnabled: true },
});
return object.insightsEnabled;
} catch (error) {
throw error;
}
};

View File

@@ -1,16 +0,0 @@
import { IS_AI_CONFIGURED } from "@/lib/constants";
import { TOrganization } from "@formbricks/types/organizations";
export const getPromptText = (questionHeadline: string, response: string) => {
return `**${questionHeadline.trim()}**\n${response.trim()}`;
};
export const getIsAIEnabled = async (organization: TOrganization) => {
// This is a temporary workaround to enable AI without checking the ee license validity, as the ee package is not available in the lib package.(but the billing plan check suffices the license check).
const billingPlan = organization.billing.plan;
return Boolean(
organization.isAIEnabled &&
IS_AI_CONFIGURED &&
(billingPlan === "startup" || billingPlan === "scale" || billingPlan === "enterprise")
);
};

View File

@@ -37,12 +37,6 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name",
AI_AZURE_LLM_API_KEY: "mock-ai",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id",
}));
vi.mock("@/modules/survey/list/actions", () => ({

View File

@@ -12,7 +12,6 @@ export const openTextQuestion: Survey["questions"][number] = {
inputType: "text",
required: true,
headline: { en: "Open Text Question" },
insightsEnabled: true,
};
export const fileUploadQuestion: Survey["questions"][number] = {

View File

@@ -1,98 +0,0 @@
"use server";
import { generateInsightsForSurvey } from "@/app/api/(internal)/insights/lib/utils";
import { getOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { getIsAIEnabled, getIsOrganizationAIReady } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
export const checkAIPermission = async (organizationId: string) => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
}
const isAIEnabled = await getIsAIEnabled({
isAIEnabled: organization.isAIEnabled,
billing: organization.billing,
});
if (!isAIEnabled) {
throw new OperationNotAllowedError("AI is not enabled for this organization");
}
};
const ZGenerateInsightsForSurveyAction = z.object({
surveyId: ZId,
});
export const generateInsightsForSurveyAction = authenticatedActionClient
.schema(ZGenerateInsightsForSurveyAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
schema: ZGenerateInsightsForSurveyAction,
data: parsedInput,
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
await checkAIPermission(organizationId);
generateInsightsForSurvey(parsedInput.surveyId);
});
const ZUpdateOrganizationAIEnabledAction = z.object({
organizationId: ZId,
data: ZOrganizationUpdateInput.pick({ isAIEnabled: true }),
});
export const updateOrganizationAIEnabledAction = authenticatedActionClient
.schema(ZUpdateOrganizationAIEnabledAction)
.action(async ({ parsedInput, ctx }) => {
const organizationId = parsedInput.organizationId;
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ isAIEnabled: true }),
data: parsedInput.data,
roles: ["owner", "manager"],
},
],
});
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
}
const isOrganizationAIReady = await getIsOrganizationAIReady(organization.billing.plan);
if (!isOrganizationAIReady) {
throw new OperationNotAllowedError("AI is not ready for this organization");
}
return await updateOrganization(parsedInput.organizationId, parsedInput.data);
});

View File

@@ -1,142 +0,0 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getEnvironmentIdFromInsightId,
getEnvironmentIdFromSurveyId,
getOrganizationIdFromDocumentId,
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromInsightId,
getProjectIdFromDocumentId,
getProjectIdFromEnvironmentId,
getProjectIdFromInsightId,
} from "@/lib/utils/helper";
import { checkAIPermission } from "@/modules/ee/insights/actions";
import {
getDocumentsByInsightId,
getDocumentsByInsightIdSurveyIdQuestionId,
} from "@/modules/ee/insights/components/insight-sheet/lib/documents";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZDocumentFilterCriteria } from "@formbricks/types/documents";
import { ZSurveyQuestionId } from "@formbricks/types/surveys/types";
import { updateDocument } from "./lib/documents";
const ZGetDocumentsByInsightIdSurveyIdQuestionIdAction = z.object({
insightId: ZId,
surveyId: ZId,
questionId: ZSurveyQuestionId,
limit: z.number().optional(),
offset: z.number().optional(),
});
export const getDocumentsByInsightIdSurveyIdQuestionIdAction = authenticatedActionClient
.schema(ZGetDocumentsByInsightIdSurveyIdQuestionIdAction)
.action(async ({ ctx, parsedInput }) => {
const insightEnvironmentId = await getEnvironmentIdFromInsightId(parsedInput.insightId);
const surveyEnvironmentId = await getEnvironmentIdFromSurveyId(parsedInput.surveyId);
if (insightEnvironmentId !== surveyEnvironmentId) {
throw new Error("Insight and survey are not in the same environment");
}
const organizationId = await getOrganizationIdFromEnvironmentId(surveyEnvironmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromEnvironmentId(surveyEnvironmentId),
},
],
});
await checkAIPermission(organizationId);
return await getDocumentsByInsightIdSurveyIdQuestionId(
parsedInput.insightId,
parsedInput.surveyId,
parsedInput.questionId,
parsedInput.limit,
parsedInput.offset
);
});
const ZGetDocumentsByInsightIdAction = z.object({
insightId: ZId,
limit: z.number().optional(),
offset: z.number().optional(),
filterCriteria: ZDocumentFilterCriteria.optional(),
});
export const getDocumentsByInsightIdAction = authenticatedActionClient
.schema(ZGetDocumentsByInsightIdAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromInsightId(parsedInput.insightId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromInsightId(parsedInput.insightId),
},
],
});
await checkAIPermission(organizationId);
return await getDocumentsByInsightId(
parsedInput.insightId,
parsedInput.limit,
parsedInput.offset,
parsedInput.filterCriteria
);
});
const ZUpdateDocumentAction = z.object({
documentId: ZId,
data: z
.object({
sentiment: z.enum(["positive", "negative", "neutral"]).optional(),
})
.strict(),
});
export const updateDocumentAction = authenticatedActionClient
.schema(ZUpdateDocumentAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromDocumentId(parsedInput.documentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromDocumentId(parsedInput.documentId),
},
],
});
await checkAIPermission(organizationId);
return await updateDocument(parsedInput.documentId, parsedInput.data);
});

View File

@@ -1,177 +0,0 @@
"use client";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TInsightWithDocumentCount } from "@/modules/ee/insights/experience/types/insights";
import { Button } from "@/modules/ui/components/button";
import { Card, CardContent, CardFooter } from "@/modules/ui/components/card";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/modules/ui/components/sheet";
import { useTranslate } from "@tolgee/react";
import { ThumbsDownIcon, ThumbsUpIcon } from "lucide-react";
import { useDeferredValue, useEffect, useState } from "react";
import Markdown from "react-markdown";
import { TDocument, TDocumentFilterCriteria } from "@formbricks/types/documents";
import { TUserLocale } from "@formbricks/types/user";
import CategoryBadge from "../../experience/components/category-select";
import SentimentSelect from "../sentiment-select";
import { getDocumentsByInsightIdAction, getDocumentsByInsightIdSurveyIdQuestionIdAction } from "./actions";
interface InsightSheetProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
insight: TInsightWithDocumentCount | null;
surveyId?: string;
questionId?: string;
handleFeedback: (feedback: "positive" | "negative") => void;
documentsFilter?: TDocumentFilterCriteria;
documentsPerPage?: number;
locale: TUserLocale;
}
export const InsightSheet = ({
isOpen,
setIsOpen,
insight,
surveyId,
questionId,
handleFeedback,
documentsFilter,
documentsPerPage = 10,
locale,
}: InsightSheetProps) => {
const { t } = useTranslate();
const [documents, setDocuments] = useState<TDocument[]>([]);
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false); // New state for loading
const [hasMore, setHasMore] = useState(false);
useEffect(() => {
if (isOpen) {
setDocuments([]);
setPage(1);
setHasMore(false); // Reset hasMore when the sheet is opened
}
if (isOpen && insight) {
fetchDocuments();
}
async function fetchDocuments() {
if (!insight) return;
if (isLoading) return; // Prevent fetching if already loading
setIsLoading(true); // Set loading state to true
try {
let documentsResponse;
if (questionId && surveyId) {
documentsResponse = await getDocumentsByInsightIdSurveyIdQuestionIdAction({
insightId: insight.id,
surveyId,
questionId,
limit: documentsPerPage,
offset: (page - 1) * documentsPerPage,
});
} else {
documentsResponse = await getDocumentsByInsightIdAction({
insightId: insight.id,
filterCriteria: documentsFilter,
limit: documentsPerPage,
offset: (page - 1) * documentsPerPage,
});
}
if (!documentsResponse?.data) {
const errorMessage = getFormattedErrorMessage(documentsResponse);
console.error(errorMessage);
return;
}
const fetchedDocuments = documentsResponse.data;
setDocuments((prevDocuments) => {
// Remove duplicates based on document ID
const uniqueDocuments = new Map<string, TDocument>([
...prevDocuments.map((doc) => [doc.id, doc]),
...fetchedDocuments.map((doc) => [doc.id, doc]),
]);
return Array.from(uniqueDocuments.values()) as TDocument[];
});
setHasMore(fetchedDocuments.length === documentsPerPage);
} finally {
setIsLoading(false); // Reset loading state
}
}
}, [isOpen, insight]);
const deferredDocuments = useDeferredValue(documents);
const handleFeedbackClick = (feedback: "positive" | "negative") => {
setIsOpen(false);
handleFeedback(feedback);
};
const loadMoreDocuments = () => {
if (hasMore) {
setPage((prevPage) => prevPage + 1);
}
};
if (!insight) {
return null;
}
return (
<Sheet open={isOpen} onOpenChange={(v) => setIsOpen(v)}>
<SheetContent className="flex h-full flex-col bg-white">
<SheetHeader className="flex flex-col gap-1.5">
<SheetTitle className="flex items-center gap-x-2">
<span>{insight.title}</span>
<CategoryBadge category={insight.category} insightId={insight.id} />
</SheetTitle>
<SheetDescription>{insight.description}</SheetDescription>
<div className="flex w-fit items-center gap-2 rounded-lg border border-slate-300 px-2 py-1 text-sm text-slate-600">
<p>{t("environments.experience.did_you_find_this_insight_helpful")}</p>
<ThumbsUpIcon
className="upvote h-4 w-4 cursor-pointer text-slate-700 hover:text-emerald-500"
onClick={() => handleFeedbackClick("positive")}
/>
<ThumbsDownIcon
className="downvote h-4 w-4 cursor-pointer text-slate-700 hover:text-amber-600"
onClick={() => handleFeedbackClick("negative")}
/>
</div>
</SheetHeader>
<hr className="my-2" />
<div className="flex flex-1 flex-col gap-y-2 overflow-auto">
{deferredDocuments.map((document, index) => (
<Card key={`${document.id}-${index}`} className="transition-opacity duration-200">
<CardContent className="p-4 text-sm whitespace-pre-wrap">
<Markdown>{document.text}</Markdown>
</CardContent>
<CardFooter className="flex justify-between rounded-br-xl rounded-bl-xl border-t border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-600">
<p>
Sentiment: <SentimentSelect documentId={document.id} sentiment={document.sentiment} />
</p>
<p>{timeSince(new Date(document.createdAt).toISOString(), locale)}</p>
</CardFooter>
</Card>
))}
</div>
{hasMore && (
<div className="flex justify-center py-5">
<Button onClick={loadMoreDocuments} variant="secondary" size="sm">
Load more
</Button>
</div>
)}
</SheetContent>
</Sheet>
);
};

View File

@@ -1,191 +0,0 @@
import { cache } from "@/lib/cache";
import { documentCache } from "@/lib/cache/document";
import { insightCache } from "@/lib/cache/insight";
import { DOCUMENTS_PER_PAGE } from "@/lib/constants";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import {
TDocument,
TDocumentFilterCriteria,
ZDocument,
ZDocumentFilterCriteria,
} from "@formbricks/types/documents";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurveyQuestionId, ZSurveyQuestionId } from "@formbricks/types/surveys/types";
export const getDocumentsByInsightId = reactCache(
async (
insightId: string,
limit?: number,
offset?: number,
filterCriteria?: TDocumentFilterCriteria
): Promise<TDocument[]> =>
cache(
async () => {
validateInputs(
[insightId, ZId],
[limit, z.number().optional()],
[offset, z.number().optional()],
[filterCriteria, ZDocumentFilterCriteria.optional()]
);
limit = limit ?? DOCUMENTS_PER_PAGE;
try {
const documents = await prisma.document.findMany({
where: {
documentInsights: {
some: {
insightId,
},
},
createdAt: {
gte: filterCriteria?.createdAt?.min,
lte: filterCriteria?.createdAt?.max,
},
},
orderBy: [
{
createdAt: "desc",
},
],
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
});
return documents;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getDocumentsByInsightId-${insightId}-${limit}-${offset}`],
{
tags: [documentCache.tag.byInsightId(insightId), insightCache.tag.byId(insightId)],
}
)()
);
export const getDocumentsByInsightIdSurveyIdQuestionId = reactCache(
async (
insightId: string,
surveyId: string,
questionId: TSurveyQuestionId,
limit?: number,
offset?: number
): Promise<TDocument[]> =>
cache(
async () => {
validateInputs(
[insightId, ZId],
[surveyId, ZId],
[questionId, ZSurveyQuestionId],
[limit, z.number().optional()],
[offset, z.number().optional()]
);
limit = limit ?? DOCUMENTS_PER_PAGE;
try {
const documents = await prisma.document.findMany({
where: {
questionId,
surveyId,
documentInsights: {
some: {
insightId,
},
},
},
orderBy: [
{
createdAt: "desc",
},
],
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
});
return documents;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getDocumentsByInsightIdSurveyIdQuestionId-${insightId}-${surveyId}-${questionId}-${limit}-${offset}`],
{
tags: [
documentCache.tag.byInsightIdSurveyIdQuestionId(insightId, surveyId, questionId),
documentCache.tag.byInsightId(insightId),
insightCache.tag.byId(insightId),
],
}
)()
);
export const getDocument = reactCache(
async (documentId: string): Promise<TDocument | null> =>
cache(
async () => {
validateInputs([documentId, ZId]);
try {
const document = await prisma.document.findUnique({
where: {
id: documentId,
},
});
return document;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getDocumentById-${documentId}`],
{
tags: [documentCache.tag.byId(documentId)],
}
)()
);
export const updateDocument = async (documentId: string, data: Partial<TDocument>): Promise<void> => {
validateInputs([documentId, ZId], [data, ZDocument.partial()]);
try {
const updatedDocument = await prisma.document.update({
where: { id: documentId },
data,
select: {
environmentId: true,
documentInsights: {
select: {
insightId: true,
},
},
},
});
documentCache.revalidate({ environmentId: updatedDocument.environmentId });
for (const { insightId } of updatedDocument.documentInsights) {
documentCache.revalidate({ insightId });
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -1,164 +0,0 @@
// InsightView.test.jsx
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import { TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { InsightView } from "./insights-view";
// --- Mocks ---
// Stub out the translation hook so that keys are returned as-is.
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key) => key,
}),
}));
// Spy on formbricks.track
vi.mock("@formbricks/js", () => ({
default: {
track: vi.fn(),
},
}));
// A simple implementation for classnames.
vi.mock("@/lib/cn", () => ({
cn: (...classes) => classes.join(" "),
}));
// Mock CategoryBadge to render a simple button.
vi.mock("../experience/components/category-select", () => ({
default: ({ category, insightId, onCategoryChange }) => (
<button data-testid="category-badge" onClick={() => onCategoryChange(insightId, category)}>
CategoryBadge: {category}
</button>
),
}));
// Mock InsightSheet to display its open/closed state and the insight title.
vi.mock("@/modules/ee/insights/components/insight-sheet", () => ({
InsightSheet: ({ isOpen, insight }) => (
<div data-testid="insight-sheet">
{isOpen ? "InsightSheet Open" : "InsightSheet Closed"}
{insight && ` - ${insight.title}`}
</div>
),
}));
// Create an array of 15 dummy insights.
// Even-indexed insights will have the category "complaint"
// and odd-indexed insights will have "praise".
const dummyInsights = Array.from({ length: 15 }, (_, i) => ({
id: `insight-${i}`,
_count: { documentInsights: i },
title: `Insight Title ${i}`,
description: `Insight Description ${i}`,
category: i % 2 === 0 ? "complaint" : "praise",
updatedAt: new Date(),
createdAt: new Date(),
environmentId: "environment-1",
})) as TSurveyQuestionSummaryOpenText["insights"];
// Helper function to render the component with default props.
const renderComponent = (props = {}) => {
const defaultProps = {
insights: dummyInsights,
questionId: "question-1",
surveyId: "survey-1",
documentsFilter: {},
isFetching: false,
documentsPerPage: 5,
locale: "en" as TUserLocale,
};
return render(<InsightView {...defaultProps} {...props} />);
};
// --- Tests ---
describe("InsightView Component", () => {
test("renders table headers", () => {
renderComponent();
expect(screen.getByText("#")).toBeInTheDocument();
expect(screen.getByText("common.title")).toBeInTheDocument();
expect(screen.getByText("common.description")).toBeInTheDocument();
expect(screen.getByText("environments.experience.category")).toBeInTheDocument();
});
test('shows "no insights found" when insights array is empty', () => {
renderComponent({ insights: [] });
expect(screen.getByText("environments.experience.no_insights_found")).toBeInTheDocument();
});
test("does not render insights when isFetching is true", () => {
renderComponent({ isFetching: true, insights: [] });
expect(screen.getByText("environments.experience.no_insights_found")).toBeInTheDocument();
});
test("filters insights based on selected tab", async () => {
renderComponent();
// Click on the "complaint" tab.
const complaintTab = screen.getAllByText("environments.experience.complaint")[0];
fireEvent.click(complaintTab);
// Grab all table rows from the table body.
const rows = await screen.findAllByRole("row");
// Check that none of the rows include text from a "praise" insight.
rows.forEach((row) => {
expect(row.textContent).not.toEqual(/Insight Title 1/);
});
});
test("load more button increases visible insights count", () => {
renderComponent();
// Initially, "Insight Title 10" should not be visible because only 10 items are shown.
expect(screen.queryByText("Insight Title 10")).not.toBeInTheDocument();
// Get all buttons with the text "common.load_more" and filter for those that are visible.
const loadMoreButtons = screen.getAllByRole("button", { name: /common\.load_more/i });
expect(loadMoreButtons.length).toBeGreaterThan(0);
// Click the first visible "load more" button.
fireEvent.click(loadMoreButtons[0]);
// Now, "Insight Title 10" should be visible.
expect(screen.getByText("Insight Title 10")).toBeInTheDocument();
});
test("opens insight sheet when a row is clicked", () => {
renderComponent();
// Get all elements that display "Insight Title 0" and use the first one to find its table row
const cells = screen.getAllByText("Insight Title 0");
expect(cells.length).toBeGreaterThan(0);
const rowElement = cells[0].closest("tr");
expect(rowElement).not.toBeNull();
// Simulate a click on the table row
fireEvent.click(rowElement!);
// Get all instances of the InsightSheet component
const sheets = screen.getAllByTestId("insight-sheet");
// Filter for the one that contains the expected text
const matchingSheet = sheets.find((sheet) =>
sheet.textContent?.includes("InsightSheet Open - Insight Title 0")
);
expect(matchingSheet).toBeDefined();
expect(matchingSheet).toHaveTextContent("InsightSheet Open - Insight Title 0");
});
test("category badge calls onCategoryChange and updates the badge (even if value remains the same)", () => {
renderComponent();
// Get the first category badge. For index 0, the category is "complaint".
const categoryBadge = screen.getAllByTestId("category-badge")[0];
// It should display "complaint" initially.
expect(categoryBadge).toHaveTextContent("CategoryBadge: complaint");
// Click the category badge to trigger onCategoryChange.
fireEvent.click(categoryBadge);
// After clicking, the badge should still display "complaint" (since our mock simply passes the current value).
expect(categoryBadge).toHaveTextContent("CategoryBadge: complaint");
});
});

View File

@@ -1,178 +0,0 @@
"use client";
import { cn } from "@/lib/cn";
import { InsightSheet } from "@/modules/ee/insights/components/insight-sheet";
import { Button } from "@/modules/ui/components/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { Insight, InsightCategory } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { UserIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import formbricks from "@formbricks/js";
import { TDocumentFilterCriteria } from "@formbricks/types/documents";
import { TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import CategoryBadge from "../experience/components/category-select";
interface InsightViewProps {
insights: TSurveyQuestionSummaryOpenText["insights"];
questionId?: string;
surveyId?: string;
documentsFilter?: TDocumentFilterCriteria;
isFetching?: boolean;
documentsPerPage?: number;
locale: TUserLocale;
}
export const InsightView = ({
insights,
questionId,
surveyId,
documentsFilter,
isFetching,
documentsPerPage,
locale,
}: InsightViewProps) => {
const { t } = useTranslate();
const [isInsightSheetOpen, setIsInsightSheetOpen] = useState(true);
const [localInsights, setLocalInsights] = useState<TSurveyQuestionSummaryOpenText["insights"]>(insights);
const [currentInsight, setCurrentInsight] = useState<
TSurveyQuestionSummaryOpenText["insights"][number] | null
>(null);
const [activeTab, setActiveTab] = useState<string>("all");
const [visibleInsights, setVisibleInsights] = useState(10);
const handleFeedback = (_feedback: "positive" | "negative") => {
formbricks.track("AI Insight Feedback");
};
const handleFilterSelect = useCallback(
(filterValue: string) => {
setActiveTab(filterValue);
if (filterValue === "all") {
setLocalInsights(insights);
} else {
setLocalInsights(insights.filter((insight) => insight.category === (filterValue as InsightCategory)));
}
},
[insights]
);
useEffect(() => {
handleFilterSelect(activeTab);
// Update currentInsight if it exists in the new insights array
if (currentInsight) {
const updatedInsight = insights.find((insight) => insight.id === currentInsight.id);
if (updatedInsight) {
setCurrentInsight(updatedInsight);
} else {
setCurrentInsight(null);
setIsInsightSheetOpen(false);
}
}
}, [insights, activeTab, handleFilterSelect]);
const handleLoadMore = () => {
setVisibleInsights((prevVisibleInsights) => Math.min(prevVisibleInsights + 10, insights.length));
};
const updateLocalInsight = (insightId: string, updates: Partial<Insight>) => {
setLocalInsights((prevInsights) =>
prevInsights.map((insight) => (insight.id === insightId ? { ...insight, ...updates } : insight))
);
};
const onCategoryChange = async (insightId: string, newCategory: InsightCategory) => {
updateLocalInsight(insightId, { category: newCategory });
};
return (
<div className={cn("mt-2")}>
<Tabs defaultValue="all" onValueChange={handleFilterSelect}>
<TabsList className={cn("ml-2")}>
<TabsTrigger value="all">{t("environments.experience.all")}</TabsTrigger>
<TabsTrigger value="complaint">{t("environments.experience.complaint")}</TabsTrigger>
<TabsTrigger value="featureRequest">{t("environments.experience.feature_request")}</TabsTrigger>
<TabsTrigger value="praise">{t("environments.experience.praise")}</TabsTrigger>
<TabsTrigger value="other">{t("common.other")}</TabsTrigger>
</TabsList>
<TabsContent value={activeTab}>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">#</TableHead>
<TableHead>{t("common.title")}</TableHead>
<TableHead>{t("common.description")}</TableHead>
<TableHead>{t("environments.experience.category")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isFetching ? null : insights.length === 0 ? (
<TableRow className="pointer-events-none">
<TableCell colSpan={4} className="py-8 text-center">
<p className="text-slate-500">{t("environments.experience.no_insights_found")}</p>
</TableCell>
</TableRow>
) : localInsights.length === 0 ? (
<TableRow className="pointer-events-none">
<TableCell colSpan={4} className="py-8 text-center">
<p className="text-slate-500">
{t("environments.experience.no_insights_for_this_filter")}
</p>
</TableCell>
</TableRow>
) : (
localInsights.slice(0, visibleInsights).map((insight) => (
<TableRow
key={insight.id}
className="group cursor-pointer hover:bg-slate-50"
onClick={() => {
setCurrentInsight(insight);
setIsInsightSheetOpen(true);
}}>
<TableCell className="flex font-medium">
{insight._count.documentInsights} <UserIcon className="ml-2 h-4 w-4" />
</TableCell>
<TableCell className="font-medium">{insight.title}</TableCell>
<TableCell className="underline-offset-2 group-hover:underline">
{insight.description}
</TableCell>
<TableCell>
<CategoryBadge
category={insight.category}
insightId={insight.id}
onCategoryChange={onCategoryChange}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TabsContent>
</Tabs>
{visibleInsights < localInsights.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
<InsightSheet
isOpen={isInsightSheetOpen}
setIsOpen={setIsInsightSheetOpen}
insight={currentInsight}
surveyId={surveyId}
questionId={questionId}
handleFeedback={handleFeedback}
documentsFilter={documentsFilter}
documentsPerPage={documentsPerPage}
locale={locale}
/>
</div>
);
};

View File

@@ -1,63 +0,0 @@
import { BadgeSelect, TBadgeSelectOption } from "@/modules/ui/components/badge-select";
import { useState } from "react";
import { TDocument, TDocumentSentiment } from "@formbricks/types/documents";
import { updateDocumentAction } from "./insight-sheet/actions";
interface SentimentSelectProps {
sentiment: TDocument["sentiment"];
documentId: string;
}
const sentimentOptions: TBadgeSelectOption[] = [
{ text: "Positive", type: "success" },
{ text: "Neutral", type: "gray" },
{ text: "Negative", type: "error" },
];
const getSentimentIndex = (sentiment: TDocumentSentiment) => {
switch (sentiment) {
case "positive":
return 0;
case "neutral":
return 1;
case "negative":
return 2;
default:
return 1; // Default to neutral
}
};
const SentimentSelect = ({ sentiment, documentId }: SentimentSelectProps) => {
const [currentSentiment, setCurrentSentiment] = useState(sentiment);
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdateSentiment = async (newSentiment: TDocumentSentiment) => {
setIsUpdating(true);
try {
await updateDocumentAction({
documentId,
data: { sentiment: newSentiment },
});
setCurrentSentiment(newSentiment); // Update the state with the new sentiment
} catch (error) {
console.error("Failed to update document sentiment:", error);
} finally {
setIsUpdating(false);
}
};
return (
<BadgeSelect
options={sentimentOptions}
selectedIndex={getSentimentIndex(currentSentiment)}
onChange={(newIndex) => {
const newSentiment = sentimentOptions[newIndex].text.toLowerCase() as TDocumentSentiment;
handleUpdateSentiment(newSentiment);
}}
size="tiny"
isLoading={isUpdating}
/>
);
};
export default SentimentSelect;

View File

@@ -1,130 +0,0 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromInsightId,
getProjectIdFromEnvironmentId,
getProjectIdFromInsightId,
} from "@/lib/utils/helper";
import { checkAIPermission } from "@/modules/ee/insights/actions";
import { ZInsightFilterCriteria } from "@/modules/ee/insights/experience/types/insights";
import { z } from "zod";
import { ZInsight } from "@formbricks/database/zod/insights";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { getInsights, updateInsight } from "./lib/insights";
import { getStats } from "./lib/stats";
const ZGetEnvironmentInsightsAction = z.object({
environmentId: ZId,
limit: z.number().optional(),
offset: z.number().optional(),
insightsFilter: ZInsightFilterCriteria.optional(),
});
export const getEnvironmentInsightsAction = authenticatedActionClient
.schema(ZGetEnvironmentInsightsAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
await checkAIPermission(organizationId);
return await getInsights(
parsedInput.environmentId,
parsedInput.limit,
parsedInput.offset,
parsedInput.insightsFilter
);
});
const ZGetStatsAction = z.object({
environmentId: ZId,
statsFrom: z.date().optional(),
});
export const getStatsAction = authenticatedActionClient
.schema(ZGetStatsAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
await checkAIPermission(organizationId);
return await getStats(parsedInput.environmentId, parsedInput.statsFrom);
});
const ZUpdateInsightAction = z.object({
insightId: ZId,
data: ZInsight.partial(),
});
export const updateInsightAction = authenticatedActionClient
.schema(ZUpdateInsightAction)
.action(async ({ ctx, parsedInput }) => {
try {
const organizationId = await getOrganizationIdFromInsightId(parsedInput.insightId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromInsightId(parsedInput.insightId),
minPermission: "readWrite",
},
],
});
await checkAIPermission(organizationId);
return await updateInsight(parsedInput.insightId, parsedInput.data);
} catch (error) {
logger.error(
{
insightId: parsedInput.insightId,
error,
},
"Error updating insight"
);
if (error instanceof Error) {
throw new Error(`Failed to update insight: ${error.message}`);
}
throw new Error("An unexpected error occurred while updating the insight");
}
});

View File

@@ -1,75 +0,0 @@
"use client";
import { BadgeSelect, TBadgeSelectOption } from "@/modules/ui/components/badge-select";
import { InsightCategory } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { updateInsightAction } from "../actions";
interface CategoryBadgeProps {
category: InsightCategory;
insightId: string;
onCategoryChange?: (insightId: string, category: InsightCategory) => void;
}
const categoryOptions: TBadgeSelectOption[] = [
{ text: "Complaint", type: "error" },
{ text: "Request", type: "warning" },
{ text: "Praise", type: "success" },
{ text: "Other", type: "gray" },
];
const categoryMapping: Record<string, InsightCategory> = {
Complaint: "complaint",
Request: "featureRequest",
Praise: "praise",
Other: "other",
};
const getCategoryIndex = (category: InsightCategory) => {
switch (category) {
case "complaint":
return 0;
case "featureRequest":
return 1;
case "praise":
return 2;
default:
return 3;
}
};
const CategoryBadge = ({ category, insightId, onCategoryChange }: CategoryBadgeProps) => {
const [isUpdating, setIsUpdating] = useState(false);
const { t } = useTranslate();
const handleUpdateCategory = async (newCategory: InsightCategory) => {
setIsUpdating(true);
try {
await updateInsightAction({ insightId, data: { category: newCategory } });
onCategoryChange?.(insightId, newCategory);
toast.success(t("environments.experience.category_updated_successfully"));
} catch (error) {
console.error(t("environments.experience.failed_to_update_category"), error);
toast.error(t("environments.experience.failed_to_update_category"));
} finally {
setIsUpdating(false);
}
};
return (
<BadgeSelect
options={categoryOptions}
selectedIndex={getCategoryIndex(category)}
onChange={(newIndex) => {
const newCategoryText = categoryOptions[newIndex].text;
const newCategory = categoryMapping[newCategoryText];
handleUpdateCategory(newCategory);
}}
size="tiny"
isLoading={isUpdating}
/>
);
};
export default CategoryBadge;

View File

@@ -1,76 +0,0 @@
"use client";
import { Greeting } from "@/modules/ee/insights/experience/components/greeting";
import { InsightsCard } from "@/modules/ee/insights/experience/components/insights-card";
import { ExperiencePageStats } from "@/modules/ee/insights/experience/components/stats";
import { getDateFromTimeRange } from "@/modules/ee/insights/experience/lib/utils";
import { TStatsPeriod } from "@/modules/ee/insights/experience/types/stats";
import { Tabs, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { useTranslate } from "@tolgee/react";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TProject } from "@formbricks/types/project";
import { TUser, TUserLocale } from "@formbricks/types/user";
interface DashboardProps {
user: TUser;
environment: TEnvironment;
project: TProject;
insightsPerPage: number;
documentsPerPage: number;
locale: TUserLocale;
}
export const Dashboard = ({
environment,
project,
user,
insightsPerPage,
documentsPerPage,
locale,
}: DashboardProps) => {
const { t } = useTranslate();
const [statsPeriod, setStatsPeriod] = useState<TStatsPeriod>("week");
const statsFrom = getDateFromTimeRange(statsPeriod);
return (
<div className="container mx-auto space-y-6 p-4">
<Greeting userName={user.name} />
<hr className="border-slate-200" />
<Tabs
value={statsPeriod}
onValueChange={(value) => {
if (value) {
setStatsPeriod(value as TStatsPeriod);
}
}}
className="flex justify-center">
<TabsList>
<TabsTrigger value="day" aria-label="Toggle day">
{t("environments.experience.today")}
</TabsTrigger>
<TabsTrigger value="week" aria-label="Toggle week">
{t("environments.experience.this_week")}
</TabsTrigger>
<TabsTrigger value="month" aria-label="Toggle month">
{t("environments.experience.this_month")}
</TabsTrigger>
<TabsTrigger value="quarter" aria-label="Toggle quarter">
{t("environments.experience.this_quarter")}
</TabsTrigger>
<TabsTrigger value="all" aria-label="Toggle all">
{t("environments.experience.all_time")}
</TabsTrigger>
</TabsList>
</Tabs>
<ExperiencePageStats statsFrom={statsFrom} environmentId={environment.id} />
<InsightsCard
statsFrom={statsFrom}
projectName={project.name}
environmentId={environment.id}
insightsPerPage={insightsPerPage}
documentsPerPage={documentsPerPage}
locale={locale}
/>
</div>
);
};

View File

@@ -1,26 +0,0 @@
"use client";
import { H1 } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react";
interface GreetingProps {
userName: string;
}
export const Greeting = ({ userName }: GreetingProps) => {
const { t } = useTranslate();
function getGreeting() {
const hour = new Date().getHours();
if (hour < 12) return t("environments.experience.good_morning");
if (hour < 18) return t("environments.experience.good_afternoon");
return t("environments.experience.good_evening");
}
const greeting = getGreeting();
return (
<H1>
{greeting}, {userName}
</H1>
);
};

View File

@@ -1,22 +0,0 @@
const LoadingRow = () => (
<div className="flex items-center justify-between">
<div className="ph-no-capture h-6 w-10 animate-pulse rounded-full bg-slate-200"></div>
<div className="ph-no-capture h-6 w-40 animate-pulse rounded-full bg-slate-200"></div>
<div className="ph-no-capture h-6 w-48 animate-pulse rounded-full bg-slate-200"></div>
<div className="ph-no-capture h-6 w-16 animate-pulse rounded-full bg-slate-200"></div>
</div>
);
export const InsightLoading = () => {
return (
<div className="space-y-4">
<div className="ph-no-capture animate-pulse rounded-lg bg-white">
<div className="space-y-4 p-4">
<LoadingRow />
<LoadingRow />
<LoadingRow />
</div>
</div>
</div>
);
};

View File

@@ -1,215 +0,0 @@
import { TInsightWithDocumentCount } from "@/modules/ee/insights/experience/types/insights";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TUserLocale } from "@formbricks/types/user";
import { InsightView } from "./insight-view";
// Mock the translation hook to simply return the key.
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock the action that fetches insights.
const mockGetEnvironmentInsightsAction = vi.fn();
vi.mock("../actions", () => ({
getEnvironmentInsightsAction: (...args: any[]) => mockGetEnvironmentInsightsAction(...args),
}));
// Mock InsightSheet so we can assert on its open state.
vi.mock("@/modules/ee/insights/components/insight-sheet", () => ({
InsightSheet: ({
isOpen,
insight,
}: {
isOpen: boolean;
insight: any;
setIsOpen: any;
handleFeedback: any;
documentsFilter: any;
documentsPerPage: number;
locale: string;
}) => (
<div data-testid="insight-sheet">
{isOpen ? `InsightSheet Open${insight ? ` - ${insight.title}` : ""}` : "InsightSheet Closed"}
</div>
),
}));
// Mock InsightLoading.
vi.mock("./insight-loading", () => ({
InsightLoading: () => <div data-testid="insight-loading">Loading...</div>,
}));
// For simplicity, we wont mock CategoryBadge so it renders normally.
// If needed, you can also mock it similar to InsightSheet.
// --- Dummy Data ---
const dummyInsight1 = {
id: "1",
title: "Insight 1",
description: "Description 1",
category: "featureRequest",
_count: { documentInsights: 5 },
};
const dummyInsight2 = {
id: "2",
title: "Insight 2",
description: "Description 2",
category: "featureRequest",
_count: { documentInsights: 3 },
};
const dummyInsightComplaint = {
id: "3",
title: "Complaint Insight",
description: "Complaint Description",
category: "complaint",
_count: { documentInsights: 10 },
};
const dummyInsightPraise = {
id: "4",
title: "Praise Insight",
description: "Praise Description",
category: "praise",
_count: { documentInsights: 8 },
};
// A helper to render the component with required props.
const renderComponent = (props = {}) => {
const defaultProps = {
statsFrom: new Date("2023-01-01"),
environmentId: "env-1",
insightsPerPage: 2,
documentsPerPage: 5,
locale: "en-US" as TUserLocale,
};
return render(<InsightView {...defaultProps} {...props} />);
};
// --- Tests ---
describe("InsightView Component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('renders "no insights found" message when insights array is empty', async () => {
// Set up the mock to return an empty array.
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [] });
renderComponent();
// Wait for the useEffect to complete.
await waitFor(() => {
expect(screen.getByText("environments.experience.no_insights_found")).toBeInTheDocument();
});
});
test("renders table rows when insights are fetched", async () => {
// Return two insights for the initial fetch.
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1, dummyInsight2] });
renderComponent();
// Wait until the insights are rendered.
await waitFor(() => {
expect(screen.getByText("Insight 1")).toBeInTheDocument();
expect(screen.getByText("Insight 2")).toBeInTheDocument();
});
});
test("opens insight sheet when a table row is clicked", async () => {
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1] });
renderComponent();
// Wait for the insight to appear.
await waitFor(() => {
expect(screen.getAllByText("Insight 1").length).toBeGreaterThan(0);
});
// Instead of grabbing the first "Insight 1" cell,
// get all table rows (they usually have role="row") and then find the row that contains "Insight 1".
const rows = screen.getAllByRole("row");
const targetRow = rows.find((row) => row.textContent?.includes("Insight 1"));
console.log(targetRow?.textContent);
expect(targetRow).toBeTruthy();
// Click the entire row.
fireEvent.click(targetRow!);
// Wait for the InsightSheet to update.
await waitFor(() => {
const sheet = screen.getAllByTestId("insight-sheet");
const matchingSheet = sheet.find((s) => s.textContent?.includes("InsightSheet Open - Insight 1"));
expect(matchingSheet).toBeInTheDocument();
});
});
test("clicking load more fetches next page of insights", async () => {
// First fetch returns two insights.
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1, dummyInsight2] });
// Second fetch returns one additional insight.
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsightPraise] });
renderComponent();
// Wait for the initial insights to be rendered.
await waitFor(() => {
expect(screen.getAllByText("Insight 1").length).toBeGreaterThan(0);
expect(screen.getAllByText("Insight 2").length).toBeGreaterThan(0);
});
// The load more button should be visible because hasMore is true.
const loadMoreButton = screen.getAllByText("common.load_more")[0];
fireEvent.click(loadMoreButton);
// Wait for the new insight to be appended.
await waitFor(() => {
expect(screen.getAllByText("Praise Insight").length).toBeGreaterThan(0);
});
});
test("changes filter tab and re-fetches insights", async () => {
// For initial active tab "featureRequest", return a featureRequest insight.
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1] });
renderComponent();
await waitFor(() => {
expect(screen.getAllByText("Insight 1")[0]).toBeInTheDocument();
});
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({
data: [dummyInsightComplaint as TInsightWithDocumentCount],
});
renderComponent();
// Find the complaint tab and click it.
const complaintTab = screen.getAllByText("environments.experience.complaint")[0];
fireEvent.click(complaintTab);
// Wait until the new complaint insight is rendered.
await waitFor(() => {
expect(screen.getAllByText("Complaint Insight")[0]).toBeInTheDocument();
});
});
test("shows loading indicator when fetching insights", async () => {
// Make the mock return a promise that doesn't resolve immediately.
let resolveFetch: any;
const fetchPromise = new Promise((resolve) => {
resolveFetch = resolve;
});
mockGetEnvironmentInsightsAction.mockReturnValueOnce(fetchPromise);
renderComponent();
// While fetching, the loading indicator should be visible.
expect(screen.getByTestId("insight-loading")).toBeInTheDocument();
// Resolve the fetch.
resolveFetch({ data: [dummyInsight1] });
await waitFor(() => {
// After fetching, the loading indicator should disappear.
expect(screen.queryByTestId("insight-loading")).not.toBeInTheDocument();
// Instead of getByText, use getAllByText to assert at least one instance of "Insight 1" exists.
expect(screen.getAllByText("Insight 1").length).toBeGreaterThan(0);
});
});
});

View File

@@ -1,197 +0,0 @@
"use client";
import { InsightSheet } from "@/modules/ee/insights/components/insight-sheet";
import {
TInsightFilterCriteria,
TInsightWithDocumentCount,
} from "@/modules/ee/insights/experience/types/insights";
import { Button } from "@/modules/ui/components/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { InsightCategory } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { UserIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import formbricks from "@formbricks/js";
import { TDocumentFilterCriteria } from "@formbricks/types/documents";
import { TUserLocale } from "@formbricks/types/user";
import { getEnvironmentInsightsAction } from "../actions";
import CategoryBadge from "./category-select";
import { InsightLoading } from "./insight-loading";
interface InsightViewProps {
statsFrom?: Date;
environmentId: string;
documentsPerPage: number;
insightsPerPage: number;
locale: TUserLocale;
}
export const InsightView = ({
statsFrom,
environmentId,
insightsPerPage,
documentsPerPage,
locale,
}: InsightViewProps) => {
const { t } = useTranslate();
const [insights, setInsights] = useState<TInsightWithDocumentCount[]>([]);
const [hasMore, setHasMore] = useState<boolean>(true);
const [isFetching, setIsFetching] = useState(false);
const [isInsightSheetOpen, setIsInsightSheetOpen] = useState(false);
const [currentInsight, setCurrentInsight] = useState<TInsightWithDocumentCount | null>(null);
const [activeTab, setActiveTab] = useState<string>("featureRequest");
const handleFeedback = (_feedback: "positive" | "negative") => {
formbricks.track("AI Insight Feedback");
};
const insightsFilter: TInsightFilterCriteria = useMemo(
() => ({
documentCreatedAt: {
min: statsFrom,
},
category: activeTab === "all" ? undefined : (activeTab as InsightCategory),
}),
[statsFrom, activeTab]
);
const documentsFilter: TDocumentFilterCriteria = useMemo(
() => ({
createdAt: {
min: statsFrom,
},
}),
[statsFrom]
);
useEffect(() => {
const fetchInitialInsights = async () => {
setIsFetching(true);
setInsights([]);
try {
const res = await getEnvironmentInsightsAction({
environmentId,
limit: insightsPerPage,
offset: 0,
insightsFilter,
});
if (res?.data) {
setInsights(res.data);
setHasMore(res.data.length >= insightsPerPage);
// Find the updated currentInsight based on its id
const updatedCurrentInsight = res.data.find((insight) => insight.id === currentInsight?.id);
// Update currentInsight with the matched insight or default to the first one
setCurrentInsight(updatedCurrentInsight || (res.data.length > 0 ? res.data[0] : null));
}
} catch (error) {
console.error("Failed to fetch insights:", error);
} finally {
setIsFetching(false); // Ensure isFetching is set to false in all cases
}
};
fetchInitialInsights();
}, [environmentId, insightsPerPage, insightsFilter]);
const fetchNextPage = useCallback(async () => {
if (!hasMore) return;
setIsFetching(true);
const res = await getEnvironmentInsightsAction({
environmentId,
limit: insightsPerPage,
offset: insights.length,
insightsFilter,
});
if (res?.data) {
setInsights((prevInsights) => [...prevInsights, ...(res.data || [])]);
setHasMore(res.data.length >= insightsPerPage);
setIsFetching(false);
}
}, [environmentId, insights, insightsPerPage, insightsFilter, hasMore]);
const handleFilterSelect = (value: string) => {
setActiveTab(value);
};
return (
<div>
<Tabs defaultValue="featureRequest" onValueChange={handleFilterSelect}>
<div className="flex items-center justify-between">
<TabsList>
<TabsTrigger value="all">{t("environments.experience.all")}</TabsTrigger>
<TabsTrigger value="complaint">{t("environments.experience.complaint")}</TabsTrigger>
<TabsTrigger value="featureRequest">{t("environments.experience.feature_request")}</TabsTrigger>
<TabsTrigger value="praise">{t("environments.experience.praise")}</TabsTrigger>
<TabsTrigger value="other">{t("common.other")}</TabsTrigger>
</TabsList>
</div>
<TabsContent value={activeTab}>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">#</TableHead>
<TableHead>{t("common.title")}</TableHead>
<TableHead>{t("common.description")}</TableHead>
<TableHead>{t("environments.experience.category")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.length === 0 && !isFetching ? (
<TableRow className="pointer-events-none">
<TableCell colSpan={4} className="py-8 text-center">
<p className="text-slate-500">{t("environments.experience.no_insights_found")}</p>
</TableCell>
</TableRow>
) : (
insights
.sort((a, b) => b._count.documentInsights - a._count.documentInsights)
.map((insight) => (
<TableRow
key={insight.id}
className="group cursor-pointer hover:bg-slate-50"
onClick={() => {
setCurrentInsight(insight);
setIsInsightSheetOpen(true);
}}>
<TableCell className="flex font-medium">
{insight._count.documentInsights} <UserIcon className="ml-2 h-4 w-4" />
</TableCell>
<TableCell className="font-medium">{insight.title}</TableCell>
<TableCell className="underline-offset-2 group-hover:underline">
{insight.description}
</TableCell>
<TableCell className="flex items-center justify-between gap-2">
<CategoryBadge category={insight.category} insightId={insight.id} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{isFetching && <InsightLoading />}
</TabsContent>
</Tabs>
{hasMore && !isFetching && (
<div className="flex justify-center py-5">
<Button onClick={fetchNextPage} variant="secondary" size="sm" loading={isFetching}>
{t("common.load_more")}
</Button>
</div>
)}
<InsightSheet
isOpen={isInsightSheetOpen}
setIsOpen={setIsInsightSheetOpen}
insight={currentInsight}
handleFeedback={handleFeedback}
documentsFilter={documentsFilter}
documentsPerPage={documentsPerPage}
locale={locale}
/>
</div>
);
};

View File

@@ -1,43 +0,0 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/modules/ui/components/card";
import { useTranslate } from "@tolgee/react";
import { TUserLocale } from "@formbricks/types/user";
import { InsightView } from "./insight-view";
interface InsightsCardProps {
environmentId: string;
insightsPerPage: number;
projectName: string;
statsFrom?: Date;
documentsPerPage: number;
locale: TUserLocale;
}
export const InsightsCard = ({
statsFrom,
environmentId,
projectName,
insightsPerPage: insightsLimit,
documentsPerPage,
locale,
}: InsightsCardProps) => {
const { t } = useTranslate();
return (
<Card>
<CardHeader>
<CardTitle>{t("environments.experience.insights_for_project", { projectName })}</CardTitle>
<CardDescription>{t("environments.experience.insights_description")}</CardDescription>
</CardHeader>
<CardContent>
<InsightView
statsFrom={statsFrom}
environmentId={environmentId}
documentsPerPage={documentsPerPage}
insightsPerPage={insightsLimit}
locale={locale}
/>
</CardContent>
</Card>
);
};

View File

@@ -1,110 +0,0 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getStatsAction } from "@/modules/ee/insights/experience/actions";
import { TStats } from "@/modules/ee/insights/experience/types/stats";
import { Badge } from "@/modules/ui/components/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/modules/ui/components/card";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { cn } from "@/modules/ui/lib/utils";
import { useTranslate } from "@tolgee/react";
import { ActivityIcon, GaugeIcon, InboxIcon, MessageCircleIcon } from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
interface ExperiencePageStatsProps {
statsFrom?: Date;
environmentId: string;
}
export const ExperiencePageStats = ({ statsFrom, environmentId }: ExperiencePageStatsProps) => {
const { t } = useTranslate();
const [stats, setStats] = useState<TStats>({
activeSurveys: 0,
newResponses: 0,
analysedFeedbacks: 0,
});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const getData = async () => {
setIsLoading(true);
const getStatsResponse = await getStatsAction({ environmentId, statsFrom });
if (getStatsResponse?.data) {
setStats(getStatsResponse.data);
} else {
const errorMessage = getFormattedErrorMessage(getStatsResponse);
toast.error(errorMessage);
}
setIsLoading(false);
};
getData();
}, [environmentId, statsFrom]);
const statsData = [
{
key: "sentimentScore",
title: t("environments.experience.sentiment_score"),
value: stats.sentimentScore ? `${Math.floor(stats.sentimentScore * 100)}%` : "-",
icon: GaugeIcon,
width: "w-20",
},
{
key: "activeSurveys",
title: t("common.active_surveys"),
value: stats.activeSurveys,
icon: MessageCircleIcon,
width: "w-10",
},
{
key: "newResponses",
title: t("environments.experience.new_responses"),
value: stats.newResponses,
icon: InboxIcon,
width: "w-10",
},
{
key: "analysedFeedbacks",
title: t("environments.experience.analysed_feedbacks"),
value: stats.analysedFeedbacks,
icon: ActivityIcon,
width: "w-10",
},
];
return (
<div className="grid gap-4 md:grid-cols-2 md:gap-8 lg:grid-cols-4">
{statsData.map((stat, index) => (
<Card key={index}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
<stat.icon className="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent className="flex items-center justify-between">
<div className="text-2xl font-bold capitalize">
{isLoading ? (
<div className={cn("h-4 animate-pulse rounded-full bg-slate-200", stat.width)}></div>
) : stat.key === "sentimentScore" ? (
<div className="flex items-center font-medium text-slate-700">
<TooltipRenderer tooltipContent={`${stat.value} positive`}>
{stats.overallSentiment === "positive" ? (
<Badge type="success" size="large" text={t("common.positive")} />
) : stats.overallSentiment === "negative" ? (
<Badge type="error" size="large" text={t("common.negative")} />
) : (
<Badge type="gray" size="large" text={t("common.neutral")} />
)}
</TooltipRenderer>
</div>
) : (
(stat.value ?? "-")
)}
</div>
</CardContent>
</Card>
))}
</div>
);
};

View File

@@ -1,39 +0,0 @@
"use client";
import { TemplateList } from "@/modules/survey/components/template-list";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/modules/ui/components/card";
import { Project } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { TEnvironment } from "@formbricks/types/environment";
import { TTemplateFilter } from "@formbricks/types/templates";
import { TUser } from "@formbricks/types/user";
interface TemplatesCardProps {
environment: TEnvironment;
project: Project;
user: TUser;
prefilledFilters: TTemplateFilter[];
}
export const TemplatesCard = ({ environment, project, user, prefilledFilters }: TemplatesCardProps) => {
const { t } = useTranslate();
return (
<Card>
<CardHeader>
<CardTitle>{t("environments.experience.templates_card_title")}</CardTitle>
<CardDescription>{t("environments.experience.templates_card_description")}</CardDescription>
</CardHeader>
<CardContent>
<TemplateList
environmentId={environment.id}
project={project}
showFilters={false}
userId={user.id}
prefilledFilters={prefilledFilters}
noPreview={true}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"></div>
</CardContent>
</Card>
);
};

View File

@@ -1,132 +0,0 @@
import { cache } from "@/lib/cache";
import { insightCache } from "@/lib/cache/insight";
import { INSIGHTS_PER_PAGE } from "@/lib/constants";
import { responseCache } from "@/lib/response/cache";
import { validateInputs } from "@/lib/utils/validate";
import {
TInsightFilterCriteria,
TInsightWithDocumentCount,
ZInsightFilterCriteria,
} from "@/modules/ee/insights/experience/types/insights";
import { Insight, Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
export const getInsights = reactCache(
async (
environmentId: string,
limit?: number,
offset?: number,
filterCriteria?: TInsightFilterCriteria
): Promise<TInsightWithDocumentCount[]> =>
cache(
async () => {
validateInputs(
[environmentId, ZId],
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZInsightFilterCriteria.optional()]
);
limit = limit ?? INSIGHTS_PER_PAGE;
try {
const insights = await prisma.insight.findMany({
where: {
environmentId,
documentInsights: {
some: {
document: {
createdAt: {
gte: filterCriteria?.documentCreatedAt?.min,
lte: filterCriteria?.documentCreatedAt?.max,
},
},
},
},
category: filterCriteria?.category,
},
include: {
_count: {
select: {
documentInsights: {
where: {
document: {
createdAt: {
gte: filterCriteria?.documentCreatedAt?.min,
lte: filterCriteria?.documentCreatedAt?.max,
},
},
},
},
},
},
},
orderBy: [
{
createdAt: "desc",
},
],
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
});
return insights;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`experience-getInsights-${environmentId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`],
{
tags: [insightCache.tag.byEnvironmentId(environmentId)],
}
)()
);
export const updateInsight = async (insightId: string, updates: Partial<Insight>): Promise<void> => {
try {
const updatedInsight = await prisma.insight.update({
where: { id: insightId },
data: updates,
select: {
environmentId: true,
documentInsights: {
select: {
document: {
select: {
surveyId: true,
},
},
},
},
},
});
const uniqueSurveyIds = Array.from(
new Set(updatedInsight.documentInsights.map((di) => di.document.surveyId))
);
insightCache.revalidate({ id: insightId, environmentId: updatedInsight.environmentId });
for (const surveyId of uniqueSurveyIds) {
if (surveyId) {
responseCache.revalidate({
surveyId,
});
}
}
} catch (error) {
logger.error(error, "Error in updateInsight");
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -1,106 +0,0 @@
import "server-only";
import { cache } from "@/lib/cache";
import { documentCache } from "@/lib/cache/document";
import { responseCache } from "@/lib/response/cache";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TStats } from "../types/stats";
export const getStats = reactCache(
async (environmentId: string, statsFrom?: Date): Promise<TStats> =>
cache(
async () => {
validateInputs([environmentId, ZId]);
try {
const groupedResponesPromise = prisma.response.groupBy({
by: ["surveyId"],
_count: {
surveyId: true,
},
where: {
survey: {
environmentId,
},
createdAt: {
gte: statsFrom,
},
},
});
const groupedSentimentsPromise = prisma.document.groupBy({
by: ["sentiment"],
_count: {
sentiment: true,
},
where: {
environmentId,
createdAt: {
gte: statsFrom,
},
},
});
const [groupedRespones, groupedSentiments] = await Promise.all([
groupedResponesPromise,
groupedSentimentsPromise,
]);
const activeSurveys = groupedRespones.length;
const newResponses = groupedRespones.reduce((acc, { _count }) => acc + _count.surveyId, 0);
const sentimentCounts = groupedSentiments.reduce(
(acc, { sentiment, _count }) => {
acc[sentiment] = _count.sentiment;
return acc;
},
{
positive: 0,
negative: 0,
neutral: 0,
}
);
// analysed feedbacks is the sum of all the sentiments
const analysedFeedbacks = Object.values(sentimentCounts).reduce((acc, count) => acc + count, 0);
// the sentiment score is the ratio of positive to total (positive + negative) sentiment counts. For this we ignore neutral sentiment counts.
let sentimentScore: number = 0,
overallSentiment: TStats["overallSentiment"];
if (sentimentCounts.positive || sentimentCounts.negative) {
sentimentScore = sentimentCounts.positive / (sentimentCounts.positive + sentimentCounts.negative);
overallSentiment =
sentimentScore > 0.5 ? "positive" : sentimentScore < 0.5 ? "negative" : "neutral";
}
return {
newResponses,
activeSurveys,
analysedFeedbacks,
sentimentScore,
overallSentiment,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error fetching stats");
throw new DatabaseError(error.message);
}
throw error;
}
},
[`stats-${environmentId}-${statsFrom?.toDateString()}`],
{
tags: [
responseCache.tag.byEnvironmentId(environmentId),
documentCache.tag.byEnvironmentId(environmentId),
],
}
)()
);

View File

@@ -1,18 +0,0 @@
import { TStatsPeriod } from "@/modules/ee/insights/experience/types/stats";
export const getDateFromTimeRange = (timeRange: TStatsPeriod): Date | undefined => {
if (timeRange === "all") {
return new Date(0);
}
const now = new Date();
switch (timeRange) {
case "day":
return new Date(now.getTime() - 1000 * 60 * 60 * 24);
case "week":
return new Date(now.getTime() - 1000 * 60 * 60 * 24 * 7);
case "month":
return new Date(now.getTime() - 1000 * 60 * 60 * 24 * 30);
case "quarter":
return new Date(now.getTime() - 1000 * 60 * 60 * 24 * 90);
}
};

View File

@@ -1,75 +0,0 @@
import { DOCUMENTS_PER_PAGE, INSIGHTS_PER_PAGE } from "@/lib/constants";
import { getEnvironment } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Dashboard } from "@/modules/ee/insights/experience/components/dashboard";
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";
export const ExperiencePage = async (props) => {
const params = await props.params;
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Session not found");
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error("User not found");
}
const [environment, project, organization] = await Promise.all([
getEnvironment(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
]);
if (!environment) {
throw new Error("Environment not found");
}
if (!project) {
throw new Error("Project not found");
}
if (!organization) {
throw new Error("Organization not found");
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isBilling } = getAccessFlags(currentUserMembership?.role);
if (isBilling) {
notFound();
}
const isAIEnabled = await getIsAIEnabled({
isAIEnabled: organization.isAIEnabled,
billing: organization.billing,
});
if (!isAIEnabled) {
notFound();
}
const locale = await findMatchingLocale();
return (
<PageContentWrapper>
<Dashboard
environment={environment}
insightsPerPage={INSIGHTS_PER_PAGE}
project={project}
user={user}
documentsPerPage={DOCUMENTS_PER_PAGE}
locale={locale}
/>
</PageContentWrapper>
);
};

View File

@@ -1,21 +0,0 @@
import { Insight } from "@prisma/client";
import { z } from "zod";
import { ZInsight } from "@formbricks/database/zod/insights";
export const ZInsightFilterCriteria = z.object({
documentCreatedAt: z
.object({
min: z.date().optional(),
max: z.date().optional(),
})
.optional(),
category: ZInsight.shape.category.optional(),
});
export type TInsightFilterCriteria = z.infer<typeof ZInsightFilterCriteria>;
export interface TInsightWithDocumentCount extends Insight {
_count: {
documentInsights: number;
};
}

View File

@@ -1,14 +0,0 @@
import { z } from "zod";
export const ZStats = z.object({
sentimentScore: z.number().optional(),
overallSentiment: z.enum(["positive", "negative", "neutral"]).optional(),
activeSurveys: z.number(),
newResponses: z.number(),
analysedFeedbacks: z.number(),
});
export type TStats = z.infer<typeof ZStats>;
export const ZStatsPeriod = z.enum(["all", "day", "week", "month", "quarter"]);
export type TStatsPeriod = z.infer<typeof ZStatsPeriod>;

View File

@@ -3,7 +3,6 @@ import { cache, revalidateTag } from "@/lib/cache";
import {
E2E_TESTING,
ENTERPRISE_LICENSE_KEY,
IS_AI_CONFIGURED,
IS_FORMBRICKS_CLOUD,
PROJECT_FEATURE_KEYS,
} from "@/lib/constants";
@@ -389,25 +388,6 @@ export const getIsSamlSsoEnabled = async (): Promise<boolean> => {
return licenseFeatures.sso && licenseFeatures.saml;
};
export const getIsOrganizationAIReady = async (billingPlan: Organization["billing"]["plan"]) => {
if (!IS_AI_CONFIGURED) return false;
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.features ? previousResult.features.ai : false;
}
const license = await getEnterpriseLicense();
if (IS_FORMBRICKS_CLOUD) {
return Boolean(license.features?.ai && billingPlan !== PROJECT_FEATURE_KEYS.FREE);
}
return Boolean(license.features?.ai);
};
export const getIsAIEnabled = async (organization: Pick<Organization, "isAIEnabled" | "billing">) => {
return organization.isAIEnabled && (await getIsOrganizationAIReady(organization.billing.plan));
};
export const getOrganizationProjectsLimit = async (
limits: Organization["billing"]["limits"]
): Promise<number> => {

View File

@@ -1,13 +1,11 @@
import { segmentCache } from "@/lib/cache/segment";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { surveyCache } from "@/lib/survey/cache";
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
import { subscribeOrganizationMembersToSurveyResponses } from "@/modules/survey/components/template-list/lib/organization";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { selectSurvey } from "@/modules/survey/lib/survey";
import { doesSurveyHasOpenTextQuestion, getInsightsEnabled } from "@/modules/survey/lib/utils";
import { ActionClass, Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
@@ -52,33 +50,6 @@ export const createSurvey = async (
throw new ResourceNotFoundError("Organization", null);
}
//AI Insights
const isAIEnabled = await getIsAIEnabled(organization);
if (isAIEnabled) {
if (doesSurveyHasOpenTextQuestion(data.questions ?? [])) {
const openTextQuestions = data.questions?.filter((question) => question.type === "openText") ?? [];
const insightsEnabledValues = await Promise.all(
openTextQuestions.map(async (question) => {
const insightsEnabled = await getInsightsEnabled(question);
return { id: question.id, insightsEnabled };
})
);
data.questions = data.questions?.map((question) => {
const index = insightsEnabledValues.findIndex((item) => item.id === question.id);
if (index !== -1) {
return {
...question,
insightsEnabled: insightsEnabledValues[index].insightsEnabled,
};
}
return question;
});
}
}
// Survey follow-ups
if (restSurveyBody.followUps?.length) {
data.followUps = {
@@ -106,14 +77,6 @@ export const createSurvey = async (
// if the survey created is an "app" survey, we also create a private segment for it.
if (survey.type === "app") {
// const newSegment = await createSegment({
// environmentId: parsedEnvironmentId,
// surveyId: survey.id,
// filters: [],
// title: survey.id,
// isPrivate: true,
// });
const newSegment = await prisma.segment.create({
data: {
title: survey.id,

View File

@@ -44,12 +44,6 @@ vi.mock("@/lib/constants", () => ({
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
WEBAPP_URL: "mock-webapp-url",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
IS_PRODUCTION: true,
FB_LOGO_URL: "https://example.com/mock-logo.png",
SMTP_HOST: "mock-smtp-host",

View File

@@ -1,17 +1,15 @@
import { segmentCache } from "@/lib/cache/segment";
import { surveyCache } from "@/lib/survey/cache";
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getSurvey, selectSurvey } from "@/modules/survey/lib/survey";
import { doesSurveyHasOpenTextQuestion, getInsightsEnabled } from "@/modules/survey/lib/utils";
import { ActionClass, Prisma, Survey } from "@prisma/client";
import { ActionClass, Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey, TSurveyOpenTextQuestion } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
try {
@@ -253,71 +251,6 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
throw new ResourceNotFoundError("Organization", null);
}
//AI Insights
const isAIEnabled = await getIsAIEnabled(organization);
if (isAIEnabled) {
if (doesSurveyHasOpenTextQuestion(data.questions ?? [])) {
const openTextQuestions = data.questions?.filter((question) => question.type === "openText") ?? [];
const currentSurveyOpenTextQuestions = currentSurvey.questions?.filter(
(question) => question.type === "openText"
);
// find the questions that have been updated or added
const questionsToCheckForInsights: Survey["questions"] = [];
for (const question of openTextQuestions) {
const existingQuestion = currentSurveyOpenTextQuestions?.find((ques) => ques.id === question.id) as
| TSurveyOpenTextQuestion
| undefined;
const isExistingQuestion = !!existingQuestion;
if (
isExistingQuestion &&
question.headline.default === existingQuestion.headline.default &&
existingQuestion.insightsEnabled !== undefined
) {
continue;
} else {
questionsToCheckForInsights.push(question);
}
}
if (questionsToCheckForInsights.length > 0) {
const insightsEnabledValues = await Promise.all(
questionsToCheckForInsights.map(async (question) => {
const insightsEnabled = await getInsightsEnabled(question);
return { id: question.id, insightsEnabled };
})
);
data.questions = data.questions?.map((question) => {
const index = insightsEnabledValues.findIndex((item) => item.id === question.id);
if (index !== -1) {
return {
...question,
insightsEnabled: insightsEnabledValues[index].insightsEnabled,
};
}
return question;
});
}
}
} else {
// check if an existing question got changed that had insights enabled
const insightsEnabledOpenTextQuestions = currentSurvey.questions?.filter(
(question) => question.type === "openText" && question.insightsEnabled !== undefined
);
// if question headline changed, remove insightsEnabled
for (const question of insightsEnabledOpenTextQuestions) {
const updatedQuestion = data.questions?.find((q) => q.id === question.id);
if (updatedQuestion && updatedQuestion.headline.default !== question.headline.default) {
updatedQuestion.insightsEnabled = undefined;
}
}
}
surveyData.updatedAt = new Date();
data = {

View File

@@ -1,33 +1,8 @@
import "server-only";
import { llmModel } from "@/lib/aiModels";
import { Prisma } from "@prisma/client";
import { generateObject } from "ai";
import { z } from "zod";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSegment } from "@formbricks/types/segment";
import {
TSurvey,
TSurveyFilterCriteria,
TSurveyQuestion,
TSurveyQuestions,
} from "@formbricks/types/surveys/types";
export const getInsightsEnabled = async (question: TSurveyQuestion): Promise<boolean> => {
try {
const { object } = await generateObject({
model: llmModel,
schema: z.object({
insightsEnabled: z.boolean(),
}),
prompt: `We extract insights (e.g. feature requests, complaints, other) from survey questions. Can we find them in this question?: ${question.headline.default}`,
experimental_telemetry: { isEnabled: true },
});
return object.insightsEnabled;
} catch (error) {
throw error;
}
};
import { TSurvey, TSurveyFilterCriteria } from "@formbricks/types/surveys/types";
export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSurvey>(
surveyPrisma: any
@@ -114,7 +89,3 @@ export const anySurveyHasFilters = (surveys: TSurvey[]): boolean => {
return false;
});
};
export const doesSurveyHasOpenTextQuestion = (questions: TSurveyQuestions): boolean => {
return questions.some((question) => question.type === "openText");
};

View File

@@ -21,12 +21,6 @@ vi.mock("@/lib/constants", () => ({
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
WEBAPP_URL: "mock-webapp-url",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
IS_PRODUCTION: true,
FB_LOGO_URL: "https://example.com/mock-logo.png",
SMTP_HOST: "mock-smtp-host",

View File

@@ -27,12 +27,6 @@ vi.mock("@/lib/constants", () => ({
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
WEBAPP_URL: "mock-webapp-url",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
IS_PRODUCTION: true,
FB_LOGO_URL: "https://example.com/mock-logo.png",
SMTP_HOST: "mock-smtp-host",

View File

@@ -1,91 +0,0 @@
"use server";
import { llmModel } from "@/lib/aiModels";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAIKeys } from "@/modules/survey/lib/organization";
import { createSurvey } from "@/modules/survey/templates/lib/survey";
import { createId } from "@paralleldrive/cuid2";
import { generateObject } from "ai";
import { z } from "zod";
import { ZSurveyQuestion } from "@formbricks/types/surveys/types";
const ZCreateAISurveyAction = z.object({
environmentId: z.string().cuid2(),
prompt: z.string(),
});
export const createAISurveyAction = authenticatedActionClient
.schema(ZCreateAISurveyAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
});
const organization = await getOrganizationAIKeys(organizationId);
if (!organization) {
throw new Error("Organization not found");
}
const isAIEnabled = await getIsAIEnabled({
isAIEnabled: organization.isAIEnabled,
billing: organization.billing,
});
if (!isAIEnabled) {
throw new Error("AI is not enabled for this organization");
}
const { object } = await generateObject({
model: llmModel,
schema: z.object({
name: z.string(),
questions: z.array(
z.object({
headline: z.string(),
subheader: z.string(),
type: z.enum(["openText", "multipleChoiceSingle", "multipleChoiceMulti"]),
choices: z
.array(z.string())
.min(2, { message: "Multiple Choice Question must have at least two choices" })
.optional(),
})
),
}),
system: `You are a survey AI. Create a survey with 3 questions max that fits the schema and user input.`,
prompt: parsedInput.prompt,
experimental_telemetry: { isEnabled: true },
});
const parsedQuestions = object.questions.map((question) => {
return ZSurveyQuestion.parse({
id: createId(),
headline: { default: question.headline },
subheader: { default: question.subheader },
type: question.type,
choices: question.choices
? question.choices.map((choice) => ({ id: createId(), label: { default: choice } }))
: undefined,
required: true,
});
});
return await createSurvey(parsedInput.environmentId, { name: object.name, questions: parsedQuestions });
});

View File

@@ -1,83 +0,0 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createAISurveyAction } from "@/modules/survey/templates/actions";
import { Button } from "@/modules/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/modules/ui/components/card";
import { Textarea } from "@/modules/ui/components/textarea";
import { useTranslate } from "@tolgee/react";
import { Sparkles } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
interface FormbricksAICardProps {
environmentId: string;
}
export const FormbricksAICard = ({ environmentId }: FormbricksAICardProps) => {
const { t } = useTranslate();
const router = useRouter();
const [aiPrompt, setAiPrompt] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
// Here you would typically send the data to your backend
const createSurveyResponse = await createAISurveyAction({
environmentId,
prompt: aiPrompt,
});
if (createSurveyResponse?.data) {
router.push(`/environments/${environmentId}/surveys/${createSurveyResponse.data.id}/edit`);
} else {
const errorMessage = getFormattedErrorMessage(createSurveyResponse);
toast.error(errorMessage);
}
// Reset form field after submission
setAiPrompt("");
setIsLoading(false);
};
return (
<Card className="mx-auto w-full bg-gradient-to-tr from-slate-100 to-slate-200">
<CardHeader>
<CardTitle className="text-2xl font-bold">Formbricks AI</CardTitle>
<CardDescription>{t("environments.surveys.edit.formbricks_ai_description")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<Textarea
className="bg-slate-50"
id="ai-prompt"
placeholder={t("environments.surveys.edit.formbricks_ai_prompt_placeholder")}
value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)}
required
aria-label="AI Prompt"
/>
</form>
</CardContent>
<CardFooter>
<Button
className="w-full shadow-sm"
type="submit"
onClick={handleSubmit}
variant="secondary"
loading={isLoading}>
<Sparkles className="mr-2 h-4 w-4" />
{t("environments.surveys.edit.formbricks_ai_generate")}
</Button>
</CardFooter>
</Card>
);
};

View File

@@ -2,11 +2,9 @@
import { customSurveyTemplate } from "@/app/lib/templates";
import { TemplateList } from "@/modules/survey/components/template-list";
import { FormbricksAICard } from "@/modules/survey/templates/components/formbricks-ai-card";
import { MenuBar } from "@/modules/survey/templates/components/menu-bar";
import { PreviewSurvey } from "@/modules/ui/components/preview-survey";
import { SearchBar } from "@/modules/ui/components/search-bar";
import { Separator } from "@/modules/ui/components/separator";
import { Project } from "@prisma/client";
import { Environment } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
@@ -20,7 +18,6 @@ type TemplateContainerWithPreviewProps = {
environment: Pick<Environment, "id" | "appSetupCompleted">;
userId: string;
prefilledFilters: (TProjectConfigChannel | TProjectConfigIndustry | TTemplateRole | null)[];
isAIEnabled: boolean;
};
export const TemplateContainerWithPreview = ({
@@ -28,7 +25,6 @@ export const TemplateContainerWithPreview = ({
environment,
userId,
prefilledFilters,
isAIEnabled,
}: TemplateContainerWithPreviewProps) => {
const { t } = useTranslate();
const initialTemplate = customSurveyTemplate(t);
@@ -41,7 +37,7 @@ export const TemplateContainerWithPreview = ({
<MenuBar />
<div className="relative z-0 flex flex-1 overflow-hidden">
<div className="flex-1 flex-col overflow-auto bg-slate-50">
<div className="mb-3 ml-6 mt-6 flex flex-col items-center justify-between md:flex-row md:items-end">
<div className="mt-6 mb-3 ml-6 flex flex-col items-center justify-between md:flex-row md:items-end">
<h1 className="text-2xl font-bold text-slate-800">
{t("environments.surveys.templates.create_a_new_survey")}
</h1>
@@ -54,16 +50,6 @@ export const TemplateContainerWithPreview = ({
/>
</div>
</div>
{isAIEnabled && (
<>
<div className="px-6">
<FormbricksAICard environmentId={environment.id} />
</div>
<Separator className="mt-4" />
</>
)}
<TemplateList
environmentId={environment.id}
project={project}

View File

@@ -1,8 +1,6 @@
import { segmentCache } from "@/lib/cache/segment";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { surveyCache } from "@/lib/survey/cache";
import { getInsightsEnabled } from "@/modules/survey/lib/utils";
import { doesSurveyHasOpenTextQuestion } from "@/modules/survey/lib/utils";
import { Prisma, Survey } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
@@ -13,30 +11,6 @@ export const createSurvey = async (
surveyBody: Pick<Survey, "name" | "questions">
): Promise<{ id: string }> => {
try {
if (doesSurveyHasOpenTextQuestion(surveyBody.questions ?? [])) {
const openTextQuestions =
surveyBody.questions?.filter((question) => question.type === "openText") ?? [];
const insightsEnabledValues = await Promise.all(
openTextQuestions.map(async (question) => {
const insightsEnabled = await getInsightsEnabled(question);
return { id: question.id, insightsEnabled };
})
);
surveyBody.questions = surveyBody.questions?.map((question) => {
const index = insightsEnabledValues.findIndex((item) => item.id === question.id);
if (index !== -1) {
return {
...question,
insightsEnabled: insightsEnabledValues[index].insightsEnabled,
};
}
return question;
});
}
const survey = await prisma.survey.create({
data: {
...surveyBody,

View File

@@ -43,8 +43,6 @@ export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
environment={environment}
project={project}
prefilledFilters={prefilledFilters}
// AI Survey Creation -- Need improvement
isAIEnabled={false}
/>
);
};

View File

@@ -17,7 +17,6 @@
"generate-and-merge-api-specs": "npm run generate-api-specs && npm run merge-client-endpoints"
},
"dependencies": {
"@ai-sdk/azure": "1.1.9",
"@aws-sdk/client-s3": "3.782.0",
"@aws-sdk/s3-presigned-post": "3.782.0",
"@aws-sdk/s3-request-presigner": "3.782.0",
@@ -85,7 +84,6 @@
"@vercel/functions": "2.0.0",
"@vercel/og": "0.6.8",
"@vercel/otel": "1.10.4",
"ai": "4.3.4",
"autoprefixer": "10.4.21",
"aws-crt": "1.26.2",
"bcryptjs": "3.0.2",

View File

@@ -60,7 +60,6 @@ export default defineConfig({
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/ContactInfoSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/CTASummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/DateQuestionSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/EnableInsightsBanner.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/SummaryDropOffs.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/SummaryPage.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/RankingSummary.tsx",