mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-06 00:49:42 -06:00
chore: remove old AI classification feature (#5529)
Co-authored-by: Victor Santos <victor@formbricks.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -171,7 +171,6 @@ describe("SummaryPage", () => {
|
||||
surveyId: "survey-123",
|
||||
webAppUrl: "https://app.example.com",
|
||||
totalResponseCount: 50,
|
||||
isAIEnabled: true,
|
||||
locale,
|
||||
isReadOnly: false,
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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}`;
|
||||
@@ -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">;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "篩選器已成功更新",
|
||||
|
||||
@@ -392,8 +392,6 @@ export const mockSurveySummaryOutput = {
|
||||
},
|
||||
summary: [
|
||||
{
|
||||
insights: undefined,
|
||||
insightsEnabled: undefined,
|
||||
question: {
|
||||
headline: { default: "Question Text", de: "Fragetext" },
|
||||
id: "ars2tjk8hsi8oqk1uac00mo8",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
};
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 won’t 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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),
|
||||
],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -43,8 +43,6 @@ export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
|
||||
environment={environment}
|
||||
project={project}
|
||||
prefilledFilters={prefilledFilters}
|
||||
// AI Survey Creation -- Need improvement
|
||||
isAIEnabled={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user