chore: remove old AI classification feature (#5529)

Co-authored-by: Victor Santos <victor@formbricks.com>
This commit is contained in:
Matti Nannt
2025-04-28 21:18:07 +02:00
committed by GitHub
parent a9eedd3c7a
commit 51001d07b6
82 changed files with 68 additions and 5051 deletions
@@ -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] = {
-98
View File
@@ -1,98 +0,0 @@
"use server";
import { generateInsightsForSurvey } from "@/app/api/(internal)/insights/lib/utils";
import { getOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { getIsAIEnabled, getIsOrganizationAIReady } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
export const checkAIPermission = async (organizationId: string) => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
}
const isAIEnabled = await getIsAIEnabled({
isAIEnabled: organization.isAIEnabled,
billing: organization.billing,
});
if (!isAIEnabled) {
throw new OperationNotAllowedError("AI is not enabled for this organization");
}
};
const ZGenerateInsightsForSurveyAction = z.object({
surveyId: ZId,
});
export const generateInsightsForSurveyAction = authenticatedActionClient
.schema(ZGenerateInsightsForSurveyAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
schema: ZGenerateInsightsForSurveyAction,
data: parsedInput,
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
await checkAIPermission(organizationId);
generateInsightsForSurvey(parsedInput.surveyId);
});
const ZUpdateOrganizationAIEnabledAction = z.object({
organizationId: ZId,
data: ZOrganizationUpdateInput.pick({ isAIEnabled: true }),
});
export const updateOrganizationAIEnabledAction = authenticatedActionClient
.schema(ZUpdateOrganizationAIEnabledAction)
.action(async ({ parsedInput, ctx }) => {
const organizationId = parsedInput.organizationId;
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ isAIEnabled: true }),
data: parsedInput.data,
roles: ["owner", "manager"],
},
],
});
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
}
const isOrganizationAIReady = await getIsOrganizationAIReady(organization.billing.plan);
if (!isOrganizationAIReady) {
throw new OperationNotAllowedError("AI is not ready for this organization");
}
return await updateOrganization(parsedInput.organizationId, parsedInput.data);
});
@@ -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 wont mock CategoryBadge so it renders normally.
// If needed, you can also mock it similar to InsightSheet.
// --- Dummy Data ---
const dummyInsight1 = {
id: "1",
title: "Insight 1",
description: "Description 1",
category: "featureRequest",
_count: { documentInsights: 5 },
};
const dummyInsight2 = {
id: "2",
title: "Insight 2",
description: "Description 2",
category: "featureRequest",
_count: { documentInsights: 3 },
};
const dummyInsightComplaint = {
id: "3",
title: "Complaint Insight",
description: "Complaint Description",
category: "complaint",
_count: { documentInsights: 10 },
};
const dummyInsightPraise = {
id: "4",
title: "Praise Insight",
description: "Praise Description",
category: "praise",
_count: { documentInsights: 8 },
};
// A helper to render the component with required props.
const renderComponent = (props = {}) => {
const defaultProps = {
statsFrom: new Date("2023-01-01"),
environmentId: "env-1",
insightsPerPage: 2,
documentsPerPage: 5,
locale: "en-US" as TUserLocale,
};
return render(<InsightView {...defaultProps} {...props} />);
};
// --- Tests ---
describe("InsightView Component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('renders "no insights found" message when insights array is empty', async () => {
// Set up the mock to return an empty array.
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [] });
renderComponent();
// Wait for the useEffect to complete.
await waitFor(() => {
expect(screen.getByText("environments.experience.no_insights_found")).toBeInTheDocument();
});
});
test("renders table rows when insights are fetched", async () => {
// Return two insights for the initial fetch.
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1, dummyInsight2] });
renderComponent();
// Wait until the insights are rendered.
await waitFor(() => {
expect(screen.getByText("Insight 1")).toBeInTheDocument();
expect(screen.getByText("Insight 2")).toBeInTheDocument();
});
});
test("opens insight sheet when a table row is clicked", async () => {
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1] });
renderComponent();
// Wait for the insight to appear.
await waitFor(() => {
expect(screen.getAllByText("Insight 1").length).toBeGreaterThan(0);
});
// Instead of grabbing the first "Insight 1" cell,
// get all table rows (they usually have role="row") and then find the row that contains "Insight 1".
const rows = screen.getAllByRole("row");
const targetRow = rows.find((row) => row.textContent?.includes("Insight 1"));
console.log(targetRow?.textContent);
expect(targetRow).toBeTruthy();
// Click the entire row.
fireEvent.click(targetRow!);
// Wait for the InsightSheet to update.
await waitFor(() => {
const sheet = screen.getAllByTestId("insight-sheet");
const matchingSheet = sheet.find((s) => s.textContent?.includes("InsightSheet Open - Insight 1"));
expect(matchingSheet).toBeInTheDocument();
});
});
test("clicking load more fetches next page of insights", async () => {
// First fetch returns two insights.
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1, dummyInsight2] });
// Second fetch returns one additional insight.
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsightPraise] });
renderComponent();
// Wait for the initial insights to be rendered.
await waitFor(() => {
expect(screen.getAllByText("Insight 1").length).toBeGreaterThan(0);
expect(screen.getAllByText("Insight 2").length).toBeGreaterThan(0);
});
// The load more button should be visible because hasMore is true.
const loadMoreButton = screen.getAllByText("common.load_more")[0];
fireEvent.click(loadMoreButton);
// Wait for the new insight to be appended.
await waitFor(() => {
expect(screen.getAllByText("Praise Insight").length).toBeGreaterThan(0);
});
});
test("changes filter tab and re-fetches insights", async () => {
// For initial active tab "featureRequest", return a featureRequest insight.
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({ data: [dummyInsight1] });
renderComponent();
await waitFor(() => {
expect(screen.getAllByText("Insight 1")[0]).toBeInTheDocument();
});
mockGetEnvironmentInsightsAction.mockResolvedValueOnce({
data: [dummyInsightComplaint as TInsightWithDocumentCount],
});
renderComponent();
// Find the complaint tab and click it.
const complaintTab = screen.getAllByText("environments.experience.complaint")[0];
fireEvent.click(complaintTab);
// Wait until the new complaint insight is rendered.
await waitFor(() => {
expect(screen.getAllByText("Complaint Insight")[0]).toBeInTheDocument();
});
});
test("shows loading indicator when fetching insights", async () => {
// Make the mock return a promise that doesn't resolve immediately.
let resolveFetch: any;
const fetchPromise = new Promise((resolve) => {
resolveFetch = resolve;
});
mockGetEnvironmentInsightsAction.mockReturnValueOnce(fetchPromise);
renderComponent();
// While fetching, the loading indicator should be visible.
expect(screen.getByTestId("insight-loading")).toBeInTheDocument();
// Resolve the fetch.
resolveFetch({ data: [dummyInsight1] });
await waitFor(() => {
// After fetching, the loading indicator should disappear.
expect(screen.queryByTestId("insight-loading")).not.toBeInTheDocument();
// Instead of getByText, use getAllByText to assert at least one instance of "Insight 1" exists.
expect(screen.getAllByText("Insight 1").length).toBeGreaterThan(0);
});
});
});
@@ -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",
+2 -69
View File
@@ -1,17 +1,15 @@
import { segmentCache } from "@/lib/cache/segment";
import { surveyCache } from "@/lib/survey/cache";
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getSurvey, selectSurvey } from "@/modules/survey/lib/survey";
import { doesSurveyHasOpenTextQuestion, getInsightsEnabled } from "@/modules/survey/lib/utils";
import { ActionClass, Prisma, Survey } from "@prisma/client";
import { ActionClass, Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey, TSurveyOpenTextQuestion } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
try {
@@ -253,71 +251,6 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
throw new ResourceNotFoundError("Organization", null);
}
//AI Insights
const isAIEnabled = await getIsAIEnabled(organization);
if (isAIEnabled) {
if (doesSurveyHasOpenTextQuestion(data.questions ?? [])) {
const openTextQuestions = data.questions?.filter((question) => question.type === "openText") ?? [];
const currentSurveyOpenTextQuestions = currentSurvey.questions?.filter(
(question) => question.type === "openText"
);
// find the questions that have been updated or added
const questionsToCheckForInsights: Survey["questions"] = [];
for (const question of openTextQuestions) {
const existingQuestion = currentSurveyOpenTextQuestions?.find((ques) => ques.id === question.id) as
| TSurveyOpenTextQuestion
| undefined;
const isExistingQuestion = !!existingQuestion;
if (
isExistingQuestion &&
question.headline.default === existingQuestion.headline.default &&
existingQuestion.insightsEnabled !== undefined
) {
continue;
} else {
questionsToCheckForInsights.push(question);
}
}
if (questionsToCheckForInsights.length > 0) {
const insightsEnabledValues = await Promise.all(
questionsToCheckForInsights.map(async (question) => {
const insightsEnabled = await getInsightsEnabled(question);
return { id: question.id, insightsEnabled };
})
);
data.questions = data.questions?.map((question) => {
const index = insightsEnabledValues.findIndex((item) => item.id === question.id);
if (index !== -1) {
return {
...question,
insightsEnabled: insightsEnabledValues[index].insightsEnabled,
};
}
return question;
});
}
}
} else {
// check if an existing question got changed that had insights enabled
const insightsEnabledOpenTextQuestions = currentSurvey.questions?.filter(
(question) => question.type === "openText" && question.insightsEnabled !== undefined
);
// if question headline changed, remove insightsEnabled
for (const question of insightsEnabledOpenTextQuestions) {
const updatedQuestion = data.questions?.find((q) => q.id === question.id);
if (updatedQuestion && updatedQuestion.headline.default !== question.headline.default) {
updatedQuestion.insightsEnabled = undefined;
}
}
}
surveyData.updatedAt = new Date();
data = {
+1 -30
View File
@@ -1,33 +1,8 @@
import "server-only";
import { llmModel } from "@/lib/aiModels";
import { Prisma } from "@prisma/client";
import { generateObject } from "ai";
import { z } from "zod";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSegment } from "@formbricks/types/segment";
import {
TSurvey,
TSurveyFilterCriteria,
TSurveyQuestion,
TSurveyQuestions,
} from "@formbricks/types/surveys/types";
export const getInsightsEnabled = async (question: TSurveyQuestion): Promise<boolean> => {
try {
const { object } = await generateObject({
model: llmModel,
schema: z.object({
insightsEnabled: z.boolean(),
}),
prompt: `We extract insights (e.g. feature requests, complaints, other) from survey questions. Can we find them in this question?: ${question.headline.default}`,
experimental_telemetry: { isEnabled: true },
});
return object.insightsEnabled;
} catch (error) {
throw error;
}
};
import { TSurvey, TSurveyFilterCriteria } from "@formbricks/types/surveys/types";
export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSurvey>(
surveyPrisma: any
@@ -114,7 +89,3 @@ export const anySurveyHasFilters = (surveys: TSurvey[]): boolean => {
return false;
});
};
export const doesSurveyHasOpenTextQuestion = (questions: TSurveyQuestions): boolean => {
return questions.some((question) => question.type === "openText");
};
@@ -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}
/>
);
};