mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-11 12:30:52 -05:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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%");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
34
apps/web/app/api/lib/utils.ts
Normal file
34
apps/web/app/api/lib/utils.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
48
apps/web/app/api/v1/lib/utils.ts
Normal file
48
apps/web/app/api/v1/lib/utils.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -19,7 +19,7 @@ export type AuditLoggingCtx = {
|
||||
contactId?: string;
|
||||
apiKeyId?: string;
|
||||
responseId?: string;
|
||||
|
||||
quotaId?: string;
|
||||
teamId?: string;
|
||||
integrationId?: string;
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,9 +22,9 @@ export const ZAuditTarget = z.enum([
|
||||
"membership",
|
||||
"twoFactorAuth",
|
||||
"apiKey",
|
||||
|
||||
"integration",
|
||||
"file",
|
||||
"quota",
|
||||
]);
|
||||
export const ZAuditAction = z.enum([
|
||||
"created",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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:"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>;
|
||||
|
||||
199
apps/web/modules/ee/quotas/actions.ts
Normal file
199
apps/web/modules/ee/quotas/actions.ts
Normal 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 };
|
||||
}
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
205
apps/web/modules/ee/quotas/components/quota-list.test.tsx
Normal file
205
apps/web/modules/ee/quotas/components/quota-list.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
65
apps/web/modules/ee/quotas/components/quota-list.tsx
Normal file
65
apps/web/modules/ee/quotas/components/quota-list.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
645
apps/web/modules/ee/quotas/components/quota-modal.test.tsx
Normal file
645
apps/web/modules/ee/quotas/components/quota-modal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
484
apps/web/modules/ee/quotas/components/quota-modal.tsx
Normal file
484
apps/web/modules/ee/quotas/components/quota-modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
486
apps/web/modules/ee/quotas/components/quotas-card.test.tsx
Normal file
486
apps/web/modules/ee/quotas/components/quotas-card.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
274
apps/web/modules/ee/quotas/components/quotas-card.tsx
Normal file
274
apps/web/modules/ee/quotas/components/quotas-card.tsx
Normal 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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
141
apps/web/modules/ee/quotas/components/quotas-summary.test.tsx
Normal file
141
apps/web/modules/ee/quotas/components/quotas-summary.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
47
apps/web/modules/ee/quotas/components/quotas-summary.tsx
Normal file
47
apps/web/modules/ee/quotas/components/quotas-summary.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
442
apps/web/modules/ee/quotas/lib/evaluation-service.test.ts
Normal file
442
apps/web/modules/ee/quotas/lib/evaluation-service.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
79
apps/web/modules/ee/quotas/lib/evaluation-service.ts
Normal file
79
apps/web/modules/ee/quotas/lib/evaluation-service.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
64
apps/web/modules/ee/quotas/lib/helpers.test.ts
Normal file
64
apps/web/modules/ee/quotas/lib/helpers.test.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
27
apps/web/modules/ee/quotas/lib/helpers.ts
Normal file
27
apps/web/modules/ee/quotas/lib/helpers.ts
Normal 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
Reference in New Issue
Block a user