feat: Quota management(part 1 & part 2) (#6521)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
This commit is contained in:
Piyush Gupta
2025-09-09 18:55:05 +05:30
committed by GitHub
parent a5433f6748
commit feee22b5c3
147 changed files with 10020 additions and 950 deletions

View File

@@ -4,14 +4,15 @@ import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/
import { TFnType, useTranslate } from "@tolgee/react";
import React from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse, TResponseDataValue, TResponseTableData } from "@formbricks/types/responses";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseDataValue, TResponseTableData, TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
interface ResponseDataViewProps {
survey: TSurvey;
responses: TResponse[];
responses: TResponseWithQuotas[];
user?: TUser;
environment: TEnvironment;
environmentTags: TTag[];
@@ -19,9 +20,11 @@ interface ResponseDataViewProps {
fetchNextPage: () => void;
hasMore: boolean;
updateResponseList: (responseIds: string[]) => void;
updateResponse: (responseId: string, updatedResponse: TResponse) => void;
updateResponse: (responseId: string, updatedResponse: TResponseWithQuotas) => void;
isFetchingFirstPage: boolean;
locale: TUserLocale;
isQuotasAllowed: boolean;
quotas: TSurveyQuota[];
}
// Export for testing
@@ -47,7 +50,7 @@ export const formatContactInfoData = (responseValue: TResponseDataValue): Record
};
// Export for testing
export const extractResponseData = (response: TResponse, survey: TSurvey): Record<string, any> => {
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
let responseData: Record<string, any> = {};
survey.questions.forEach((question) => {
@@ -78,7 +81,7 @@ export const extractResponseData = (response: TResponse, survey: TSurvey): Recor
// Export for testing
export const mapResponsesToTableData = (
responses: TResponse[],
responses: TResponseWithQuotas[],
survey: TSurvey,
t: TFnType
): TResponseTableData[] => {
@@ -101,6 +104,7 @@ export const mapResponsesToTableData = (
person: response.contact,
contactAttributes: response.contactAttributes,
meta: response.meta,
quotas: response.quotas?.map((quota) => quota.name),
}));
};
@@ -117,6 +121,8 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
updateResponse,
isFetchingFirstPage,
locale,
isQuotasAllowed,
quotas,
}) => {
const { t } = useTranslate();
const data = mapResponsesToTableData(responses, survey, t);
@@ -137,6 +143,8 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
updateResponse={updateResponse}
isFetchingFirstPage={isFetchingFirstPage}
locale={locale}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
/>
</div>
);

View File

@@ -9,7 +9,8 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
@@ -23,6 +24,8 @@ interface ResponsePageProps {
responsesPerPage: number;
locale: TUserLocale;
isReadOnly: boolean;
isQuotasAllowed: boolean;
quotas: TSurveyQuota[];
}
export const ResponsePage = ({
@@ -34,8 +37,10 @@ export const ResponsePage = ({
responsesPerPage,
locale,
isReadOnly,
isQuotasAllowed,
quotas,
}: ResponsePageProps) => {
const [responses, setResponses] = useState<TResponse[]>([]);
const [responses, setResponses] = useState<TResponseWithQuotas[]>([]);
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
const [isFetchingFirstPage, setFetchingFirstPage] = useState<boolean>(true);
@@ -53,7 +58,7 @@ export const ResponsePage = ({
const fetchNextPage = useCallback(async () => {
const newPage = page + 1;
let newResponses: TResponse[] = [];
let newResponses: TResponseWithQuotas[] = [];
const getResponsesActionResponse = await getResponsesAction({
surveyId,
@@ -74,7 +79,7 @@ export const ResponsePage = ({
setResponses((prev) => prev.filter((r) => !responseIds.includes(r.id)));
};
const updateResponse = (responseId: string, updatedResponse: TResponse) => {
const updateResponse = (responseId: string, updatedResponse: TResponseWithQuotas) => {
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
};
@@ -92,7 +97,7 @@ export const ResponsePage = ({
const fetchInitialResponses = async () => {
try {
setFetchingFirstPage(true);
let responses: TResponse[] = [];
let responses: TResponseWithQuotas[] = [];
const getResponsesActionResponse = await getResponsesAction({
surveyId,
@@ -138,6 +143,8 @@ export const ResponsePage = ({
updateResponse={updateResponse}
isFetchingFirstPage={isFetchingFirstPage}
locale={locale}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
/>
</>
);

View File

@@ -32,7 +32,8 @@ import { useTranslate } from "@tolgee/react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseTableData, TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
@@ -40,7 +41,7 @@ import { TUser, TUserLocale } from "@formbricks/types/user";
interface ResponseTableProps {
data: TResponseTableData[];
survey: TSurvey;
responses: TResponse[] | null;
responses: TResponseWithQuotas[] | null;
environment: TEnvironment;
user?: TUser;
environmentTags: TTag[];
@@ -48,9 +49,11 @@ interface ResponseTableProps {
fetchNextPage: () => void;
hasMore: boolean;
updateResponseList: (responseIds: string[]) => void;
updateResponse: (responseId: string, updatedResponse: TResponse) => void;
updateResponse: (responseId: string, updatedResponse: TResponseWithQuotas) => void;
isFetchingFirstPage: boolean;
locale: TUserLocale;
isQuotasAllowed: boolean;
quotas: TSurveyQuota[];
}
export const ResponseTable = ({
@@ -67,6 +70,8 @@ export const ResponseTable = ({
updateResponse,
isFetchingFirstPage,
locale,
isQuotasAllowed,
quotas,
}: ResponseTableProps) => {
const { t } = useTranslate();
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
@@ -78,8 +83,9 @@ export const ResponseTable = ({
const [columnOrder, setColumnOrder] = useState<string[]>([]);
const [parent] = useAutoAnimate();
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t);
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn);
// Save settings to localStorage when they change
useEffect(() => {
@@ -178,8 +184,8 @@ export const ResponseTable = ({
}
};
const deleteResponse = async (responseId: string) => {
await deleteResponseAction({ responseId });
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
await deleteResponseAction({ responseId, decrementQuotas: params?.decrementQuotas ?? false });
};
// Handle downloading selected responses
@@ -225,6 +231,7 @@ export const ResponseTable = ({
type="response"
deleteAction={deleteResponse}
downloadRowsAction={downloadSelectedRows}
isQuotasAllowed={isQuotasAllowed}
/>
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
<div className="w-full overflow-x-auto">

View File

@@ -246,30 +246,30 @@ describe("generateResponseTableColumns", () => {
});
test("should include selection column when not read-only", () => {
const columns = generateResponseTableColumns(mockSurvey, false, false, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, false, t as any, false);
expect(columns[0].id).toBe("select");
expect(vi.mocked(getSelectionColumn)).toHaveBeenCalledTimes(1);
});
test("should not include selection column when read-only", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
expect(columns[0].id).not.toBe("select");
expect(vi.mocked(getSelectionColumn)).not.toHaveBeenCalled();
});
test("should include Verified Email column when survey.isVerifyEmailEnabled is true", () => {
const surveyWithVerifiedEmail = { ...mockSurvey, isVerifyEmailEnabled: true };
const columns = generateResponseTableColumns(surveyWithVerifiedEmail, false, true, t as any);
const columns = generateResponseTableColumns(surveyWithVerifiedEmail, false, true, t as any, false);
expect(columns.some((col) => (col as any).accessorKey === "verifiedEmail")).toBe(true);
});
test("should not include Verified Email column when survey.isVerifyEmailEnabled is false", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
expect(columns.some((col) => (col as any).accessorKey === "verifiedEmail")).toBe(false);
});
test("should generate columns for variables", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const var1Col = columns.find((col) => (col as any).accessorKey === "VARIABLE_var1");
expect(var1Col).toBeDefined();
const var1Cell = (var1Col?.cell as any)?.({ row: { original: mockResponseData } } as any);
@@ -282,7 +282,7 @@ describe("generateResponseTableColumns", () => {
});
test("should generate columns for hidden fields if fieldIds exist", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const hf1Col = columns.find((col) => (col as any).accessorKey === "HIDDEN_FIELD_hf1");
expect(hf1Col).toBeDefined();
const hf1Cell = (hf1Col?.cell as any)?.({ row: { original: mockResponseData } } as any);
@@ -291,7 +291,7 @@ describe("generateResponseTableColumns", () => {
test("should not generate columns for hidden fields if fieldIds is undefined", () => {
const surveyWithoutHiddenFieldIds = { ...mockSurvey, hiddenFields: { enabled: true } };
const columns = generateResponseTableColumns(surveyWithoutHiddenFieldIds, false, true, t as any);
const columns = generateResponseTableColumns(surveyWithoutHiddenFieldIds, false, true, t as any, false);
const hf1Col = columns.find((col) => (col as any).accessorKey === "hf1");
expect(hf1Col).toBeUndefined();
});
@@ -316,7 +316,7 @@ describe("ResponseTableColumns", () => {
const isReadOnly = false;
// Act
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT, false);
// Assert
const verifiedEmailColumn: any = columns.find((col: any) => col.accessorKey === "verifiedEmail");
@@ -344,7 +344,7 @@ describe("ResponseTableColumns", () => {
const isReadOnly = false;
// Act
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT, false);
// Assert
const verifiedEmailColumn = columns.find((col: any) => col.accessorKey === "verifiedEmail");
@@ -358,7 +358,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
});
test("dateColumn renders with formatted date", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const dateColumn: any = columns.find((col) => (col as any).accessorKey === "createdAt");
expect(dateColumn).toBeDefined();
@@ -376,7 +376,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
});
test("personColumn renders anonymous when person is null", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
expect(personColumn).toBeDefined();
@@ -399,7 +399,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
});
test("personColumn renders person identifier when person exists", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
expect(personColumn).toBeDefined();
@@ -420,7 +420,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
});
test("tagsColumn returns undefined when tags is not an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const tagsColumn: any = columns.find((col) => (col as any).accessorKey === "tags");
expect(tagsColumn).toBeDefined();
@@ -435,7 +435,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
});
test("variableColumns render variable values correctly", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
// Find the variable column for var1
const var1Column: any = columns.find((col) => (col as any).accessorKey === "VARIABLE_var1");
@@ -467,7 +467,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
});
test("hiddenFieldColumns render when fieldIds exist", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
// Find the hidden field column
const hfColumn: any = columns.find((col) => (col as any).accessorKey === "HIDDEN_FIELD_hf1");
@@ -494,7 +494,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
hiddenFields: { enabled: true }, // no fieldIds
};
const columns = generateResponseTableColumns(surveyWithNoHiddenFields, false, true, t as any);
const columns = generateResponseTableColumns(surveyWithNoHiddenFields, false, true, t as any, false);
// Check that no hidden field columns were created
const hfColumn = columns.find((col) => (col as any).accessorKey === "HIDDEN_FIELD_hf1");
@@ -512,7 +512,7 @@ describe("ResponseTableColumns - Multiple Choice Questions", () => {
});
test("generates two columns for multipleChoiceSingle questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
@@ -524,7 +524,7 @@ describe("ResponseTableColumns - Multiple Choice Questions", () => {
});
test("generates two columns for multipleChoiceMulti questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multi");
@@ -536,7 +536,7 @@ describe("ResponseTableColumns - Multiple Choice Questions", () => {
});
test("multipleChoiceSingle main column renders RenderResponse component", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
const mockRow = {
@@ -552,7 +552,7 @@ describe("ResponseTableColumns - Multiple Choice Questions", () => {
});
test("multipleChoiceMulti main column renders RenderResponse component", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multi");
const mockRow = {
@@ -578,7 +578,7 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
});
test("option IDs column calls extractChoiceIdsFromResponse for string response", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const optionIdsColumn: any = columns.find(
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
);
@@ -600,7 +600,7 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
});
test("option IDs column calls extractChoiceIdsFromResponse for array response", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const optionIdsColumn: any = columns.find(
(col) => (col as any).accessorKey === "QUESTION_q6multioptionIds"
);
@@ -622,7 +622,7 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
});
test("option IDs column renders IdBadge components for choice IDs", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const optionIdsColumn: any = columns.find(
(col) => (col as any).accessorKey === "QUESTION_q6multioptionIds"
);
@@ -646,7 +646,7 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
});
test("option IDs column returns null for non-string/array response values", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const optionIdsColumn: any = columns.find(
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
);
@@ -665,7 +665,7 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
});
test("option IDs column returns null when no choice IDs found", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const optionIdsColumn: any = columns.find(
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
);
@@ -686,7 +686,7 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
});
test("option IDs column handles missing language gracefully", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const optionIdsColumn: any = columns.find(
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
);
@@ -718,7 +718,7 @@ describe("ResponseTableColumns - Helper Functions", () => {
});
test("question headers are properly created for multiple choice questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
const optionIdsColumn: any = columns.find(
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
@@ -736,7 +736,7 @@ describe("ResponseTableColumns - Helper Functions", () => {
});
test("question headers include proper icons for multiple choice questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const singleChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
const multiChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multi");
@@ -760,7 +760,7 @@ describe("ResponseTableColumns - Integration Tests", () => {
});
test("multiple choice questions work end-to-end with real data", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
// Find all multiple choice related columns
const singleMainCol = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");

View File

@@ -257,7 +257,8 @@ export const generateResponseTableColumns = (
survey: TSurvey,
isExpanded: boolean,
isReadOnly: boolean,
t: TFnType
t: TFnType,
showQuotasColumn: boolean
): ColumnDef<TResponseTableData>[] => {
const questionColumns = survey.questions.flatMap((question) =>
getQuestionColumnsData(question, survey, isExpanded, t)
@@ -306,6 +307,17 @@ export const generateResponseTableColumns = (
},
};
const quotasColumn: ColumnDef<TResponseTableData> = {
accessorKey: "quota",
header: t("common.quota"),
cell: ({ row }) => {
const quotas = row.original.quotas;
const items = quotas?.map((quota) => ({ value: quota })) ?? [];
return <ResponseBadges items={items} showId={false} />;
},
size: 200,
};
const statusColumn: ColumnDef<TResponseTableData> = {
accessorKey: "status",
size: 200,
@@ -393,6 +405,7 @@ export const generateResponseTableColumns = (
const baseColumns = [
personColumn,
dateColumn,
...(showQuotasColumn ? [quotasColumn] : []),
statusColumn,
...(survey.isVerifyEmailEnabled ? [verifiedEmailColumn] : []),
...questionColumns,

View File

@@ -10,8 +10,11 @@ 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 { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
@@ -102,6 +105,19 @@ vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div data-testid="page-content-wrapper">{children}</div>),
}));
vi.mock("@/modules/survey/lib/organization", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
}));
vi.mock("@/modules/survey/lib/survey", () => ({
getOrganizationBilling: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsQuotasEnabled: vi.fn(),
getIsContactsEnabled: vi.fn(),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ pageTitle, children, cta }) => (
<div data-testid="page-header">
@@ -194,6 +210,9 @@ describe("ResponsesPage", () => {
test("renders correctly with all data", async () => {
const props = { params: mockParams };
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("mock-organization-id");
vi.mocked(getOrganizationBilling).mockResolvedValue({ plan: "scale" });
vi.mocked(getIsQuotasEnabled).mockResolvedValue(false);
const jsx = await Page(props);
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);

View File

@@ -10,8 +10,11 @@ import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
@@ -46,6 +49,19 @@ const Page = async (props) => {
const locale = await findMatchingLocale();
const publicDomain = getPublicDomain();
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
if (!organizationId) {
throw new Error(t("common.organization_not_found"));
}
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error(t("common.organization_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
return (
<PageContentWrapper>
<PageHeader
@@ -75,6 +91,8 @@ const Page = async (props) => {
responsesPerPage={RESPONSES_PER_PAGE}
locale={locale}
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
/>
</PageContentWrapper>
);

View File

@@ -28,6 +28,8 @@ const baseSummary = {
startsPercentage: 75,
totalResponses: 4,
ttcAverage: 65000,
quotasCompleted: 0,
quotasCompletedPercentage: 0,
};
describe("SummaryMetadata", () => {
@@ -38,25 +40,26 @@ describe("SummaryMetadata", () => {
test("renders loading skeletons when isLoading=true", () => {
const { container } = render(
<SummaryMetadata
showDropOffs={false}
setShowDropOffs={() => {}}
tab={undefined}
setTab={() => {}}
surveySummary={baseSummary}
isLoading={true}
isQuotasAllowed={true}
/>
);
expect(container.getElementsByClassName("animate-pulse")).toHaveLength(5);
expect(container.getElementsByClassName("animate-pulse")).toHaveLength(6);
});
test("renders all stats and formats time correctly, toggles dropOffs icon", async () => {
const Wrapper = () => {
const [show, setShow] = useState(false);
return (
<SummaryMetadata
showDropOffs={show}
setShowDropOffs={setShow}
tab={"dropOffs"}
setTab={() => {}}
surveySummary={baseSummary}
isLoading={false}
isQuotasAllowed={false}
/>
);
};
@@ -83,10 +86,11 @@ describe("SummaryMetadata", () => {
const smallSummary = { ...baseSummary, ttcAverage: 5000 };
render(
<SummaryMetadata
showDropOffs={false}
setShowDropOffs={() => {}}
tab="dropOffs"
setTab={() => {}}
surveySummary={smallSummary}
isLoading={false}
isQuotasAllowed={false}
/>
);
expect(screen.getByText("5.00s")).toBeInTheDocument();
@@ -95,13 +99,13 @@ describe("SummaryMetadata", () => {
test("renders '-' for dropOffCount=0 and still toggles icon", async () => {
const zeroSummary = { ...baseSummary, dropOffCount: 0 };
const Wrapper = () => {
const [show, setShow] = useState(false);
return (
<SummaryMetadata
showDropOffs={show}
setShowDropOffs={setShow}
tab="dropOffs"
setTab={() => {}}
surveySummary={zeroSummary}
isLoading={false}
isQuotasAllowed={false}
/>
);
};
@@ -119,10 +123,11 @@ describe("SummaryMetadata", () => {
const dispZero = { ...baseSummary, displayCount: 0 };
render(
<SummaryMetadata
showDropOffs={false}
setShowDropOffs={() => {}}
tab="dropOffs"
setTab={() => {}}
surveySummary={dispZero}
isLoading={false}
isQuotasAllowed={false}
/>
);
expect(screen.getAllByText("-")).toHaveLength(1);
@@ -132,10 +137,11 @@ describe("SummaryMetadata", () => {
const totZero = { ...baseSummary, totalResponses: 0 };
render(
<SummaryMetadata
showDropOffs={false}
setShowDropOffs={() => {}}
tab="dropOffs"
setTab={() => {}}
surveySummary={totZero}
isLoading={false}
isQuotasAllowed={false}
/>
);
expect(screen.getAllByText("-")).toHaveLength(1);

View File

@@ -1,44 +1,19 @@
"use client";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { InteractiveCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/interactive-card";
import { StatCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/stat-card";
import { cn } from "@/modules/ui/lib/utils";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { TSurveySummary } from "@formbricks/types/surveys/types";
interface SummaryMetadataProps {
setShowDropOffs: React.Dispatch<React.SetStateAction<boolean>>;
showDropOffs: boolean;
surveySummary: TSurveySummary["meta"];
isLoading: boolean;
tab: "dropOffs" | "quotas" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
isQuotasAllowed: boolean;
}
const StatCard = ({ label, percentage, value, tooltipText, isLoading }) => {
return (
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
<p className="flex items-center gap-1 text-sm text-slate-600">
{label}
{typeof percentage === "number" && !isNaN(percentage) && !isLoading && (
<span className="ml-1 rounded-xl bg-slate-100 px-2 py-1 text-xs">{percentage}%</span>
)}
</p>
{isLoading ? (
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
) : (
<p className="text-2xl font-bold text-slate-800">{value}</p>
)}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
const formatTime = (ttc) => {
const seconds = ttc / 1000;
let formattedValue;
@@ -55,10 +30,11 @@ const formatTime = (ttc) => {
};
export const SummaryMetadata = ({
setShowDropOffs,
showDropOffs,
surveySummary,
isLoading,
tab,
setTab,
isQuotasAllowed,
}: SummaryMetadataProps) => {
const {
completedPercentage,
@@ -69,13 +45,24 @@ export const SummaryMetadata = ({
startsPercentage,
totalResponses,
ttcAverage,
quotasCompleted,
quotasCompletedPercentage,
} = surveySummary;
const { t } = useTranslate();
const displayCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
const handleTabChange = (val: "dropOffs" | "quotas") => {
const change = tab === val ? undefined : val;
setTab(change);
};
return (
<div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-5 md:gap-x-2 lg:col-span-4">
<div
className={cn(
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
isQuotasAllowed && "2xl:grid-cols-6"
)}>
<StatCard
label={t("environments.surveys.summary.impressions")}
percentage={null}
@@ -98,41 +85,17 @@ export const SummaryMetadata = ({
isLoading={isLoading}
/>
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger onClick={() => setShowDropOffs(!showDropOffs)} data-testid="dropoffs-toggle">
<div className="flex h-full cursor-pointer flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
<span className="text-sm text-slate-600">
{t("environments.surveys.summary.drop_offs")}
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
<span className="ml-1 rounded-xl bg-slate-100 px-2 py-1 text-xs">{`${Math.round(dropOffPercentage)}%`}</span>
)}
</span>
<div className="flex w-full items-end justify-between">
<span className="text-2xl font-bold text-slate-800">
{isLoading ? (
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
) : (
displayCountValue
)}
</span>
{!isLoading && (
<div className="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
{showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</div>
)}
</div>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t("environments.surveys.summary.drop_offs_tooltip")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<InteractiveCard
key="dropOffs"
tab="dropOffs"
label={t("environments.surveys.summary.drop_offs")}
percentage={dropOffPercentage}
value={dropoffCountValue}
tooltipText={t("environments.surveys.summary.drop_offs_tooltip")}
isLoading={isLoading}
onClick={() => handleTabChange("dropOffs")}
isActive={tab === "dropOffs"}
/>
<StatCard
label={t("environments.surveys.summary.time_to_complete")}
@@ -141,6 +104,20 @@ export const SummaryMetadata = ({
tooltipText={t("environments.surveys.summary.ttc_tooltip")}
isLoading={isLoading}
/>
{isQuotasAllowed && (
<InteractiveCard
key="quotas"
tab="quotas"
label={t("environments.surveys.summary.quotas_completed")}
percentage={quotasCompletedPercentage}
value={quotasCompleted}
tooltipText={t("environments.surveys.summary.quotas_completed_tooltip")}
isLoading={isLoading}
onClick={() => handleTabChange("quotas")}
isActive={tab === "quotas"}
/>
)}
</div>
</div>
);

View File

@@ -20,6 +20,8 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/
startsPercentage: 100,
totalResponses: 50,
ttcAverage: 120,
quotasCompleted: 0,
quotasCompletedPercentage: 0,
},
dropOff: [
{
@@ -67,10 +69,10 @@ vi.mock(
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata",
() => ({
SummaryMetadata: ({ showDropOffs, setShowDropOffs, isLoading }: any) => (
SummaryMetadata: ({ tab, setTab, isLoading }: any) => (
<div data-testid="summary-metadata">
<span>Is Loading: {isLoading ? "true" : "false"}</span>
<button onClick={() => setShowDropOffs(!showDropOffs)}>Toggle Dropoffs</button>
<button onClick={() => setTab(tab === "dropOffs" ? "quotas" : "dropOffs")}>Toggle Dropoffs</button>
</div>
),
})
@@ -166,7 +168,7 @@ describe("SummaryPage", () => {
expect(screen.queryByTestId("summary-drop-offs")).not.toBeInTheDocument();
// Toggle drop-offs
await user.click(screen.getByText("Toggle Dropoffs"));
await user.click(screen.getByRole("button", { name: "Toggle Dropoffs" }));
// Drop-offs should now be visible
expect(screen.getByTestId("summary-drop-offs")).toBeInTheDocument();

View File

@@ -7,6 +7,7 @@ import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/survey
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
@@ -25,8 +26,11 @@ const defaultSurveySummary: TSurveySummary = {
startsPercentage: 0,
totalResponses: 0,
ttcAverage: 0,
quotasCompleted: 0,
quotasCompletedPercentage: 0,
},
dropOff: [],
quotas: [],
summary: [],
};
@@ -36,6 +40,7 @@ interface SummaryPageProps {
surveyId: string;
locale: TUserLocale;
initialSurveySummary?: TSurveySummary;
isQuotasAllowed: boolean;
}
export const SummaryPage = ({
@@ -44,13 +49,15 @@ export const SummaryPage = ({
surveyId,
locale,
initialSurveySummary,
isQuotasAllowed,
}: SummaryPageProps) => {
const searchParams = useSearchParams();
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
initialSurveySummary || defaultSurveySummary
);
const [showDropOffs, setShowDropOffs] = useState<boolean>(false);
const [tab, setTab] = useState<"dropOffs" | "quotas" | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
@@ -108,11 +115,13 @@ export const SummaryPage = ({
<>
<SummaryMetadata
surveySummary={surveySummary.meta}
showDropOffs={showDropOffs}
setShowDropOffs={setShowDropOffs}
isLoading={isLoading}
tab={tab}
setTab={setTab}
isQuotasAllowed={isQuotasAllowed}
/>
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
{tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
<div className="flex gap-1.5">
<CustomFilter survey={surveyMemoized} />
</div>

View File

@@ -0,0 +1,83 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { BaseCard } from "./base-card";
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-provider">{children}</div>
),
Tooltip: ({ children }: { children: React.ReactNode }) => <div data-testid="tooltip">{children}</div>,
TooltipTrigger: ({
children,
onClick,
"data-testid": dataTestId,
}: {
children: React.ReactNode;
onClick?: () => void;
"data-testid"?: string;
}) => (
<div data-testid="tooltip-trigger" onClick={onClick} data-testid-value={dataTestId}>
{children}
</div>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-content">{children}</div>
),
}));
describe("BaseCard", () => {
afterEach(() => {
cleanup();
});
test("renders basic card with label and children", () => {
render(
<BaseCard label="Test Label" tooltipText="Test tooltip">
<div>Test Content</div>
</BaseCard>
);
expect(screen.getByText("Test Label")).toBeInTheDocument();
expect(screen.getByText("Test Content")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
expect(screen.getByTestId("tooltip")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-trigger")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-content")).toBeInTheDocument();
});
test("displays percentage when provided as valid number", () => {
render(
<BaseCard label="Test Label" percentage={75}>
<div>Test Content</div>
</BaseCard>
);
expect(screen.getByText("75%")).toBeInTheDocument();
});
test("does not display percentage when loading", () => {
render(
<BaseCard label="Test Label" percentage={75} isLoading={true}>
<div>Test Content</div>
</BaseCard>
);
expect(screen.queryByText("75%")).not.toBeInTheDocument();
});
test("calls onClick when card is clicked", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<BaseCard label="Test Label" onClick={handleClick}>
<div>Test Content</div>
</BaseCard>
);
await user.click(screen.getByTestId("tooltip-trigger"));
expect(handleClick).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,65 @@
"use client";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { cn } from "@/modules/ui/lib/utils";
import { ReactNode } from "react";
interface BaseCardProps {
label: ReactNode;
percentage?: number | null;
tooltipText?: ReactNode;
isLoading?: boolean;
onClick?: () => void;
children: ReactNode;
className?: string;
testId?: string;
id?: string;
}
export const BaseCard = ({
label,
percentage = null,
tooltipText,
isLoading = false,
onClick,
children,
className,
testId,
id,
}: BaseCardProps) => {
const isClickable = !!onClick;
return (
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger onClick={onClick} data-testid={testId}>
<div
className={cn(
"flex h-full flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm",
isClickable ? "cursor-pointer" : "cursor-default",
className
)}
id={id}>
<p className="flex items-center gap-1 text-sm text-slate-600">
{label}
{typeof percentage === "number" &&
!isNaN(percentage) &&
Number.isFinite(percentage) &&
!isLoading && (
<span className="ml-1 rounded-xl bg-slate-100 px-2 py-1 text-xs">
{Math.round(percentage)}%
</span>
)}
</p>
{children}
</div>
</TooltipTrigger>
{tooltipText && (
<TooltipContent>
<p>{tooltipText}</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
);
};

View File

@@ -0,0 +1,127 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { InteractiveCard } from "./interactive-card";
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card",
() => ({
BaseCard: ({
label,
percentage,
tooltipText,
isLoading,
onClick,
testId,
id,
children,
}: {
label: React.ReactNode;
percentage?: number | null;
tooltipText?: React.ReactNode;
isLoading?: boolean;
onClick?: () => void;
testId?: string;
id?: string;
children: React.ReactNode;
}) => (
<div data-testid="base-card" onClick={onClick}>
<div data-testid="base-card-label">{label}</div>
{percentage !== null && percentage !== undefined && (
<div data-testid="base-card-percentage">{percentage}%</div>
)}
{tooltipText && <div data-testid="base-card-tooltip">{tooltipText}</div>}
<div data-testid="base-card-loading">{isLoading ? "loading" : "not-loading"}</div>
<div data-testid="base-card-testid">{testId}</div>
<div data-testid="base-card-id">{id}</div>
<div data-testid="base-card-children">{children}</div>
</div>
),
})
);
vi.mock("lucide-react", () => ({
ChevronDownIcon: ({ className }: { className: string }) => (
<div data-testid="chevron-down-icon" className={className}>
ChevronDown
</div>
),
ChevronUpIcon: ({ className }: { className: string }) => (
<div data-testid="chevron-up-icon" className={className}>
ChevronUp
</div>
),
}));
describe("InteractiveCard", () => {
afterEach(() => {
cleanup();
});
const defaultProps = {
tab: "dropOffs" as const,
label: "Test Label",
percentage: 75,
value: "Test Value",
tooltipText: "Test tooltip",
isLoading: false,
onClick: vi.fn(),
isActive: false,
};
test("renders with basic props", () => {
render(<InteractiveCard {...defaultProps} />);
expect(screen.getByTestId("base-card")).toBeInTheDocument();
expect(screen.getByTestId("base-card-label")).toHaveTextContent("Test Label");
expect(screen.getByTestId("base-card-percentage")).toHaveTextContent("75%");
expect(screen.getByTestId("base-card-tooltip")).toHaveTextContent("Test tooltip");
expect(screen.getByTestId("base-card-loading")).toHaveTextContent("not-loading");
expect(screen.getByText("Test Value")).toBeInTheDocument();
});
test("generates correct testId and id based on tab", () => {
render(<InteractiveCard {...defaultProps} tab="quotas" />);
expect(screen.getByTestId("base-card-testid")).toHaveTextContent("quotas-toggle");
expect(screen.getByTestId("base-card-id")).toHaveTextContent("quotas-toggle");
});
test("calls onClick when clicked", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<InteractiveCard {...defaultProps} onClick={handleClick} />);
await user.click(screen.getByTestId("base-card"));
expect(handleClick).toHaveBeenCalledOnce();
});
test("renders loading state with skeleton", () => {
render(<InteractiveCard {...defaultProps} isLoading={true} />);
expect(screen.getByTestId("base-card-loading")).toHaveTextContent("loading");
const skeleton = screen.getByTestId("base-card-children").querySelector(".animate-pulse");
expect(skeleton).toHaveClass("h-6", "w-12", "animate-pulse", "rounded-full", "bg-slate-200");
expect(screen.queryByText("Test Value")).not.toBeInTheDocument();
expect(screen.queryByTestId("chevron-down-icon")).not.toBeInTheDocument();
expect(screen.queryByTestId("chevron-up-icon")).not.toBeInTheDocument();
});
test("shows chevron up icon when active", () => {
render(<InteractiveCard {...defaultProps} isActive={true} />);
expect(screen.getByTestId("chevron-up-icon")).toBeInTheDocument();
expect(screen.getByTestId("chevron-up-icon")).toHaveClass("h-4", "w-4");
expect(screen.queryByTestId("chevron-down-icon")).not.toBeInTheDocument();
});
test("handles zero percentage", () => {
render(<InteractiveCard {...defaultProps} percentage={0} />);
expect(screen.getByTestId("base-card-percentage")).toHaveTextContent("0%");
});
});

View File

@@ -0,0 +1,48 @@
"use client";
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
interface InteractiveCardProps {
tab: "dropOffs" | "quotas";
label: string;
percentage: number;
value: React.ReactNode;
tooltipText: string;
isLoading: boolean;
onClick: () => void;
isActive: boolean;
}
export const InteractiveCard = ({
tab,
label,
percentage,
value,
tooltipText,
isLoading,
onClick,
isActive,
}: InteractiveCardProps) => {
return (
<BaseCard
label={label}
percentage={percentage}
tooltipText={tooltipText}
isLoading={isLoading}
onClick={onClick}
testId={`${tab}-toggle`}
id={`${tab}-toggle`}>
<div className="flex w-full items-end justify-between">
<span className="text-2xl font-bold text-slate-800">
{isLoading ? <div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div> : value}
</span>
{!isLoading && (
<div className="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
{isActive ? <ChevronUpIcon className="h-4 w-4" /> : <ChevronDownIcon className="h-4 w-4" />}
</div>
)}
</div>
</BaseCard>
);
};

View File

@@ -0,0 +1,80 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { StatCard } from "./stat-card";
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card",
() => ({
BaseCard: ({
label,
percentage,
tooltipText,
isLoading,
children,
}: {
label: React.ReactNode;
percentage?: number | null;
tooltipText?: React.ReactNode;
isLoading?: boolean;
children: React.ReactNode;
}) => (
<div data-testid="base-card">
<div data-testid="base-card-label">{label}</div>
{percentage !== null && percentage !== undefined && (
<div data-testid="base-card-percentage">{percentage}%</div>
)}
{tooltipText && <div data-testid="base-card-tooltip">{tooltipText}</div>}
<div data-testid="base-card-loading">{isLoading ? "loading" : "not-loading"}</div>
<div data-testid="base-card-children">{children}</div>
</div>
),
})
);
describe("StatCard", () => {
afterEach(() => {
cleanup();
});
test("renders with basic props", () => {
render(<StatCard label="Test Label" value="Test Value" />);
expect(screen.getByTestId("base-card")).toBeInTheDocument();
expect(screen.getByTestId("base-card-label")).toHaveTextContent("Test Label");
expect(screen.getByTestId("base-card-loading")).toHaveTextContent("not-loading");
expect(screen.getByText("Test Value")).toBeInTheDocument();
});
test("passes percentage prop to BaseCard", () => {
render(<StatCard label="Test Label" value="Test Value" percentage={75} />);
expect(screen.getByTestId("base-card-percentage")).toHaveTextContent("75%");
});
test("renders loading state with skeleton", () => {
render(<StatCard label="Test Label" value="Test Value" isLoading={true} />);
expect(screen.getByTestId("base-card-loading")).toHaveTextContent("loading");
const skeleton = screen.getByTestId("base-card-children").querySelector("div");
expect(skeleton).toHaveClass("h-6", "w-12", "animate-pulse", "rounded-full", "bg-slate-200");
expect(screen.queryByText("Test Value")).not.toBeInTheDocument();
});
test("renders value when not loading", () => {
render(<StatCard label="Test Label" value="Test Value" isLoading={false} />);
expect(screen.getByTestId("base-card-loading")).toHaveTextContent("not-loading");
expect(screen.getByText("Test Value")).toBeInTheDocument();
expect(screen.getByText("Test Value")).toHaveClass("text-2xl", "font-bold", "text-slate-800");
});
test("renders dash value correctly", () => {
const dashValue = <span>-</span>;
render(<StatCard label="Test Label" value={dashValue} />);
expect(screen.getByText("-")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,30 @@
"use client";
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
import { ReactNode } from "react";
interface StatCardProps {
label: ReactNode;
percentage?: number | null;
value: ReactNode;
tooltipText?: ReactNode;
isLoading?: boolean;
}
export const StatCard = ({
label,
percentage = null,
value,
tooltipText,
isLoading = false,
}: StatCardProps) => {
return (
<BaseCard label={label} percentage={percentage} tooltipText={tooltipText} isLoading={isLoading}>
{isLoading ? (
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
) : (
<p className="text-2xl font-bold text-slate-800">{value}</p>
)}
</BaseCard>
);
};

View File

@@ -1,8 +1,9 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError } from "@formbricks/types/errors";
import { deleteResponsesAndDisplaysForSurvey } from "./survey";
import { deleteResponsesAndDisplaysForSurvey, getQuotasSummary } from "./survey";
// Mock prisma
vi.mock("@formbricks/database", () => ({
@@ -14,6 +15,9 @@ vi.mock("@formbricks/database", () => ({
deleteMany: vi.fn(),
},
$transaction: vi.fn(),
surveyQuota: {
findMany: vi.fn(),
},
},
}));
@@ -81,3 +85,74 @@ describe("Tests for deleteResponsesAndDisplaysForSurvey service", () => {
});
});
});
describe("Tests for getQuotasSummary service", () => {
test("Returns the correct quotas summary", async () => {
vi.mocked(prisma.surveyQuota.findMany).mockResolvedValue([
{
id: "quota123",
name: "Test Quota",
limit: 100,
_count: {
quotaLinks: 0,
},
},
]);
const result = await getQuotasSummary(surveyId);
expect(result).toEqual([
{
id: "quota123",
name: "Test Quota",
limit: 100,
count: 0,
percentage: 0,
},
]);
});
test("Returns 0 percentage if limit is 0", async () => {
vi.mocked(prisma.surveyQuota.findMany).mockResolvedValue([
{
id: "quota123",
name: "Test Quota",
limit: 0,
_count: {
quotaLinks: 0,
},
},
]);
const result = await getQuotasSummary(surveyId);
expect(result).toEqual([
{
id: "quota123",
name: "Test Quota",
limit: 0,
count: 0,
percentage: 0,
},
]);
});
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
const { prisma } = await import("@formbricks/database");
vi.mocked(prisma.surveyQuota.findMany).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Database error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
})
);
await expect(getQuotasSummary(surveyId)).rejects.toThrow(DatabaseError);
});
test("Throws a generic Error for other exceptions", async () => {
const { prisma } = await import("@formbricks/database");
vi.mocked(prisma.surveyQuota.findMany).mockRejectedValue(new Error("Database error"));
await expect(getQuotasSummary(surveyId)).rejects.toThrow(Error);
});
});

View File

@@ -1,4 +1,5 @@
import "server-only";
import { convertFloatTo2Decimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
@@ -34,3 +35,47 @@ export const deleteResponsesAndDisplaysForSurvey = async (
throw error;
}
};
export const getQuotasSummary = async (surveyId: string) => {
try {
const quotas = await prisma.surveyQuota.findMany({
where: {
surveyId: surveyId,
},
select: {
_count: {
select: {
quotaLinks: {
where: {
status: "screenedIn",
},
},
},
},
id: true,
name: true,
limit: true,
},
orderBy: {
createdAt: "desc",
},
});
return quotas.map((quota) => {
const { _count, ...rest } = quota;
const count = _count.quotaLinks;
return {
...rest,
count,
percentage: quota.limit > 0 ? convertFloatTo2Decimal((count / quota.limit) * 100) : 0,
};
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -1,3 +1,4 @@
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getResponseCountBySurveyId } from "@/lib/response/service";
@@ -58,6 +59,11 @@ vi.mock("@formbricks/database", () => ({
},
},
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey", () => ({
getQuotasSummary: vi.fn(),
}));
vi.mock("./utils", () => ({
convertFloatTo2Decimal: vi.fn((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
@@ -138,6 +144,23 @@ const mockResponses = [
},
] as any;
const mockQuotas = [
{
id: "quota1",
name: "Quota 1",
limit: 10,
count: 5,
percentage: 50,
},
{
id: "quota2",
name: "Quota 2",
limit: 20,
count: 10,
percentage: 50,
},
];
describe("getSurveySummaryMeta", () => {
beforeEach(() => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
@@ -146,7 +169,7 @@ describe("getSurveySummaryMeta", () => {
});
test("calculates meta correctly", () => {
const meta = getSurveySummaryMeta(mockResponses, 10);
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
expect(meta.displayCount).toBe(10);
expect(meta.totalResponses).toBe(3);
expect(meta.startsPercentage).toBe(30);
@@ -155,16 +178,18 @@ describe("getSurveySummaryMeta", () => {
expect(meta.dropOffCount).toBe(1);
expect(meta.dropOffPercentage).toBe(33.33); // (1/3)*100
expect(meta.ttcAverage).toBe(125); // (100+150)/2
expect(meta.quotasCompleted).toBe(0);
expect(meta.quotasCompletedPercentage).toBe(0);
});
test("handles zero display count", () => {
const meta = getSurveySummaryMeta(mockResponses, 0);
const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas);
expect(meta.startsPercentage).toBe(0);
expect(meta.completedPercentage).toBe(0);
});
test("handles zero responses", () => {
const meta = getSurveySummaryMeta([], 10);
const meta = getSurveySummaryMeta([], 10, mockQuotas);
expect(meta.totalResponses).toBe(0);
expect(meta.completedResponses).toBe(0);
expect(meta.dropOffCount).toBe(0);
@@ -702,6 +727,7 @@ describe("getSurveySummary", () => {
// Default mocks for services
vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(mockResponses.length);
vi.mocked(getQuotasSummary).mockResolvedValue(mockQuotas);
// For getResponsesForSummary mock, we need to ensure it's correctly used by getSurveySummary
// Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany
// which is used by the actual implementation of getResponsesForSummary.

View File

@@ -1,4 +1,5 @@
import "server-only";
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
@@ -55,7 +56,8 @@ interface TSurveySummaryResponse {
export const getSurveySummaryMeta = (
responses: TSurveySummaryResponse[],
displayCount: number
displayCount: number,
quotas: TSurveySummary["quotas"]
): TSurveySummary["meta"] => {
const completedResponses = responses.filter((response) => response.finished).length;
@@ -75,6 +77,9 @@ export const getSurveySummaryMeta = (
const dropOffPercentage = responseCount > 0 ? (dropOffCount / responseCount) * 100 : 0;
const ttcAverage = ttcResponseCount > 0 ? ttcSum / ttcResponseCount : 0;
const quotasCompleted = quotas.filter((quota) => quota.count >= quota.limit).length;
const quotasCompletedPercentage = quotas.length > 0 ? (quotasCompleted / quotas.length) * 100 : 0;
return {
displayCount: displayCount || 0,
totalResponses: responseCount,
@@ -84,6 +89,8 @@ export const getSurveySummaryMeta = (
dropOffCount,
dropOffPercentage: convertFloatTo2Decimal(dropOffPercentage),
ttcAverage: convertFloatTo2Decimal(ttcAverage),
quotasCompleted,
quotasCompletedPercentage,
};
};
@@ -934,18 +941,26 @@ export const getSurveySummary = reactCache(
const responseIds = hasFilter ? responses.map((response) => response.id) : [];
const displayCount = await getDisplayCountBySurveyId(surveyId, {
createdAt: filterCriteria?.createdAt,
...(hasFilter && { responseIds }),
});
const [displayCount, quotas] = await Promise.all([
getDisplayCountBySurveyId(surveyId, {
createdAt: filterCriteria?.createdAt,
...(hasFilter && { responseIds }),
}),
getQuotasSummary(surveyId),
]);
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
const [meta, questionWiseSummary] = await Promise.all([
getSurveySummaryMeta(responses, displayCount),
getSurveySummaryMeta(responses, displayCount, quotas),
getQuestionSummary(survey, responses, dropOff),
]);
return { meta, dropOff, summary: questionWiseSummary };
return {
meta,
dropOff,
summary: questionWiseSummary,
quotas,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);

View File

@@ -8,8 +8,11 @@ import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { cleanup, render, screen } from "@testing-library/react";
import { notFound } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -114,6 +117,19 @@ vi.mock("next/navigation", () => ({
}),
}));
vi.mock("@/modules/survey/lib/organization", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
}));
vi.mock("@/modules/survey/lib/survey", () => ({
getOrganizationBilling: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsQuotasEnabled: vi.fn(),
getIsContactsEnabled: vi.fn(),
}));
const mockEnvironmentId = "test-environment-id";
const mockSurveyId = "test-survey-id";
const mockUserId = "test-user-id";
@@ -191,7 +207,10 @@ const mockSurveySummary = {
startsPercentage: 80,
totalResponses: 20,
ttcAverage: 120,
quotasCompleted: 0,
quotasCompletedPercentage: 0,
},
quotas: [],
dropOff: [],
summary: [],
};
@@ -203,6 +222,9 @@ describe("SurveyPage", () => {
environment: mockEnvironment,
isReadOnly: false,
} as unknown as TEnvironmentAuth);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("mock-organization-id");
vi.mocked(getOrganizationBilling).mockResolvedValue({ plan: "scale" });
vi.mocked(getIsQuotasEnabled).mockResolvedValue(false);
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);

View File

@@ -7,8 +7,10 @@ import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -41,6 +43,16 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const isContactsEnabled = await getIsContactsEnabled();
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
if (!organizationId) {
throw new Error(t("common.organization_not_found"));
}
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error(t("common.organization_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId);
@@ -72,6 +84,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
surveyId={params.surveyId}
locale={user.locale ?? DEFAULT_LOCALE}
initialSurveySummary={initialSurveySummary}
isQuotasAllowed={isQuotasAllowed}
/>
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />

View File

@@ -9,9 +9,12 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -59,9 +62,11 @@ export const getSurveyFilterDataAction = authenticatedActionClient
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
organizationId: organizationId,
access: [
{
type: "organization",
@@ -75,12 +80,20 @@ export const getSurveyFilterDataAction = authenticatedActionClient
],
});
const [tags, { contactAttributes: attributes, meta, hiddenFields }] = await Promise.all([
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new ResourceNotFoundError("Organization", organizationId);
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const [tags, { contactAttributes: attributes, meta, hiddenFields }, quotas = []] = await Promise.all([
getTagsByEnvironmentId(survey.environmentId),
getResponseFilteringValues(parsedInput.surveyId),
isQuotasAllowed ? getQuotas(parsedInput.surveyId) : [],
]);
return { environmentTags: tags, attributes, meta, hiddenFields };
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
});
/**

View File

@@ -33,6 +33,7 @@ import {
ListOrderedIcon,
MessageSquareTextIcon,
MousePointerClickIcon,
PieChartIcon,
Rows3Icon,
SmartphoneIcon,
StarIcon,
@@ -48,6 +49,7 @@ export enum OptionsType {
OTHERS = "Other Filters",
META = "Meta",
HIDDEN_FIELDS = "Hidden Fields",
QUOTAS = "Quotas",
}
export type QuestionOption = {
@@ -102,6 +104,9 @@ const questionIcons = {
// tags
[OptionsType.TAGS]: HashIcon,
// quotas
[OptionsType.QUOTAS]: PieChartIcon,
};
const getIcon = (type: string) => {
@@ -122,6 +127,8 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
return getIcon(label);
} else if (type === OptionsType.TAGS) {
return getIcon(OptionsType.TAGS);
} else if (type === OptionsType.QUOTAS) {
return getIcon(OptionsType.QUOTAS);
}
}
};
@@ -133,6 +140,8 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
return "bg-brand-dark";
} else if (type === OptionsType.TAGS) {
return "bg-indigo-500";
} else if (type === OptionsType.QUOTAS) {
return "bg-slate-500";
} else {
return "bg-amber-500";
}

View File

@@ -25,7 +25,7 @@ import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/type
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
export type QuestionFilterOptions = {
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages";
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
filterOptions: string[];
filterComboBoxOptions: string[];
id: string;
@@ -51,13 +51,14 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
if (!surveyFilterData?.data) return;
const { attributes, meta, environmentTags, hiddenFields } = surveyFilterData.data;
const { attributes, meta, environmentTags, hiddenFields, quotas } = surveyFilterData.data;
const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions(
survey,
environmentTags,
attributes,
meta,
hiddenFields
hiddenFields,
quotas
);
setSelectedOptions({ questionFilterOptions, questionOptions });
}

View File

@@ -0,0 +1,34 @@
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { Organization } from "@prisma/client";
import { logger } from "@formbricks/logger";
export const handleBillingLimitsCheck = async (
environmentId: string,
organizationId: string,
organizationBilling: Organization["billing"]
): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD) return;
const responsesCount = await getMonthlyOrganizationResponseCount(organizationId);
const responsesLimit = organizationBilling.limits.monthly.responses;
if (responsesLimit && responsesCount >= responsesLimit) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organizationBilling.plan,
limits: {
projects: null,
monthly: {
responses: responsesLimit,
miu: null,
},
},
});
} catch (err) {
// Log error but do not throw
logger.error(err, "Error sending plan limits reached event to Posthog");
}
}
};

View File

@@ -0,0 +1,145 @@
import { updateResponse } from "@/lib/response/service";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { updateResponseWithQuotaEvaluation } from "./response";
vi.mock("@/lib/response/service");
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
const mockUpdateResponse = vi.mocked(updateResponse);
const mockEvaluateResponseQuotas = vi.mocked(evaluateResponseQuotas);
type MockTx = {
response: {
update: ReturnType<typeof vi.fn>;
};
};
let mockTx: MockTx;
describe("updateResponseWithQuotaEvaluation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTx = {
response: {
update: vi.fn(),
},
};
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
});
const mockResponseId = "response123";
const mockResponseInput = {
data: { question1: "answer1" },
finished: true,
};
const mockResponse: TResponse = {
id: "response123",
surveyId: "survey123",
finished: true,
data: { question1: "answer1" },
meta: {},
ttc: {},
variables: { var1: "value1" },
contactAttributes: {},
singleUseId: null,
language: "en",
displayId: null,
endingId: null,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
contact: null,
tags: [],
};
const mockQuotaFull: TSurveyQuota = {
id: "quota123",
surveyId: "survey123",
name: "Test Quota",
action: "endSurvey",
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
countPartialSubmissions: false,
endingCardId: null,
limit: 100,
logic: {
connector: "and",
conditions: [],
},
};
test("should return response with quotaFull when quota evaluation returns quotaFull", async () => {
mockUpdateResponse.mockResolvedValue(mockResponse);
mockEvaluateResponseQuotas.mockResolvedValue({
quotaFull: mockQuotaFull,
shouldEndSurvey: true,
});
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
expect(mockUpdateResponse).toHaveBeenCalledWith(mockResponseId, mockResponseInput, mockTx);
expect(mockEvaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: mockResponse.surveyId,
responseId: mockResponse.id,
data: mockResponse.data,
variables: mockResponse.variables,
language: mockResponse.language,
responseFinished: mockResponse.finished,
tx: mockTx,
});
expect(result).toEqual({
...mockResponse,
quotaFull: mockQuotaFull,
});
});
test("should return response without quotaFull when quota evaluation returns no quotaFull", async () => {
mockUpdateResponse.mockResolvedValue(mockResponse);
mockEvaluateResponseQuotas.mockResolvedValue({
shouldEndSurvey: false,
});
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
expect(mockUpdateResponse).toHaveBeenCalledWith(mockResponseId, mockResponseInput, mockTx);
expect(mockEvaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: mockResponse.surveyId,
responseId: mockResponse.id,
data: mockResponse.data,
variables: mockResponse.variables,
language: mockResponse.language,
responseFinished: mockResponse.finished,
tx: mockTx,
});
expect(result).toEqual(mockResponse);
expect(result).not.toHaveProperty("quotaFull");
});
test("should use default language when response language is null", async () => {
const responseWithNullLanguage = { ...mockResponse, language: null };
mockUpdateResponse.mockResolvedValue(responseWithNullLanguage);
mockEvaluateResponseQuotas.mockResolvedValue({
shouldEndSurvey: false,
});
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
expect(mockEvaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: responseWithNullLanguage.surveyId,
responseId: responseWithNullLanguage.id,
data: responseWithNullLanguage.data,
variables: responseWithNullLanguage.variables,
language: "default",
responseFinished: responseWithNullLanguage.finished,
tx: mockTx,
});
expect(result).toEqual(responseWithNullLanguage);
});
});

View File

@@ -0,0 +1,31 @@
import { updateResponse } from "@/lib/response/service";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { prisma } from "@formbricks/database";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseUpdateInput } from "@formbricks/types/responses";
export const updateResponseWithQuotaEvaluation = async (
responseId: string,
responseInput: TResponseUpdateInput
): Promise<TResponseWithQuotaFull> => {
const txResponse = await prisma.$transaction(async (tx) => {
const response = await updateResponse(responseId, responseInput, tx);
const quotaResult = await evaluateResponseQuotas({
surveyId: response.surveyId,
responseId: response.id,
data: response.data,
variables: response.variables,
language: response.language || "default",
responseFinished: response.finished,
tx,
});
return {
...response,
...(quotaResult.quotaFull && { quotaFull: quotaResult.quotaFull }),
};
});
return txResponse;
};

View File

@@ -3,13 +3,15 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponse, updateResponse } from "@/lib/response/service";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
@@ -111,10 +113,10 @@ export const PUT = withV1ApiWrapper({
};
}
// update response
// update response with quota evaluation
let updatedResponse;
try {
updatedResponse = await updateResponse(responseId, inputValidation.data);
updatedResponse = await updateResponseWithQuotaEvaluation(responseId, inputValidation.data);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
@@ -137,13 +139,15 @@ export const PUT = withV1ApiWrapper({
}
}
const { quotaFull, ...responseData } = updatedResponse;
// send response update to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
response: updatedResponse,
response: responseData,
});
if (updatedResponse.finished) {
@@ -153,11 +157,19 @@ export const PUT = withV1ApiWrapper({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
response: updatedResponse,
response: responseData,
});
}
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return {
response: responses.successResponse({}, true),
response: responses.successResponse(responseDataWithQuota, true),
};
},
});

View File

@@ -4,13 +4,15 @@ import {
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseInput } from "@formbricks/types/responses";
import { createResponse } from "./response";
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
let mockIsFormbricksCloud = false;
@@ -18,6 +20,7 @@ vi.mock("@/lib/constants", () => ({
get IS_FORMBRICKS_CLOUD() {
return mockIsFormbricksCloud;
},
ENCRYPTION_KEY: "test",
}));
vi.mock("@/lib/organization/service", () => ({
@@ -46,6 +49,7 @@ vi.mock("@formbricks/database", () => ({
response: {
create: vi.fn(),
},
$transaction: vi.fn(),
},
}));
@@ -59,6 +63,10 @@ vi.mock("./contact", () => ({
getContactByUserId: vi.fn(),
}));
vi.mock("@/modules/ee/quotas/lib/evaluation-service", () => ({
evaluateResponseQuotas: vi.fn(),
}));
const environmentId = "test-environment-id";
const surveyId = "test-survey-id";
const organizationId = "test-organization-id";
@@ -100,6 +108,13 @@ const mockResponsePrisma = {
tags: [],
};
type MockTx = {
response: {
create: ReturnType<typeof vi.fn>;
};
};
let mockTx: MockTx;
describe("createResponse", () => {
beforeEach(() => {
vi.resetAllMocks();
@@ -127,7 +142,7 @@ describe("createResponse", () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
await createResponse(mockResponseInput);
await createResponse(mockResponseInput, prisma);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
@@ -137,7 +152,7 @@ describe("createResponse", () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
await createResponse(mockResponseInput);
await createResponse(mockResponseInput, prisma);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
@@ -154,7 +169,7 @@ describe("createResponse", () => {
test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma known request error", async () => {
@@ -186,3 +201,159 @@ describe("createResponse", () => {
);
});
});
describe("createResponseWithQuotaEvaluation", () => {
beforeEach(() => {
vi.resetAllMocks();
mockTx = {
response: {
create: vi.fn(),
},
};
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any);
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma as any);
vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ttc);
});
afterEach(() => {
mockIsFormbricksCloud = false;
});
test("should return response without quotaFull when no quota violations", async () => {
// Mock quota evaluation to return no violations
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false,
quotaFull: undefined,
});
const result = await createResponseWithQuotaEvaluation(mockResponseInput, mockTx);
expect(evaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: mockResponseInput.surveyId,
responseId: responseId,
data: mockResponseInput.data,
variables: mockResponseInput.variables,
language: mockResponseInput.language,
responseFinished: mockResponseInput.finished,
tx: mockTx,
});
expect(result).toEqual({
id: responseId,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
surveyId,
finished: false,
data: { question1: "answer1" },
meta: { source: "web" },
ttc: { question1: 1000 },
variables: {},
contactAttributes: {},
singleUseId: null,
language: null,
displayId: null,
contact: null,
tags: [],
});
expect(result).not.toHaveProperty("quotaFull");
});
test("should return response with quotaFull when quota is exceeded with endSurvey action", async () => {
const mockQuotaFull: TSurveyQuota = {
id: "quota-123",
name: "Test Quota",
limit: 100,
action: "endSurvey",
endingCardId: "ending-123",
surveyId,
createdAt: new Date(),
updatedAt: new Date(),
logic: {
connector: "and",
conditions: [],
},
countPartialSubmissions: true,
};
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: true,
quotaFull: mockQuotaFull,
});
const result = await createResponseWithQuotaEvaluation(mockResponseInput, mockTx);
expect(evaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: mockResponseInput.surveyId,
responseId: responseId,
data: mockResponseInput.data,
variables: mockResponseInput.variables,
language: mockResponseInput.language,
responseFinished: mockResponseInput.finished,
tx: mockTx,
});
expect(result).toEqual({
id: responseId,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
surveyId,
finished: false,
data: { question1: "answer1" },
meta: { source: "web" },
ttc: { question1: 1000 },
variables: {},
contactAttributes: {},
singleUseId: null,
language: null,
displayId: null,
contact: null,
tags: [],
quotaFull: mockQuotaFull,
});
});
test("should return response with quotaFull when quota is exceeded with continueSurvey action", async () => {
const mockQuotaFull: TSurveyQuota = {
id: "quota-456",
name: "Continue Test Quota",
limit: 50,
action: "continueSurvey",
endingCardId: null,
surveyId,
createdAt: new Date(),
updatedAt: new Date(),
logic: {
connector: "or",
conditions: [],
},
countPartialSubmissions: false,
};
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false,
quotaFull: mockQuotaFull,
});
const result = await createResponseWithQuotaEvaluation(mockResponseInput, mockTx);
expect(result).toEqual({
id: responseId,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
surveyId,
finished: false,
data: { question1: "answer1" },
meta: { source: "web" },
ttc: { question1: 1000 },
variables: {},
contactAttributes: {},
singleUseId: null,
language: null,
displayId: null,
contact: null,
tags: [],
quotaFull: mockQuotaFull,
});
});
});

View File

@@ -1,18 +1,16 @@
import "server-only";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { getContactByUserId } from "./contact";
@@ -55,25 +53,39 @@ export const responseSelection = {
},
} satisfies Prisma.ResponseSelect;
export const createResponse = async (responseInput: TResponseInput): Promise<TResponse> => {
export const createResponseWithQuotaEvaluation = async (
responseInput: TResponseInput
): Promise<TResponseWithQuotaFull> => {
const txResponse = await prisma.$transaction(async (tx) => {
const response = await createResponse(responseInput, tx);
const quotaResult = await evaluateResponseQuotas({
surveyId: responseInput.surveyId,
responseId: response.id,
data: responseInput.data,
variables: responseInput.variables,
language: responseInput.language,
responseFinished: response.finished,
tx,
});
return {
...response,
...(quotaResult.quotaFull && { quotaFull: quotaResult.quotaFull }),
};
});
return txResponse;
};
export const createResponse = async (
responseInput: TResponseInput,
tx: Prisma.TransactionClient
): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const {
environmentId,
language,
userId,
surveyId,
displayId,
finished,
data,
meta,
singleUseId,
variables,
ttc: initialTtc,
createdAt,
updatedAt,
} = responseInput;
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
try {
let contact: { id: string; attributes: TContactAttributes } | null = null;
@@ -89,38 +101,16 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
const prismaData: Prisma.ResponseCreateInput = {
survey: {
connect: {
id: surveyId,
},
},
display: displayId ? { connect: { id: displayId } } : undefined,
finished: finished,
data: data,
language: language,
...(contact?.id && {
contact: {
connect: {
id: contact.id,
},
},
contactAttributes: contact.attributes,
}),
...(meta && ({ meta } as Prisma.JsonObject)),
singleUseId,
...(variables && { variables }),
ttc: ttc,
createdAt,
updatedAt,
};
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const responsePrisma = await prisma.response.create({
const prismaClient = tx ?? prisma;
const responsePrisma = await prismaClient.response.create({
data: prismaData,
select: responseSelection,
});
const response: TResponse = {
const response = {
...responsePrisma,
contact: contact
? {
@@ -131,28 +121,7 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
if (IS_FORMBRICKS_CLOUD) {
const responsesCount = await getMonthlyOrganizationResponseCount(organization.id);
const responsesLimit = organization.billing.limits.monthly.responses;
if (responsesLimit && responsesCount >= responsesLimit) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: {
responses: responsesLimit,
miu: null,
},
},
});
} catch (err) {
// Log error but do not throw
logger.error(err, "Error sending plan limits reached event to Posthog");
}
}
}
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
return response;
} catch (error) {

View File

@@ -6,14 +6,16 @@ import { validateFileUploads } from "@/lib/fileValidation";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { headers } from "next/headers";
import { NextRequest } from "next/server";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { createResponse } from "./lib/response";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { createResponseWithQuotaEvaluation } from "./lib/response";
interface Context {
params: Promise<{
@@ -121,7 +123,7 @@ export const POST = withV1ApiWrapper({
};
}
let response: TResponse;
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInput["meta"] = {
source: responseInputData?.meta?.source,
@@ -135,7 +137,7 @@ export const POST = withV1ApiWrapper({
action: responseInputData?.meta?.action,
};
response = await createResponse({
response = await createResponseWithQuotaEvaluation({
...responseInputData,
meta,
});
@@ -152,29 +154,38 @@ export const POST = withV1ApiWrapper({
}
}
const { quotaFull, ...responseData } = response;
sendToPipeline({
event: "responseCreated",
environmentId: survey.environmentId,
surveyId: response.surveyId,
response: response,
surveyId: responseData.surveyId,
response: responseData,
});
if (responseInput.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: response.surveyId,
response: response,
surveyId: responseData.surveyId,
response: responseData,
});
}
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
surveyId: response.surveyId,
surveyId: responseData.surveyId,
surveyType: survey.type,
});
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return {
response: responses.successResponse({ id: response.id }, true),
response: responses.successResponse(responseDataWithQuota, true),
};
},
});

View File

@@ -0,0 +1,48 @@
import { Prisma } from "@prisma/client";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { TResponseInput } from "@formbricks/types/responses";
export const buildPrismaResponseData = (
responseInput: TResponseInput,
contact: { id: string; attributes: TContactAttributes } | null,
ttc: Record<string, number>
): Prisma.ResponseCreateInput => {
const {
surveyId,
displayId,
finished,
data,
language,
meta,
singleUseId,
variables,
createdAt,
updatedAt,
} = responseInput;
return {
survey: {
connect: {
id: surveyId,
},
},
display: displayId ? { connect: { id: displayId } } : undefined,
finished: finished,
data: data,
language: language,
...(contact?.id && {
contact: {
connect: {
id: contact.id,
},
},
contactAttributes: contact.attributes,
}),
...(meta && ({ meta } as Prisma.JsonObject)),
singleUseId,
...(variables && { variables }),
ttc: ttc,
createdAt,
updatedAt,
};
};

View File

@@ -0,0 +1,195 @@
import { updateResponse } from "@/lib/response/service";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TResponse, TResponseInput } from "@formbricks/types/responses";
import { updateResponseWithQuotaEvaluation } from "./response";
vi.mock("@/lib/response/service");
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
const mockUpdateResponse = vi.mocked(updateResponse);
const mockEvaluateResponseQuotas = vi.mocked(evaluateResponseQuotas);
type MockTx = {
response: {
update: ReturnType<typeof vi.fn>;
};
};
let mockTx: MockTx;
describe("updateResponseWithQuotaEvaluation", () => {
const mockResponseId = "response123";
const mockResponseInput: Partial<TResponseInput> = {
data: { question1: "answer1" },
finished: true,
language: "en",
};
const mockResponse: TResponse = {
id: "response123",
surveyId: "survey123",
finished: true,
data: { question1: "answer1" },
meta: {},
ttc: {},
variables: { var1: "value1" },
contactAttributes: {},
singleUseId: null,
language: "en",
displayId: null,
endingId: null,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
contact: {
id: "contact123",
userId: "user123",
},
tags: [
{
id: "tag123",
name: "important",
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
environmentId: "env123",
},
],
};
const mockRefreshedResponse = {
id: "response123",
surveyId: "survey123",
finished: true,
data: { question1: "updated_answer" },
meta: {},
ttc: {},
variables: { var1: "updated_value" },
contactAttributes: {},
singleUseId: null,
language: "en",
displayId: null,
endingId: null,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-02"),
contactId: "contact123",
contact: mockResponse.contact,
tags: mockResponse.tags,
};
beforeEach(() => {
vi.clearAllMocks();
mockTx = {
response: {
update: vi.fn(),
},
};
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
});
test("should return original response when quota doesn't end survey", async () => {
mockUpdateResponse.mockResolvedValue(mockResponse);
mockEvaluateResponseQuotas.mockResolvedValue({
shouldEndSurvey: false,
});
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
expect(mockUpdateResponse).toHaveBeenCalledWith(mockResponseId, mockResponseInput, mockTx);
expect(mockEvaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: mockResponse.surveyId,
responseId: mockResponse.id,
data: mockResponse.data,
variables: mockResponse.variables,
language: mockResponse.language,
responseFinished: mockResponse.finished,
tx: mockTx,
});
expect(result).toEqual(mockResponse);
});
test("should return refreshed response when quota ends survey and refreshedResponse exists", async () => {
mockUpdateResponse.mockResolvedValue(mockResponse);
mockEvaluateResponseQuotas.mockResolvedValue({
shouldEndSurvey: true,
refreshedResponse: mockRefreshedResponse,
});
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
expect(mockUpdateResponse).toHaveBeenCalledWith(mockResponseId, mockResponseInput, mockTx);
expect(mockEvaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: mockResponse.surveyId,
responseId: mockResponse.id,
data: mockResponse.data,
variables: mockResponse.variables,
language: mockResponse.language,
responseFinished: mockResponse.finished,
tx: mockTx,
});
expect(result).toEqual({
...mockRefreshedResponse,
tags: mockResponse.tags,
contact: mockResponse.contact,
});
});
test("should return original response when quota ends survey but no refreshedResponse", async () => {
mockUpdateResponse.mockResolvedValue(mockResponse);
mockEvaluateResponseQuotas.mockResolvedValue({
shouldEndSurvey: true,
refreshedResponse: null,
});
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
expect(mockUpdateResponse).toHaveBeenCalledWith(mockResponseId, mockResponseInput, mockTx);
expect(mockEvaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: mockResponse.surveyId,
responseId: mockResponse.id,
data: mockResponse.data,
variables: mockResponse.variables,
language: mockResponse.language,
responseFinished: mockResponse.finished,
tx: mockTx,
});
expect(result).toEqual(mockResponse);
});
test("should return original response when quota ends survey but refreshedResponse is undefined", async () => {
mockUpdateResponse.mockResolvedValue(mockResponse);
mockEvaluateResponseQuotas.mockResolvedValue({
shouldEndSurvey: true,
refreshedResponse: undefined,
});
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
expect(mockUpdateResponse).toHaveBeenCalledWith(mockResponseId, mockResponseInput, mockTx);
expect(result).toEqual(mockResponse);
});
test("should use default language when response language is null", async () => {
const responseWithNullLanguage = { ...mockResponse, language: null };
mockUpdateResponse.mockResolvedValue(responseWithNullLanguage);
mockEvaluateResponseQuotas.mockResolvedValue({
shouldEndSurvey: false,
});
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
expect(mockEvaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: responseWithNullLanguage.surveyId,
responseId: responseWithNullLanguage.id,
data: responseWithNullLanguage.data,
variables: responseWithNullLanguage.variables,
language: "default",
responseFinished: responseWithNullLanguage.finished,
tx: mockTx,
});
expect(result).toEqual(responseWithNullLanguage);
});
});

View File

@@ -0,0 +1,36 @@
import "server-only";
import { updateResponse } from "@/lib/response/service";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { prisma } from "@formbricks/database";
import { TResponse, TResponseInput } from "@formbricks/types/responses";
export const updateResponseWithQuotaEvaluation = async (
responseId: string,
responseInput: Partial<TResponseInput>
): Promise<TResponse> => {
const txResponse = await prisma.$transaction(async (tx) => {
const response = await updateResponse(responseId, responseInput, tx);
const quotaResult = await evaluateResponseQuotas({
surveyId: response.surveyId,
responseId: response.id,
data: response.data,
variables: response.variables,
language: response.language || "default",
responseFinished: response.finished,
tx,
});
if (quotaResult.shouldEndSurvey && quotaResult.refreshedResponse) {
return {
...quotaResult.refreshedResponse,
tags: response.tags,
contact: response.contact,
};
}
return response;
});
return txResponse;
};

View File

@@ -3,12 +3,13 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
async function fetchAndAuthorizeResponse(
responseId: string,
@@ -148,7 +149,7 @@ export const PUT = withV1ApiWrapper({
};
}
const updated = await updateResponse(params.responseId, inputValidation.data);
const updated = await updateResponseWithQuotaEvaluation(params.responseId, inputValidation.data);
auditLog.newObject = updated;
return {
response: responses.successResponse(updated),

View File

@@ -134,9 +134,23 @@ vi.mock("@formbricks/database", () => ({
vi.mock("@formbricks/logger");
vi.mock("./contact");
type MockTx = {
response: {
create: ReturnType<typeof vi.fn>;
};
};
let mockTx: MockTx;
describe("Response Lib Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTx = {
response: {
create: vi.fn(),
},
};
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
});
describe("createResponse", () => {
@@ -145,16 +159,16 @@ describe("Response Lib Tests", () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getContactByUserId).mockResolvedValue(mockContact);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(prisma.response.create).mockResolvedValue({
vi.mocked(mockTx.response.create).mockResolvedValue({
...mockResponsePrisma,
});
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
const response = await createResponse(mockResponseInputWithUserId);
const response = await createResponse(mockResponseInputWithUserId, mockTx);
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, mockUserId);
expect(prisma.response.create).toHaveBeenCalledWith(
expect(mockTx.response.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
contact: { connect: { id: mockContact.id } },
@@ -167,9 +181,9 @@ describe("Response Lib Tests", () => {
test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError);
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(ResourceNotFoundError);
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
expect(prisma.response.create).not.toHaveBeenCalled();
expect(mockTx.response.create).not.toHaveBeenCalled();
});
test("should handle PrismaClientKnownRequestError", async () => {
@@ -178,9 +192,9 @@ describe("Response Lib Tests", () => {
clientVersion: "2.0",
});
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(DatabaseError);
expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError
});
@@ -190,18 +204,18 @@ describe("Response Lib Tests", () => {
clientVersion: "2.0",
});
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
await expect(createResponse(mockResponseInput)).rejects.toThrow("Display ID does not exist");
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(DatabaseError);
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow("Display ID does not exist");
});
test("should handle generic errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
vi.mocked(mockTx.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
});
describe("Cloud specific tests", () => {
@@ -214,10 +228,10 @@ describe("Response Lib Tests", () => {
} as any;
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
await createResponse(mockResponseInput);
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
@@ -231,10 +245,10 @@ describe("Response Lib Tests", () => {
} as any;
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit - 1); // Limit not reached
await createResponse(mockResponseInput);
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
@@ -249,12 +263,12 @@ describe("Response Lib Tests", () => {
const posthogError = new Error("Posthog error");
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
// Expecting successful response creation despite PostHog error
const response = await createResponse(mockResponseInput);
const response = await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();

View File

@@ -1,19 +1,18 @@
import "server-only";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseContact } from "@/lib/response/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -59,25 +58,44 @@ export const responseSelection = {
},
} satisfies Prisma.ResponseSelect;
export const createResponse = async (responseInput: TResponseInput): Promise<TResponse> => {
export const createResponseWithQuotaEvaluation = async (
responseInput: TResponseInput
): Promise<TResponse> => {
const txResponse = await prisma.$transaction(async (tx) => {
const response = await createResponse(responseInput, tx);
const quotaResult = await evaluateResponseQuotas({
surveyId: responseInput.surveyId,
responseId: response.id,
data: responseInput.data,
variables: responseInput.variables,
language: responseInput.language,
responseFinished: response.finished,
tx,
});
if (quotaResult.shouldEndSurvey && quotaResult.refreshedResponse) {
return {
...quotaResult.refreshedResponse,
tags: response.tags,
contact: response.contact,
};
}
return response;
});
return txResponse;
};
export const createResponse = async (
responseInput: TResponseInput,
tx?: Prisma.TransactionClient
): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const {
environmentId,
language,
userId,
surveyId,
displayId,
finished,
data,
meta,
singleUseId,
variables,
ttc: initialTtc,
createdAt,
updatedAt,
} = responseInput;
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
try {
let contact: { id: string; attributes: TContactAttributes } | null = null;
@@ -93,33 +111,11 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
const prismaData: Prisma.ResponseCreateInput = {
survey: {
connect: {
id: surveyId,
},
},
display: displayId ? { connect: { id: displayId } } : undefined,
finished: finished,
data: data,
language: language,
...(contact?.id && {
contact: {
connect: {
id: contact.id,
},
},
contactAttributes: contact.attributes,
}),
...(meta && ({ meta } as Prisma.JsonObject)),
singleUseId,
...(variables && { variables }),
ttc: ttc,
createdAt,
updatedAt,
};
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const responsePrisma = await prisma.response.create({
const prismaClient = tx ?? prisma;
const responsePrisma = await prismaClient.response.create({
data: prismaData,
select: responseSelection,
});
@@ -135,28 +131,7 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
if (IS_FORMBRICKS_CLOUD) {
const responsesCount = await getMonthlyOrganizationResponseCount(organization.id);
const responsesLimit = organization.billing.limits.monthly.responses;
if (responsesLimit && responsesCount >= responsesLimit) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: {
responses: responsesLimit,
miu: null,
},
},
});
} catch (err) {
// Log error but do not throw
logger.error(err, "Error sending plan limits reached event to Posthog");
}
}
}
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
return response;
} catch (error) {
@@ -211,3 +186,47 @@ export const getResponsesByEnvironmentIds = reactCache(
}
}
);
export const getResponses = reactCache(
async (surveyId: string, limit?: number, offset?: number): Promise<TResponse[]> => {
validateInputs([surveyId, ZId], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
limit = limit ?? RESPONSES_PER_PAGE;
const survey = await getSurvey(surveyId);
if (!survey) return [];
try {
const responses = await prisma.response.findMany({
where: { surveyId },
select: responseSelection,
orderBy: [
{
createdAt: "desc",
},
{
id: "desc", // Secondary sort by ID for consistent pagination
},
],
take: limit,
skip: offset,
});
const transformedResponses: TResponse[] = await Promise.all(
responses.map((responsePrisma) => {
return {
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
})
);
return transformedResponses;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);

View File

@@ -2,14 +2,17 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { createResponse, getResponsesByEnvironmentIds } from "./lib/response";
import {
createResponseWithQuotaEvaluation,
getResponses,
getResponsesByEnvironmentIds,
} from "./lib/response";
export const GET = withV1ApiWrapper({
handler: async ({
@@ -150,7 +153,7 @@ export const POST = withV1ApiWrapper({
}
try {
const response = await createResponse(responseInput);
const response = await createResponseWithQuotaEvaluation(responseInput);
auditLog.targetId = response.id;
auditLog.newObject = response;
return {

View File

@@ -7,16 +7,18 @@ import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { getContact } from "./contact";
import { createResponse } from "./response";
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
let mockIsFormbricksCloud = false;
@@ -51,6 +53,7 @@ vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/utils");
vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate");
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
@@ -67,7 +70,6 @@ const organizationId = "test-organization-id";
const responseId = "test-response-id";
const contactId = "test-contact-id";
const userId = "test-user-id";
const displayId = "test-display-id";
const mockOrganization = {
id: organizationId,
@@ -122,13 +124,44 @@ const expectedResponse: TResponse = {
tags: [],
};
const mockQuota: TSurveyQuota = {
id: "quota-id",
createdAt: new Date(),
updatedAt: new Date(),
surveyId,
name: "Test Quota",
limit: 100,
logic: {
connector: "and",
conditions: [],
},
action: "endSurvey",
endingCardId: "ending-card-id",
countPartialSubmissions: false,
};
type MockTx = {
response: {
create: ReturnType<typeof vi.fn>;
};
};
let mockTx: MockTx;
describe("createResponse V2", () => {
beforeEach(() => {
vi.resetAllMocks();
mockTx = {
response: {
create: vi.fn(),
},
};
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
vi.mocked(validateInputs).mockImplementation(() => {});
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any);
vi.mocked(getContact).mockResolvedValue(mockContact);
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma as any);
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma as any);
vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ({
...ttc,
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
@@ -136,6 +169,10 @@ describe("createResponse V2", () => {
vi.mocked(captureTelemetry).mockResolvedValue(undefined);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockResolvedValue(undefined);
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false,
quotaFull: null,
});
});
afterEach(() => {
@@ -144,7 +181,7 @@ describe("createResponse V2", () => {
test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => {
mockIsFormbricksCloud = true;
await createResponse(mockResponseInput);
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
@@ -153,7 +190,7 @@ describe("createResponse V2", () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
await createResponse(mockResponseInput);
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
@@ -170,7 +207,7 @@ describe("createResponse V2", () => {
test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError);
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma known request error", async () => {
@@ -178,14 +215,14 @@ describe("createResponse V2", () => {
code: "P2002",
clientVersion: "test",
});
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(DatabaseError);
});
test("should throw original error on other errors", async () => {
const genericError = new Error("Generic database error");
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
vi.mocked(mockTx.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
});
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
@@ -194,7 +231,7 @@ describe("createResponse V2", () => {
const posthogError = new Error("PostHog error");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
await createResponse(mockResponseInput); // Should not throw
await createResponse(mockResponseInput, mockTx); // Should not throw
expect(logger.error).toHaveBeenCalledWith(
posthogError,
@@ -209,9 +246,71 @@ describe("createResponse V2", () => {
tags: [{ tag: mockTag }],
};
vi.mocked(prisma.response.create).mockResolvedValue(prismaResponseWithTags as any);
vi.mocked(mockTx.response.create).mockResolvedValue(prismaResponseWithTags as any);
const result = await createResponse(mockResponseInput);
const result = await createResponse(mockResponseInput, mockTx);
expect(result.tags).toEqual([mockTag]);
});
});
describe("createResponseWithQuotaEvaluation V2", () => {
beforeEach(() => {
vi.resetAllMocks();
mockTx = {
response: {
create: vi.fn(),
},
};
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any);
vi.mocked(getContact).mockResolvedValue(mockContact);
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma as any);
vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ({
...ttc,
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
}));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false,
quotaFull: null,
});
});
test("should create response and return it without quotaFull when no quota is full", async () => {
const result = await createResponseWithQuotaEvaluation(mockResponseInput);
expect(result).toEqual(expectedResponse);
expect(evaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: mockResponseInput.surveyId,
responseId: expectedResponse.id,
data: mockResponseInput.data,
variables: mockResponseInput.variables,
language: mockResponseInput.language,
responseFinished: expectedResponse.finished,
tx: mockTx,
});
});
test("should include quotaFull in response when quota evaluation returns a full quota", async () => {
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false,
quotaFull: mockQuota,
});
const result: TResponseWithQuotaFull = await createResponseWithQuotaEvaluation(mockResponseInput);
expect(result).toEqual({
...expectedResponse,
quotaFull: mockQuota,
});
expect(evaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: mockResponseInput.surveyId,
responseId: expectedResponse.id,
data: mockResponseInput.data,
variables: mockResponseInput.variables,
language: mockResponseInput.language,
responseFinished: expectedResponse.finished,
tx: mockTx,
});
});
});

View File

@@ -1,45 +1,100 @@
import "server-only";
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { getContact } from "./contact";
export const createResponse = async (responseInput: TResponseInputV2): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
export const createResponseWithQuotaEvaluation = async (
responseInput: TResponseInputV2
): Promise<TResponseWithQuotaFull> => {
const txResponse = await prisma.$transaction(async (tx) => {
const response = await createResponse(responseInput, tx);
const quotaResult = await evaluateResponseQuotas({
surveyId: responseInput.surveyId,
responseId: response.id,
data: responseInput.data,
variables: responseInput.variables,
language: responseInput.language,
responseFinished: response.finished,
tx,
});
return {
...response,
...(quotaResult.quotaFull && { quotaFull: quotaResult.quotaFull }),
};
});
return txResponse;
};
const buildPrismaResponseData = (
responseInput: TResponseInputV2,
contact: { id: string; attributes: TContactAttributes } | null,
ttc: Record<string, number>
): Prisma.ResponseCreateInput => {
const {
environmentId,
language,
contactId,
surveyId,
displayId,
endingId,
finished,
data,
language,
meta,
singleUseId,
variables,
ttc: initialTtc,
createdAt,
updatedAt,
} = responseInput;
return {
survey: {
connect: {
id: surveyId,
},
},
display: displayId ? { connect: { id: displayId } } : undefined,
finished: finished,
data: data,
language: language,
...(contact?.id && {
contact: {
connect: {
id: contact.id,
},
},
contactAttributes: contact.attributes,
}),
...(meta && ({ meta } as Prisma.JsonObject)),
singleUseId,
...(variables && { variables }),
ttc: ttc,
createdAt,
updatedAt,
};
};
export const createResponse = async (
responseInput: TResponseInputV2,
tx?: Prisma.TransactionClient
): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, contactId, finished, ttc: initialTtc } = responseInput;
try {
let contact: { id: string; attributes: TContactAttributes } | null = null;
@@ -54,34 +109,11 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
const prismaData: Prisma.ResponseCreateInput = {
survey: {
connect: {
id: surveyId,
},
},
display: displayId ? { connect: { id: displayId } } : undefined,
finished,
endingId,
data: data,
language: language,
...(contact?.id && {
contact: {
connect: {
id: contact.id,
},
},
contactAttributes: contact.attributes,
}),
...(meta && ({ meta } as Prisma.JsonObject)),
singleUseId,
...(variables && { variables }),
ttc: ttc,
createdAt,
updatedAt,
};
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const responsePrisma = await prisma.response.create({
const prismaClient = tx ?? prisma;
const responsePrisma = await prismaClient.response.create({
data: prismaData,
select: responseSelection,
});
@@ -97,28 +129,7 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
if (IS_FORMBRICKS_CLOUD) {
const responsesCount = await getMonthlyOrganizationResponseCount(organization.id);
const responsesLimit = organization.billing.limits.monthly.responses;
if (responsesLimit && responsesCount >= responsesLimit) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: {
responses: responsesLimit,
miu: null,
},
},
});
} catch (err) {
// Log error but do not throw
logger.error(err, "Error sending plan limits reached event to Posthog");
}
}
}
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
return response;
} catch (error) {

View File

@@ -6,13 +6,14 @@ import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponse } from "@formbricks/types/responses";
import { createResponse } from "./lib/response";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { createResponseWithQuotaEvaluation } from "./lib/response";
import { TResponseInputV2, ZResponseInputV2 } from "./types/response";
interface Context {
@@ -104,7 +105,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
);
}
let response: TResponse;
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInputV2["meta"] = {
source: responseInputData?.meta?.source,
@@ -118,7 +119,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
action: responseInputData?.meta?.action,
};
response = await createResponse({
response = await createResponseWithQuotaEvaluation({
...responseInputData,
meta,
});
@@ -129,27 +130,35 @@ export const POST = async (request: Request, context: Context): Promise<Response
logger.error({ error, url: request.url }, "Error creating response");
return responses.internalServerErrorResponse(error.message);
}
const { quotaFull, ...responseData } = response;
sendToPipeline({
event: "responseCreated",
environmentId,
surveyId: response.surveyId,
response: response,
surveyId: responseData.surveyId,
response: responseData,
});
if (responseInput.finished) {
if (responseData.finished) {
sendToPipeline({
event: "responseFinished",
environmentId,
surveyId: response.surveyId,
response: response,
surveyId: responseData.surveyId,
response: responseData,
});
}
await capturePosthogEnvironmentEvent(environmentId, "response created", {
surveyId: response.surveyId,
surveyId: responseData.surveyId,
surveyType: survey.type,
});
return responses.successResponse({ id: response.id }, true);
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return responses.successResponse(responseDataWithQuota, true);
};

View File

@@ -39,7 +39,7 @@ describe("surveys", () => {
status: "draft",
} as unknown as TSurvey;
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {});
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, []);
expect(result.questionOptions.length).toBeGreaterThan(0);
expect(result.questionOptions[0].header).toBe(OptionsType.QUESTIONS);
@@ -62,7 +62,7 @@ describe("surveys", () => {
{ id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
];
const result = generateQuestionAndFilterOptions(survey, tags, {}, {}, {});
const result = generateQuestionAndFilterOptions(survey, tags, {}, {}, {}, []);
const tagsHeader = result.questionOptions.find((opt) => opt.header === OptionsType.TAGS);
expect(tagsHeader).toBeDefined();
@@ -85,7 +85,7 @@ describe("surveys", () => {
role: ["admin", "user"],
};
const result = generateQuestionAndFilterOptions(survey, undefined, attributes, {}, {});
const result = generateQuestionAndFilterOptions(survey, undefined, attributes, {}, {}, []);
const attributesHeader = result.questionOptions.find((opt) => opt.header === OptionsType.ATTRIBUTES);
expect(attributesHeader).toBeDefined();
@@ -108,7 +108,7 @@ describe("surveys", () => {
source: ["web", "mobile"],
};
const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {});
const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {}, []);
const metaHeader = result.questionOptions.find((opt) => opt.header === OptionsType.META);
expect(metaHeader).toBeDefined();
@@ -131,7 +131,7 @@ describe("surveys", () => {
segment: ["free", "paid"],
};
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, hiddenFields);
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, hiddenFields, []);
const hiddenFieldsHeader = result.questionOptions.find(
(opt) => opt.header === OptionsType.HIDDEN_FIELDS
@@ -153,7 +153,7 @@ describe("surveys", () => {
languages: [{ language: { code: "en" } as unknown as TLanguage } as unknown as TSurveyLanguage],
} as unknown as TSurvey;
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {});
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, []);
const othersHeader = result.questionOptions.find((opt) => opt.header === OptionsType.OTHERS);
expect(othersHeader).toBeDefined();
@@ -223,7 +223,7 @@ describe("surveys", () => {
status: "draft",
} as unknown as TSurvey;
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {});
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, []);
expect(result.questionFilterOptions.length).toBe(8);
expect(result.questionFilterOptions.some((o) => o.id === "q1")).toBeTruthy();
@@ -248,7 +248,7 @@ describe("surveys", () => {
source: ["web", "mobile"],
};
const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {});
const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {}, []);
const urlFilterOption = result.questionFilterOptions.find((o) => o.id === "url");
const sourceFilterOption = result.questionFilterOptions.find((o) => o.id === "source");

View File

@@ -9,6 +9,7 @@ import {
QuestionOptions,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
import { TSurveyQuota } from "@formbricks/types/quota";
import {
TResponseFilterCriteria,
TResponseHiddenFieldsFilter,
@@ -65,7 +66,8 @@ export const generateQuestionAndFilterOptions = (
environmentTags: TTag[] | undefined,
attributes: TSurveyContactAttributes,
meta: TSurveyMetaFieldFilter,
hiddenFields: TResponseHiddenFieldsFilter
hiddenFields: TResponseHiddenFieldsFilter,
quotas: TSurveyQuota[]
): {
questionOptions: QuestionOptions[];
questionFilterOptions: QuestionFilterOptions[];
@@ -219,6 +221,22 @@ export const generateQuestionAndFilterOptions = (
}
questionOptions = [...questionOptions, { header: OptionsType.OTHERS, option: languageQuestion }];
if (quotas.length > 0) {
const quotaOptions = quotas.map((quota) => {
return { label: quota.name, type: OptionsType.QUOTAS, id: quota.id };
});
questionOptions = [...questionOptions, { header: OptionsType.QUOTAS, option: quotaOptions }];
quotas.forEach((quota) => {
questionFilterOptions.push({
type: "Quotas",
filterOptions: ["Status"],
filterComboBoxOptions: ["Screened in", "Screened out (overquota)", "Screened out (not in quota)"],
id: quota.id,
});
});
}
return { questionOptions: [...questionOptions], questionFilterOptions: [...questionFilterOptions] };
};
@@ -236,6 +254,7 @@ export const getFormattedFilters = (
const others: FilterValue[] = [];
const meta: FilterValue[] = [];
const hiddenFields: FilterValue[] = [];
const quotas: FilterValue[] = [];
selectedFilter.filter.forEach((filter) => {
if (filter.questionType?.type === "Questions") {
@@ -250,6 +269,8 @@ export const getFormattedFilters = (
meta.push(filter);
} else if (filter.questionType?.type === "Hidden Fields") {
hiddenFields.push(filter);
} else if (filter.questionType?.type === "Quotas") {
quotas.push(filter);
}
});
@@ -514,6 +535,22 @@ export const getFormattedFilters = (
});
}
if (quotas.length) {
quotas.forEach(({ filterType, questionType }) => {
filters.quotas ??= {};
const quotaId = questionType.id;
if (!quotaId) return;
const statusMap: Record<string, "screenedIn" | "screenedOut" | "screenedOutNotInQuota"> = {
"Screened in": "screenedIn",
"Screened out (overquota)": "screenedOut",
"Screened out (not in quota)": "screenedOutNotInQuota",
};
const op = statusMap[String(filterType.filterComboBoxValue)];
if (op) filters.quotas[quotaId] = { op };
});
}
return filters;
};

View File

@@ -43,10 +43,11 @@ export const getDisplayCountBySurveyId = reactCache(
}
);
export const deleteDisplay = async (displayId: string): Promise<TDisplay> => {
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
validateInputs([displayId, ZId]);
try {
const display = await prisma.display.delete({
const prismaClient = tx ?? prisma;
const display = await prismaClient.display.delete({
where: {
id: displayId,
},

View File

@@ -1,4 +1,8 @@
import "server-only";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
@@ -11,6 +15,7 @@ import {
TResponseContact,
TResponseFilterCriteria,
TResponseUpdateInput,
TResponseWithQuotas,
ZResponseFilterCriteria,
ZResponseUpdateInput,
} from "@formbricks/types/responses";
@@ -86,7 +91,7 @@ export const getResponseContact = (
};
export const getResponsesByContactId = reactCache(
async (contactId: string, page?: number): Promise<TResponse[] | null> => {
async (contactId: string, page?: number): Promise<TResponseWithQuotas[]> => {
validateInputs([contactId, ZId], [page, ZOptionalNumber]);
try {
@@ -94,7 +99,22 @@ export const getResponsesByContactId = reactCache(
where: {
contactId,
},
select: responseSelection,
select: {
...responseSelection,
quotaLinks: {
where: {
status: "screenedIn",
},
include: {
quota: {
select: {
id: true,
name: true,
},
},
},
},
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
orderBy: {
@@ -102,11 +122,7 @@ export const getResponsesByContactId = reactCache(
},
});
if (!responsePrisma) {
throw new ResourceNotFoundError("Response from ContactId", contactId);
}
let responses: TResponse[] = [];
let responses: TResponseWithQuotas[] = [];
await Promise.all(
responsePrisma.map(async (response) => {
@@ -121,6 +137,7 @@ export const getResponsesByContactId = reactCache(
contact: responseContact,
tags: response.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
quotas: response.quotaLinks.map((quotaLinkPrisma) => quotaLinkPrisma.quota),
});
})
);
@@ -241,7 +258,7 @@ export const getResponses = reactCache(
offset?: number,
filterCriteria?: TResponseFilterCriteria,
cursor?: string
): Promise<TResponse[]> => {
): Promise<TResponseWithQuotas[]> => {
validateInputs(
[surveyId, ZId],
[limit, ZOptionalNumber],
@@ -268,7 +285,22 @@ export const getResponses = reactCache(
const responses = await prisma.response.findMany({
where: whereClause,
select: responseSelection,
select: {
...responseSelection,
quotaLinks: {
where: {
status: "screenedIn",
},
include: {
quota: {
select: {
id: true,
name: true,
},
},
},
},
},
orderBy: [
{
createdAt: "desc",
@@ -281,12 +313,14 @@ export const getResponses = reactCache(
skip: offset,
});
const transformedResponses: TResponse[] = await Promise.all(
const transformedResponses: TResponseWithQuotas[] = await Promise.all(
responses.map((responsePrisma) => {
const { quotaLinks, ...response } = responsePrisma;
return {
...responsePrisma,
...response,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
quotas: quotaLinks.map((quotaLinkPrisma) => quotaLinkPrisma.quota),
};
})
);
@@ -342,11 +376,24 @@ export const getResponseDownloadUrl = async (
responses
);
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
if (!organizationId) {
throw new Error("Organization ID not found");
}
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error("Organization billing not found");
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const headers = [
"No.",
"Response ID",
"Timestamp",
"Finished",
...(isQuotasAllowed ? ["Quotas"] : []),
"Survey ID",
"Formbricks ID (internal)",
"User ID",
@@ -362,7 +409,14 @@ export const getResponseDownloadUrl = async (
if (survey.isVerifyEmailEnabled) {
headers.push("Verified Email");
}
const jsonData = getResponsesJson(survey, responses, questions, userAttributes, hiddenFields);
const jsonData = getResponsesJson(
survey,
responses,
questions,
userAttributes,
hiddenFields,
isQuotasAllowed
);
const fileName = getResponsesFileName(survey?.name || "", format);
let fileBuffer: Buffer;
@@ -430,12 +484,14 @@ export const getResponsesByEnvironmentId = reactCache(
export const updateResponse = async (
responseId: string,
responseInput: TResponseUpdateInput
responseInput: TResponseUpdateInput,
tx?: Prisma.TransactionClient
): Promise<TResponse> => {
validateInputs([responseId, ZId], [responseInput, ZResponseUpdateInput]);
try {
const prismaClient = tx ?? prisma;
// use direct prisma call to avoid cache issues
const currentResponse = await prisma.response.findUnique({
const currentResponse = await prismaClient.response.findUnique({
where: {
id: responseId,
},
@@ -462,7 +518,7 @@ export const updateResponse = async (
...responseInput.variables,
};
const responsePrisma = await prisma.response.update({
const responsePrisma = await prismaClient.response.update({
where: {
id: responseId,
},
@@ -522,40 +578,61 @@ const findAndDeleteUploadedFilesInResponse = async (response: TResponse, survey:
await Promise.all(deletionPromises);
};
export const deleteResponse = async (responseId: string): Promise<TResponse> => {
export const deleteResponse = async (
responseId: string,
decrementQuotas: boolean = false
): Promise<TResponse> => {
validateInputs([responseId, ZId]);
try {
const responsePrisma = await prisma.response.delete({
where: {
id: responseId,
},
select: responseSelection,
const txResponse = await prisma.$transaction(async (tx) => {
const responsePrisma = await tx.response.delete({
where: {
id: responseId,
},
select: {
...responseSelection,
quotaLinks: {
where: {
status: "screenedIn",
},
include: {
quota: {
select: {
id: true,
},
},
},
},
},
});
const { quotaLinks, ...responseWithoutQuotas } = responsePrisma;
const response: TResponse = {
...responseWithoutQuotas,
contact: getResponseContact(responsePrisma),
tags: responseWithoutQuotas.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
if (response.displayId) {
await deleteDisplay(response.displayId, tx);
}
if (decrementQuotas) {
const quotaIds = quotaLinks?.map((link) => link.quota.id) ?? [];
await reduceQuotaLimits(quotaIds, tx);
}
return response;
});
const response: TResponse = {
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
if (response.displayId) {
deleteDisplay(response.displayId);
}
const survey = await getSurvey(response.surveyId);
const survey = await getSurvey(txResponse.surveyId);
if (survey) {
await findAndDeleteUploadedFilesInResponse(
{
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tag) => tag.tag),
},
survey
);
await findAndDeleteUploadedFilesInResponse(txResponse, survey);
}
return response;
return txResponse;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);

View File

@@ -2,6 +2,7 @@ import { mockWelcomeCard } from "@/lib/i18n/i18n.mock";
import { Prisma } from "@prisma/client";
import { isAfter, isBefore, isSameDay } from "date-fns";
import { TDisplay } from "@formbricks/types/displays";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponse, TResponseFilterCriteria, TResponseUpdateInput } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
@@ -79,6 +80,35 @@ export const mockResponse: ResponseMock = {
variables: {},
};
const mockSurveyQuota: TSurveyQuota = {
id: constantsForTests.uuid,
surveyId: mockSurveyId,
name: "Quota 1",
action: "endSurvey",
createdAt: new Date(),
updatedAt: new Date(),
countPartialSubmissions: false,
endingCardId: null,
limit: 1,
logic: {
connector: "and",
conditions: [],
},
};
type mockResponseWithQuotas = ResponseMock & {
quotaLinks: { quota: TSurveyQuota }[];
};
export const mockResponseWithQuotas: mockResponseWithQuotas = {
...mockResponse,
quotaLinks: [
{
quota: mockSurveyQuota,
},
],
};
const getMockTags = (tags: string[]): { tag: TTag }[] => {
return tags.map((tag) => ({
tag: {
@@ -358,9 +388,12 @@ export const mockSurveySummaryOutput = {
dropOffPercentage: 0,
dropOffCount: 0,
startsPercentage: 0,
quotasCompleted: 0,
quotasCompletedPercentage: 0,
totalResponses: 1,
ttcAverage: 0,
},
quotas: [],
summary: [
{
question: {

View File

@@ -5,6 +5,7 @@ import {
mockEnvironmentId,
mockResponse,
mockResponseData,
mockResponseWithQuotas,
mockSingleUseId,
mockSurveyId,
mockSurveySummaryOutput,
@@ -13,7 +14,7 @@ import {
import { prisma } from "@/lib/__mocks__/database";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { testInputValidation } from "vitestSetup";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -31,6 +32,7 @@ import {
getResponseCountBySurveyId,
getResponseDownloadUrl,
getResponsesByEnvironmentId,
responseSelection,
updateResponse,
} from "../service";
@@ -165,6 +167,7 @@ describe("Tests for getSurveySummary service", () => {
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.response.findMany.mockResolvedValue([mockResponse]);
prisma.contactAttributeKey.findMany.mockResolvedValueOnce([mockContactAttributeKey]);
prisma.surveyQuota.findMany.mockResolvedValue([]);
const summary = await getSurveySummary(mockSurveyId);
expect(summary).toEqual(mockSurveySummaryOutput);
@@ -205,7 +208,8 @@ describe("Tests for getResponseDownloadUrl service", () => {
test("Returns a download URL for the csv response file", async () => {
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.response.count.mockResolvedValue(1);
prisma.response.findMany.mockResolvedValue([mockResponse]);
prisma.response.findMany.mockResolvedValue([mockResponseWithQuotas]);
prisma.surveyQuota.findMany.mockResolvedValue([]);
const url = await getResponseDownloadUrl(mockSurveyId, "csv");
const fileExtension = url.split(".").pop();
@@ -215,7 +219,7 @@ describe("Tests for getResponseDownloadUrl service", () => {
test("Returns a download URL for the xlsx response file", async () => {
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.response.count.mockResolvedValue(1);
prisma.response.findMany.mockResolvedValue([mockResponse]);
prisma.response.findMany.mockResolvedValue([mockResponseWithQuotas]);
const url = await getResponseDownloadUrl(mockSurveyId, "xlsx", { finished: true });
const fileExtension = url.split(".").pop();
@@ -229,7 +233,7 @@ describe("Tests for getResponseDownloadUrl service", () => {
test("Throws error if response file is of different format than expected", async () => {
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.response.count.mockResolvedValue(1);
prisma.response.findMany.mockResolvedValue([mockResponse]);
prisma.response.findMany.mockResolvedValue([mockResponseWithQuotas]);
const url = await getResponseDownloadUrl(mockSurveyId, "csv", { finished: true });
const fileExtension = url.split(".").pop();
@@ -359,9 +363,47 @@ describe("Tests for updateResponse Service", () => {
});
describe("Tests for deleteResponse service", () => {
type MockTx = {
response: {
delete: ReturnType<typeof vi.fn>;
};
};
let mockTx: MockTx;
beforeEach(() => {
vi.clearAllMocks();
mockTx = {
response: {
delete: vi.fn(),
},
};
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
});
describe("Happy Path", () => {
test("Successfully deletes a response based on its ID", async () => {
vi.mocked(mockTx.response.delete).mockResolvedValue({
...mockResponse,
quotaLinks: mockResponseWithQuotas.quotaLinks,
});
const response = await deleteResponse(mockResponse.id);
expect(mockTx.response.delete).toHaveBeenCalledWith({
where: { id: mockResponse.id },
select: {
...responseSelection,
quotaLinks: {
where: { status: "screenedIn" },
include: {
quota: {
select: {
id: true,
},
},
},
},
},
});
expect(response).toEqual(expectedResponseWithoutPerson);
});
});
@@ -376,14 +418,14 @@ describe("Tests for deleteResponse service", () => {
clientVersion: "0.0.1",
});
prisma.response.delete.mockRejectedValue(errToThrow);
mockTx.response.delete.mockRejectedValue(errToThrow);
await expect(deleteResponse(mockResponse.id)).rejects.toThrow(DatabaseError);
});
test("Throws a generic Error for any unhandled exception during deletion", async () => {
const mockErrorMessage = "Mock error message";
prisma.response.delete.mockRejectedValue(new Error(mockErrorMessage));
mockTx.response.delete.mockRejectedValue(new Error(mockErrorMessage));
await expect(deleteResponse(mockResponse.id)).rejects.toThrow(Error);
});

View File

@@ -470,7 +470,8 @@ describe("Response Utils", () => {
mockResponses as TResponse[],
questionsHeadlines,
userAttributes,
hiddenFields
hiddenFields,
false
);
expect(result[0]["Response ID"]).toBe("response1");
expect(result[0]["userAgent - browser"]).toBe("Chrome");

View File

@@ -6,6 +6,7 @@ import {
TResponseFilterCriteria,
TResponseHiddenFieldsFilter,
TResponseTtc,
TResponseWithQuotas,
TSurveyContactAttributes,
TSurveyMetaFieldFilter,
} from "@formbricks/types/responses";
@@ -593,6 +594,43 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
id: { in: filterCriteria.responseIds },
});
}
// For quota filters
if (filterCriteria?.quotas) {
const quotaFilters: Prisma.ResponseWhereInput[] = [];
Object.entries(filterCriteria.quotas).forEach(([quotaId, { op }]) => {
if (op === "screenedOutNotInQuota") {
// Responses that don't have any quota link with this quota
quotaFilters.push({
NOT: {
quotaLinks: {
some: {
quotaId,
},
},
},
});
} else {
// Responses that have a quota link with this quota and the specified status
quotaFilters.push({
quotaLinks: {
some: {
quotaId,
status: op,
},
},
});
}
});
if (quotaFilters.length > 0) {
whereClause.push({
AND: quotaFilters,
});
}
}
return { AND: whereClause };
};
@@ -650,10 +688,11 @@ export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) =>
export const getResponsesJson = (
survey: TSurvey,
responses: TResponse[],
responses: TResponseWithQuotas[],
questionsHeadlines: string[][],
userAttributes: string[],
hiddenFields: string[]
hiddenFields: string[],
isQuotasAllowed: boolean = false
): Record<string, string | number>[] => {
const jsonData: Record<string, string | number>[] = [];
@@ -670,6 +709,10 @@ export const getResponsesJson = (
Tags: response.tags.map((tag) => tag.name).join(", "),
});
if (isQuotasAllowed) {
jsonData[idx]["Quotas"] = response.quotas?.map((quota) => quota.name).join(", ") || "";
}
// meta details
Object.entries(response.meta ?? {}).forEach(([key, value]) => {
if (typeof value === "object" && value !== null) {

View File

@@ -19,7 +19,7 @@ export type AuditLoggingCtx = {
contactId?: string;
apiKeyId?: string;
responseId?: string;
quotaId?: string;
teamId?: string;
integrationId?: string;
};

View File

@@ -18,6 +18,7 @@ import {
getOrganizationIdFromInviteId,
getOrganizationIdFromLanguageId,
getOrganizationIdFromProjectId,
getOrganizationIdFromQuotaId,
getOrganizationIdFromResponseId,
getOrganizationIdFromSegmentId,
getOrganizationIdFromSurveyId,
@@ -32,6 +33,7 @@ import {
getProjectIdFromInsightId,
getProjectIdFromIntegrationId,
getProjectIdFromLanguageId,
getProjectIdFromQuotaId,
getProjectIdFromResponseId,
getProjectIdFromSegmentId,
getProjectIdFromSurveyId,
@@ -47,7 +49,7 @@ vi.mock("@/lib/utils/services", () => ({
getSurvey: vi.fn(),
getResponse: vi.fn(),
getContact: vi.fn(),
getQuota: vi.fn(),
getSegment: vi.fn(),
getActionClass: vi.fn(),
getIntegration: vi.fn(),
@@ -406,6 +408,24 @@ describe("Helper Utilities", () => {
vi.mocked(services.getDocument).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromDocumentId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getOrganizationIdFromQuotaId returns organization ID correctly", async () => {
vi.mocked(services.getQuota).mockResolvedValueOnce({
surveyId: "survey1",
});
vi.mocked(services.getSurvey).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
vi.mocked(services.getProject).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromQuotaId("quota1");
expect(orgId).toBe("org1");
});
});
describe("Project ID retrieval functions", () => {
@@ -630,6 +650,21 @@ describe("Helper Utilities", () => {
vi.mocked(services.getWebhook).mockResolvedValueOnce(null);
await expect(getProjectIdFromWebhookId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getProjectIdFromQuotaId returns project ID correctly", async () => {
vi.mocked(services.getQuota).mockResolvedValueOnce({
surveyId: "survey1",
});
vi.mocked(services.getSurvey).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
const projectId = await getProjectIdFromQuotaId("quota1");
expect(projectId).toBe("project1");
});
});
describe("Environment ID retrieval functions", () => {

View File

@@ -9,6 +9,7 @@ import {
getInvite,
getLanguage,
getProject,
getQuota,
getResponse,
getSegment,
getSurvey,
@@ -193,6 +194,12 @@ export const getOrganizationIdFromDocumentId = async (documentId: string) => {
return await getOrganizationIdFromEnvironmentId(document.environmentId);
};
export const getOrganizationIdFromQuotaId = async (quotaId: string) => {
const quota = await getQuota(quotaId);
return await getOrganizationIdFromSurveyId(quota.surveyId);
};
// project id helpers
export const getProjectIdFromEnvironmentId = async (environmentId: string) => {
const environment = await getEnvironment(environmentId);
@@ -302,6 +309,12 @@ export const getProjectIdFromWebhookId = async (webhookId: string) => {
return await getProjectIdFromEnvironmentId(webhook.environmentId);
};
export const getProjectIdFromQuotaId = async (quotaId: string) => {
const quota = await getQuota(quotaId);
return await getProjectIdFromSurveyId(quota.surveyId);
};
// environment id helpers
export const getEnvironmentIdFromSurveyId = async (surveyId: string) => {
const survey = await getSurvey(surveyId);

View File

@@ -1,8 +1,10 @@
import { validateInputs } from "@/lib/utils/validate";
import { getQuota as getQuotaService } from "@/modules/ee/quotas/lib/quotas";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota";
import {
getActionClass,
getApiKey,
@@ -14,6 +16,7 @@ import {
getInvite,
getLanguage,
getProject,
getQuota,
getResponse,
getSegment,
getSurvey,
@@ -80,9 +83,16 @@ vi.mock("@formbricks/database", () => ({
segment: {
findUnique: vi.fn(),
},
surveyQuota: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/modules/ee/quotas/lib/quotas", () => ({
getQuota: vi.fn(),
}));
describe("Service Functions", () => {
beforeEach(() => {
vi.resetAllMocks();
@@ -389,6 +399,30 @@ describe("Service Functions", () => {
});
});
describe("getQuota", () => {
const quotaId = "quota123";
test("returns surveyId when found (delegates to getQuotaService)", async () => {
const mockQuota = { surveyId: "survey123" } as TSurveyQuota;
vi.mocked(getQuotaService).mockResolvedValue(mockQuota);
const result = await getQuota(quotaId);
expect(validateInputs).toHaveBeenCalled();
expect(getQuotaService).toHaveBeenCalledWith(quotaId);
expect(result).toEqual(mockQuota);
});
test("throws DatabaseError when underlying service fails", async () => {
vi.mocked(getQuotaService).mockRejectedValue(new DatabaseError("error"));
await expect(getQuota(quotaId)).rejects.toThrow(DatabaseError);
});
test("throws ResourceNotFoundError when quota not found", async () => {
vi.mocked(getQuotaService).mockRejectedValue(new ResourceNotFoundError("Quota", quotaId));
await expect(getQuota(quotaId)).rejects.toThrow(ResourceNotFoundError);
});
});
describe("getTeam", () => {
const teamId = "team123";

View File

@@ -1,6 +1,7 @@
"use server";
import { validateInputs } from "@/lib/utils/validate";
import { getQuota as getQuotaService } from "@/modules/ee/quotas/lib/quotas";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
@@ -240,6 +241,14 @@ export const getWebhook = async (id: string): Promise<{ environmentId: string }
}
};
export const getQuota = reactCache(async (quotaId: string): Promise<{ surveyId: string }> => {
validateInputs([quotaId, ZId]);
const quota = await getQuotaService(quotaId);
return { surveyId: quota.surveyId };
});
export const getTeam = reactCache(async (teamId: string): Promise<{ organizationId: string } | null> => {
validateInputs([teamId, ZString]);

View File

@@ -154,6 +154,7 @@ export const deleteTagOnResponseAction = authenticatedActionClient.schema(ZDelet
const ZDeleteResponseAction = z.object({
responseId: ZId,
decrementQuotas: z.boolean().default(false),
});
export const deleteResponseAction = authenticatedActionClient.schema(ZDeleteResponseAction).action(
@@ -179,7 +180,7 @@ export const deleteResponseAction = authenticatedActionClient.schema(ZDeleteResp
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.responseId = parsedInput.responseId;
const result = await deleteResponse(parsedInput.responseId);
const result = await deleteResponse(parsedInput.responseId, parsedInput.decrementQuotas);
ctx.auditLoggingCtx.oldObject = result;
return result;
}

View File

@@ -16,12 +16,12 @@ describe("HiddenFields", () => {
cleanup();
});
test("renders empty container when no fieldIds are provided", () => {
test("does not render empty container when no fieldIds are provided", () => {
render(
<HiddenFields hiddenFields={{ fieldIds: [] } as unknown as TSurveyHiddenFields} responseData={{}} />
);
const container = screen.getByTestId("main-hidden-fields-div");
expect(container).toBeDefined();
const container = screen.queryByTestId("main-hidden-fields-div");
expect(container).not.toBeInTheDocument();
});
test("renders nothing for fieldIds with no corresponding response data", () => {

View File

@@ -14,14 +14,29 @@ interface HiddenFieldsProps {
export const HiddenFields = ({ hiddenFields, responseData }: HiddenFieldsProps) => {
const { t } = useTranslate();
const fieldIds = hiddenFields.fieldIds ?? [];
let hiddenFieldsData: { field: string; value: string }[] = [];
fieldIds.forEach((field) => {
if (responseData[field]) {
hiddenFieldsData.push({
field,
value: typeof responseData[field] === "string" ? responseData[field] : "",
});
}
});
if (hiddenFieldsData.length === 0) {
return null;
}
return (
<div data-testid="main-hidden-fields-div" className="mt-6 flex flex-col gap-6">
{fieldIds.map((field) => {
if (!responseData[field]) return;
{hiddenFieldsData.map((fieldData) => {
return (
<div key={field}>
<div key={fieldData.field}>
<div className="flex space-x-2 text-sm text-slate-500">
<p>{field}</p>
<p>{fieldData.field}</p>
<div className="flex items-center space-x-2 rounded-full bg-slate-100 px-2">
<TooltipProvider delayDuration={50}>
<Tooltip>
@@ -35,9 +50,7 @@ export const HiddenFields = ({ hiddenFields, responseData }: HiddenFieldsProps)
</TooltipProvider>
</div>
</div>
<p className="ph-no-capture mt-2 font-semibold text-slate-700">
{typeof responseData[field] === "string" ? (responseData[field] as string) : ""}
</p>
<p className="ph-no-capture mt-2 font-semibold text-slate-700">{fieldData.value}</p>
</div>
);
})}

View File

@@ -2,9 +2,10 @@
import { getLocalizedValue } from "@/lib/i18n/utils";
import { parseRecallInfo } from "@/lib/utils/recall";
import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas";
import { useTranslate } from "@tolgee/react";
import { CheckCircle2Icon } from "lucide-react";
import { TResponse } from "@formbricks/types/responses";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { isValidValue } from "../util";
import { HiddenFields } from "./HiddenFields";
@@ -15,7 +16,7 @@ import { VerifiedEmail } from "./VerifiedEmail";
interface SingleResponseCardBodyProps {
survey: TSurvey;
response: TResponse;
response: TResponseWithQuotas;
skippedQuestions: string[][];
}
@@ -120,6 +121,9 @@ export const SingleResponseCardBody = ({
{survey.hiddenFields.enabled && survey.hiddenFields.fieldIds && (
<HiddenFields hiddenFields={survey.hiddenFields} responseData={response.data} />
)}
<ResponseCardQuotas quotas={response.quotas} />
{response.finished && (
<div className="mt-4 flex items-center">
<CheckCircle2Icon className="h-6 w-6 text-slate-400" />

View File

@@ -117,7 +117,10 @@ describe("SingleResponseCard", () => {
const deleteButton = await screen.findByTestId("DeleteDialog");
await userEvent.click(deleteButton);
await waitFor(() => {
expect(deleteResponseAction).toHaveBeenCalledWith({ responseId: dummyResponse.id });
expect(deleteResponseAction).toHaveBeenCalledWith({
responseId: dummyResponse.id,
decrementQuotas: false,
});
});
expect(dummyUpdateResponseList).toHaveBeenCalledWith([dummyResponse.id]);

View File

@@ -1,12 +1,13 @@
"use client";
import { DecrementQuotasCheckbox } from "@/modules/ui/components/decrement-quotas-checkbox";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TResponse, TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
@@ -18,7 +19,7 @@ import { isValidValue } from "./util";
interface SingleResponseCardProps {
survey: TSurvey;
response: TResponse;
response: TResponseWithQuotas;
user?: TUser;
environmentTags: TTag[];
environment: TEnvironment;
@@ -41,6 +42,8 @@ export const SingleResponseCard = ({
setSelectedResponseId,
locale,
}: SingleResponseCardProps) => {
const hasQuotas = (response.quotas && response.quotas.length > 0) ?? false;
const [decrementQuotas, setDecrementQuotas] = useState(hasQuotas);
const { t } = useTranslate();
const environmentId = survey.environmentId;
const router = useRouter();
@@ -86,7 +89,7 @@ export const SingleResponseCard = ({
if (isReadOnly) {
throw new Error(t("common.not_authorized"));
}
await deleteResponseAction({ responseId: response.id });
await deleteResponseAction({ responseId: response.id, decrementQuotas });
updateResponseList?.([response.id]);
router.refresh();
if (setSelectedResponseId) setSelectedResponseId(null);
@@ -138,8 +141,15 @@ export const SingleResponseCard = ({
deleteWhat={t("common.response")}
onDelete={handleDeleteResponse}
isDeleting={isDeleting}
text={t("environments.surveys.responses.delete_response_confirmation")}
/>
text={t("environments.surveys.responses.delete_response_confirmation")}>
{hasQuotas && (
<DecrementQuotasCheckbox
title={t("environments.surveys.responses.delete_response_quotas")}
checked={decrementQuotas}
onCheckedChange={setDecrementQuotas}
/>
)}
</DeleteDialog>
</div>
</div>
);

View File

@@ -3,6 +3,7 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
import { findAndDeleteUploadedFilesInResponse } from "@/modules/api/v2/management/responses/[responseId]/lib/utils";
import { ZResponseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { Prisma, Response } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
@@ -76,10 +77,12 @@ export const deleteResponse = async (responseId: string): Promise<Result<Respons
export const updateResponse = async (
responseId: string,
responseInput: z.infer<typeof ZResponseUpdateSchema>
responseInput: z.infer<typeof ZResponseUpdateSchema>,
tx?: Prisma.TransactionClient
): Promise<Result<Response, ApiErrorResponseV2>> => {
try {
const updatedResponse = await prisma.response.update({
const prismaClient = tx ?? prisma;
const updatedResponse = await prismaClient.response.update({
where: {
id: responseId,
},
@@ -105,3 +108,46 @@ export const updateResponse = async (
});
}
};
export const updateResponseWithQuotaEvaluation = async (
responseId: string,
responseInput: z.infer<typeof ZResponseUpdateSchema>
): Promise<Result<Response, ApiErrorResponseV2>> => {
const txResponse = await prisma.$transaction<Result<Response, ApiErrorResponseV2>>(async (tx) => {
const responseResult = await updateResponse(responseId, responseInput, tx);
if (!responseResult.ok) {
return responseResult;
}
const response = responseResult.data;
const quotaResult = await evaluateResponseQuotas({
surveyId: response.surveyId,
responseId: response.id,
data: response.data,
variables: response.variables,
language: response.language || "default",
responseFinished: response.finished,
tx,
});
if (quotaResult.shouldEndSurvey) {
if (quotaResult.refreshedResponse) {
return ok(quotaResult.refreshedResponse);
}
return ok({
...response,
finished: true,
...(quotaResult.quotaFull?.endingCardId && {
endingId: quotaResult.quotaFull.endingCardId,
}),
});
}
return ok(response);
});
return txResponse;
};

View File

@@ -1,14 +1,33 @@
import { response, responseId, responseInput, survey } from "./__mocks__/response.mock";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ok, okVoid } from "@formbricks/types/error-handlers";
import { TSurveyQuota } from "@formbricks/types/quota";
import { deleteDisplay } from "../display";
import { deleteResponse, getResponse, updateResponse } from "../response";
import { deleteResponse, getResponse, updateResponse, updateResponseWithQuotaEvaluation } from "../response";
import { getSurveyQuestions } from "../survey";
import { findAndDeleteUploadedFilesInResponse } from "../utils";
// Mock quota object for testing
const mockQuota: TSurveyQuota = {
id: "quota-id",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "kbr8tnr2q2vgztyrfnqlgfjt",
name: "Test Quota",
limit: 100,
logic: {
connector: "and",
conditions: [],
},
action: "endSurvey",
endingCardId: "ending-card-id",
countPartialSubmissions: false,
};
vi.mock("../display", () => ({
deleteDisplay: vi.fn(),
}));
@@ -21,6 +40,10 @@ vi.mock("../utils", () => ({
findAndDeleteUploadedFilesInResponse: vi.fn(),
}));
vi.mock("@/modules/ee/quotas/lib/evaluation-service", () => ({
evaluateResponseQuotas: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
@@ -239,4 +262,156 @@ describe("Response Lib", () => {
}
});
});
describe("updateResponseWithQuotaEvaluation", () => {
type MockTx = {
response: {
update: ReturnType<typeof vi.fn>;
};
};
let mockTx: MockTx;
beforeEach(() => {
vi.clearAllMocks();
mockTx = {
response: {
update: vi.fn(),
},
};
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
});
test("update response and continue when quota evaluation says not to end survey", async () => {
vi.mocked(mockTx.response.update).mockResolvedValue(response);
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false,
quotaFull: null,
refreshedResponse: null,
});
const result = await updateResponseWithQuotaEvaluation(responseId, responseInput);
expect(mockTx.response.update).toHaveBeenCalledWith({
where: { id: responseId },
data: responseInput,
});
expect(evaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: response.surveyId,
responseId: response.id,
data: response.data,
variables: response.variables,
language: response.language,
responseFinished: response.finished,
tx: mockTx,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(response);
}
});
test("handle quota evaluation with default language when response.language is null", async () => {
const responseWithoutLanguage = { ...response, language: null };
vi.mocked(mockTx.response.update).mockResolvedValue(responseWithoutLanguage);
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false,
quotaFull: null,
refreshedResponse: null,
});
const result = await updateResponseWithQuotaEvaluation(responseId, responseInput);
expect(evaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: responseWithoutLanguage.surveyId,
responseId: responseWithoutLanguage.id,
data: responseWithoutLanguage.data,
variables: responseWithoutLanguage.variables,
language: "default",
responseFinished: responseWithoutLanguage.finished,
tx: mockTx,
});
expect(result.ok).toBe(true);
});
test("end survey and return refreshed response when quota is full and refreshedResponse exists", async () => {
const refreshedResponse = { ...response, finished: true, endingId: "new-ending-id" };
vi.mocked(mockTx.response.update).mockResolvedValue(response);
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: true,
quotaFull: mockQuota,
refreshedResponse: refreshedResponse,
});
const result = await updateResponseWithQuotaEvaluation(responseId, responseInput);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(refreshedResponse);
}
});
test("end survey and set finished=true with endingCardId when quota is full but no refreshedResponse", async () => {
vi.mocked(mockTx.response.update).mockResolvedValue(response);
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: true,
quotaFull: mockQuota,
refreshedResponse: null,
});
const result = await updateResponseWithQuotaEvaluation(responseId, responseInput);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
...response,
finished: true,
endingId: "ending-card-id",
});
}
});
test("end survey and set finished=true when quota is full with no quotaFull object", async () => {
vi.mocked(mockTx.response.update).mockResolvedValue(response);
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: true,
quotaFull: null,
refreshedResponse: null,
});
const result = await updateResponseWithQuotaEvaluation(responseId, responseInput);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
...response,
finished: true,
});
}
});
test("propagate error when updateResponse fails", async () => {
vi.mocked(mockTx.response.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Response not found", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "1.0.0",
meta: {
cause: "Response not found",
},
})
);
const result = await updateResponseWithQuotaEvaluation(responseId, responseInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "response", issue: "not found" }],
});
}
expect(evaluateResponseQuotas).not.toHaveBeenCalled();
});
});
});

View File

@@ -7,7 +7,7 @@ import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import {
deleteResponse,
getResponse,
updateResponse,
updateResponseWithQuotaEvaluation,
} from "@/modules/api/v2/management/responses/[responseId]/lib/response";
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
@@ -190,7 +190,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
});
}
const response = await updateResponse(params.responseId, body);
const response = await updateResponseWithQuotaEvaluation(params.responseId, body);
if (!response.ok) {
return handleApiError(request, response.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error

View File

@@ -12,14 +12,42 @@ import { getResponsesQuery } from "@/modules/api/v2/management/responses/lib/uti
import { TGetResponsesFilter, TResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { Prisma, Response } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getResponses = async (
environmentIds: string[],
params: TGetResponsesFilter
): Promise<Result<ApiResponseWithMeta<Response[]>, ApiErrorResponseV2>> => {
try {
const query = getResponsesQuery(environmentIds, params);
const whereClause = query.where;
const [responses, totalCount] = await Promise.all([
prisma.response.findMany(query),
prisma.response.count({ where: whereClause }),
]);
return ok({
data: responses,
meta: {
total: totalCount,
limit: params.limit,
offset: params.skip,
},
});
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "responses", issue: error.message }] });
}
};
export const createResponse = async (
environmentId: string,
responseInput: TResponseInput
responseInput: TResponseInput,
tx?: Prisma.TransactionClient
): Promise<Result<Response, ApiErrorResponseV2>> => {
captureTelemetry("response created");
@@ -78,7 +106,9 @@ export const createResponse = async (
}
const billingData = billing.data;
const response = await prisma.response.create({
const prismaClient = tx ?? prisma;
const response = await prismaClient.response.create({
data: prismaData,
});
@@ -116,32 +146,44 @@ export const createResponse = async (
}
};
export const getResponses = async (
environmentIds: string[],
params: TGetResponsesFilter
): Promise<Result<ApiResponseWithMeta<Response[]>, ApiErrorResponseV2>> => {
try {
const query = getResponsesQuery(environmentIds, params);
const whereClause = query.where;
const [responses, totalCount] = await Promise.all([
prisma.response.findMany(query),
prisma.response.count({ where: whereClause }),
]);
if (!responses) {
return err({ type: "not_found", details: [{ field: "responses", issue: "not found" }] });
export const createResponseWithQuotaEvaluation = async (
environmentId: string,
responseInput: TResponseInput
): Promise<Result<Response, ApiErrorResponseV2>> => {
const txResponse = await prisma.$transaction<Result<Response, ApiErrorResponseV2>>(async (tx) => {
const responseResult = await createResponse(environmentId, responseInput, tx);
if (!responseResult.ok) {
return responseResult;
}
return ok({
data: responses,
meta: {
total: totalCount,
limit: params.limit,
offset: params.skip,
},
const response = responseResult.data;
const quotaResult = await evaluateResponseQuotas({
surveyId: responseInput.surveyId,
responseId: response.id,
data: responseInput.data,
variables: responseInput.variables,
language: responseInput.language || "default",
responseFinished: response.finished,
tx,
});
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "responses", issue: error.message }] });
}
if (quotaResult.shouldEndSurvey) {
if (quotaResult.refreshedResponse) {
return ok(quotaResult.refreshedResponse);
}
return ok({
...response,
finished: true,
...(quotaResult.quotaFull?.endingCardId && {
endingId: quotaResult.quotaFull.endingCardId,
}),
});
}
return ok(response);
});
return txResponse;
};

View File

@@ -43,6 +43,7 @@ vi.mock("@formbricks/database", () => ({
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
IS_PRODUCTION: false,
ENCRYPTION_KEY: "test",
}));
describe("Response Lib", () => {
@@ -233,20 +234,6 @@ describe("Response Lib", () => {
}
});
test("return a not_found error if responses are not found", async () => {
(prisma.response.findMany as any).mockResolvedValue(null);
(prisma.response.count as any).mockResolvedValue(0);
const result = await getResponses([environmentId], responseFilter);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "responses", issue: "not found" }],
});
}
});
test("return an internal_server_error error if prisma findMany fails", async () => {
(prisma.response.findMany as any).mockRejectedValue(new Error("Internal server error"));
(prisma.response.count as any).mockResolvedValue(0);

View File

@@ -10,7 +10,7 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { Response } from "@prisma/client";
import { NextRequest } from "next/server";
import { createResponse, getResponses } from "./lib/response";
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
@@ -126,7 +126,7 @@ export const POST = async (request: Request) =>
});
}
const createResponseResult = await createResponse(environmentId, body);
const createResponseResult = await createResponseWithQuotaEvaluation(environmentId, body);
if (!createResponseResult.ok) {
return handleApiError(request, createResponseResult.error, auditLog);
}

View File

@@ -286,10 +286,12 @@ export const withAuditLogging = <TParsedInput = Record<string, unknown>, TResult
case "response":
targetId = auditLoggingCtx.responseId;
break;
case "integration":
targetId = auditLoggingCtx.integrationId;
break;
case "quota":
targetId = auditLoggingCtx.quotaId;
break;
default:
targetId = UNKNOWN_DATA;
break;

View File

@@ -22,9 +22,9 @@ export const ZAuditTarget = z.enum([
"membership",
"twoFactorAuth",
"apiKey",
"integration",
"file",
"quota",
]);
export const ZAuditAction = z.enum([
"created",

View File

@@ -14,9 +14,15 @@ interface DeleteContactButtonProps {
environmentId: string;
contactId: string;
isReadOnly: boolean;
isQuotasAllowed: boolean;
}
export const DeleteContactButton = ({ environmentId, contactId, isReadOnly }: DeleteContactButtonProps) => {
export const DeleteContactButton = ({
environmentId,
contactId,
isReadOnly,
isQuotasAllowed,
}: DeleteContactButtonProps) => {
const router = useRouter();
const { t } = useTranslate();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -63,7 +69,13 @@ export const DeleteContactButton = ({ environmentId, contactId, isReadOnly }: De
deleteWhat="person"
onDelete={handleDeletePerson}
isDeleting={isDeletingPerson}
text={t("environments.contacts.delete_contact_confirmation")}
text={
isQuotasAllowed
? t("environments.contacts.delete_contact_confirmation_with_quotas", {
value: 1,
})
: t("environments.contacts.delete_contact_confirmation")
}
/>
</>
);

View File

@@ -9,7 +9,7 @@ import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
@@ -17,7 +17,7 @@ import { TUser, TUserLocale } from "@formbricks/types/user";
interface ResponseTimelineProps {
surveys: TSurvey[];
user: TUser;
responses: TResponse[];
responses: TResponseWithQuotas[];
environment: TEnvironment;
environmentTags: TTag[];
locale: TUserLocale;
@@ -43,7 +43,7 @@ export const ResponseFeed = ({
setFetchedResponses((prev) => prev.filter((r) => !responseIds.includes(r.id)));
};
const updateResponse = (responseId: string, updatedResponse: TResponse) => {
const updateResponse = (responseId: string, updatedResponse: TResponseWithQuotas) => {
setFetchedResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
};
@@ -82,13 +82,13 @@ const ResponseSurveyCard = ({
locale,
projectPermission,
}: {
response: TResponse;
response: TResponseWithQuotas;
surveys: TSurvey[];
user: TUser;
environmentTags: TTag[];
environment: TEnvironment;
updateResponseList: (responseIds: string[]) => void;
updateResponse: (responseId: string, response: TResponse) => void;
updateResponse: (responseId: string, response: TResponseWithQuotas) => void;
locale: TUserLocale;
projectPermission: TTeamPermission | null;
}) => {

View File

@@ -5,7 +5,7 @@ import { useTranslate } from "@tolgee/react";
import { ArrowDownUpIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
@@ -14,7 +14,7 @@ import { ResponseFeed } from "./response-feed";
interface ResponseTimelineProps {
surveys: TSurvey[];
user: TUser;
responses: TResponse[];
responses: TResponseWithQuotas[];
environment: TEnvironment;
environmentTags: TTag[];
locale: TUserLocale;

View File

@@ -4,6 +4,7 @@ import { DeleteContactButton } from "@/modules/ee/contacts/[contactId]/component
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { getContactIdentifier } from "@/modules/ee/contacts/lib/utils";
import { getIsQuotasEnabled } 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";
@@ -16,7 +17,7 @@ export const SingleContactPage = async (props: {
const params = await props.params;
const t = await getTranslate();
const { environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const { environment, isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
const [environmentTags, contact, contactAttributes] = await Promise.all([
getTagsByEnvironmentId(params.environmentId),
@@ -28,12 +29,15 @@ export const SingleContactPage = async (props: {
throw new Error(t("environments.contacts.contact_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organization.billing.plan);
const getDeletePersonButton = () => {
return (
<DeleteContactButton
environmentId={environment.id}
contactId={params.contactId}
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
/>
);
};

View File

@@ -22,6 +22,7 @@ interface ContactDataViewProps {
itemsPerPage: number;
isReadOnly: boolean;
hasMore: boolean;
isQuotasAllowed: boolean;
}
export const ContactDataView = ({
@@ -31,6 +32,7 @@ export const ContactDataView = ({
isReadOnly,
hasMore: initialHasMore,
initialContacts,
isQuotasAllowed,
}: ContactDataViewProps) => {
const [contacts, setContacts] = useState<TContactWithAttributes[]>([...initialContacts]);
const [hasMore, setHasMore] = useState<boolean>(initialHasMore);
@@ -144,6 +146,7 @@ export const ContactDataView = ({
searchValue={searchValue}
setSearchValue={setSearchValue}
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
/>
);
};

View File

@@ -42,6 +42,7 @@ interface ContactsTableProps {
searchValue: string;
setSearchValue: (value: string) => void;
isReadOnly: boolean;
isQuotasAllowed: boolean;
}
export const ContactsTable = ({
@@ -54,6 +55,7 @@ export const ContactsTable = ({
searchValue,
setSearchValue,
isReadOnly,
isQuotasAllowed,
}: ContactsTableProps) => {
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [columnOrder, setColumnOrder] = useState<string[]>([]);
@@ -237,6 +239,7 @@ export const ContactsTable = ({
updateRowList={updateContactList}
type="contact"
deleteAction={deleteContact}
isQuotasAllowed={isQuotasAllowed}
/>
<div className="w-full overflow-x-auto rounded-xl border border-slate-200">
<Table className="w-full" style={{ tableLayout: "fixed" }}>

View File

@@ -2,7 +2,7 @@ import { IS_FORMBRICKS_CLOUD, ITEMS_PER_PAGE } from "@/lib/constants";
import { UploadContactsCSVButton } from "@/modules/ee/contacts/components/upload-contacts-button";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getContacts } from "@/modules/ee/contacts/lib/contacts";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getIsContactsEnabled, getIsQuotasEnabled } 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";
@@ -18,12 +18,14 @@ export const ContactsPage = async ({
}) => {
const params = await paramsProps;
const { environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const { environment, isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
const t = await getTranslate();
const isContactsEnabled = await getIsContactsEnabled();
const isQuotasAllowed = await getIsQuotasEnabled(organization.billing.plan);
const contactAttributeKeys = await getContactAttributeKeys(params.environmentId);
const initialContacts = await getContacts(params.environmentId, 0);
@@ -48,6 +50,7 @@ export const ContactsPage = async ({
isReadOnly={isReadOnly}
initialContacts={initialContacts}
hasMore={initialContacts.length >= ITEMS_PER_PAGE}
isQuotasAllowed={isQuotasAllowed}
/>
) : (
<div className="flex items-center justify-center">

View File

@@ -104,6 +104,7 @@ describe("License Core Logic", () => {
auditLogs: true,
multiLanguageSurveys: true,
accessControl: true,
quotas: true,
};
const mockFetchedLicenseDetails: TEnterpriseLicenseDetails = {
status: "active",
@@ -234,6 +235,7 @@ describe("License Core Logic", () => {
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
quotas: false,
},
lastChecked: expect.any(Date),
},
@@ -255,6 +257,7 @@ describe("License Core Logic", () => {
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
quotas: false,
},
lastChecked: expect.any(Date),
isPendingDowngrade: false,
@@ -284,6 +287,7 @@ describe("License Core Logic", () => {
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
quotas: false,
};
expect(mockCache.set).toHaveBeenCalledWith(
expect.stringContaining("fb:license:"),

View File

@@ -53,6 +53,7 @@ const LicenseFeaturesSchema = z.object({
auditLogs: z.boolean(),
multiLanguageSurveys: z.boolean(),
accessControl: z.boolean(),
quotas: z.boolean(),
});
const LicenseDetailsSchema = z.object({
@@ -115,6 +116,7 @@ const DEFAULT_FEATURES: TEnterpriseLicenseFeatures = {
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
quotas: false,
};
// Helper functions

View File

@@ -55,6 +55,7 @@ const getSpecificFeatureFlag = async (
| "auditLogs"
| "multiLanguageSurveys"
| "accessControl"
| "quotas"
>
): Promise<boolean> => {
const licenseFeatures = await getLicenseFeatures();
@@ -78,6 +79,15 @@ export const getIsSsoEnabled = async (): Promise<boolean> => {
return getSpecificFeatureFlag("sso");
};
export const getIsQuotasEnabled = async (billingPlan: Organization["billing"]["plan"]): Promise<boolean> => {
const isEnabled = await getSpecificFeatureFlag("quotas");
// If the feature is enabled in the license, return true
if (isEnabled) return true;
// If the feature is not enabled in the license, check the fallback(Backwards compatibility)
return featureFlagFallback(billingPlan);
};
export const getIsAuditLogsEnabled = async (): Promise<boolean> => {
if (!AUDIT_LOG_ENABLED) return false;
return getSpecificFeatureFlag("auditLogs");

View File

@@ -18,6 +18,7 @@ const ZEnterpriseLicenseFeatures = z.object({
auditLogs: z.boolean(),
multiLanguageSurveys: z.boolean(),
accessControl: z.boolean(),
quotas: z.boolean(),
});
export type TEnterpriseLicenseFeatures = z.infer<typeof ZEnterpriseLicenseFeatures>;

View File

@@ -0,0 +1,199 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import {
getOrganizationIdFromQuotaId,
getOrganizationIdFromSurveyId,
getProjectIdFromQuotaId,
getProjectIdFromSurveyId,
} from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotaLinkCountByQuotaId } from "@/modules/ee/quotas/lib/quota-link";
import { createQuota, deleteQuota, updateQuota } from "@/modules/ee/quotas/lib/quotas";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZSurveyQuotaInput } from "@formbricks/types/quota";
const ZDeleteQuotaAction = z.object({
quotaId: ZId,
surveyId: ZId,
});
const checkQuotasEnabled = async (organizationId: string) => {
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error("Organization billing not found");
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
if (!isQuotasAllowed) {
throw new OperationNotAllowedError("Quotas are not enabled");
}
};
export const deleteQuotaAction = authenticatedActionClient.schema(ZDeleteQuotaAction).action(
withAuditLogging(
"deleted",
"quota",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteQuotaAction>;
}) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkQuotasEnabled(organizationId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.quotaId = parsedInput.quotaId;
const result = await deleteQuota(parsedInput.quotaId);
ctx.auditLoggingCtx.oldObject = result;
return result;
}
)
);
const ZUpdateQuotaAction = z.object({
quotaId: ZId,
quota: ZSurveyQuotaInput,
});
export const updateQuotaAction = authenticatedActionClient.schema(ZUpdateQuotaAction).action(
withAuditLogging(
"updated",
"quota",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateQuotaAction>;
}) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.quota.surveyId);
await checkQuotasEnabled(organizationId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.quota.surveyId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await updateQuota(parsedInput.quota, parsedInput.quotaId);
ctx.auditLoggingCtx.quotaId = parsedInput.quotaId;
ctx.auditLoggingCtx.oldObject = parsedInput.quota;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
const ZCreateQuotaAction = z.object({
quota: ZSurveyQuotaInput,
});
export const createQuotaAction = authenticatedActionClient.schema(ZCreateQuotaAction).action(
withAuditLogging(
"created",
"quota",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateQuotaAction>;
}) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.quota.surveyId);
await checkQuotasEnabled(organizationId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.quota.surveyId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createQuota(parsedInput.quota);
ctx.auditLoggingCtx.quotaId = result.id;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
const ZGetQuotaResponseCountAction = z.object({
quotaId: ZId,
});
export const getQuotaResponseCountAction = authenticatedActionClient
.schema(ZGetQuotaResponseCountAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetQuotaResponseCountAction>;
}) => {
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
await checkQuotasEnabled(organizationId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
minPermission: "readWrite",
},
],
});
const count = await getQuotaLinkCountByQuotaId(parsedInput.quotaId);
return { count };
}
);

View File

@@ -0,0 +1,180 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { EndingCardSelector } from "./ending-card-selector";
// Mock Radix UI Select components
vi.mock("@/modules/ui/components/select", () => ({
Select: ({ children, value, onValueChange }: any) => (
<div data-testid="select" data-value={value} onClick={() => onValueChange?.("test-value")}>
{children}
</div>
),
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
SelectGroup: ({ children }: any) => <div data-testid="select-group">{children}</div>,
SelectItem: ({ children, value }: any) => (
<div data-testid="select-item" data-value={value}>
{children}
</div>
),
SelectTrigger: ({ children }: any) => <div data-testid="select-trigger">{children}</div>,
SelectValue: ({ placeholder }: any) => <div data-testid="select-value">{placeholder}</div>,
}));
// Mock localization utils
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (value: any, locale: string) => {
if (typeof value === "object" && value !== null) {
return value[locale] || value.default || "Test Headline";
}
return value || "Test Headline";
},
}));
describe("EndingCardSelector", () => {
const mockOnChange = vi.fn();
const mockSurveyWithEndings: TSurvey = {
id: "survey1",
endings: [
{
id: "ending1",
type: "endScreen",
headline: { default: "Thank you!" },
subheader: { default: "Survey complete" },
},
{
id: "ending2",
type: "endScreen",
headline: { default: "Survey Complete" },
subheader: { default: "Thanks for participating" },
},
{
id: "redirect1",
type: "redirectToUrl",
url: "https://example.com",
},
{
id: "redirect2",
type: "redirectToUrl",
url: "https://test.com",
},
],
} as TSurvey;
const mockSurveyEmpty: TSurvey = {
id: "survey2",
endings: [],
} as unknown as TSurvey;
beforeEach(() => {
mockOnChange.mockClear();
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders select component", () => {
render(<EndingCardSelector endings={mockSurveyWithEndings.endings} value="" onChange={mockOnChange} />);
expect(screen.getByTestId("select")).toBeInTheDocument();
expect(screen.getByTestId("select-trigger")).toBeInTheDocument();
expect(screen.getByTestId("select-value")).toBeInTheDocument();
});
test("shows placeholder when no value selected", () => {
render(<EndingCardSelector endings={mockSurveyWithEndings.endings} value="" onChange={mockOnChange} />);
expect(screen.getByText("environments.surveys.edit.quotas.select_ending_card")).toBeInTheDocument();
});
test("displays ending card options", () => {
render(<EndingCardSelector endings={mockSurveyWithEndings.endings} value="" onChange={mockOnChange} />);
// Should show ending card section
expect(screen.getByText("common.ending_card")).toBeInTheDocument();
// Should show ending card items
const endingItems = screen.getAllByTestId("select-item");
const endingCardItems = endingItems.filter(
(item) => item.getAttribute("data-value") === "ending1" || item.getAttribute("data-value") === "ending2"
);
expect(endingCardItems).toHaveLength(2);
});
test("displays redirect URL options", () => {
render(<EndingCardSelector endings={mockSurveyWithEndings.endings} value="" onChange={mockOnChange} />);
// Should show redirect URL section
expect(screen.getByText("environments.surveys.edit.redirect_to_url")).toBeInTheDocument();
// Should show redirect items with generic labels
expect(screen.getByText("environments.surveys.edit.redirect_to_url")).toBeInTheDocument();
});
test("calls onChange when selection is made", async () => {
const user = userEvent.setup();
render(<EndingCardSelector endings={mockSurveyWithEndings.endings} value="" onChange={mockOnChange} />);
const select = screen.getByTestId("select");
await user.click(select);
expect(mockOnChange).toHaveBeenCalledWith("test-value");
});
test("handles survey with no endings", () => {
render(<EndingCardSelector endings={mockSurveyEmpty.endings} value="" onChange={mockOnChange} />);
expect(screen.getByTestId("select")).toBeInTheDocument();
expect(screen.queryByText("common.ending_card")).not.toBeInTheDocument();
expect(screen.queryByText("environments.surveys.edit.redirect_to_url")).not.toBeInTheDocument();
});
test("filters endings correctly by type", () => {
render(<EndingCardSelector endings={mockSurveyWithEndings.endings} value="" onChange={mockOnChange} />);
// Should only show endScreen endings in ending card section
const endingCardSection = screen.getByText("common.ending_card").closest("[data-testid='select-group']");
expect(endingCardSection).toBeInTheDocument();
// Should only show redirectToUrl endings in redirect section
const redirectSection = screen
.getByText("environments.surveys.edit.redirect_to_url")
.closest("[data-testid='select-group']");
expect(redirectSection).toBeInTheDocument();
});
test("shows correct value when selected", () => {
render(
<EndingCardSelector endings={mockSurveyWithEndings.endings} value="ending1" onChange={mockOnChange} />
);
const select = screen.getByTestId("select");
expect(select).toHaveAttribute("data-value", "ending1");
});
test("handles ending without headline gracefully", () => {
const surveyWithEndingNoHeadline: TSurvey = {
id: "survey4",
endings: [
{
id: "ending3",
type: "endScreen",
subheader: { default: "Just subheader" },
},
],
} as unknown as TSurvey;
render(
<EndingCardSelector endings={surveyWithEndingNoHeadline.endings} value="" onChange={mockOnChange} />
);
// Should still render the component without errors
expect(screen.getByTestId("select")).toBeInTheDocument();
expect(screen.getByText("common.ending_card")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,66 @@
"use client";
import { getLocalizedValue } from "@/lib/i18n/utils";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { useTranslate } from "@tolgee/react";
import { HandshakeIcon, Undo2Icon } from "lucide-react";
import { TSurveyEndings } from "@formbricks/types/surveys/types";
interface EndingCardSelectorProps {
endings: TSurveyEndings;
value: string;
onChange: (value: string) => void;
}
export const EndingCardSelector = ({ endings, value, onChange }: EndingCardSelectorProps) => {
const availableEndings = endings;
const { t } = useTranslate();
const endingCards = availableEndings.filter((ending) => ending.type === "endScreen");
const redirectToUrls = availableEndings.filter((ending) => ending.type === "redirectToUrl");
return (
<div className="space-y-1 text-sm">
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder={t("environments.surveys.edit.quotas.select_ending_card")} />
</SelectTrigger>
<SelectContent>
{endingCards.length > 0 && (
<SelectGroup>
<div className="flex items-center gap-2 p-2 text-sm text-slate-500">
<HandshakeIcon className="h-4 w-4" />
<span>{t("common.ending_card")}</span>
</div>
{/* Custom endings */}
{endingCards.map((ending) => (
<SelectItem key={ending.id} value={ending.id}>
{getLocalizedValue(ending.headline, "default")}
</SelectItem>
))}
</SelectGroup>
)}
{redirectToUrls.length > 0 && (
<SelectGroup>
<div className="flex items-center gap-2 p-2 text-sm text-slate-500">
<Undo2Icon className="h-4 w-4" />
<span>{t("environments.surveys.edit.redirect_to_url")}</span>
</div>
{redirectToUrls.map((ending) => (
<SelectItem key={ending.id} value={ending.id}>
{ending.label}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</div>
);
};

View File

@@ -0,0 +1,244 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurveyQuotaLogic } from "@formbricks/types/quota";
import { TSurvey } from "@formbricks/types/surveys/types";
import { QuotaConditionBuilder } from "./quota-condition-builder";
// Mock the ConditionsEditor component
vi.mock("@/modules/ui/components/conditions-editor", () => ({
ConditionsEditor: ({ conditions, config, callbacks }: any) => (
<div data-testid="conditions-editor">
<div data-testid="conditions-data">{JSON.stringify(conditions)}</div>
<div data-testid="config-data">{JSON.stringify(config)}</div>
<button
data-testid="trigger-change"
onClick={() => callbacks.onUpdateCondition?.("test-id", { operator: "equals" })}>
Trigger Change
</button>
</div>
),
}));
// Mock the shared conditions factory
vi.mock("@/modules/survey/editor/lib/shared-conditions-factory", () => ({
quotaConditionsToGeneric: vi.fn((conditions) => ({
id: "root",
connector: conditions.connector,
conditions: conditions.conditions,
})),
genericConditionsToQuota: vi.fn((genericConditions) => ({
connector: genericConditions.connector,
conditions: genericConditions.conditions,
})),
createSharedConditionsFactory: vi.fn(() => ({
config: {
getLeftOperandOptions: vi.fn(),
getOperatorOptions: vi.fn(),
getValueProps: vi.fn(),
getDefaultOperator: vi.fn(() => "equals"),
formatLeftOperandValue: vi.fn(),
},
callbacks: {
onAddConditionBelow: vi.fn(),
onRemoveCondition: vi.fn(),
onDuplicateCondition: vi.fn(),
onUpdateCondition: vi.fn(),
onToggleGroupConnector: vi.fn(),
},
})),
}));
// Mock @tolgee/react
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock @paralleldrive/cuid2
vi.mock("@paralleldrive/cuid2", () => ({
createId: () => "test-id-123",
}));
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
describe("QuotaConditionBuilder", () => {
const mockOnChange = vi.fn();
const mockSurvey: TSurvey = {
id: "survey1",
questions: [
{
id: "q1",
type: "openText",
headline: { default: "What is your name?" },
required: false,
inputType: "text",
},
{
id: "q2",
type: "multipleChoiceSingle",
headline: { default: "Choose an option" },
required: false,
choices: [
{ id: "choice1", label: { default: "Option 1" } },
{ id: "choice2", label: { default: "Option 2" } },
],
},
],
} as unknown as TSurvey;
const mockConditions: TSurveyQuotaLogic = {
connector: "and",
conditions: [
{
id: "condition1",
leftOperand: { type: "question", value: "q1" },
operator: "equals",
rightOperand: { type: "static", value: "test" },
},
],
};
const mockEmptyConditions: TSurveyQuotaLogic = {
connector: "and",
conditions: [],
};
beforeEach(() => {
mockOnChange.mockClear();
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders conditions editor", () => {
render(<QuotaConditionBuilder survey={mockSurvey} conditions={mockConditions} onChange={mockOnChange} />);
expect(screen.getByTestId("conditions-editor")).toBeInTheDocument();
});
test("passes converted conditions to editor", async () => {
render(<QuotaConditionBuilder survey={mockSurvey} conditions={mockConditions} onChange={mockOnChange} />);
const conditionsData = screen.getByTestId("conditions-data");
expect(conditionsData).toBeInTheDocument();
});
test("creates configuration for conditions editor", async () => {
const { createSharedConditionsFactory } = await vi.importMock(
"@/modules/survey/editor/lib/shared-conditions-factory"
);
render(<QuotaConditionBuilder survey={mockSurvey} conditions={mockConditions} onChange={mockOnChange} />);
const configData = screen.getByTestId("config-data");
expect(configData).toBeInTheDocument();
// Verify that createSharedConditionsFactory was called
expect(createSharedConditionsFactory).toHaveBeenCalled();
});
test("does not initialize when conditions already exist", () => {
render(<QuotaConditionBuilder survey={mockSurvey} conditions={mockConditions} onChange={mockOnChange} />);
// Should not call onChange for initialization when conditions exist
expect(mockOnChange).not.toHaveBeenCalled();
});
test("does not initialize when survey has no questions", () => {
const surveyWithoutQuestions = {
...mockSurvey,
questions: [],
};
render(
<QuotaConditionBuilder
survey={surveyWithoutQuestions}
conditions={mockEmptyConditions}
onChange={mockOnChange}
/>
);
// Should not call onChange when no questions available
expect(mockOnChange).not.toHaveBeenCalled();
});
test("creates callbacks for conditions editor", async () => {
const { createSharedConditionsFactory } = await vi.importMock(
"@/modules/survey/editor/lib/shared-conditions-factory"
);
render(<QuotaConditionBuilder survey={mockSurvey} conditions={mockConditions} onChange={mockOnChange} />);
// Verify that createSharedConditionsFactory was called which creates both config and callbacks
expect(createSharedConditionsFactory).toHaveBeenCalled();
});
test("handles conditions with different connectors", async () => {
const { quotaConditionsToGeneric } = await vi.importMock(
"@/modules/survey/editor/lib/shared-conditions-factory"
);
const orConditions: TSurveyQuotaLogic = {
connector: "or",
conditions: [
{
id: "condition1",
leftOperand: { type: "question", value: "q1" },
operator: "contains",
rightOperand: { type: "static", value: "test" },
},
],
};
render(<QuotaConditionBuilder survey={mockSurvey} conditions={orConditions} onChange={mockOnChange} />);
expect(quotaConditionsToGeneric).toHaveBeenCalledWith(orConditions);
});
test("handles multiple criteria", async () => {
const { quotaConditionsToGeneric } = await vi.importMock(
"@/modules/survey/editor/lib/shared-conditions-factory"
);
const multipleConditions: TSurveyQuotaLogic = {
connector: "and",
conditions: [
{
id: "condition1",
leftOperand: { type: "question", value: "q1" },
operator: "equals",
rightOperand: { type: "static", value: "test1" },
},
{
id: "condition2",
leftOperand: { type: "question", value: "q2" },
operator: "contains",
rightOperand: { type: "static", value: "test2" },
},
],
};
render(
<QuotaConditionBuilder survey={mockSurvey} conditions={multipleConditions} onChange={mockOnChange} />
);
expect(screen.getByTestId("conditions-editor")).toBeInTheDocument();
expect(quotaConditionsToGeneric).toHaveBeenCalledWith(multipleConditions);
});
});

View File

@@ -0,0 +1,72 @@
"use client";
import {
TQuotaConditionGroup,
createSharedConditionsFactory,
genericConditionsToQuota,
quotaConditionsToGeneric,
} from "@/modules/survey/editor/lib/shared-conditions-factory";
import { ConditionsEditor } from "@/modules/ui/components/conditions-editor";
import { useTranslate } from "@tolgee/react";
import { useCallback, useMemo } from "react";
import { FieldErrors } from "react-hook-form";
import { TSurveyQuotaInput, TSurveyQuotaLogic } from "@formbricks/types/quota";
import { TSurvey } from "@formbricks/types/surveys/types";
interface QuotaConditionBuilderProps {
survey: TSurvey;
conditions: TSurveyQuotaLogic;
onChange: (conditions: TSurveyQuotaLogic) => void;
quotaErrors?: FieldErrors<TSurveyQuotaInput>;
}
export const QuotaConditionBuilder = ({
survey,
conditions,
onChange,
quotaErrors,
}: QuotaConditionBuilderProps) => {
const { t } = useTranslate();
// Convert quota conditions to generic format
const genericConditions = useMemo(() => quotaConditionsToGeneric(conditions), [conditions]);
// Handle changes from the generic editor
const handleGenericChange = useCallback(
(newGenericConditions: TQuotaConditionGroup) => {
const newQuotaConditions = genericConditionsToQuota(newGenericConditions);
onChange(newQuotaConditions);
},
[onChange]
);
// Create both config and callbacks in a single useMemo using the shared factory
const { config, callbacks } = useMemo(
() =>
createSharedConditionsFactory(
{
survey,
t,
getDefaultOperator: () => "equals",
},
{
onConditionsChange: (updater) => {
const newConditions = updater(genericConditions);
handleGenericChange(newConditions);
},
}
),
[survey, t, genericConditions, handleGenericChange]
);
return (
<div className="space-y-4">
<ConditionsEditor
conditions={genericConditions}
config={config}
callbacks={callbacks}
quotaErrors={quotaErrors}
/>
</div>
);
};

View File

@@ -0,0 +1,205 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurveyQuota, TSurveyQuotaInput } from "@formbricks/types/quota";
import { QuotaList } from "./quota-list";
// Mock the createQuotaAction
vi.mock("@/modules/ee/quotas/actions", () => ({
createQuotaAction: (quota: TSurveyQuotaInput) => {
return {
data: {
...quota,
},
};
},
}));
// Mock Next.js router
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
}),
}));
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
// Mock UI components
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, className, variant, size, ...props }: any) => (
<button onClick={onClick} className={className} data-variant={variant} data-size={size} {...props}>
{children}
</button>
),
}));
vi.mock("@radix-ui/react-dropdown-menu", () => ({
Label: ({ children, className }: any) => <label className={className}>{children}</label>,
}));
describe("QuotaList", () => {
const mockOnEdit = vi.fn();
const mockDeleteQuota = vi.fn();
const mockDuplicateQuota = vi.fn();
const mockQuotas: TSurveyQuota[] = [
{
id: "quota1",
surveyId: "survey1",
name: "Test Quota 1",
limit: 100,
logic: {
connector: "and",
conditions: [],
},
action: "endSurvey",
endingCardId: null,
countPartialSubmissions: false,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "quota2",
surveyId: "survey1",
name: "Test Quota 2",
limit: 50,
logic: {
connector: "or",
conditions: [],
},
action: "continueSurvey",
endingCardId: "ending1",
countPartialSubmissions: true,
createdAt: new Date(),
updatedAt: new Date(),
},
];
beforeEach(() => {
mockOnEdit.mockClear();
mockDeleteQuota.mockClear();
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders list of quotas", () => {
render(
<QuotaList
quotas={mockQuotas}
onEdit={mockOnEdit}
deleteQuota={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
/>
);
expect(screen.getByText("Test Quota 1")).toBeInTheDocument();
expect(screen.getByText("Test Quota 2")).toBeInTheDocument();
});
test("calls onEdit when quota item is clicked", async () => {
const user = userEvent.setup();
render(
<QuotaList
quotas={mockQuotas}
onEdit={mockOnEdit}
deleteQuota={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
/>
);
const quotaItem = screen.getByText("Test Quota 1").closest("div");
expect(quotaItem).toBeInTheDocument();
await user.click(quotaItem!);
expect(mockOnEdit).toHaveBeenCalledWith(mockQuotas[0]);
});
test("renders empty list when no quotas", () => {
render(
<QuotaList
quotas={[]}
onEdit={mockOnEdit}
deleteQuota={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
/>
);
expect(screen.queryByText("Test Quota 1")).not.toBeInTheDocument();
expect(screen.queryByText("Test Quota 2")).not.toBeInTheDocument();
});
test("renders quota items with correct styling classes", () => {
render(
<QuotaList
quotas={mockQuotas}
onEdit={mockOnEdit}
deleteQuota={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
/>
);
const quotaItems = screen
.getAllByRole("button")
.filter((button) => button.className?.includes("cursor-pointer"));
quotaItems.forEach((item) => {
expect(item).toHaveClass("cursor-pointer");
expect(item).toHaveClass("rounded-lg");
expect(item).toHaveClass("bg-slate-50");
});
});
test("renders action buttons with correct variants", () => {
render(
<QuotaList
quotas={mockQuotas}
onEdit={mockOnEdit}
deleteQuota={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
/>
);
const actionButtons = screen
.getAllByRole("button")
.filter((button) => button.getAttribute("data-variant") === "ghost");
expect(actionButtons.length).toBeGreaterThan(0);
actionButtons.forEach((button) => {
expect(button).toHaveAttribute("data-variant", "ghost");
expect(button).toHaveAttribute("data-size", "sm");
});
});
test("handles quota with special characters in name", () => {
const quotaWithSpecialChars: TSurveyQuota = {
...mockQuotas[0],
name: "Test Quota with @#$%^&*()_+ characters",
};
render(
<QuotaList
quotas={[quotaWithSpecialChars]}
onEdit={mockOnEdit}
deleteQuota={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
/>
);
expect(screen.getByText("Test Quota with @#$%^&*()_+ characters")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,65 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { useTranslate } from "@tolgee/react";
import { CopyIcon, Trash2Icon } from "lucide-react";
import { TSurveyQuota } from "@formbricks/types/quota";
interface QuotaListProps {
quotas: TSurveyQuota[];
onEdit: (quota: TSurveyQuota) => void;
deleteQuota: (quota: TSurveyQuota) => void;
duplicateQuota: (quota: TSurveyQuota) => void;
}
export const QuotaList = ({ quotas, onEdit, deleteQuota, duplicateQuota }: QuotaListProps) => {
const { t } = useTranslate();
return (
<div className="space-y-3">
{quotas.map((quota) => (
// Using div instead of button to avoid nested button HTML validation errors
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions, jsx-a11y/prefer-tag-over-role
<div
key={quota.id}
className="flex w-full cursor-pointer items-center justify-between rounded-lg bg-slate-50 p-4 transition-colors hover:bg-slate-100"
onClick={() => onEdit(quota)}
role="button"
tabIndex={0}>
<div className="text-left">
<Label className="text-sm font-medium text-slate-800">{quota.name}</Label>
<div className="mt-1 text-sm text-slate-500">
{t("environments.surveys.edit.quotas.limited_to_x_responses", {
limit: quota.limit.toLocaleString(),
})}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
deleteQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<Trash2Icon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
duplicateQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<CopyIcon className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,645 @@
import { createQuotaAction, updateQuotaAction } from "@/modules/ee/quotas/actions";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TSurvey } from "@formbricks/types/surveys/types";
import { QuotaModal } from "./quota-modal";
// Mock @paralleldrive/cuid2
vi.mock("@paralleldrive/cuid2", () => ({
createId: () => "test-id",
}));
// Mock helper functions
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((result: any) => result?.serverError || "Unknown error"),
}));
// Mock zodResolver
vi.mock("@hookform/resolvers/zod", () => ({
zodResolver: vi.fn(() => ({})),
}));
// Mock server actions
vi.mock("@/modules/ee/quotas/actions", () => ({
createQuotaAction: vi.fn(),
updateQuotaAction: vi.fn(),
}));
// Mock Next.js router
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
}),
}));
// Mock UI components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open }: any) => (open ? <div data-testid="dialog">{children}</div> : null),
DialogContent: ({ children }: any) => <div data-testid="dialog-content">{children}</div>,
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
}));
vi.mock("@/modules/ui/components/form", () => ({
FormProvider: ({ children }: any) => <div data-testid="form-provider">{children}</div>,
FormField: ({ render, name }: any) => {
const field = {
value:
name === "conditions"
? { connector: "and", criteria: [] }
: name === "limit"
? 100
: name === "action"
? "endSurvey"
: name === "countPartialSubmissions"
? false
: "",
onChange: vi.fn(),
onBlur: vi.fn(),
};
const fieldState = {
error: undefined,
};
return <div data-testid={`form-field-${name}`}>{render({ field, fieldState })}</div>;
},
FormItem: ({ children }: any) => <div data-testid="form-item">{children}</div>,
FormLabel: ({ children }: any) => <label data-testid="form-label">{children}</label>,
FormControl: ({ children }: any) => <div data-testid="form-control">{children}</div>,
FormDescription: ({ children }: any) => <p data-testid="form-description">{children}</p>,
FormError: ({ children }: any) => (
<span data-testid="form-error" className="text-red-500">
{children}
</span>
),
}));
vi.mock("@/modules/ui/components/input", () => ({
Input: (props: any) => <input data-testid="input" {...props} />,
}));
vi.mock("@/modules/ui/components/select", () => ({
Select: ({ children, value, onValueChange }: any) => (
<div data-testid="select" data-value={value} onClick={() => onValueChange?.("endSurvey")}>
{children}
</div>
),
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
SelectItem: ({ children, value }: any) => (
<div data-testid="select-item" data-value={value}>
{children}
</div>
),
SelectTrigger: ({ children }: any) => <div data-testid="select-trigger">{children}</div>,
SelectValue: ({ placeholder }: any) => <div data-testid="select-value">{placeholder}</div>,
}));
vi.mock("@/modules/ui/components/switch", () => ({
Switch: ({ checked, onCheckedChange }: any) => (
<button data-testid="switch" data-checked={checked} onClick={() => onCheckedChange?.(!checked)}>
{checked ? "ON" : "OFF"}
</button>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, loading, disabled, type, variant }: any) => (
<button
data-testid="button"
onClick={(e) => {
if (!disabled && !loading && onClick) {
onClick(e);
}
}}
disabled={disabled || loading}
type={type}
data-variant={variant}
data-loading={loading}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/confirmation-modal", () => ({
ConfirmationModal: ({ open, onConfirm }: any) =>
open ? (
<div data-testid="confirmation-modal" onClick={onConfirm}>
Confirmation Modal
</div>
) : null,
}));
// Mock child components
vi.mock("./ending-card-selector", () => ({
EndingCardSelector: ({ value, onChange }: any) => (
<div data-testid="ending-card-selector" data-value={value} onClick={() => onChange?.("ending1")}>
Ending Card Selector
</div>
),
}));
vi.mock("./quota-condition-builder", () => ({
QuotaConditionBuilder: ({ onChange }: any) => (
<div data-testid="quota-condition-builder" onClick={() => onChange?.({ connector: "and", criteria: [] })}>
Quota Condition Builder
</div>
),
}));
// Mock react-hook-form
vi.mock("react-hook-form", () => ({
useForm: () => ({
handleSubmit: (fn: any) => (e: any) => {
e.preventDefault();
fn({
name: "Test Quota",
limit: 100,
logic: { connector: "and", conditions: [] },
action: "endSurvey",
endingCardId: null,
countPartialSubmissions: false,
});
},
reset: vi.fn(),
watch: vi.fn(() => "endSurvey"),
setValue: vi.fn(),
getValues: vi.fn((field: string) => {
if (field === "logic") {
return { connector: "and", conditions: [] };
}
return "";
}),
control: {},
formState: {
isSubmitting: false,
isDirty: false, // Default to false
errors: {},
isValid: true,
},
}),
}));
describe("QuotaModal", () => {
const mockOnClose = vi.fn();
const mockOnOpenChange = vi.fn();
const mockDeleteQuota = vi.fn();
const mockDuplicateQuota = vi.fn();
const mockSurvey: TSurvey = {
id: "survey1",
environmentId: "env1",
questions: [
{
id: "q1",
type: "openText",
headline: { default: "What is your name?" },
required: false,
inputType: "text",
},
],
endings: [
{
id: "ending1",
type: "endScreen",
headline: { default: "Thank you!" },
},
],
} as unknown as TSurvey;
const mockQuota: TSurveyQuota = {
id: "quota1",
surveyId: "survey1",
name: "Test Quota",
limit: 100,
logic: {
connector: "and",
conditions: [],
},
action: "endSurvey",
endingCardId: null,
countPartialSubmissions: false,
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(() => {
mockOnClose.mockClear();
mockOnOpenChange.mockClear();
mockDeleteQuota.mockClear();
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders modal when open", () => {
render(
<QuotaModal
open={true}
onOpenChange={mockOnOpenChange}
survey={mockSurvey}
quota={null}
setQuotaToDelete={mockDeleteQuota}
onClose={mockOnClose}
duplicateQuota={mockDuplicateQuota}
hasResponses={false}
/>
);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
});
test("does not render modal when closed", () => {
render(
<QuotaModal
open={false}
onOpenChange={mockOnOpenChange}
survey={mockSurvey}
quota={null}
setQuotaToDelete={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
onClose={mockOnClose}
hasResponses={false}
/>
);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("shows create title when no quota provided", () => {
render(
<QuotaModal
open={true}
onOpenChange={mockOnOpenChange}
survey={mockSurvey}
quota={null}
setQuotaToDelete={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
onClose={mockOnClose}
hasResponses={false}
/>
);
expect(screen.getByTestId("dialog-title")).toHaveTextContent(
"environments.surveys.edit.quotas.new_quota"
);
});
test("shows edit title when quota provided", () => {
render(
<QuotaModal
open={true}
onOpenChange={mockOnOpenChange}
survey={mockSurvey}
quota={mockQuota}
setQuotaToDelete={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
onClose={mockOnClose}
hasResponses={false}
/>
);
expect(screen.getByTestId("dialog-title")).toHaveTextContent(
"environments.surveys.edit.quotas.edit_quota"
);
});
test("renders all form fields", () => {
render(
<QuotaModal
open={true}
onOpenChange={mockOnOpenChange}
survey={mockSurvey}
quota={null}
setQuotaToDelete={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
onClose={mockOnClose}
hasResponses={false}
/>
);
expect(screen.getByTestId("form-field-name")).toBeInTheDocument();
expect(screen.getByTestId("form-field-limit")).toBeInTheDocument();
expect(screen.getByTestId("form-field-logic")).toBeInTheDocument();
expect(screen.getByTestId("form-field-action")).toBeInTheDocument();
expect(screen.getByTestId("form-field-countPartialSubmissions")).toBeInTheDocument();
});
test("renders quota condition builder", () => {
render(
<QuotaModal
open={true}
onOpenChange={mockOnOpenChange}
survey={mockSurvey}
quota={null}
setQuotaToDelete={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
onClose={mockOnClose}
hasResponses={false}
/>
);
expect(screen.getByTestId("form-field-logic")).toBeInTheDocument();
});
test("shows ending card selector when action is endSurvey", () => {
render(
<QuotaModal
open={true}
onOpenChange={mockOnOpenChange}
survey={mockSurvey}
quota={mockQuota}
setQuotaToDelete={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
onClose={mockOnClose}
hasResponses={false}
/>
);
expect(screen.getByTestId("ending-card-selector")).toBeInTheDocument();
});
test("calls createQuotaAction when creating new quota", async () => {
vi.mocked(createQuotaAction).mockResolvedValue({
data: {
id: "new-quota",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Quota",
logic: { connector: "and", conditions: [] },
action: "endSurvey",
endingCardId: null,
countPartialSubmissions: false,
surveyId: "survey1",
limit: 100,
},
});
const { container } = render(
<QuotaModal
open={true}
onOpenChange={mockOnOpenChange}
survey={mockSurvey}
quota={null}
setQuotaToDelete={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
onClose={mockOnClose}
hasResponses={false}
/>
);
const form = container.querySelector("form");
const submitEvent = new Event("submit", { bubbles: true, cancelable: true });
form!.dispatchEvent(submitEvent);
await waitFor(() => {
expect(vi.mocked(createQuotaAction)).toHaveBeenCalledWith({
quota: expect.objectContaining({
surveyId: "survey1",
name: "Test Quota",
limit: 100,
action: "endSurvey",
logic: { connector: "and", conditions: [] },
countPartialSubmissions: false,
}),
});
});
});
test("calls updateQuotaAction when updating existing quota", async () => {
vi.mocked(updateQuotaAction).mockResolvedValue({
data: {
id: "quota1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Quota",
logic: { connector: "and", conditions: [] },
action: "endSurvey",
endingCardId: null,
countPartialSubmissions: false,
surveyId: "survey1",
limit: 100,
},
});
const { container } = render(
<QuotaModal
open={true}
onOpenChange={mockOnOpenChange}
survey={mockSurvey}
quota={mockQuota}
setQuotaToDelete={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
onClose={mockOnClose}
hasResponses={false}
/>
);
const form = container.querySelector("form");
const submitEvent = new Event("submit", { bubbles: true, cancelable: true });
form!.dispatchEvent(submitEvent);
await waitFor(() => {
expect(vi.mocked(updateQuotaAction)).toHaveBeenCalledWith({
quota: expect.objectContaining({
name: "Test Quota",
limit: 100,
}),
quotaId: "quota1",
});
});
});
test("shows success toast on successful create", async () => {
vi.mocked(createQuotaAction).mockResolvedValue({
data: {
id: "new-quota",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Quota",
logic: { connector: "and", conditions: [] },
action: "endSurvey",
endingCardId: null,
countPartialSubmissions: false,
surveyId: "survey1",
limit: 100,
},
});
const { container } = render(
<QuotaModal
open={true}
onOpenChange={mockOnOpenChange}
survey={mockSurvey}
quota={null}
setQuotaToDelete={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
onClose={mockOnClose}
hasResponses={false}
/>
);
const form = container.querySelector("form");
const submitEvent = new Event("submit", { bubbles: true, cancelable: true });
form!.dispatchEvent(submitEvent);
await waitFor(() => {
expect(vi.mocked(toast.success)).toHaveBeenCalledWith(
"environments.surveys.edit.quotas.quota_created_successfull_toast"
);
});
});
test("shows error toast on failed create", async () => {
vi.mocked(createQuotaAction).mockResolvedValue({
serverError: "Failed",
});
const { container } = render(
<QuotaModal
open={true}
onOpenChange={mockOnOpenChange}
survey={mockSurvey}
quota={null}
setQuotaToDelete={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
onClose={mockOnClose}
hasResponses={false}
/>
);
const form = container.querySelector("form");
const submitEvent = new Event("submit", { bubbles: true, cancelable: true });
form!.dispatchEvent(submitEvent);
await waitFor(() => {
expect(vi.mocked(toast.error)).toHaveBeenCalledWith("Failed");
});
});
test("shows delete button when editing quota", () => {
render(
<QuotaModal
open={true}
onOpenChange={mockOnOpenChange}
survey={mockSurvey}
quota={mockQuota}
setQuotaToDelete={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
onClose={mockOnClose}
hasResponses={false}
/>
);
const deleteButton = screen
.getAllByTestId("button")
.find((button) => button.getAttribute("data-variant") === "destructive");
expect(deleteButton).toBeInTheDocument();
expect(deleteButton).toHaveTextContent("common.delete");
});
test("shows cancel button when creating new quota", () => {
render(
<QuotaModal
open={true}
onOpenChange={mockOnOpenChange}
survey={mockSurvey}
quota={null}
setQuotaToDelete={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
onClose={mockOnClose}
hasResponses={false}
/>
);
const cancelButton = screen
.getAllByTestId("button")
.find((button) => button.getAttribute("data-variant") === "outline");
expect(cancelButton).toBeInTheDocument();
expect(cancelButton).toHaveTextContent("common.cancel");
});
test("calls deleteQuota when delete button is clicked", async () => {
const user = userEvent.setup();
render(
<QuotaModal
open={true}
onOpenChange={mockOnOpenChange}
survey={mockSurvey}
quota={mockQuota}
setQuotaToDelete={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
onClose={mockOnClose}
hasResponses={false}
/>
);
const deleteButton = screen
.getAllByTestId("button")
.find((button) => button.getAttribute("data-variant") === "destructive");
await user.click(deleteButton!);
expect(mockDeleteQuota).toHaveBeenCalledWith(mockQuota);
});
test("calls onClose when cancel button is clicked", async () => {
const user = userEvent.setup();
render(
<QuotaModal
open={true}
onOpenChange={mockOnOpenChange}
survey={mockSurvey}
quota={null}
setQuotaToDelete={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
onClose={mockOnClose}
hasResponses={false}
/>
);
const cancelButton = screen
.getAllByTestId("button")
.find((button) => button.getAttribute("data-variant") === "outline");
await user.click(cancelButton!);
expect(mockOnClose).toHaveBeenCalled();
});
test("handles condition changes", async () => {
const user = userEvent.setup();
render(
<QuotaModal
open={true}
onOpenChange={mockOnOpenChange}
survey={mockSurvey}
quota={null}
setQuotaToDelete={mockDeleteQuota}
duplicateQuota={mockDuplicateQuota}
onClose={mockOnClose}
hasResponses={false}
/>
);
const conditionBuilder = screen.getByTestId("form-field-logic");
await user.click(conditionBuilder);
// The click should trigger the onChange callback in the mocked component
expect(conditionBuilder).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,484 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createQuotaAction, updateQuotaAction } from "@/modules/ee/quotas/actions";
import { EndingCardSelector } from "@/modules/ee/quotas/components/ending-card-selector";
import { getDefaultOperatorForQuestion } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import {
FormControl,
FormDescription,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { Switch } from "@/modules/ui/components/switch";
import { zodResolver } from "@hookform/resolvers/zod";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import { PieChart, Trash2Icon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import {
TSurveyQuota,
TSurveyQuotaInput,
TSurveyQuotaLogic,
ZSurveyQuotaAction,
ZSurveyQuotaInput,
} from "@formbricks/types/quota";
import { TSurvey } from "@formbricks/types/surveys/types";
import { QuotaConditionBuilder } from "./quota-condition-builder";
interface QuotaModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
survey: TSurvey;
setQuotaToDelete: (quota: TSurveyQuota) => void;
quota?: TSurveyQuota | null;
onClose: () => void;
duplicateQuota: (quota: TSurveyQuota) => void;
hasResponses: boolean;
quotaResponseCount: number;
}
export const QuotaModal = ({
open,
onOpenChange,
survey,
quota,
setQuotaToDelete,
onClose,
duplicateQuota,
hasResponses,
quotaResponseCount,
}: QuotaModalProps) => {
const router = useRouter();
const isEditing = !!quota;
const { t } = useTranslate();
const [openConfirmationModal, setOpenConfirmationModal] = useState(false);
const [openConfirmChangesInInclusionCriteria, setOpenConfirmChangesInInclusionCriteria] = useState(false);
const defaultValues = useMemo(() => {
return {
name: quota?.name || "",
limit: quota?.limit || 1,
logic: quota?.logic || {
connector: "and",
conditions: [
{
id: createId(),
leftOperand: { type: "question", value: survey.questions[0]?.id },
operator: getDefaultOperatorForQuestion(survey.questions[0], t),
},
],
},
action: quota?.action || "endSurvey",
endingCardId: quota?.endingCardId || survey.endings[0]?.id || null,
countPartialSubmissions: quota?.countPartialSubmissions || false,
surveyId: survey.id,
};
}, [quota, survey]);
const form = useForm<TSurveyQuotaInput>({
defaultValues,
resolver: zodResolver(
quotaResponseCount > 0
? ZSurveyQuotaInput.innerType().extend({
limit: z.number().min(quotaResponseCount, {
message: t(
"environments.surveys.edit.quotas.limit_must_be_greater_than_or_equal_to_the_number_of_responses",
{ value: quotaResponseCount }
),
}),
})
: ZSurveyQuotaInput
),
mode: "onSubmit",
criteriaMode: "all",
});
const {
handleSubmit,
reset,
watch,
control,
formState: { isSubmitting, isDirty, errors, isValid },
} = form;
// Watch form values for conditional logic
const action = watch("action");
useEffect(() => {
if (open) {
form.reset(defaultValues);
}
}, [open, defaultValues, form]);
const handleCreateQuota = useCallback(
async (quota: TSurveyQuotaInput) => {
const createQuotaActionResult = await createQuotaAction({
quota: quota,
});
if (createQuotaActionResult?.data) {
toast.success(t("environments.surveys.edit.quotas.quota_created_successfull_toast"));
router.refresh();
onClose();
} else {
const errorMessage = getFormattedErrorMessage(createQuotaActionResult);
toast.error(errorMessage);
}
},
[t, router, onClose]
);
const handleUpdateQuota = useCallback(
async (updatedQuota: TSurveyQuotaInput, quotaId: string) => {
const updateQuotaActionResult = await updateQuotaAction({
quotaId,
quota: updatedQuota,
});
if (updateQuotaActionResult?.data) {
toast.success(t("environments.surveys.edit.quotas.quota_updated_successfull_toast"));
router.refresh();
onClose();
} else {
const errorMessage = getFormattedErrorMessage(updateQuotaActionResult);
toast.error(errorMessage);
}
setOpenConfirmChangesInInclusionCriteria(false);
},
[t, router, onClose]
);
const submitQuota = async (data: TSurveyQuotaInput) => {
const trimmedName = data.name.trim();
if (data.limit < quotaResponseCount) {
form.setError("limit", {
message: t(
"environments.surveys.edit.quotas.limit_must_be_greater_than_or_equal_to_the_number_of_responses"
),
});
return;
}
let payload = {
name: trimmedName || t("environments.surveys.edit.quotas.new_quota"),
limit: data.limit,
logic: data.logic,
action: data.action,
endingCardId: data.endingCardId || null,
countPartialSubmissions: data.countPartialSubmissions,
surveyId: survey.id,
};
if (isEditing) {
await handleUpdateQuota(payload, quota.id);
} else {
await handleCreateQuota(payload);
}
};
// Form submission handler with confirmation logic
const onSubmit = async (data: TSurveyQuotaInput) => {
if (isEditing) {
const checkIfInclusionCriteriaHasChanged =
hasResponses && JSON.stringify(form.getValues("logic")) !== JSON.stringify(quota.logic);
if (checkIfInclusionCriteriaHasChanged && isValid) {
setOpenConfirmChangesInInclusionCriteria(true);
return;
}
}
await submitQuota(data);
};
const handleConditionsChange = useCallback(
(newConditions: TSurveyQuotaLogic) => {
form.setValue("logic", newConditions, { shouldDirty: true, shouldValidate: true });
},
[form]
);
const quotaActions = [
{
label: t("environments.surveys.edit.quotas.end_survey_for_matching_participants"),
value: ZSurveyQuotaAction.enum.endSurvey,
},
{
label: t("environments.surveys.edit.quotas.continue_survey_normally"),
value: ZSurveyQuotaAction.enum.continueSurvey,
},
];
const handleClose = () => {
if (isDirty) {
setOpenConfirmationModal(true);
} else {
onClose();
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<FormProvider {...form}>
<DialogHeader>
<div className="flex items-center gap-2">
<PieChart className="h-4 w-4" />
<div className="flex flex-col">
<DialogTitle>
{isEditing
? t("environments.surveys.edit.quotas.edit_quota")
: t("environments.surveys.edit.quotas.new_quota")}
</DialogTitle>
<DialogDescription>{t("common.quotas_description")}</DialogDescription>
</div>
</div>
</DialogHeader>
<DialogBody className="space-y-6 px-1">
{/* Quota Name Field */}
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.label")}</FormLabel>
<FormControl>
<Input
{...field}
placeholder={
isEditing
? t("environments.surveys.edit.quotas.quota_name_placeholder")
: t("environments.surveys.edit.quotas.new_quota")
}
className="bg-white"
autoFocus={!isEditing}
/>
</FormControl>
{errors.name?.message && <FormError>{errors.name.message}</FormError>}
</FormItem>
)}
/>
{/* Quota Limit Field */}
<FormField
control={control}
name="limit"
render={({ field }) => (
<FormItem>
<FormLabel>{t("environments.surveys.edit.quotas.response_limit")}</FormLabel>
<FormControl>
<Input
{...field}
type="number"
className="w-32 bg-white"
onChange={(e) => {
const value = e.target.value;
field.onChange(value === "" ? 1 : parseInt(value, 10));
}}
/>
</FormControl>
{errors.limit?.message && <FormError>{errors.limit.message}</FormError>}
</FormItem>
)}
/>
{/* Inclusion Criteria Field */}
<FormField
control={control}
name="logic"
render={({ field }) => (
<FormItem>
<div className="space-y-4 rounded-lg bg-slate-50 p-3">
<FormLabel>{t("environments.surveys.edit.quotas.inclusion_criteria")}</FormLabel>
<FormControl>
{field.value && (
<QuotaConditionBuilder
survey={survey}
conditions={field.value}
onChange={handleConditionsChange}
quotaErrors={errors}
/>
)}
</FormControl>
</div>
</FormItem>
)}
/>
{/* Quota Action Fields */}
<FormField
control={control}
name="action"
render={({ field }) => (
<FormItem className="space-y-2">
<FormLabel>{t("environments.surveys.edit.quotas.when_quota_has_been_reached")}</FormLabel>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div className="space-y-2">
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-white">
<SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent>
{quotaActions.map((action) => (
<SelectItem key={action.value} value={action.value}>
{action.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</div>
{action === "endSurvey" && (
<FormField
control={control}
name="endingCardId"
render={({ field: endingCardField }) => (
<div className="space-y-2">
<FormControl>
<EndingCardSelector
endings={survey.endings}
value={endingCardField.value || ""}
onChange={(value) => {
form.setValue("endingCardId", value, {
shouldDirty: true,
shouldValidate: true,
});
form.setValue("action", "endSurvey", {
shouldDirty: true,
shouldValidate: true,
});
}}
/>
</FormControl>
</div>
)}
/>
)}
</div>
{errors.action?.message && <FormError>{errors.action.message}</FormError>}
</FormItem>
)}
/>
{/* Count Partial Submissions Field */}
<FormField
control={control}
name="countPartialSubmissions"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="cursor-pointer">
{t("environments.surveys.edit.quotas.count_partial_submissions")}
</FormLabel>
<FormDescription>
{t("environments.surveys.edit.quotas.count_partial_submissions_description")}
</FormDescription>
</div>
</FormItem>
)}
/>
</DialogBody>
{/* Footer */}
<DialogFooter>
<div className="flex w-full justify-between gap-2">
<Button
type="button"
variant="destructive"
onClick={() => {
if (quota) {
setQuotaToDelete(quota);
}
}}
className="flex items-center gap-2"
disabled={isSubmitting || !isEditing}>
<Trash2Icon className="h-4 w-4" />
{t("common.delete")}
</Button>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={handleClose} disabled={isSubmitting}>
{t("common.cancel")}
</Button>
<Button type="submit" loading={isSubmitting} disabled={isSubmitting || !isDirty}>
{t("common.save")}
</Button>
</div>
</div>
</DialogFooter>
<ConfirmationModal
title={t("environments.surveys.edit.quotas.confirm_quota_changes")}
open={openConfirmationModal}
buttonVariant="default"
buttonLoading={isSubmitting}
setOpen={setOpenConfirmationModal}
onConfirm={() => {
setOpenConfirmationModal(false);
form.handleSubmit(submitQuota)();
}}
body={t("environments.surveys.edit.quotas.confirm_quota_changes_body")}
buttonText={t("common.save")}
cancelButtonText={t("common.discard")}
onCancel={() => {
reset();
onOpenChange(false);
}}
/>
<ConfirmationModal
open={openConfirmChangesInInclusionCriteria}
setOpen={setOpenConfirmChangesInInclusionCriteria}
title={t("environments.surveys.edit.quotas.change_quota_for_public_survey")}
description={t("environments.surveys.edit.quotas.save_changes_confirmation_text")}
body={t("environments.surveys.edit.quotas.save_changes_confirmation_body")}
buttonText={t("common.continue")}
buttonVariant="default"
onConfirm={form.handleSubmit(submitQuota)}
secondaryButton={{
text: t("environments.surveys.edit.quotas.duplicate_quota"),
variant: "secondary",
onAction: () => {
if (quota) {
const updatedQuota = {
...quota,
...form.getValues(),
};
duplicateQuota(updatedQuota);
onOpenChange(false);
setOpenConfirmChangesInInclusionCriteria(false);
}
},
}}
/>
</FormProvider>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,486 @@
import { deleteQuotaAction, getQuotaResponseCountAction } from "@/modules/ee/quotas/actions";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TSurvey } from "@formbricks/types/surveys/types";
import { QuotasCard } from "./quotas-card";
// Mock server actions
vi.mock("@/modules/ee/quotas/actions", () => ({
deleteQuotaAction: vi.fn(),
getQuotaResponseCountAction: vi.fn(),
}));
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
// Mock @tolgee/react
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string, params?: any) => {
if (params) {
let result = key;
Object.keys(params).forEach((param) => {
result = result.replace(`{{${param}}}`, params[param]);
});
return result;
}
return key;
},
}),
}));
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
push: vi.fn(),
}),
}));
// Mock @formkit/auto-animate/react
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [null, () => {}],
}));
// Mock Radix UI Collapsible
vi.mock("@radix-ui/react-collapsible", () => ({
Root: ({ children, open, onOpenChange }: any) => (
<div data-testid="collapsible-root" data-open={open} onClick={() => onOpenChange?.(!open)}>
{children}
</div>
),
Trigger: ({ children, asChild }: any) =>
asChild ? children : <button data-testid="collapsible-trigger">{children}</button>,
Content: ({ children }: any) => <div data-testid="collapsible-content">{children}</div>,
}));
// Mock UI components
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, size, disabled, loading }: any) => (
<button
data-testid="button"
onClick={onClick}
data-variant={variant}
data-size={size}
disabled={disabled || loading}
data-loading={loading}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
UpgradePrompt: ({ title, description, buttons }: any) => (
<div data-testid="upgrade-prompt">
<h3>{title}</h3>
<p>{description}</p>
{buttons?.map((button: any, index: number) => (
<a key={index} href={button.href} data-testid="upgrade-link">
{button.text}
</a>
))}
</div>
),
}));
vi.mock("@/modules/ui/components/confirmation-modal", () => ({
ConfirmationModal: ({ open, title, text, onConfirm, buttonText, buttonLoading }: any) =>
open ? (
<div data-testid="confirmation-modal">
<h3>{title}</h3>
<p>{text}</p>
<button
data-testid="confirm-button"
onClick={onConfirm}
disabled={buttonLoading}
data-loading={buttonLoading}>
{buttonText}
</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, onDelete, deleteWhat, text, isDeleting, setOpen, ...props }: any) =>
open ? (
<div data-testid="delete-quota-dialog" {...props}>
<h3>Delete {deleteWhat}</h3>
<p>{text}</p>
<button data-testid="cancel-button" onClick={() => setOpen(false)}>
Cancel
</button>
<button
data-testid="confirm-delete-button"
onClick={onDelete}
disabled={isDeleting}
data-loading={isDeleting}>
Delete
</button>
</div>
) : null,
}));
// Mock child components
vi.mock("./quota-list", () => ({
QuotaList: ({ quotas, onEdit, deleteQuota }: any) => (
<div data-testid="quota-list">
{quotas.map((quota: any) => (
<div key={quota.id} data-testid={`quota-item-${quota.id}`}>
<span>{quota.name}</span>
<button data-testid={`edit-${quota.id}`} onClick={() => onEdit(quota)}>
Edit
</button>
<button data-testid={`delete-${quota.id}`} onClick={() => deleteQuota(quota)}>
Delete
</button>
</div>
))}
</div>
),
}));
vi.mock("./quota-modal", () => ({
QuotaModal: ({ open, quota, onClose, setQuotaToDelete }: any) =>
open ? (
<div data-testid="quota-modal">
<span data-testid="modal-quota-id">{quota?.id || "new"}</span>
<button data-testid="modal-close" onClick={onClose}>
Close
</button>
<button
data-testid="modal-delete"
onClick={() => {
if (quota && setQuotaToDelete) {
setQuotaToDelete(quota);
onClose();
}
}}>
Delete from Modal
</button>
</div>
) : null,
}));
describe("QuotasCard", () => {
const mockSurvey: TSurvey = {
id: "survey1",
environmentId: "env1",
questions: [
{
id: "q1",
type: "openText",
headline: { default: "Test question" },
required: false,
inputType: "text",
},
],
} as unknown as TSurvey;
const mockQuotas: TSurveyQuota[] = [
{
id: "quota1",
surveyId: "survey1",
name: "Test Quota 1",
limit: 100,
logic: { connector: "and", conditions: [] },
action: "endSurvey",
endingCardId: null,
countPartialSubmissions: false,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "quota2",
surveyId: "survey1",
name: "Test Quota 2",
limit: 50,
logic: { connector: "or", conditions: [] },
action: "continueSurvey",
endingCardId: "ending1",
countPartialSubmissions: true,
createdAt: new Date(),
updatedAt: new Date(),
},
];
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders quotas card", () => {
render(
<QuotasCard localSurvey={mockSurvey} isQuotasAllowed={true} quotas={mockQuotas} hasResponses={false} />
);
expect(screen.getByTestId("collapsible-root")).toBeInTheDocument();
expect(screen.getByText("common.quotas")).toBeInTheDocument();
expect(screen.getByText("common.quotas_description")).toBeInTheDocument();
});
test("shows upgrade prompt when quotas not enabled", () => {
render(<QuotasCard localSurvey={mockSurvey} isQuotasAllowed={false} quotas={[]} hasResponses={false} />);
expect(screen.getByTestId("upgrade-prompt")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.quotas.upgrade_prompt_title")).toBeInTheDocument();
});
test("shows quota list when quotas enabled and quotas exist", () => {
render(
<QuotasCard localSurvey={mockSurvey} isQuotasAllowed={true} quotas={mockQuotas} hasResponses={false} />
);
expect(screen.getByTestId("quota-list")).toBeInTheDocument();
expect(screen.getByTestId("quota-item-quota1")).toBeInTheDocument();
expect(screen.getByTestId("quota-item-quota2")).toBeInTheDocument();
});
test("shows no quotas message when enabled but no quotas exist", () => {
render(<QuotasCard localSurvey={mockSurvey} isQuotasAllowed={true} quotas={[]} hasResponses={false} />);
expect(screen.getByText("environments.surveys.edit.quotas.add_quota")).toBeInTheDocument();
});
test("opens quota modal when add quota button is clicked (no existing quotas)", async () => {
const user = userEvent.setup();
render(<QuotasCard localSurvey={mockSurvey} isQuotasAllowed={true} quotas={[]} hasResponses={false} />);
const addButton = screen.getByRole("button", { name: /environments.surveys.edit.quotas.add_quota/i });
await user.click(addButton);
expect(screen.getByTestId("quota-modal")).toBeInTheDocument();
expect(screen.getByTestId("modal-quota-id")).toHaveTextContent("new");
});
test("opens quota modal when add quota button is clicked (with existing quotas)", async () => {
const user = userEvent.setup();
render(
<QuotasCard localSurvey={mockSurvey} isQuotasAllowed={true} quotas={mockQuotas} hasResponses={false} />
);
const addButtons = screen.getAllByRole("button", { name: /environments.surveys.edit.quotas.add_quota/i });
const addButton = addButtons[0]; // Should be the add button in the bottom section
await user.click(addButton);
expect(screen.getByTestId("quota-modal")).toBeInTheDocument();
expect(screen.getByTestId("modal-quota-id")).toHaveTextContent("new");
});
test("opens quota modal for editing when edit is clicked", async () => {
const user = userEvent.setup();
render(
<QuotasCard localSurvey={mockSurvey} isQuotasAllowed={true} quotas={mockQuotas} hasResponses={false} />
);
vi.mocked(getQuotaResponseCountAction).mockResolvedValue({ data: { count: 10 } });
const editButton = screen.getByTestId("edit-quota1");
await user.click(editButton);
expect(screen.getByTestId("quota-modal")).toBeInTheDocument();
expect(screen.getByTestId("modal-quota-id")).toHaveTextContent("quota1");
});
test("shows confirmation modal when delete is clicked", async () => {
const user = userEvent.setup();
render(
<QuotasCard localSurvey={mockSurvey} isQuotasAllowed={true} quotas={mockQuotas} hasResponses={false} />
);
const deleteButton = screen.getByTestId("delete-quota1");
await user.click(deleteButton);
expect(screen.getByTestId("delete-quota-dialog")).toBeInTheDocument();
});
test("deletes quota when confirmed", async () => {
const user = userEvent.setup();
vi.mocked(deleteQuotaAction).mockResolvedValue({ data: mockQuotas[0], serverError: undefined });
render(
<QuotasCard localSurvey={mockSurvey} isQuotasAllowed={true} quotas={mockQuotas} hasResponses={false} />
);
// Click delete button
const deleteButton = screen.getByTestId("delete-quota1");
await user.click(deleteButton);
// Confirm deletion
const confirmButton = screen.getByTestId("confirm-delete-button");
await user.click(confirmButton);
await waitFor(() => {
expect(vi.mocked(deleteQuotaAction)).toHaveBeenCalledWith({
quotaId: "quota1",
surveyId: "survey1",
});
});
});
test("shows success toast on successful delete", async () => {
const user = userEvent.setup();
vi.mocked(deleteQuotaAction).mockResolvedValue({ data: mockQuotas[0], serverError: undefined });
render(
<QuotasCard localSurvey={mockSurvey} isQuotasAllowed={true} quotas={mockQuotas} hasResponses={false} />
);
const deleteButton = screen.getByTestId("delete-quota1");
await user.click(deleteButton);
const confirmButton = screen.getByTestId("confirm-delete-button");
await user.click(confirmButton);
await waitFor(() => {
expect(vi.mocked(toast.success)).toHaveBeenCalledWith(
"environments.surveys.edit.quotas.quota_deleted_successfull_toast"
);
});
});
test("shows error toast on failed delete", async () => {
const user = userEvent.setup();
vi.mocked(deleteQuotaAction).mockResolvedValue({ serverError: "Failed" });
render(
<QuotasCard localSurvey={mockSurvey} isQuotasAllowed={true} quotas={mockQuotas} hasResponses={false} />
);
const deleteButton = screen.getByTestId("delete-quota1");
await user.click(deleteButton);
const confirmButton = screen.getByTestId("confirm-delete-button");
await user.click(confirmButton);
await waitFor(() => {
expect(vi.mocked(toast.error)).toHaveBeenCalledWith("Failed");
});
});
test("closes quota modal when onClose is called", async () => {
const user = userEvent.setup();
render(<QuotasCard localSurvey={mockSurvey} isQuotasAllowed={true} quotas={[]} hasResponses={false} />);
// Open modal
const addButton = screen.getByRole("button", { name: /environments.surveys.edit.quotas.add_quota/i });
await user.click(addButton);
expect(screen.getByTestId("quota-modal")).toBeInTheDocument();
// Close modal
const closeButton = screen.getByTestId("modal-close");
await user.click(closeButton);
expect(screen.queryByTestId("quota-modal")).not.toBeInTheDocument();
});
test("shows correct upgrade buttons for Formbricks Cloud", () => {
render(
<QuotasCard
localSurvey={mockSurvey}
isQuotasAllowed={false}
isFormbricksCloud={true}
quotas={[]}
hasResponses={false}
/>
);
const upgradeLinks = screen.getAllByTestId("upgrade-link");
expect(upgradeLinks[0]).toHaveTextContent("common.start_free_trial");
expect(upgradeLinks[0]).toHaveAttribute("href", "/environments/env1/settings/billing");
});
test("shows correct upgrade buttons for self-hosted", () => {
render(
<QuotasCard
localSurvey={mockSurvey}
isQuotasAllowed={false}
isFormbricksCloud={false}
quotas={[]}
hasResponses={false}
/>
);
const upgradeLinks = screen.getAllByTestId("upgrade-link");
expect(upgradeLinks[0]).toHaveTextContent("common.request_trial_license");
expect(upgradeLinks[0]).toHaveAttribute("href", "https://formbricks.com/upgrade-self-hosting-license");
});
test("toggles collapsible state", async () => {
const user = userEvent.setup();
const { container } = render(
<QuotasCard localSurvey={mockSurvey} isQuotasAllowed={true} quotas={mockQuotas} hasResponses={false} />
);
const collapsibleRoot = container.querySelector("[data-testid='collapsible-root']");
expect(collapsibleRoot).toHaveAttribute("data-open", "false");
await user.click(collapsibleRoot!);
expect(collapsibleRoot).toHaveAttribute("data-open", "true");
});
test("handles quota deletion from modal", async () => {
const user = userEvent.setup();
const { container } = render(
<QuotasCard localSurvey={mockSurvey} isQuotasAllowed={true} quotas={mockQuotas} hasResponses={false} />
);
vi.mocked(getQuotaResponseCountAction).mockResolvedValue({ data: { count: 10 } });
// Open edit modal - use container to be more specific
const editButton = container.querySelector("[data-testid='edit-quota1']");
await user.click(editButton!);
// Delete from modal
const modalDeleteButton = screen.getByTestId("modal-delete");
await user.click(modalDeleteButton);
// Should show delete dialog
expect(screen.getByTestId("delete-quota-dialog")).toBeInTheDocument();
});
test("disables delete button when deletion is in progress", async () => {
const user = userEvent.setup();
// Make delete action slow
vi.mocked(deleteQuotaAction).mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve({ data: mockQuotas[0], serverError: undefined }), 1000)
)
);
render(
<QuotasCard localSurvey={mockSurvey} isQuotasAllowed={true} quotas={mockQuotas} hasResponses={false} />
);
const deleteButton = screen.getByTestId("delete-quota1");
await user.click(deleteButton);
const confirmButton = screen.getByTestId("confirm-delete-button");
await user.click(confirmButton);
// Button should be disabled while deletion is in progress
expect(confirmButton).toHaveAttribute("data-loading", "true");
});
});

View File

@@ -0,0 +1,274 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
createQuotaAction,
deleteQuotaAction,
getQuotaResponseCountAction,
} from "@/modules/ee/quotas/actions";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Collapsible from "@radix-ui/react-collapsible";
import { TFnType, useTranslate } from "@tolgee/react";
import { CheckIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { TSurveyQuota, TSurveyQuotaInput } from "@formbricks/types/quota";
import { TSurvey } from "@formbricks/types/surveys/types";
import { QuotaList } from "./quota-list";
import { QuotaModal } from "./quota-modal";
interface QuotasCardProps {
localSurvey: TSurvey;
isQuotasAllowed: boolean;
isFormbricksCloud?: boolean;
quotas: TSurveyQuota[];
hasResponses: boolean;
}
const AddQuotaButton = ({
setIsQuotaModalOpen,
setActiveQuota,
t,
hasResponses,
setOpenCreateQuotaConfirmationModal,
}: {
setIsQuotaModalOpen: (open: boolean) => void;
setActiveQuota: (quota: TSurveyQuota | null) => void;
t: TFnType;
hasResponses: boolean;
setOpenCreateQuotaConfirmationModal: (open: boolean) => void;
}) => {
return (
<Button
variant="secondary"
size="sm"
onClick={() => {
if (hasResponses) {
setOpenCreateQuotaConfirmationModal(true);
} else {
setIsQuotaModalOpen(true);
setActiveQuota(null);
}
}}>
{t("environments.surveys.edit.quotas.add_quota")}
</Button>
);
};
export const QuotasCard = ({
localSurvey,
isQuotasAllowed,
isFormbricksCloud,
quotas,
hasResponses,
}: QuotasCardProps) => {
const { t } = useTranslate();
const [open, setOpen] = useState(false);
const [isQuotaModalOpen, setIsQuotaModalOpen] = useState(false);
const [activeQuota, setActiveQuota] = useState<TSurveyQuota | null>(null);
const environmentId = localSurvey.environmentId;
const [quotaToDelete, setQuotaToDelete] = useState<TSurveyQuota | null>(null);
const [quotaResponseCount, setQuotaResponseCount] = useState(0);
const [isDeletingQuota, setIsDeletingQuota] = useState(false);
const [openCreateQuotaConfirmationModal, setOpenCreateQuotaConfirmationModal] = useState(false);
const router = useRouter();
const [parent] = useAutoAnimate();
const handleQuotaDelete = async (quotaId: string) => {
setIsDeletingQuota(true);
const deleteQuotaActionResult = await deleteQuotaAction({
quotaId: quotaId,
surveyId: localSurvey.id,
});
if (deleteQuotaActionResult?.data) {
toast.success(t("environments.surveys.edit.quotas.quota_deleted_successfull_toast"));
// Clear activeQuota if we're deleting the currently active quota
if (activeQuota?.id === quotaId) {
setActiveQuota(null);
}
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(deleteQuotaActionResult);
toast.error(errorMessage);
}
setQuotaToDelete(null);
setIsDeletingQuota(false);
setActiveQuota(null);
setQuotaResponseCount(0);
setIsQuotaModalOpen(false);
};
const duplicateQuota = async (quota: TSurveyQuota) => {
const { id, createdAt, updatedAt, ...rest } = quota;
const quotaInput: TSurveyQuotaInput = {
...rest,
name: `${quota.name} (Copy)`,
};
const duplicateQuotaActionResult = await createQuotaAction({
quota: quotaInput,
});
if (duplicateQuotaActionResult?.data) {
toast.success(t("environments.surveys.edit.quotas.quota_duplicated_successfull_toast"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(duplicateQuotaActionResult);
toast.error(errorMessage);
}
};
const openEditQuotaModal = async (quota: TSurveyQuota) => {
const quotaResponseCountActionResult = await getQuotaResponseCountAction({
quotaId: quota.id,
});
if (quotaResponseCountActionResult?.data) {
setQuotaResponseCount(quotaResponseCountActionResult.data.count);
} else {
const errorMessage = getFormattedErrorMessage(quotaResponseCountActionResult);
toast.error(errorMessage);
return;
}
setActiveQuota(quota);
setIsQuotaModalOpen(true);
};
const hasQuotas = quotas.length > 0;
return (
<>
<Collapsible.Root
open={open}
onOpenChange={setOpen}
className="w-full rounded-lg border border-slate-300 bg-white">
<Collapsible.Trigger
asChild
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50"
id="quotasCardTrigger">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
</div>
<div>
<p className="font-semibold text-slate-800">{t("common.quotas")}</p>
<p className="mt-1 text-sm text-slate-500">{t("common.quotas_description")}</p>
</div>
</div>
</Collapsible.Trigger>
<Collapsible.Content className="flex flex-col" ref={parent}>
<hr className="py-1 text-slate-600" />
<div className="px-3 pb-3 pt-1">
{!isQuotasAllowed ? (
<UpgradePrompt
title={t("environments.surveys.edit.quotas.upgrade_prompt_title")}
description={t("common.quotas_description")}
buttons={[
{
text: isFormbricksCloud
? t("common.start_free_trial")
: t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
},
]}
/>
) : (
<div className="space-y-4">
{hasQuotas ? (
<QuotaList
quotas={quotas}
onEdit={openEditQuotaModal}
deleteQuota={setQuotaToDelete}
duplicateQuota={duplicateQuota}
/>
) : (
<div className="rounded-lg border p-3 text-center">
<p className="mb-4 text-sm text-slate-500">{t("common.quotas_description")}</p>
<AddQuotaButton
setIsQuotaModalOpen={setIsQuotaModalOpen}
setActiveQuota={setActiveQuota}
t={t}
hasResponses={hasResponses}
setOpenCreateQuotaConfirmationModal={setOpenCreateQuotaConfirmationModal}
/>
</div>
)}
{hasQuotas && (
<div>
<AddQuotaButton
setIsQuotaModalOpen={setIsQuotaModalOpen}
setActiveQuota={setActiveQuota}
t={t}
hasResponses={hasResponses}
setOpenCreateQuotaConfirmationModal={setOpenCreateQuotaConfirmationModal}
/>
</div>
)}
</div>
)}
</div>
</Collapsible.Content>
</Collapsible.Root>
{isQuotasAllowed && (
<QuotaModal
open={isQuotaModalOpen}
onOpenChange={setIsQuotaModalOpen}
survey={localSurvey}
quota={activeQuota}
setQuotaToDelete={setQuotaToDelete}
duplicateQuota={duplicateQuota}
onClose={() => {
setIsQuotaModalOpen(false);
setActiveQuota(null);
setQuotaResponseCount(0);
}}
hasResponses={hasResponses}
quotaResponseCount={quotaResponseCount}
/>
)}
<DeleteDialog
open={!!quotaToDelete}
setOpen={(open) => !open && setQuotaToDelete(null)}
deleteWhat={t("common.quota")}
text={t("environments.surveys.edit.quotas.delete_quota_confirmation_text", {
quotaName: `"${quotaToDelete?.name}"`,
})}
onDelete={() => quotaToDelete && handleQuotaDelete(quotaToDelete.id)}
isDeleting={isDeletingQuota}
/>
<ConfirmationModal
title={t("environments.surveys.edit.quotas.create_quota_for_public_survey")}
description={t("environments.surveys.edit.quotas.create_quota_for_public_survey_description")}
body={t("environments.surveys.edit.quotas.create_quota_for_public_survey_text")}
open={openCreateQuotaConfirmationModal}
setOpen={setOpenCreateQuotaConfirmationModal}
onConfirm={() => {
setOpenCreateQuotaConfirmationModal(false);
setIsQuotaModalOpen(true);
setActiveQuota(null);
setQuotaResponseCount(0);
}}
buttonVariant="default"
buttonText={t("common.continue")}
/>
</>
);
};

View File

@@ -0,0 +1,141 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurveySummary } from "@formbricks/types/surveys/types";
import { QuotasSummary } from "./quotas-summary";
vi.mock("@/modules/ui/components/progress-bar", () => ({
ProgressBar: ({ progress, barColor, height }: { progress: number; barColor: string; height: number }) => (
<div data-testid="progress-bar" data-progress={progress} data-bar-color={barColor} data-height={height} />
),
}));
describe("QuotasSummary", () => {
afterEach(() => {
cleanup();
});
const mockQuotas: TSurveySummary["quotas"] = [
{
id: "quota1",
name: "Demographics Quota",
limit: 100,
count: 75,
percentage: 75,
},
{
id: "quota2",
name: "Age Group Quota",
limit: 50,
count: 25,
percentage: 50,
},
];
test("renders quotas table header correctly", () => {
render(<QuotasSummary quotas={mockQuotas} />);
expect(screen.getByText("common.progress")).toBeInTheDocument();
expect(screen.getByText("common.label")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.limit")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.current_count")).toBeInTheDocument();
});
test("renders quotas data correctly", () => {
render(<QuotasSummary quotas={mockQuotas} />);
expect(screen.getByText("Demographics Quota")).toBeInTheDocument();
expect(screen.getByText("Age Group Quota")).toBeInTheDocument();
expect(screen.getByText("100")).toBeInTheDocument();
expect(screen.getByText("50")).toBeInTheDocument();
expect(screen.getByText("75")).toBeInTheDocument();
expect(screen.getByText("25")).toBeInTheDocument();
expect(screen.getByText("75%")).toBeInTheDocument();
expect(screen.getByText("50%")).toBeInTheDocument();
});
test("renders progress bars with correct props", () => {
render(<QuotasSummary quotas={mockQuotas} />);
const progressBars = screen.getAllByTestId("progress-bar");
expect(progressBars).toHaveLength(2);
expect(progressBars[0]).toHaveAttribute("data-progress", "0.75");
expect(progressBars[0]).toHaveAttribute("data-bar-color", "bg-brand-dark");
expect(progressBars[0]).toHaveAttribute("data-height", "2");
expect(progressBars[1]).toHaveAttribute("data-progress", "0.5");
expect(progressBars[1]).toHaveAttribute("data-bar-color", "bg-brand-dark");
expect(progressBars[1]).toHaveAttribute("data-height", "2");
});
test("renders no quotas message when quotas array is empty", () => {
render(<QuotasSummary quotas={[]} />);
expect(screen.getByText("common.no_quotas_found")).toBeInTheDocument();
expect(screen.queryByTestId("progress-bar")).not.toBeInTheDocument();
expect(screen.queryByText("Demographics Quota")).not.toBeInTheDocument();
});
test("renders single quota correctly", () => {
const singleQuota: TSurveySummary["quotas"] = [
{
id: "quota1",
name: "Single Quota",
limit: 200,
count: 150,
percentage: 75,
},
];
render(<QuotasSummary quotas={singleQuota} />);
expect(screen.getByText("Single Quota")).toBeInTheDocument();
expect(screen.getByText("200")).toBeInTheDocument();
expect(screen.getByText("150")).toBeInTheDocument();
expect(screen.getByText("75%")).toBeInTheDocument();
const progressBar = screen.getByTestId("progress-bar");
expect(progressBar).toHaveAttribute("data-progress", "0.75");
});
test("handles zero percentage correctly", () => {
const zeroPercentageQuota: TSurveySummary["quotas"] = [
{
id: "quota1",
name: "Zero Quota",
limit: 100,
count: 0,
percentage: 0,
},
];
render(<QuotasSummary quotas={zeroPercentageQuota} />);
expect(screen.getByText("Zero Quota")).toBeInTheDocument();
expect(screen.getByText("0%")).toBeInTheDocument();
const progressBar = screen.getByTestId("progress-bar");
expect(progressBar).toHaveAttribute("data-progress", "0");
});
test("handles 100 percentage correctly", () => {
const fullPercentageQuota: TSurveySummary["quotas"] = [
{
id: "quota1",
name: "Full Quota",
limit: 50,
count: 50,
percentage: 100,
},
];
render(<QuotasSummary quotas={fullPercentageQuota} />);
expect(screen.getByText("Full Quota")).toBeInTheDocument();
expect(screen.getByText("100%")).toBeInTheDocument();
const progressBar = screen.getByTestId("progress-bar");
expect(progressBar).toHaveAttribute("data-progress", "1");
});
});

View File

@@ -0,0 +1,47 @@
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { useTranslate } from "@tolgee/react";
import type { TSurveySummary } from "@formbricks/types/surveys/types";
interface QuotasSummaryProps {
quotas: TSurveySummary["quotas"];
}
export const QuotasSummary = ({ quotas }: QuotasSummaryProps) => {
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div>
<div className="grid min-h-10 grid-cols-6 items-center rounded-t-xl border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-500">
<div className="px-2">{t("common.progress")}</div>
<div className="col-span-3 px-2">{t("common.label")}</div>
<div className="px-2 text-right">{t("environments.surveys.summary.limit")}</div>
<div className="px-2 text-right md:mr-1 md:pl-6">
{t("environments.surveys.summary.current_count")}
</div>
</div>
{quotas.length > 0 ? (
quotas.map((quota) => (
<div
key={quota.id}
className="grid h-[52px] grid-cols-6 border-b border-slate-100 text-xs text-slate-900 md:text-sm">
<div className="col-span-1 flex h-full items-center justify-center p-2">
<ProgressBar progress={quota.percentage / 100} barColor="bg-brand-dark" height={2} />
</div>
<div className="col-span-3 flex items-center whitespace-pre-wrap p-2">{quota.name}</div>
<div className="flex items-center justify-end whitespace-pre-wrap p-2">{quota.limit}</div>
<div className="flex items-center justify-end gap-2 p-2 text-right">
<span className="rounded-xl bg-slate-100 px-2 py-1 text-xs">{quota.percentage}%</span>
<span>{quota.count}</span>
</div>
</div>
))
) : (
<div className="grid h-[52px] border-b border-slate-100 text-xs text-slate-900 md:text-sm">
<div className="flex items-center justify-center p-2">{t("common.no_quotas_found")}</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,30 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ResponseCardQuotas } from "./single-response-card-quotas";
vi.mock("@/modules/ui/components/response-badges", () => ({
ResponseBadges: ({ items, showId }: { items: { value: string }[]; showId: boolean }) => (
<div data-testid="response-badges">{items.map((item) => item.value).join(", ")}</div>
),
}));
describe("ResponseCardQuotas", () => {
afterEach(() => {
cleanup();
});
test("renders response card quotas", () => {
render(<ResponseCardQuotas quotas={[{ id: "quota1", name: "Quota 1" }]} />);
expect(screen.getByText("Quota 1")).toBeInTheDocument();
expect(screen.getByTestId("response-badges")).toBeInTheDocument();
});
test("renders no response card quotas", () => {
render(<ResponseCardQuotas quotas={[]} />);
expect(screen.queryByTestId("response-badges")).not.toBeInTheDocument();
expect(screen.queryByTestId("main-quotas-div")).toBeNull();
});
});

View File

@@ -0,0 +1,24 @@
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { useTranslate } from "@tolgee/react";
import { TResponseWithQuotas } from "@formbricks/types/responses";
interface ResponseCardQuotasProps {
quotas: TResponseWithQuotas["quotas"];
}
export const ResponseCardQuotas = ({ quotas }: ResponseCardQuotasProps) => {
const { t } = useTranslate();
if (!quotas || quotas.length === 0) return null;
return (
<div data-testid="main-quotas-div" className="mt-6 flex flex-col gap-1">
<p className="text-sm text-slate-500">{t("common.quotas")}</p>
<div className="flex flex-wrap gap-2">
{quotas.map((quota) => (
<ResponseBadges key={quota.id} items={[{ value: quota.name }]} showId={false} />
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,442 @@
import { getSurvey } from "@/lib/survey/service";
import { Response } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
import { QuotaEvaluationInput, evaluateResponseQuotas } from "./evaluation-service";
import { getQuotas } from "./quotas";
import { evaluateQuotas, handleQuotas } from "./utils";
// Mock dependencies
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: vi.fn(),
response: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("./quotas", () => ({
getQuotas: vi.fn(),
}));
vi.mock("./utils", () => ({
evaluateQuotas: vi.fn(),
handleQuotas: vi.fn(),
}));
type MockTx = {
$transaction: ReturnType<typeof vi.fn>;
response: {
findUnique: ReturnType<typeof vi.fn>;
};
};
let mockTx: MockTx;
describe("Quota Evaluation Service", () => {
const mockSurveyId = "survey123";
const mockResponseId = "response123";
const mockQuotaId = "quota123";
const mockEndingCardId = "ending123";
const mockSurvey: TSurvey = {
id: mockSurveyId,
name: "Test Survey",
type: "link",
status: "inProgress",
welcomeCard: {
html: { default: "Welcome" },
enabled: false,
headline: { default: "Welcome!" },
buttonLabel: { default: "Next" },
timeToFinish: false,
showResponseCount: false,
},
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "What's your age?" },
required: true,
charLimit: {},
inputType: "number",
longAnswer: false,
buttonLabel: { default: "Next" },
placeholder: { default: "Enter age" },
},
],
endings: [
{
id: mockEndingCardId,
type: "endScreen",
headline: { default: "Thank you!" },
subheader: { default: "Survey completed" },
buttonLink: "https://example.com",
buttonLabel: { default: "Done" },
},
],
hiddenFields: { enabled: true, fieldIds: [] },
variables: [],
displayOption: "displayOnce",
recontactDays: null,
displayLimit: null,
autoClose: null,
delay: 0,
displayPercentage: null,
isBackButtonHidden: false,
projectOverwrites: null,
styling: null,
showLanguageSwitch: null,
languages: [],
triggers: [],
segment: null,
recaptcha: null,
createdAt: new Date("2024-01-01"),
autoComplete: null,
closeOnDate: null,
createdBy: null,
followUps: [],
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
surveyClosedMessage: null,
singleUse: null,
pin: null,
environmentId: "env123",
metadata: {},
runOnDate: null,
updatedAt: new Date("2024-01-01"),
};
const mockQuota: TSurveyQuota = {
id: mockQuotaId,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
surveyId: mockSurveyId,
name: "Age 18-25 Quota",
limit: 50,
logic: {
connector: "and",
conditions: [
{
id: "c1",
leftOperand: { type: "question", value: "q1" },
operator: "isGreaterThanOrEqual",
rightOperand: { type: "static", value: 18 },
},
],
},
action: "endSurvey",
endingCardId: mockEndingCardId,
countPartialSubmissions: false,
};
const mockResponseData: TResponseData = {
q1: "22",
};
const mockVariablesData: TResponseVariables = {};
const mockResponse: Response = {
id: mockResponseId,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
surveyId: mockSurveyId,
finished: false,
data: mockResponseData,
ttc: null,
contactAttributes: {},
variables: mockVariablesData,
meta: {},
contactId: null,
singleUseId: null,
language: "default",
endingId: null,
displayId: null,
};
beforeEach(() => {
vi.clearAllMocks();
mockTx = {
$transaction: vi.fn(),
response: {
findUnique: vi.fn(),
},
};
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
});
afterEach(() => {
vi.clearAllMocks();
});
describe("evaluateResponseQuotas", () => {
test("should return shouldEndSurvey false when no quotas exist", async () => {
const input: QuotaEvaluationInput = {
surveyId: mockSurveyId,
responseId: mockResponseId,
data: mockResponseData,
responseFinished: true,
};
vi.mocked(getQuotas).mockResolvedValue([]);
const result = await evaluateResponseQuotas(input);
expect(result).toEqual({
shouldEndSurvey: false,
});
expect(getQuotas).toHaveBeenCalledWith(mockSurveyId);
expect(getSurvey).not.toHaveBeenCalled();
});
test("should return shouldEndSurvey false when survey not found", async () => {
const input: QuotaEvaluationInput = {
surveyId: mockSurveyId,
responseId: mockResponseId,
data: mockResponseData,
responseFinished: true,
};
vi.mocked(getQuotas).mockResolvedValue([mockQuota]);
vi.mocked(getSurvey).mockResolvedValue(null);
const result = await evaluateResponseQuotas(input);
expect(result).toEqual({
shouldEndSurvey: false,
});
expect(getQuotas).toHaveBeenCalledWith(mockSurveyId);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
});
test("should process quotas successfully and return shouldEndSurvey false when quota action is not endSurvey", async () => {
const input: QuotaEvaluationInput = {
surveyId: mockSurveyId,
responseId: mockResponseId,
data: mockResponseData,
variables: mockVariablesData,
language: "en",
responseFinished: true,
tx: mockTx,
};
const continueSurveyQuota: TSurveyQuota = {
...mockQuota,
action: "continueSurvey",
};
const evaluateResult = {
passedQuotas: [continueSurveyQuota],
failedQuotas: [],
};
vi.mocked(getQuotas).mockResolvedValue([continueSurveyQuota]);
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(evaluateQuotas).mockReturnValue(evaluateResult);
vi.mocked(handleQuotas).mockResolvedValue(continueSurveyQuota);
const result = await evaluateResponseQuotas(input);
expect(result).toEqual({
quotaFull: continueSurveyQuota,
shouldEndSurvey: false,
});
expect(getQuotas).toHaveBeenCalledWith(mockSurveyId);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(evaluateQuotas).toHaveBeenCalledWith(
mockSurvey,
mockResponseData,
mockVariablesData,
[continueSurveyQuota],
"en"
);
expect(handleQuotas).toHaveBeenCalledWith(mockSurveyId, mockResponseId, evaluateResult, true, mockTx);
});
test("should process quotas successfully and return shouldEndSurvey true when quota action is endSurvey", async () => {
const input: QuotaEvaluationInput = {
surveyId: mockSurveyId,
responseId: mockResponseId,
data: mockResponseData,
variables: mockVariablesData,
language: "en",
responseFinished: true,
tx: mockTx,
};
const evaluateResult = {
passedQuotas: [mockQuota],
failedQuotas: [],
};
vi.mocked(getQuotas).mockResolvedValue([mockQuota]);
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(evaluateQuotas).mockReturnValue(evaluateResult);
vi.mocked(handleQuotas).mockResolvedValue(mockQuota);
vi.mocked(mockTx.response.findUnique).mockResolvedValue(mockResponse);
const result = await evaluateResponseQuotas(input);
expect(result).toEqual({
quotaFull: mockQuota,
shouldEndSurvey: true,
refreshedResponse: mockResponse,
});
expect(getQuotas).toHaveBeenCalledWith(mockSurveyId);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(evaluateQuotas).toHaveBeenCalledWith(
mockSurvey,
mockResponseData,
mockVariablesData,
[mockQuota],
"en"
);
expect(handleQuotas).toHaveBeenCalledWith(mockSurveyId, mockResponseId, evaluateResult, true, mockTx);
expect(mockTx.response.findUnique).toHaveBeenCalledWith({
where: { id: mockResponseId },
});
});
test("should process quotas successfully and return shouldEndSurvey true when quota action is endSurvey and responseFinished is false", async () => {
const input: QuotaEvaluationInput = {
surveyId: mockSurveyId,
responseId: mockResponseId,
data: mockResponseData,
variables: mockVariablesData,
responseFinished: false,
tx: mockTx,
};
const mockPartialSubmissionQuota = {
...mockQuota,
countPartialSubmissions: true,
};
const evaluateResult = {
passedQuotas: [mockPartialSubmissionQuota],
failedQuotas: [],
};
vi.mocked(getQuotas).mockResolvedValue([mockPartialSubmissionQuota]);
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(evaluateQuotas).mockReturnValue(evaluateResult);
vi.mocked(handleQuotas).mockResolvedValue(mockPartialSubmissionQuota);
vi.mocked(mockTx.response.findUnique).mockResolvedValue(mockResponse);
const result = await evaluateResponseQuotas(input);
expect(result).toEqual({
quotaFull: mockPartialSubmissionQuota,
shouldEndSurvey: true,
refreshedResponse: mockResponse,
});
expect(getQuotas).toHaveBeenCalledWith(mockSurveyId);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(evaluateQuotas).toHaveBeenCalledWith(
mockSurvey,
mockResponseData,
mockVariablesData,
[mockPartialSubmissionQuota],
"default"
);
expect(handleQuotas).toHaveBeenCalledWith(mockSurveyId, mockResponseId, evaluateResult, false, mockTx);
expect(mockTx.response.findUnique).toHaveBeenCalledWith({ where: { id: mockResponseId } });
});
test("should return shouldEndSurvey false when handleQuotas returns null", async () => {
const input: QuotaEvaluationInput = {
surveyId: mockSurveyId,
responseId: mockResponseId,
data: mockResponseData,
variables: mockVariablesData,
language: "en",
responseFinished: true,
};
const evaluateResult = {
passedQuotas: [mockQuota],
failedQuotas: [],
};
vi.mocked(getQuotas).mockResolvedValue([mockQuota]);
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(evaluateQuotas).mockReturnValue(evaluateResult);
vi.mocked(handleQuotas).mockResolvedValue(null);
const result = await evaluateResponseQuotas(input);
expect(result).toEqual({
quotaFull: null,
shouldEndSurvey: false,
});
});
test("should handle getSurvey error gracefully", async () => {
const input: QuotaEvaluationInput = {
surveyId: mockSurveyId,
responseId: mockResponseId,
data: mockResponseData,
responseFinished: true,
};
vi.mocked(getQuotas).mockResolvedValue([mockQuota]);
vi.mocked(getSurvey).mockRejectedValue(new Error("Survey service error"));
const result = await evaluateResponseQuotas(input);
expect(result).toEqual({
shouldEndSurvey: false,
});
expect(logger.error).toHaveBeenCalledWith(
{ error: expect.any(Error), responseId: mockResponseId },
"Error evaluating quotas for response"
);
});
test("should handle evaluateQuotas error gracefully", async () => {
const input: QuotaEvaluationInput = {
surveyId: mockSurveyId,
responseId: mockResponseId,
data: mockResponseData,
responseFinished: true,
};
vi.mocked(getQuotas).mockResolvedValue([mockQuota]);
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(evaluateQuotas).mockImplementation(() => {
throw new Error("Evaluation error");
});
const result = await evaluateResponseQuotas(input);
expect(result).toEqual({
shouldEndSurvey: false,
});
expect(logger.error).toHaveBeenCalledWith(
{ error: expect.any(Error), responseId: mockResponseId },
"Error evaluating quotas for response"
);
});
});
});

View File

@@ -0,0 +1,79 @@
import "server-only";
import { getSurvey } from "@/lib/survey/service";
import { Prisma, Response } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TSurveyQuota } from "@formbricks/types/quota";
import { getQuotas } from "./quotas";
import { evaluateQuotas, handleQuotas } from "./utils";
export interface QuotaEvaluationInput {
surveyId: string;
responseId: string;
data: Response["data"];
responseFinished: boolean;
variables?: Response["variables"];
language?: string;
tx?: Prisma.TransactionClient;
}
export interface QuotaEvaluationResult {
quotaFull?: TSurveyQuota | null;
shouldEndSurvey: boolean;
refreshedResponse?: Response | null;
}
/**
* Reusable common quota evaluation logic for all API versions
* @param input - The quota evaluation input containing survey, response, and form data
* @returns The quota evaluation result with quotaFull, shouldEndSurvey, and refreshedResponse
*/
export const evaluateResponseQuotas = async (input: QuotaEvaluationInput): Promise<QuotaEvaluationResult> => {
const {
surveyId,
responseId,
data,
variables = {},
language = "default",
responseFinished = false,
tx,
} = input;
const prismaClient = tx ?? prisma;
try {
const quotas = await getQuotas(surveyId);
if (!quotas || quotas.length === 0) {
return { shouldEndSurvey: false };
}
const survey = await getSurvey(surveyId);
if (!survey) {
return { shouldEndSurvey: false };
}
const result = evaluateQuotas(survey, data, variables, quotas, language);
const quotaFull = await handleQuotas(surveyId, responseId, result, responseFinished, prismaClient);
if (quotaFull && quotaFull.action === "endSurvey") {
const refreshedResponse = await prismaClient.response.findUnique({
where: { id: responseId },
});
return {
quotaFull,
shouldEndSurvey: true,
refreshedResponse,
};
}
return {
quotaFull,
shouldEndSurvey: false,
};
} catch (error) {
logger.error({ error, responseId }, "Error evaluating quotas for response");
return { shouldEndSurvey: false };
}
};

View File

@@ -0,0 +1,64 @@
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { describe, expect, test } from "vitest";
describe("helpers", () => {
describe("createQuotaFullObject", () => {
test("should return quotaFull: false when quota is not provided", () => {
const result = createQuotaFullObject();
expect(result).toEqual({ quotaFull: false });
});
test("should return quotaFull: true when quota is provided", () => {
const result = createQuotaFullObject({
id: "1",
action: "endSurvey",
countPartialSubmissions: false,
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "1",
name: "1",
limit: 1,
logic: { connector: "and", conditions: [] },
endingCardId: "e1",
});
expect(result).toEqual({
quotaFull: true,
quota: { id: "1", action: "endSurvey", endingCardId: "e1" },
});
const result2 = createQuotaFullObject({
id: "1",
action: "endSurvey",
endingCardId: "2",
countPartialSubmissions: false,
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "1",
name: "1",
limit: 1,
logic: { connector: "and", conditions: [] },
});
expect(result2).toEqual({
quotaFull: true,
quota: { id: "1", action: "endSurvey", endingCardId: "2" },
});
const result3 = createQuotaFullObject({
id: "1",
action: "endSurvey",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "1",
name: "1",
limit: 1,
logic: { connector: "and", conditions: [] },
countPartialSubmissions: false,
endingCardId: null,
});
expect(result3).toEqual({
quotaFull: true,
quota: { id: "1", action: "endSurvey" },
});
});
});
});

View File

@@ -0,0 +1,27 @@
import { TSurveyQuota, TSurveyQuotaAction } from "@formbricks/types/quota";
type QuotaFull =
| {
quotaFull: true;
quota: {
id: string;
action: TSurveyQuotaAction;
endingCardId?: string;
};
}
| {
quotaFull: false;
};
export const createQuotaFullObject = (quota?: TSurveyQuota): QuotaFull => {
if (!quota) return { quotaFull: false };
return {
quotaFull: true,
quota: {
id: quota.id,
action: quota.action,
...(quota.endingCardId ? { endingCardId: quota.endingCardId } : {}),
},
};
};

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