Compare commits

..

11 Commits

Author SHA1 Message Date
pandeymangg
873ce66bc4 fixes 2026-03-10 19:58:48 +05:30
pandeymangg
a5a036ae05 fixes 2026-03-10 19:45:56 +05:30
pandeymangg
72385e6351 chore: merge with main 2026-03-10 17:46:44 +05:30
pandeymangg
5c05337dc8 fix: apps/web TS strict check to true 2026-03-10 17:18:18 +05:30
Johannes
fa882dd4cc fix: improve survey validation error handling in SurveyMenuBar component (#7447) 2026-03-10 10:23:05 +00:00
Matti Nannt
0b82c6de77 feat: move multi-language surveys and workspace languages to AGPL (#7426)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-10 09:28:01 +00:00
Balázs Úr
a944d7626e chore: use Unicode punctuation, remove contractions, make wording consistent (#7355)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-10 07:06:30 +00:00
Balázs Úr
d1b12dc228 fix: mark strings as translatable in survey editor (#7369)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-10 06:14:29 +00:00
Bhagya Amarasinghe
9f7d6038b1 docs: add CDN guidance for self-hosting (#7446)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-10 06:12:36 +00:00
Balázs Úr
1da92addd2 fix: Hungarian translations (#7434)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-09 12:31:24 +00:00
Dhruwang Jariwala
1e4aa5f54b fix: strip inline styles preserve target attr (#7441)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:09:51 +00:00
406 changed files with 3525 additions and 2559 deletions

View File

@@ -4,7 +4,10 @@ import { AuthorizationError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
const OnboardingLayout = async (props) => {
const OnboardingLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;

View File

@@ -2,6 +2,7 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TXMTemplate } from "@formbricks/types/templates";
import { replacePresetPlaceholders } from "./utils";
@@ -39,13 +40,13 @@ const mockTemplate: TXMTemplate = {
elements: [
{
id: "q1",
type: "openText" as const,
type: "openText" as TSurveyElementTypeEnum.OpenText,
inputType: "text" as const,
headline: { default: "$[projectName] Question" },
subheader: { default: "" },
required: false,
placeholder: { default: "" },
charLimit: 1000,
charLimit: { enabled: true, max: 1000 },
},
],
},

View File

@@ -14,7 +14,7 @@ describe("xm-templates", () => {
});
test("getXMSurveyDefault returns default survey template", () => {
const tMock = vi.fn((key) => key) as TFunction;
const tMock = vi.fn((key: string) => key) as unknown as TFunction;
const result = getXMSurveyDefault(tMock);
expect(result).toEqual({
@@ -29,7 +29,7 @@ describe("xm-templates", () => {
});
test("getXMTemplates returns all templates", () => {
const tMock = vi.fn((key) => key) as TFunction;
const tMock = vi.fn((key: string) => key) as unknown as TFunction;
const result = getXMTemplates(tMock);
expect(result).toHaveLength(6);
@@ -44,7 +44,7 @@ describe("xm-templates", () => {
test("getXMTemplates handles errors gracefully", async () => {
const tMock = vi.fn(() => {
throw new Error("Test error");
}) as TFunction;
}) as unknown as TFunction;
const result = getXMTemplates(tMock);

View File

@@ -19,8 +19,8 @@ describe("getTeamsByOrganizationId", () => {
test("returns mapped teams", async () => {
const mockTeams = [
{ id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2" },
{ id: "t1", name: "Team 1", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
{ id: "t2", name: "Team 2", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
];
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
const result = await getTeamsByOrganizationId("org1");

View File

@@ -5,7 +5,10 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
const LandingLayout = async (props) => {
const LandingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;

View File

@@ -10,7 +10,7 @@ import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
const params = await props.params;
const t = await getTranslate();

View File

@@ -8,7 +8,10 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
const ProjectOnboardingLayout = async (props) => {
const ProjectOnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;

View File

@@ -8,7 +8,10 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
const OnboardingLayout = async (props) => {
const OnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;

View File

@@ -16,7 +16,7 @@ interface OnboardingOptionsContainerProps {
}
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
const getOptionCard = (option) => {
const getOptionCard = (option: OnboardingOptionsContainerProps["options"][number]) => {
const Icon = option.icon;
return (
<OptionCard

View File

@@ -2,7 +2,10 @@ import { redirect } from "next/navigation";
import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
const SurveyEditorEnvironmentLayout = async (props) => {
const SurveyEditorEnvironmentLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;

View File

@@ -3,13 +3,13 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
export const dynamic = "force-dynamic";
const Page = async (props) => {
const Page = async (props: { searchParams: Promise<{ environmentId?: string }> }) => {
const searchParams = await props.searchParams;
const { environmentId } = searchParams;
return (
<PageContentWrapper>
<ConfirmationPage environmentId={environmentId?.toString()} />
<ConfirmationPage environmentId={environmentId?.toString() ?? ""} />
</PageContentWrapper>
);
};

View File

@@ -126,7 +126,7 @@ export const MainNavigation = ({
name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`,
icon: Cog,
isActive: pathname?.includes("/project"),
isActive: pathname?.includes("/workspace"),
},
],
[t, environment.id, pathname, isFormbricksCloud]

View File

@@ -4,7 +4,7 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
const EnvironmentPage = async (props) => {
const EnvironmentPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const { session, organization } = await getEnvironmentAuth(params.environmentId);

View File

@@ -4,7 +4,10 @@ import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
const AccountSettingsLayout = async (props) => {
const AccountSettingsLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;

View File

@@ -16,8 +16,8 @@ const setCompleteNotificationSettings = (
notificationSettings: TUserNotificationSettings,
memberships: Membership[]
): TUserNotificationSettings => {
const newNotificationSettings = {
alert: {},
const newNotificationSettings: TUserNotificationSettings = {
alert: {} as Record<string, boolean>,
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
};
for (const membership of memberships) {
@@ -26,7 +26,8 @@ const setCompleteNotificationSettings = (
for (const environment of project.environments) {
for (const survey of environment.surveys) {
newNotificationSettings.alert[survey.id] =
notificationSettings[survey.id]?.responseFinished ||
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
?.responseFinished ||
(notificationSettings.alert && notificationSettings.alert[survey.id]) ||
false; // check for legacy notification settings w/o "alerts" key
}
@@ -136,7 +137,10 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
return memberships;
};
const Page = async (props) => {
const Page = async (props: {
params: Promise<{ environmentId: string }>;
searchParams: Promise<Record<string, string>>;
}) => {
const searchParams = await props.searchParams;
const params = await props.params;
const t = await getTranslate();

View File

@@ -11,7 +11,7 @@ import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {

View File

@@ -107,7 +107,7 @@ const DeleteOrganizationModal = ({
}: DeleteOrganizationModalProps) => {
const [inputValue, setInputValue] = useState("");
const { t } = useTranslation();
const handleInputChange = (e) => {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};

View File

@@ -61,7 +61,7 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO
toast.error(errorMessage);
}
} catch (err) {
toast.error(`Error: ${err.message}`);
toast.error(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`);
}
};

View File

@@ -4,7 +4,7 @@ import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
const Layout = async (props) => {
const Layout = async (props: { params: Promise<{ environmentId: string }>; children: React.ReactNode }) => {
const params = await props.params;
const { children } = props;

View File

@@ -1,3 +1,3 @@
export const SettingsTitle = ({ title }) => {
export const SettingsTitle = ({ title }: { title: string }) => {
return <h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">{title}</h2>;
};

View File

@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/settings/profile`);
};

View File

@@ -27,7 +27,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
};
};
const SurveyLayout = async ({ children }) => {
const SurveyLayout = async ({ children }: { children: React.ReactNode }) => {
return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
};

View File

@@ -205,16 +205,21 @@ export const ResponseTable = ({
};
// Handle downloading selected responses
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
const downloadSelectedRows = async (responseIds: string[], format: string) => {
const validFormat = format === "xlsx" ? "xlsx" : "csv";
try {
const downloadResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id,
format: format,
format: validFormat,
filterCriteria: { responseIds },
});
if (downloadResponse?.data) {
downloadResponsesFile(downloadResponse.data.fileName, downloadResponse.data.fileContents, format);
downloadResponsesFile(
downloadResponse.data.fileName,
downloadResponse.data.fileContents,
validFormat
);
} else {
toast.error(t("environments.surveys.responses.error_downloading_responses"));
}

View File

@@ -5,7 +5,7 @@ import { TFunction } from "i18next";
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
import Link from "next/link";
import { TResponseTableData } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
@@ -46,7 +46,7 @@ const getElementColumnsData = (
const ElementHeader = () => (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{ELEMENTS_ICON_MAP[elementType]}</span>
<span className="h-4 w-4">{ELEMENTS_ICON_MAP[elementType as TSurveyElementTypeEnum]}</span>
<span className="truncate">{title}</span>
</div>
</div>
@@ -232,7 +232,7 @@ const getMetadataColumnsData = (t: TFunction): ColumnDef<TResponseTableData>[] =
const metadataColumns: ColumnDef<TResponseTableData>[] = [];
METADATA_FIELDS.forEach((label) => {
const IconComponent = COLUMNS_ICON_MAP[label];
const IconComponent = COLUMNS_ICON_MAP[label as keyof typeof COLUMNS_ICON_MAP];
metadataColumns.push({
accessorKey: "METADATA_" + label,

View File

@@ -1,4 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { TFunction } from "i18next";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -38,7 +39,7 @@ describe("utils", () => {
"environments.surveys.responses.source": "Source",
};
return translations[key] || key;
});
}) as unknown as TFunction;
describe("getAddressFieldLabel", () => {
test("returns correct label for addressLine1", () => {

View File

@@ -80,9 +80,24 @@ export const COLUMNS_ICON_MAP = {
const userAgentFields = ["browser", "os", "device"];
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
export const getMetadataValue = (meta: TResponseMeta, label: string) => {
if (userAgentFields.includes(label)) {
return meta.userAgent?.[label];
export const getMetadataValue = (
meta: TResponseMeta,
label: (typeof METADATA_FIELDS)[number]
): string | undefined => {
switch (label) {
case "browser":
return meta.userAgent?.browser;
case "os":
return meta.userAgent?.os;
case "device":
return meta.userAgent?.device;
case "action":
return meta.action;
case "country":
return meta.country;
case "source":
return meta.source;
case "url":
return meta.url;
}
return meta[label];
};

View File

@@ -17,7 +17,7 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
const params = await props.params;
const t = await getTranslate();

View File

@@ -60,7 +60,9 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
},
};
const filter = filters[group];
const filter = (filters as Record<string, { comparison: string; values: string | string[] | undefined }>)[
group
];
if (filter) {
setFilter(
@@ -104,7 +106,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
<TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
{(["promoters", "passives", "detractors", "dismissed"] as const).map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}

View File

@@ -15,7 +15,7 @@ interface SummaryMetadataProps {
isQuotasAllowed: boolean;
}
const formatTime = (ttc) => {
const formatTime = (ttc: number) => {
const seconds = ttc / 1000;
let formattedValue;

View File

@@ -105,7 +105,7 @@ export const SummaryPage = ({
setDisplays(data);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
toast.error(error);
toast.error(error instanceof Error ? error.message : String(error));
setDisplays([]);
setHasMoreDisplays(false);
} finally {

View File

@@ -75,17 +75,7 @@ export const ShareSurveyModal = ({
const [showView, setShowView] = useState<ModalView>(modalView);
const { email } = user;
const { t } = useTranslation();
const linkTabs: {
id: ShareViaType | ShareSettingsType;
type: LinkTabsType;
label: string;
icon: React.ElementType;
title: string;
description: string;
componentType: React.ComponentType<unknown>;
componentProps: unknown;
disabled?: boolean;
}[] = useMemo(() => {
const linkTabs = useMemo(() => {
const tabs = [
{
id: ShareViaType.ANON_LINKS,

View File

@@ -39,7 +39,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
}
}
} catch (error) {
logger.error("Failed to generate QR code:", error);
logger.error(error as Error, "Failed to generate QR code");
setHasError(true);
} finally {
setIsLoading(false);
@@ -66,7 +66,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
downloadInstance.download({ name: "survey-qr-code", extension: "png" });
toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon"));
} catch (error) {
logger.error("Failed to download QR code:", error);
logger.error(error as Error, "Failed to download QR code");
toast.error(t("environments.surveys.summary.qr_code_download_failed"));
} finally {
setIsDownloading(false);

View File

@@ -4,6 +4,10 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import {
ShareSettingsType,
ShareViaType,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { Badge } from "@/modules/ui/components/badge";
@@ -13,9 +17,9 @@ interface SuccessViewProps {
publicDomain: string;
setSurveyUrl: (url: string) => void;
user: TUser;
tabs: { id: string; label: string; icon: React.ElementType }[];
handleViewChange: (view: string) => void;
handleEmbedViewWithTab: (tabId: string) => void;
tabs: { id: ShareViaType | ShareSettingsType; label: string; icon: React.ElementType }[];
handleViewChange: (view: "start" | "share") => void;
handleEmbedViewWithTab: (tabId: ShareViaType | ShareSettingsType) => void;
isReadOnly: boolean;
}

View File

@@ -96,7 +96,7 @@ describe("Tests for getQuotasSummary service", () => {
_count: {
quotaLinks: 0,
},
},
} as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number],
]);
const result = await getQuotasSummary(surveyId);
@@ -120,7 +120,7 @@ describe("Tests for getQuotasSummary service", () => {
_count: {
quotaLinks: 0,
},
},
} as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number],
]);
const result = await getQuotasSummary(surveyId);

View File

@@ -662,17 +662,23 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3);
// Item 1 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
const item1 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
expect(item1.count).toBe(2);
expect(item1.avgRanking).toBe(1.5);
// Item 2 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
const item2 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
expect(item2.count).toBe(2);
expect(item2.avgRanking).toBe(1.5);
// Item 3 is in position 3 twice, so avg ranking should be 3
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
const item3 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
expect(item3.count).toBe(2);
expect(item3.avgRanking).toBe(3);
});
@@ -747,17 +753,23 @@ describe("getQuestionSummary", () => {
expect(summary[0].responseCount).toBe(1);
// Item 1 is in position 2, so avg ranking should be 2
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
const item1 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
expect(item1.count).toBe(1);
expect(item1.avgRanking).toBe(2);
// Item 2 is in position 1, so avg ranking should be 1
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
const item2 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
expect(item2.count).toBe(1);
expect(item2.avgRanking).toBe(1);
// Item 3 is in position 3, so avg ranking should be 3
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
const item3 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
expect(item3.count).toBe(1);
expect(item3.avgRanking).toBe(3);
});
@@ -830,10 +842,12 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3);
// All items should have count 0 and avgRanking 0
(summary[0] as any).choices.forEach((choice) => {
expect(choice.count).toBe(0);
expect(choice.avgRanking).toBe(0);
});
(summary[0] as any).choices.forEach(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0);
expect(choice.avgRanking).toBe(0);
}
);
});
test("getQuestionSummary handles ranking question with non-array answers", async () => {
@@ -894,10 +908,12 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3);
// All items should have count 0 and avgRanking 0 since we had no valid ranking data
(summary[0] as any).choices.forEach((choice) => {
expect(choice.count).toBe(0);
expect(choice.avgRanking).toBe(0);
});
(summary[0] as any).choices.forEach(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0);
expect(choice.avgRanking).toBe(0);
}
);
});
test("getQuestionSummary handles ranking question with values not in choices", async () => {
@@ -958,17 +974,23 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3);
// Item 1 is in position 1, so avg ranking should be 1
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
const item1 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
expect(item1.count).toBe(1);
expect(item1.avgRanking).toBe(1);
// Item 2 was not ranked, so should have count 0 and avgRanking 0
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
const item2 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
expect(item2.count).toBe(0);
expect(item2.avgRanking).toBe(0);
// Item 3 is in position 3, so avg ranking should be 3
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
const item3 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
expect(item3.count).toBe(1);
expect(item3.avgRanking).toBe(3);
});
@@ -986,7 +1008,11 @@ describe("getSurveySummary", () => {
// Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany
// which is used by the actual implementation of getResponsesForSummary.
vi.mocked(prisma.response.findMany).mockResolvedValue(
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
mockResponses.map((r: Record<string, unknown>) => ({
...r,
contactId: null,
personAttributes: {},
})) as any
);
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(10);
@@ -1020,8 +1046,8 @@ describe("getSurveySummary", () => {
test("handles filterCriteria", async () => {
const filterCriteria: TResponseFilterCriteria = { finished: true };
const finishedResponses = mockResponses
.filter((r) => r.finished)
.map((r) => ({ ...r, contactId: null, personAttributes: {} }));
.filter((r: Record<string, unknown>) => r.finished)
.map((r: Record<string, unknown>) => ({ ...r, contactId: null, personAttributes: {} }));
vi.mocked(prisma.response.findMany).mockResolvedValue(finishedResponses as any);
await getSurveySummary(mockSurveyId, filterCriteria);
@@ -1043,7 +1069,11 @@ describe("getResponsesForSummary", () => {
vi.resetAllMocks();
vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey);
vi.mocked(prisma.response.findMany).mockResolvedValue(
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
mockResponses.map((r: Record<string, unknown>) => ({
...r,
contactId: null,
personAttributes: {},
})) as any
);
// React cache is already mocked globally - no need to mock it again
});
@@ -1842,23 +1872,63 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2);
// Verify Speed row
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(2);
expect(speedRow.columnPercentages).toHaveLength(4); // 4 columns
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(50);
expect(
speedRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(50);
// Verify Quality row
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(2);
expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(50);
expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
expect(
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Excellent"
).percentage
).toBe(50);
expect(
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Good"
).percentage
).toBe(50);
// Verify Price row
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
const priceRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(2);
expect(priceRow.columnPercentages.find((col) => col.column === "Poor").percentage).toBe(50);
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
expect(
priceRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Poor")
.percentage
).toBe(50);
expect(
priceRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(50);
});
test("getQuestionSummary correctly processes Matrix question with non-default language responses", async () => {
@@ -1949,19 +2019,48 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(1);
// Verify Speed row with localized values mapped to default language
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(1);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Verify Quality row
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(1);
expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(100);
expect(
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Excellent"
).percentage
).toBe(100);
// Verify Price row
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
const priceRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(1);
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100);
expect(
priceRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(100);
});
test("getQuestionSummary handles missing or invalid data for Matrix questions", async () => {
@@ -2055,12 +2154,18 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(3); // Count is 3 because responses 2, 3, and 4 have the "matrix-q1" property
// All rows should have zero responses for all columns
summary[0].data.forEach((row) => {
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col) => {
expect(col.percentage).toBe(0);
});
});
summary[0].data.forEach(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => {
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0);
});
}
);
});
test("getQuestionSummary handles partial and incomplete matrix responses", async () => {
@@ -2147,22 +2252,59 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2);
// Verify Speed row - both responses provided data
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(2);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(50);
expect(
speedRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(50);
// Verify Quality row - only one response provided data
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(1);
expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
expect(
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Good"
).percentage
).toBe(100);
// Verify Price row - both responses provided data
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
const priceRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(2);
// ExtraRow should not appear in the summary
expect(summary[0].data.find((row) => row.rowLabel === "ExtraRow")).toBeUndefined();
expect(
summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "ExtraRow"
)
).toBeUndefined();
});
test("getQuestionSummary handles zero responses for Matrix question correctly", async () => {
@@ -2221,12 +2363,18 @@ describe("Matrix question type tests", () => {
// All rows should have proper structure but zero counts
expect(summary[0].data).toHaveLength(2); // 2 rows
summary[0].data.forEach((row) => {
expect(row.columnPercentages).toHaveLength(2); // 2 columns
expect(row.totalResponsesForRow).toBe(0);
expect(row.columnPercentages[0].percentage).toBe(0);
expect(row.columnPercentages[1].percentage).toBe(0);
});
summary[0].data.forEach(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => {
expect(row.columnPercentages).toHaveLength(2); // 2 columns
expect(row.totalResponsesForRow).toBe(0);
expect(row.columnPercentages[0].percentage).toBe(0);
expect(row.columnPercentages[1].percentage).toBe(0);
}
);
});
test("getQuestionSummary handles Matrix question with mixed valid and invalid column values", async () => {
@@ -2296,21 +2444,46 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(1);
// Speed row should have a valid response
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(1);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Quality row should have no valid responses
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(0);
qualityRow.columnPercentages.forEach((col) => {
qualityRow.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0);
});
// Price row should have a valid response
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
const priceRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(1);
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100);
expect(
priceRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(100);
});
test("getQuestionSummary handles Matrix question with invalid row labels", async () => {
@@ -2381,17 +2554,48 @@ describe("Matrix question type tests", () => {
expect(summary[0].data).toHaveLength(2); // 2 rows
// Speed row should have a valid response
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(1);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Quality row should have no responses
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(0);
// Invalid rows should not appear in the summary
expect(summary[0].data.find((row) => row.rowLabel === "InvalidRow")).toBeUndefined();
expect(summary[0].data.find((row) => row.rowLabel === "AnotherInvalidRow")).toBeUndefined();
expect(
summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "InvalidRow"
)
).toBeUndefined();
expect(
summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "AnotherInvalidRow"
)
).toBeUndefined();
});
test("getQuestionSummary handles Matrix question with mixed language responses", async () => {
@@ -2493,12 +2697,27 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2);
// Speed row should have both responses
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(2);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Quality row should have no responses
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(0);
});
@@ -2557,12 +2776,18 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(0); // Counts as response even with null data
// Both rows should have zero responses
summary[0].data.forEach((row) => {
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col) => {
expect(col.percentage).toBe(0);
});
});
summary[0].data.forEach(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => {
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0);
});
}
);
});
});
@@ -2994,23 +3219,33 @@ describe("Rating question type tests", () => {
expect(summary[0].average).toBe(4.25);
// Verify each rating option count and percentage
const rating5 = summary[0].choices.find((c) => c.rating === 5);
const rating5 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 5
);
expect(rating5.count).toBe(2);
expect(rating5.percentage).toBe(50); // 2/4 * 100
const rating4 = summary[0].choices.find((c) => c.rating === 4);
const rating4 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 4
);
expect(rating4.count).toBe(1);
expect(rating4.percentage).toBe(25); // 1/4 * 100
const rating3 = summary[0].choices.find((c) => c.rating === 3);
const rating3 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 3
);
expect(rating3.count).toBe(1);
expect(rating3.percentage).toBe(25); // 1/4 * 100
const rating2 = summary[0].choices.find((c) => c.rating === 2);
const rating2 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 2
);
expect(rating2.count).toBe(0);
expect(rating2.percentage).toBe(0);
const rating1 = summary[0].choices.find((c) => c.rating === 1);
const rating1 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 1
);
expect(rating1.count).toBe(0);
expect(rating1.percentage).toBe(0);
@@ -3154,10 +3389,12 @@ describe("Rating question type tests", () => {
expect(summary[0].average).toBe(0);
// Verify all ratings have 0 count and percentage
summary[0].choices.forEach((choice) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
});
summary[0].choices.forEach(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
}
);
// Verify dismissed is 0
expect(summary[0].dismissed.count).toBe(0);
@@ -3232,15 +3469,21 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(3); // Total selections: img1, img2, img3
// Check individual choice counts
const img1 = summary[0].choices.find((c) => c.id === "img1");
const img1 = summary[0].choices.find(
(c: { id: string; count: number; percentage: number }) => c.id === "img1"
);
expect(img1.count).toBe(1);
expect(img1.percentage).toBe(50);
const img2 = summary[0].choices.find((c) => c.id === "img2");
const img2 = summary[0].choices.find(
(c: { id: string; count: number; percentage: number }) => c.id === "img2"
);
expect(img2.count).toBe(1);
expect(img2.percentage).toBe(50);
const img3 = summary[0].choices.find((c) => c.id === "img3");
const img3 = summary[0].choices.find(
(c: { id: string; count: number; percentage: number }) => c.id === "img3"
);
expect(img3.count).toBe(1);
expect(img3.percentage).toBe(50);
});
@@ -3311,10 +3554,12 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(0);
// All choices should have zero count
summary[0].choices.forEach((choice) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
});
summary[0].choices.forEach(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
}
);
});
test("getQuestionSummary handles PictureSelection with invalid choice ids", async () => {
@@ -3373,17 +3618,23 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(2); // Total selections including invalid one
// img1 should be counted
const img1 = summary[0].choices.find((c) => c.id === "img1");
const img1 = summary[0].choices.find(
(c: { id: string; count: number; percentage: number }) => c.id === "img1"
);
expect(img1.count).toBe(1);
expect(img1.percentage).toBe(100);
// img2 should not be counted
const img2 = summary[0].choices.find((c) => c.id === "img2");
const img2 = summary[0].choices.find(
(c: { id: string; count: number; percentage: number }) => c.id === "img2"
);
expect(img2.count).toBe(0);
expect(img2.percentage).toBe(0);
// Invalid ID should not appear in choices
expect(summary[0].choices.find((c) => c.id === "invalid-id")).toBeUndefined();
expect(
summary[0].choices.find((c: { id: string; count: number; percentage: number }) => c.id === "invalid-id")
).toBeUndefined();
});
});

View File

@@ -14,11 +14,7 @@ import {
TResponseVariables,
ZResponseFilterCriteria,
} from "@formbricks/types/responses";
import {
TSurveyElement,
TSurveyElementChoice,
TSurveyElementTypeEnum,
} from "@formbricks/types/surveys/elements";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
TSurvey,
TSurveyElementSummaryAddress,
@@ -293,7 +289,10 @@ const checkForI18n = (
) => {
const element = elements.find((element) => element.id === id);
if (element?.type === "multipleChoiceMulti" || element?.type === "ranking") {
if (
element?.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
element?.type === TSurveyElementTypeEnum.Ranking
) {
// Initialize an array to hold the choice values
let choiceValues = [] as string[];
@@ -318,13 +317,9 @@ const checkForI18n = (
}
// Return the localized value of the choice fo multiSelect single element
if (element && "choices" in element) {
const choice = element.choices?.find(
(choice: TSurveyElementChoice) => choice.label?.[languageCode] === responseData[id]
);
return choice && "label" in choice
? getLocalizedValue(choice.label, "default") || responseData[id]
: responseData[id];
if (element?.type === TSurveyElementTypeEnum.MultipleChoiceSingle) {
const choice = element.choices?.find((choice) => choice.label[languageCode] === responseData[id]);
return choice ? getLocalizedValue(choice.label, "default") || responseData[id] : responseData[id];
}
return responseData[id];
@@ -832,13 +827,19 @@ export const getElementSummary = async (
let totalResponseCount = 0;
// Initialize count object
const countMap: Record<string, string> = rows.reduce((acc, row) => {
acc[row] = columns.reduce((colAcc, col) => {
colAcc[col] = 0;
return colAcc;
}, {});
return acc;
}, {});
const countMap: Record<string, Record<string, number>> = rows.reduce(
(acc: Record<string, Record<string, number>>, row) => {
acc[row] = columns.reduce(
(colAcc: Record<string, number>, col) => {
colAcc[col] = 0;
return colAcc;
},
{} as Record<string, number>
);
return acc;
},
{} as Record<string, Record<string, number>>
);
responses.forEach((response) => {
const selectedResponses = response.data[element.id] as Record<string, string>;

View File

@@ -15,7 +15,6 @@ import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/co
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";
@@ -155,10 +154,6 @@ export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey)
await checkSurveyFollowUpsPermission(organizationId);
}
if (parsedInput.languages?.length) {
await checkMultiLanguagePermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;

View File

@@ -164,12 +164,12 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
const datePickerRef = useRef<HTMLDivElement>(null);
const extractMetadataKeys = useCallback((obj, parentKey = "") => {
const extractMetadataKeys = useCallback((obj: Record<string, unknown>, parentKey = "") => {
let keys: string[] = [];
for (let key in obj) {
if (typeof obj[key] === "object" && obj[key] !== null) {
keys = keys.concat(extractMetadataKeys(obj[key], parentKey + key + " - "));
keys = keys.concat(extractMetadataKeys(obj[key] as Record<string, unknown>, parentKey + key + " - "));
} else {
keys.push(parentKey + key);
}

View File

@@ -113,7 +113,9 @@ const elementIcons = {
};
const getIcon = (type: string) => {
const IconComponent = elementIcons[type];
const IconComponent = (elementIcons as Record<string, (typeof elementIcons)[keyof typeof elementIcons]>)[
type
];
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
};

View File

@@ -198,7 +198,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
};
setFilterValue({ ...filterValue });
};
const handleRemoveMultiSelect = (value: string[], index) => {
const handleRemoveMultiSelect = (value: string[], index: number) => {
filterValue.filter[index] = {
...filterValue.filter[index],
filterType: {

View File

@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/surveys/${params.surveyId}/summary`);
};

View File

@@ -110,7 +110,7 @@ export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: Work
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="from-brand-light to-brand-dark mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br shadow-md">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-light to-brand-dark shadow-md">
<Sparkles className="h-6 w-6 text-white" />
</div>
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
@@ -123,7 +123,7 @@ export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: Work
onChange={(e) => setPromptValue(e.target.value)}
placeholder={t("workflows.placeholder")}
rows={5}
className="focus:border-brand-dark focus:ring-brand-light/20 w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:outline-none focus:ring-2"
className="w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-brand-light/20"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleGenerateWorkflow();
@@ -156,7 +156,7 @@ export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: Work
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
<Sparkles className="text-brand-dark h-6 w-6" />
<Sparkles className="h-6 w-6 text-brand-dark" />
</div>
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
{t("workflows.coming_soon_title")}
@@ -175,7 +175,7 @@ export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: Work
onChange={(e) => setDetailsValue(e.target.value)}
placeholder={t("workflows.follow_up_placeholder")}
rows={4}
className="focus:border-brand-dark focus:ring-brand-light/20 w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:bg-white focus:outline-none focus:ring-2"
className="w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:border-brand-dark focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-light/20"
/>
<div className="mt-4 flex items-center justify-end gap-3">
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">

View File

@@ -284,7 +284,7 @@ export const AddIntegrationModal = ({
}
handleClose();
} catch (e) {
toast.error(e.message);
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
}
};
@@ -322,7 +322,7 @@ export const AddIntegrationModal = ({
toast.success(t("environments.integrations.integration_removed_successfully"));
} catch (e) {
toast.error(e.message);
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
}
};

View File

@@ -13,7 +13,7 @@ import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const isEnabled = !!AIRTABLE_CLIENT_ID;

View File

@@ -195,7 +195,7 @@ export const AddIntegrationModal = ({
resetForm();
setOpen(false);
} catch (e) {
toast.error(e.message);
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
} finally {
setIsLinkingSheet(false);
}
@@ -237,7 +237,7 @@ export const AddIntegrationModal = ({
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {
toast.error(error.message);
toast.error(error instanceof Error ? error.message : "Unknown error occurred");
} finally {
setIsDeleting(false);
}

View File

@@ -16,7 +16,7 @@ import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);

View File

@@ -234,7 +234,7 @@ export const AddIntegrationModal = ({
resetForm();
setOpen(false);
} catch (e) {
toast.error(e.message);
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
} finally {
setIsLinkingDatabase(false);
}
@@ -255,7 +255,7 @@ export const AddIntegrationModal = ({
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {
toast.error(error.message);
toast.error(error instanceof Error ? error.message : "Unknown error occurred");
} finally {
setIsDeleting(false);
}

View File

@@ -65,7 +65,7 @@ const MappingErrorMessage = ({
question_label: element.label,
col_name: col.name,
col_type: col.type,
mapped_type: TYPE_MAPPING[element.id].join(" ,"),
mapped_type: (TYPE_MAPPING as Record<string, string[]>)[element.id].join(" ,"),
})}
</>
);
@@ -135,7 +135,7 @@ export const MappingRow = ({
return copy;
}
const isValidColType = TYPE_MAPPING[item.type].includes(col.type);
const isValidColType = (TYPE_MAPPING as Record<string, string[]>)[item.type]?.includes(col.type);
if (!isValidColType) {
copy[idx] = {
...copy[idx],
@@ -166,7 +166,7 @@ export const MappingRow = ({
return copy;
}
const isValidElemType = TYPE_MAPPING[elem.type].includes(item.type);
const isValidElemType = (TYPE_MAPPING as Record<string, string[]>)[elem.type]?.includes(item.type);
if (!isValidElemType) {
copy[idx] = {
...copy[idx],

View File

@@ -36,7 +36,7 @@ export const NotionWrapper = ({
}: NotionWrapperProps) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isConnected, setIsConnected] = useState(
notionIntegration ? notionIntegration.config.key?.bot_id : false
notionIntegration ? !!notionIntegration.config.key?.bot_id : false
);
const [selectedIntegration, setSelectedIntegration] = useState<
(TIntegrationNotionConfigData & { index: number }) | null

View File

@@ -18,7 +18,7 @@ import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const enabled = !!(

View File

@@ -27,7 +27,7 @@ const getStatusText = (count: number, t: TFunction, type: string) => {
return `${count} ${type}s`;
};
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();

View File

@@ -161,7 +161,7 @@ export const AddChannelMappingModal = ({
resetForm();
setOpen(false);
} catch (e) {
toast.error(e.message);
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
} finally {
setIsLinkingChannel(false);
}
@@ -200,7 +200,7 @@ export const AddChannelMappingModal = ({
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {
toast.error(error.message);
toast.error(error instanceof Error ? error.message : "Unknown error occurred");
} finally {
setIsDeleting(false);
}

View File

@@ -30,7 +30,7 @@ export const SlackWrapper = ({
webAppUrl,
locale,
}: SlackWrapperProps) => {
const [isConnected, setIsConnected] = useState(slackIntegration ? slackIntegration.config?.key : false);
const [isConnected, setIsConnected] = useState(slackIntegration ? !!slackIntegration.config?.key : false);
const [slackChannels, setSlackChannels] = useState<TIntegrationItem[]>([]);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [showReconnectButton, setShowReconnectButton] = useState<boolean>(false);

View File

@@ -11,7 +11,7 @@ import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);

View File

@@ -1,3 +1,3 @@
import { LanguagesLoading } from "@/modules/ee/languages/loading";
import { LanguagesLoading } from "@/modules/projects/settings/languages/loading";
export default LanguagesLoading;

View File

@@ -1,3 +1,3 @@
import { LanguagesPage } from "@/modules/ee/languages/page";
import { LanguagesPage } from "@/modules/projects/settings/languages/page";
export default LanguagesPage;

View File

@@ -7,7 +7,7 @@ import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
const AppLayout = async ({ children }) => {
const AppLayout = async ({ children }: { children: React.ReactNode }) => {
const session = await getServerSession(authOptions);
const user = session?.user?.id ? await getUser(session.user.id) : null;

View File

@@ -1,6 +1,6 @@
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
const AppLayout = async ({ children }) => {
const AppLayout = async ({ children }: { children: React.ReactNode }) => {
return (
<>
<NoMobileOverlay />

View File

@@ -169,6 +169,9 @@ export const mockSurvey: TSurvey = {
segment: null,
followUps: mockFollowUps,
metadata: {},
blocks: [],
isCaptureIpEnabled: false,
slug: null,
};
export const mockContactQuestion: TSurveyContactInfoQuestion = {

View File

@@ -156,7 +156,7 @@ const handleAirtableIntegration = async (
} catch (err) {
return {
ok: false,
error: err,
error: err instanceof Error ? err : new Error(String(err)),
};
}
};
@@ -197,7 +197,7 @@ const handleGoogleSheetsIntegration = async (
} catch (err) {
return {
ok: false,
error: err,
error: err instanceof Error ? err : new Error(String(err)),
};
}
};
@@ -239,7 +239,7 @@ const handleSlackIntegration = async (
} catch (err) {
return {
ok: false,
error: err,
error: err instanceof Error ? err : new Error(String(err)),
};
}
};
@@ -349,7 +349,7 @@ const handleNotionIntegration = async (
} catch (err) {
return {
ok: false,
error: err,
error: err instanceof Error ? err : new Error(String(err)),
};
}
};
@@ -374,8 +374,8 @@ const buildNotionPayloadProperties = (
const pictureElement = surveyElements.find((el) => el.id === resp);
responses[resp] = (pictureElement as any)?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
.filter((choice: { id: string; imageUrl: string }) => selectedChoiceIds.includes(choice.id))
.map((choice: { id: string; imageUrl: string }) => resolveStorageUrlAuto(choice.imageUrl));
}
});

View File

@@ -1,5 +1,7 @@
import * as Sentry from "@sentry/nextjs";
import NextAuth from "next-auth";
import NextAuth, { Account, Profile, User } from "next-auth";
import { AdapterUser } from "next-auth/adapters";
import { CredentialInput } from "next-auth/providers/credentials";
import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
@@ -73,7 +75,19 @@ const handler = async (req: Request, ctx: any) => {
if (error) throw error;
return result;
},
async signIn({ user, account, profile, email, credentials }) {
async signIn({
user,
account,
profile,
email,
credentials,
}: {
user: User | AdapterUser;
account: Account | null;
profile?: Profile;
email?: { verificationRequest?: boolean };
credentials?: Record<string, CredentialInput>;
}) {
let result: boolean | string = true;
let error: any = undefined;
let authMethod = "unknown";

View File

@@ -8,7 +8,9 @@ import { getContactByUserId } from "./contact";
import { createDisplay } from "./display";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn((inputs) => inputs.map((input) => input[0])), // Pass through validation for testing
validateInputs: vi.fn((inputs: [unknown, unknown][]) =>
inputs.map((input: [unknown, unknown]) => input[0])
), // Pass through validation for testing
}));
vi.mock("@formbricks/database", () => ({

View File

@@ -1,19 +1,12 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display";
interface Context {
params: Promise<{
environmentId: string;
}>;
}
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
@@ -25,7 +18,7 @@ export const OPTIONS = async (): Promise<Response> => {
};
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
const params = await props.params;
const jsonInput = await req.json();
const inputValidation = ZDisplayCreateInput.safeParse({

View File

@@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TActionClass } from "@formbricks/types/action-classes";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TJsEnvironmentState, TJsEnvironmentStateProject } from "@formbricks/types/js";

View File

@@ -1,10 +1,9 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
@@ -19,13 +18,7 @@ export const OPTIONS = async (): Promise<Response> => {
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ environmentId: string }> };
}) => {
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
const params = await props.params;
try {
@@ -102,7 +95,10 @@ export const GET = withV1ApiWrapper({
"Error in GET /api/v1/client/[environmentId]/environment"
);
return {
response: responses.internalServerErrorResponse(err.message, true),
response: responses.internalServerErrorResponse(
err instanceof Error ? err.message : "Unknown error occurred",
true
),
};
}
},

View File

@@ -1,11 +1,11 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -63,13 +63,7 @@ const validateResponse = (
};
export const PUT = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ responseId: string }> };
}) => {
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ responseId: string }> }>) => {
const params = await props.params;
const { responseId } = params;
@@ -98,7 +92,18 @@ export const PUT = withV1ApiWrapper({
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(error, req.url, endpoint, responseId),
response: handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
endpoint,
responseId
),
};
}
if (!response) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
@@ -115,7 +120,18 @@ export const PUT = withV1ApiWrapper({
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(error, req.url, endpoint, responseId),
response: handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
endpoint,
responseId
),
};
}
if (!survey) {
return {
response: responses.notFoundResponse("Survey", response.surveyId, true),
};
}
@@ -128,7 +144,7 @@ export const PUT = withV1ApiWrapper({
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: inputValidation.data.data,
surveyQuestions: survey.questions,
surveyQuestions: survey.questions as unknown as TSurveyElement[],
responseLanguage: inputValidation.data.language,
});
@@ -173,6 +189,14 @@ export const PUT = withV1ApiWrapper({
response: responses.internalServerErrorResponse(error.message),
};
}
logger.error(
{ error, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse("Something went wrong"),
};
}
const { quotaFull, ...responseData } = updatedResponse;

View File

@@ -19,7 +19,7 @@ vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn((fn) => fn), // Mock react's cache to just return the function
cache: vi.fn((fn: Function) => fn), // Mock react's cache to just return the function
};
});
@@ -34,7 +34,12 @@ describe("Contact API Lib", () => {
describe("getContact", () => {
test("should return contact if found", async () => {
const mockContactData = { id: mockContactId };
const mockContactData = {
id: mockContactId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
};
vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContactData);
const contact = await getContact(mockContactId);
@@ -77,12 +82,17 @@ describe("Contact API Lib", () => {
test("should return contact with formatted attributes if found", async () => {
const mockContactData = {
id: mockContactId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
attributes: [
{ attributeKey: { key: "userId" }, value: mockUserId },
{ attributeKey: { key: "email" }, value: "test@example.com" },
],
};
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactData);
vi.mocked(prisma.contact.findFirst).mockResolvedValue(
mockContactData as Awaited<ReturnType<typeof prisma.contact.findFirst>>
);
const contact = await getContactByUserId(mockEnvironmentId, mockUserId);

View File

@@ -54,10 +54,10 @@ export const getContactByUserId = reactCache(
return null;
}
const contactAttributes = contact.attributes.reduce((acc, attr) => {
const contactAttributes = contact.attributes.reduce<TContactAttributes>((acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
}, {}) as TContactAttributes;
}, {});
return {
id: contact.id,

View File

@@ -115,7 +115,7 @@ describe("createResponse", () => {
test("should handle finished response and calculate TTC", async () => {
const finishedInput = { ...mockResponseInput, finished: true };
await createResponse(finishedInput);
await createResponse(finishedInput, prisma);
expect(calculateTtcTotal).toHaveBeenCalledWith(mockResponseInput.ttc);
expect(prisma.response.create).toHaveBeenCalledWith(
expect.objectContaining({
@@ -135,13 +135,13 @@ describe("createResponse", () => {
clientVersion: "test",
});
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(DatabaseError);
});
test("should throw original error on other Prisma errors", async () => {
const genericError = new Error("Generic database error");
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(genericError);
});
});
@@ -170,7 +170,7 @@ describe("createResponseWithQuotaEvaluation", () => {
quotaFull: undefined,
});
const result = await createResponseWithQuotaEvaluation(mockResponseInput, mockTx);
const result = await createResponseWithQuotaEvaluation(mockResponseInput);
expect(evaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: mockResponseInput.surveyId,
@@ -224,7 +224,7 @@ describe("createResponseWithQuotaEvaluation", () => {
quotaFull: mockQuotaFull,
});
const result = await createResponseWithQuotaEvaluation(mockResponseInput, mockTx);
const result = await createResponseWithQuotaEvaluation(mockResponseInput);
expect(evaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: mockResponseInput.surveyId,
@@ -278,7 +278,7 @@ describe("createResponseWithQuotaEvaluation", () => {
quotaFull: mockQuotaFull,
});
const result = await createResponseWithQuotaEvaluation(mockResponseInput, mockTx);
const result = await createResponseWithQuotaEvaluation(mockResponseInput);
expect(result).toEqual({
id: responseId,

View File

@@ -1,5 +1,4 @@
import { headers } from "next/headers";
import { NextRequest } from "next/server";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
@@ -9,7 +8,7 @@ import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
@@ -19,12 +18,6 @@ import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation } from "./lib/response";
interface Context {
params: Promise<{
environmentId: string;
}>;
}
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
@@ -56,7 +49,7 @@ const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) =>
};
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
const params = await props.params;
const requestHeaders = await headers();
let responseInput;
@@ -66,7 +59,7 @@ export const POST = withV1ApiWrapper({
return {
response: responses.badRequestResponse(
"Invalid JSON in request body",
{ error: error.message },
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
),
};
@@ -183,7 +176,9 @@ export const POST = withV1ApiWrapper({
} else {
logger.error({ error, url: req.url }, "Error creating response");
return {
response: responses.internalServerErrorResponse(error.message),
response: responses.internalServerErrorResponse(
error instanceof Error ? error.message : "Unknown error occurred"
),
};
}
}

View File

@@ -1,9 +1,8 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TUploadPrivateFileRequest, ZUploadPrivateFileRequest } from "@formbricks/types/storage";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/service";
@@ -12,12 +11,6 @@ import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/li
import { getSignedUrlForUpload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
interface Context {
params: Promise<{
environmentId: string;
}>;
}
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
@@ -34,7 +27,7 @@ export const OPTIONS = async (): Promise<Response> => {
// use this to let users upload files to a file upload question response for example
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
const params = await props.params;
const { environmentId } = params;
let jsonInput: TUploadPrivateFileRequest;

View File

@@ -1,8 +1,7 @@
import { NextRequest } from "next/server";
import * as z from "zod";
import { logger } from "@formbricks/logger";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
@@ -21,13 +20,11 @@ const getEmail = async (token: string) => {
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
handler: async ({ req, authentication }) => {
if (!authentication || !("user" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
@@ -97,7 +94,9 @@ export const GET = withV1ApiWrapper({
} catch (error) {
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
return {
response: responses.internalServerErrorResponse(error),
response: responses.internalServerErrorResponse(
error instanceof Error ? error.message : String(error)
),
};
}
},

View File

@@ -1,20 +1,17 @@
import crypto from "crypto";
import { NextRequest } from "next/server";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
const scope = `data.records:read data.records:write schema.bases:read schema.bases:write user.email:read`;
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
handler: async ({ req, authentication }) => {
if (!authentication || !("user" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
const environmentId = req.headers.get("environmentId");
if (!environmentId) {

View File

@@ -1,20 +1,17 @@
import { NextRequest } from "next/server";
import * as z from "zod";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getTables } from "@/lib/airtable/service";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getIntegrationByType } from "@/lib/integration/service";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
handler: async ({ req, authentication }) => {
if (!authentication || !("user" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
const url = req.url;
const environmentId = req.headers.get("environmentId");
const queryParams = new URLSearchParams(url.split("?")[1]);

View File

@@ -1,7 +1,6 @@
import { NextRequest } from "next/server";
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import {
ENCRYPTION_KEY,
NOTION_OAUTH_CLIENT_ID,
@@ -14,13 +13,11 @@ import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
handler: async ({ req, authentication }) => {
if (!authentication || !("user" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter

View File

@@ -1,6 +1,5 @@
import { NextRequest } from "next/server";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import {
NOTION_AUTH_URL,
NOTION_OAUTH_CLIENT_ID,
@@ -10,13 +9,11 @@ import {
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
handler: async ({ req, authentication }) => {
if (!authentication || !("user" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
const environmentId = req.headers.get("environmentId");
if (!environmentId) {

View File

@@ -1,23 +1,20 @@
import { NextRequest } from "next/server";
import {
TIntegrationSlackConfig,
TIntegrationSlackConfigData,
TIntegrationSlackCredential,
} from "@formbricks/types/integration/slack";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
handler: async ({ req, authentication }) => {
if (!authentication || !("user" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
@@ -61,7 +58,7 @@ export const GET = withV1ApiWrapper({
const formBody: string[] = [];
for (const property in formData) {
const encodedKey = encodeURIComponent(property);
const encodedValue = encodeURIComponent(formData[property]);
const encodedValue = encodeURIComponent((formData as Record<string, string>)[property]);
formBody.push(encodedKey + "=" + encodedValue);
}
const bodyString = formBody.join("&");

View File

@@ -1,17 +1,15 @@
import { NextRequest } from "next/server";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_AUTH_URL, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
handler: async ({ req, authentication }) => {
// session authentication
if (!authentication || !("user" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
const environmentId = req.headers.get("environmentId");
if (!environmentId) {

View File

@@ -1,11 +1,10 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
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 { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -32,10 +31,11 @@ export const GET = withV1ApiWrapper({
handler: async ({
props,
authentication,
}: {
props: { params: Promise<{ actionClassId: string }> };
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
}: THandlerParams<{ params: Promise<{ actionClassId: string }> }>) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
const params = await props.params;
try {
@@ -62,12 +62,11 @@ export const PUT = withV1ApiWrapper({
props,
auditLog,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ actionClassId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
}: THandlerParams<{ params: Promise<{ actionClassId: string }> }>) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
const params = await props.params;
try {
@@ -77,7 +76,10 @@ export const PUT = withV1ApiWrapper({
response: responses.notFoundResponse("Action Class", params.actionClassId),
};
}
auditLog.oldObject = actionClass;
if (auditLog) {
auditLog.oldObject = actionClass;
}
let actionClassUpdate;
try {
@@ -104,7 +106,10 @@ export const PUT = withV1ApiWrapper({
inputValidation.data
);
if (updatedActionClass) {
auditLog.newObject = updatedActionClass;
if (auditLog) {
auditLog.newObject = updatedActionClass;
}
return {
response: responses.successResponse(updatedActionClass),
};
@@ -127,14 +132,16 @@ export const DELETE = withV1ApiWrapper({
props,
auditLog,
authentication,
}: {
props: { params: Promise<{ actionClassId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
}: THandlerParams<{ params: Promise<{ actionClassId: string }> }>) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
const params = await props.params;
auditLog.targetId = params.actionClassId;
if (auditLog) {
auditLog.targetId = params.actionClassId;
}
try {
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
@@ -144,7 +151,9 @@ export const DELETE = withV1ApiWrapper({
};
}
auditLog.oldObject = actionClass;
if (auditLog) {
auditLog.oldObject = actionClass;
}
const deletedActionClass = await deleteActionClass(params.actionClassId);
return {

View File

@@ -20,9 +20,9 @@ describe("getActionClasses", () => {
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Action 1",
description: "Test Description 1",
type: "click",
key: "test-key-1",
description: "Test Description 1" as string | null,
type: "code" as const,
key: "test-key-1" as string | null,
noCodeConfig: {},
environmentId: "env1",
},
@@ -31,9 +31,9 @@ describe("getActionClasses", () => {
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Action 2",
description: "Test Description 2",
type: "pageview",
key: "test-key-2",
description: "Test Description 2" as string | null,
type: "noCode" as const,
key: "test-key-2" as string | null,
noCodeConfig: {},
environmentId: "env2",
},

View File

@@ -1,16 +1,19 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
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 { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { createActionClass } from "@/lib/actionClass/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { getActionClasses } from "./lib/action-classes";
export const GET = withV1ApiWrapper({
handler: async ({ authentication }: { authentication: NonNullable<TApiKeyAuthentication> }) => {
handler: async ({ authentication }) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
try {
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
@@ -33,15 +36,11 @@ export const GET = withV1ApiWrapper({
});
export const POST = withV1ApiWrapper({
handler: async ({
req,
auditLog,
authentication,
}: {
req: NextRequest;
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
handler: async ({ req, auditLog, authentication }: THandlerParams) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
try {
let actionClassInput;
try {
@@ -73,8 +72,10 @@ export const POST = withV1ApiWrapper({
}
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
auditLog.targetId = actionClass.id;
auditLog.newObject = actionClass;
if (auditLog) {
auditLog.targetId = actionClass.id;
auditLog.newObject = actionClass;
}
return {
response: responses.successResponse(actionClass),
};

View File

@@ -102,7 +102,9 @@ const checkRateLimit = async (userId: string) => {
try {
await applyRateLimit(rateLimitConfigs.api.v1, userId);
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
return responses.tooManyRequestsResponse(
error instanceof Error ? error.message : "Unknown error occurred"
);
}
return null;
};

View File

@@ -1,10 +1,9 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { handleErrorResponse } from "@/app/api/v1/auth";
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 { TApiV1Authentication, THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -15,10 +14,10 @@ import { updateResponseWithQuotaEvaluation } from "./lib/response";
async function fetchAndAuthorizeResponse(
responseId: string,
authentication: TApiKeyAuthentication,
authentication: TApiV1Authentication | undefined,
requiredPermission: "GET" | "PUT" | "DELETE"
) {
if (!authentication) {
if (!authentication || !("apiKeyId" in authentication)) {
return { error: responses.notAuthenticatedResponse() };
}
@@ -40,13 +39,7 @@ async function fetchAndAuthorizeResponse(
}
export const GET = withV1ApiWrapper({
handler: async ({
props,
authentication,
}: {
props: { params: Promise<{ responseId: string }> };
authentication: TApiKeyAuthentication;
}) => {
handler: async ({ props, authentication }: THandlerParams<{ params: Promise<{ responseId: string }> }>) => {
const params = await props.params;
try {
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET");
@@ -75,13 +68,11 @@ export const DELETE = withV1ApiWrapper({
props,
auditLog,
authentication,
}: {
props: { params: Promise<{ responseId: string }> };
auditLog: TApiAuditLog;
authentication: TApiKeyAuthentication;
}) => {
}: THandlerParams<{ params: Promise<{ responseId: string }> }>) => {
const params = await props.params;
auditLog.targetId = params.responseId;
if (auditLog) {
auditLog.targetId = params.responseId;
}
try {
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
if (result.error) {
@@ -89,7 +80,9 @@ export const DELETE = withV1ApiWrapper({
response: result.error,
};
}
auditLog.oldObject = result.response;
if (auditLog) {
auditLog.oldObject = result.response;
}
const deletedResponse = await deleteResponse(params.responseId);
return {
@@ -111,14 +104,11 @@ export const PUT = withV1ApiWrapper({
props,
auditLog,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ responseId: string }> };
auditLog: TApiAuditLog;
authentication: TApiKeyAuthentication;
}) => {
}: THandlerParams<{ params: Promise<{ responseId: string }> }>) => {
const params = await props.params;
auditLog.targetId = params.responseId;
if (auditLog) {
auditLog.targetId = params.responseId;
}
try {
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
if (result.error) {
@@ -126,7 +116,9 @@ export const PUT = withV1ApiWrapper({
response: result.error,
};
}
auditLog.oldObject = result.response;
if (auditLog) {
auditLog.oldObject = result.response;
}
let responseUpdate;
try {
@@ -173,7 +165,9 @@ export const PUT = withV1ApiWrapper({
}
const updated = await updateResponseWithQuotaEvaluation(params.responseId, inputValidation.data);
auditLog.newObject = updated;
if (auditLog) {
auditLog.newObject = updated;
}
sendToPipeline({
event: "responseUpdated",

View File

@@ -18,6 +18,9 @@ const contactId = "test-contact-id";
const mockContactDbData = {
id: contactId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId,
attributes: [
{ attributeKey: { key: "userId" }, value: userId },
{ attributeKey: { key: "email" }, value: "test@example.com" },
@@ -33,7 +36,9 @@ const expectedContactAttributes: TContactAttributes = {
describe("getContactByUserId", () => {
test("should return contact with attributes when found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactDbData);
vi.mocked(prisma.contact.findFirst).mockResolvedValue(
mockContactDbData as Awaited<ReturnType<typeof prisma.contact.findFirst>>
);
const contact = await getContactByUserId(environmentId, userId);

View File

@@ -38,10 +38,10 @@ export const getContactByUserId = reactCache(
return null;
}
const contactAttributes = contact.attributes.reduce((acc, attr) => {
const contactAttributes = contact.attributes.reduce<TContactAttributes>((acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
}, {}) as TContactAttributes;
}, {});
return {
id: contact.id,

View File

@@ -154,7 +154,10 @@ describe("Response Lib Tests", () => {
...mockResponsePrisma,
});
const response = await createResponse(mockResponseInputWithUserId, mockTx);
const response = await createResponse(
mockResponseInputWithUserId,
mockTx as unknown as Prisma.TransactionClient
);
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, mockUserId);
@@ -171,7 +174,9 @@ describe("Response Lib Tests", () => {
test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(ResourceNotFoundError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(ResourceNotFoundError);
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
expect(mockTx.response.create).not.toHaveBeenCalled();
});
@@ -184,7 +189,9 @@ describe("Response Lib Tests", () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(DatabaseError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError
});
@@ -196,8 +203,12 @@ describe("Response Lib Tests", () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(DatabaseError);
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow("Display ID does not exist");
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow("Display ID does not exist");
});
test("should handle generic errors", async () => {
@@ -205,7 +216,9 @@ describe("Response Lib Tests", () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(mockTx.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(genericError);
});
});

View File

@@ -1,10 +1,9 @@
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 { 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 { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
@@ -17,13 +16,11 @@ import {
} from "./lib/response";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
handler: async ({ req, authentication }) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
const searchParams = req.nextUrl.searchParams;
const surveyId = searchParams.get("surveyId");
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined;
@@ -113,15 +110,11 @@ const validateSurvey = async (responseInput: TResponseInput, environmentId: stri
};
export const POST = withV1ApiWrapper({
handler: async ({
req,
auditLog,
authentication,
}: {
req: NextRequest;
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
handler: async ({ req, auditLog, authentication }) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
try {
const inputResult = await validateInput(req);
if (inputResult.error) {
@@ -176,8 +169,10 @@ export const POST = withV1ApiWrapper({
try {
const response = await createResponseWithQuotaEvaluation(responseInput);
auditLog.targetId = response.id;
auditLog.newObject = response;
if (auditLog) {
auditLog.targetId = response.id;
auditLog.newObject = response;
}
sendToPipeline({
event: "responseCreated",
@@ -208,7 +203,9 @@ export const POST = withV1ApiWrapper({
}
return {
response: responses.internalServerErrorResponse(error.message),
response: responses.internalServerErrorResponse(
error instanceof Error ? error.message : "Unknown error occurred"
),
};
}
} catch (error) {

View File

@@ -3,7 +3,7 @@ import { TApiV1Authentication } from "@/app/lib/api/with-api-logging";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
export const checkAuth = async (authentication: TApiV1Authentication, environmentId: string) => {
export const checkAuth = async (authentication: TApiV1Authentication | undefined, environmentId: string) => {
if (!authentication) {
return responses.notAuthenticatedResponse();
}

View File

@@ -1,10 +1,9 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TUploadPublicFileRequest, ZUploadPublicFileRequest } from "@formbricks/types/storage";
import { checkAuth } from "@/app/api/v1/management/storage/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getSignedUrlForUpload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
@@ -15,7 +14,7 @@ import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
// use this to get a signed url for uploading a public file for a specific resource, e.g. a survey's background image
export const POST = withV1ApiWrapper({
handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => {
handler: async ({ req, authentication }) => {
let storageInput: TUploadPublicFileRequest;
try {

View File

@@ -1,4 +1,3 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
@@ -12,7 +11,7 @@ import {
validateSurveyInput,
} from "@/app/lib/api/survey-transformation";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -35,13 +34,11 @@ const fetchAndAuthorizeSurvey = async (
};
export const GET = withV1ApiWrapper({
handler: async ({
props,
authentication,
}: {
props: { params: Promise<{ surveyId: string }> };
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
handler: async ({ props, authentication }: THandlerParams<{ params: Promise<{ surveyId: string }> }>) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
const params = await props.params;
try {
@@ -85,13 +82,15 @@ export const DELETE = withV1ApiWrapper({
props,
auditLog,
authentication,
}: {
props: { params: Promise<{ surveyId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
}: THandlerParams<{ params: Promise<{ surveyId: string }> }>) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
const params = await props.params;
auditLog.targetId = params.surveyId;
if (auditLog) {
auditLog.targetId = params.surveyId;
}
try {
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
if (result.error) {
@@ -99,7 +98,9 @@ export const DELETE = withV1ApiWrapper({
response: result.error,
};
}
auditLog.oldObject = result.survey;
if (auditLog) {
auditLog.oldObject = result.survey;
}
const deletedSurvey = await deleteSurvey(params.surveyId);
return {
@@ -121,14 +122,15 @@ export const PUT = withV1ApiWrapper({
props,
auditLog,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ surveyId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
}: THandlerParams<{ params: Promise<{ surveyId: string }> }>) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
const params = await props.params;
auditLog.targetId = params.surveyId;
if (auditLog) {
auditLog.targetId = params.surveyId;
}
try {
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
if (result.error) {
@@ -136,7 +138,9 @@ export const PUT = withV1ApiWrapper({
response: result.error,
};
}
auditLog.oldObject = result.survey;
if (auditLog) {
auditLog.oldObject = result.survey;
}
const organization = await getOrganizationByEnvironmentId(result.survey.environmentId);
if (!organization) {
@@ -195,7 +199,9 @@ export const PUT = withV1ApiWrapper({
try {
const updatedSurvey = await updateSurvey({ ...inputValidation.data, id: params.surveyId });
auditLog.newObject = updatedSurvey;
if (auditLog) {
auditLog.newObject = updatedSurvey;
}
if (hasQuestions) {
const surveyWithQuestions = {

View File

@@ -1,7 +1,6 @@
import { NextRequest } from "next/server";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
@@ -12,11 +11,11 @@ export const GET = withV1ApiWrapper({
req,
props,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ surveyId: string }> };
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
}: THandlerParams<{ params: Promise<{ surveyId: string }> }>) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
try {
const params = await props.params;
const survey = await getSurvey(params.surveyId);

View File

@@ -24,7 +24,7 @@ vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn((fn) => fn), // Mock reactCache to just execute the function
cache: vi.fn((fn: Function) => fn), // Mock reactCache to just execute the function
};
});
@@ -34,40 +34,42 @@ const surveyId1 = "survey1";
const surveyId2 = "survey2";
const surveyId3 = "survey3";
type PrismaSurvey = Awaited<ReturnType<typeof prisma.survey.findMany>>[number];
const mockSurveyPrisma1 = {
id: surveyId1,
environmentId: environmentId1,
name: "Survey 1",
updatedAt: new Date(),
};
} as unknown as PrismaSurvey;
const mockSurveyPrisma2 = {
id: surveyId2,
environmentId: environmentId1,
name: "Survey 2",
updatedAt: new Date(),
};
} as unknown as PrismaSurvey;
const mockSurveyPrisma3 = {
id: surveyId3,
environmentId: environmentId2,
name: "Survey 3",
updatedAt: new Date(),
};
} as unknown as PrismaSurvey;
const mockSurveyTransformed1: TSurvey = {
...mockSurveyPrisma1,
displayPercentage: null,
segment: null,
} as TSurvey;
} as unknown as TSurvey;
const mockSurveyTransformed2: TSurvey = {
...mockSurveyPrisma2,
displayPercentage: null,
segment: null,
} as TSurvey;
} as unknown as TSurvey;
const mockSurveyTransformed3: TSurvey = {
...mockSurveyPrisma3,
displayPercentage: null,
segment: null,
} as TSurvey;
} as unknown as TSurvey;
describe("getSurveys (Management API)", () => {
beforeEach(() => {

View File

@@ -5,7 +5,7 @@ import {
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { getIsSpamProtectionEnabled, getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkFeaturePermissions } from "./utils";
@@ -18,7 +18,6 @@ vi.mock("@/app/lib/api/response", () => ({
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsSpamProtectionEnabled: vi.fn(),
getMultiLanguagePermission: vi.fn(),
}));
vi.mock("@/modules/survey/follow-ups/lib/utils", () => ({
@@ -97,6 +96,7 @@ const baseSurveyData: TSurveyCreateInputWithEnvironmentId = {
languages: [],
type: "link",
welcomeCard: { enabled: false, showResponseCount: false, timeToFinish: false },
blocks: [],
followUps: [],
};
@@ -151,36 +151,10 @@ describe("checkFeaturePermissions", () => {
expect(result).toBeNull();
});
// Multi-language tests
test("should return forbiddenResponse if multi-language is used but permission denied", async () => {
vi.mocked(getMultiLanguagePermission).mockResolvedValue(false);
const surveyData: TSurveyCreateInputWithEnvironmentId = {
...baseSurveyData,
languages: [mockLanguage],
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
expect(responses.forbiddenResponse).toHaveBeenCalledWith(
"Multi language is not enabled for this organization"
);
});
test("should return null if multi-language is used and permission granted", async () => {
vi.mocked(getMultiLanguagePermission).mockResolvedValue(true);
const surveyData = {
...baseSurveyData,
languages: [mockLanguage],
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeNull();
});
// Combined tests
test("should return null if multiple features are used and all permissions granted", async () => {
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(true);
vi.mocked(getMultiLanguagePermission).mockResolvedValue(true);
const surveyData = {
...baseSurveyData,
recaptcha: { enabled: true, threshold: 0.5 },
@@ -194,7 +168,6 @@ describe("checkFeaturePermissions", () => {
test("should return forbiddenResponse for the first denied feature (recaptcha)", async () => {
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false); // Denied
vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(true);
vi.mocked(getMultiLanguagePermission).mockResolvedValue(true);
const surveyData = {
...baseSurveyData,
recaptcha: { enabled: true, threshold: 0.5 },
@@ -213,7 +186,6 @@ describe("checkFeaturePermissions", () => {
test("should return forbiddenResponse for the first denied feature (follow-ups)", async () => {
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(false); // Denied
vi.mocked(getMultiLanguagePermission).mockResolvedValue(true);
const surveyData = {
...baseSurveyData,
recaptcha: { enabled: true, threshold: 0.5 },

View File

@@ -1,7 +1,7 @@
import { TOrganization } from "@formbricks/types/organizations";
import { TSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { getIsSpamProtectionEnabled, getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
export const checkFeaturePermissions = async (
@@ -22,12 +22,5 @@ export const checkFeaturePermissions = async (
}
}
if (surveyData.languages?.length) {
const isMultiLanguageEnabled = await getMultiLanguagePermission(organization.billing.plan);
if (!isMultiLanguageEnabled) {
return responses.forbiddenResponse("Multi language is not enabled for this organization");
}
}
return null;
};

View File

@@ -1,4 +1,3 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
@@ -10,7 +9,7 @@ import {
validateSurveyInput,
} from "@/app/lib/api/survey-transformation";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -18,13 +17,11 @@ import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getSurveys } from "./lib/surveys";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
handler: async ({ req, authentication }) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
try {
const searchParams = new URL(req.url).searchParams;
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
@@ -70,15 +67,11 @@ export const GET = withV1ApiWrapper({
});
export const POST = withV1ApiWrapper({
handler: async ({
req,
auditLog,
authentication,
}: {
req: NextRequest;
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
handler: async ({ req, auditLog, authentication }) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
try {
let surveyInput;
try {
@@ -141,8 +134,10 @@ export const POST = withV1ApiWrapper({
}
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
auditLog.targetId = survey.id;
auditLog.newObject = survey;
if (auditLog) {
auditLog.targetId = survey.id;
auditLog.newObject = survey;
}
if (hasQuestions) {
const surveyWithQuestions = {

View File

@@ -41,6 +41,7 @@ describe("deleteWebhook", () => {
environmentId: "test-environment-id",
triggers: [],
surveyIds: [],
secret: null,
};
vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedWebhook);
@@ -66,6 +67,7 @@ describe("deleteWebhook", () => {
environmentId: "test-environment-id",
triggers: [],
surveyIds: [],
secret: null,
};
vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedWebhook);
@@ -137,6 +139,7 @@ describe("getWebhook", () => {
environmentId: "test-environment-id",
triggers: [],
surveyIds: [],
secret: null,
};
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(mockedWebhook);
@@ -203,6 +206,7 @@ describe("getWebhook", () => {
environmentId: "test-environment-id",
triggers: [],
surveyIds: [],
secret: null,
};
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(mockedWebhook);

View File

@@ -1,18 +1,15 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook";
import { responses } from "@/app/lib/api/response";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
export const GET = withV1ApiWrapper({
handler: async ({
props,
authentication,
}: {
props: { params: Promise<{ webhookId: string }> };
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
handler: async ({ props, authentication }: THandlerParams<{ params: Promise<{ webhookId: string }> }>) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
const params = await props.params;
const webhook = await getWebhook(params.webhookId);
@@ -38,14 +35,15 @@ export const DELETE = withV1ApiWrapper({
props,
auditLog,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ webhookId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
}: THandlerParams<{ params: Promise<{ webhookId: string }> }>) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
const params = await props.params;
auditLog.targetId = params.webhookId;
if (auditLog) {
auditLog.targetId = params.webhookId;
}
// check if webhook exists
const webhook = await getWebhook(params.webhookId);
@@ -60,7 +58,9 @@ export const DELETE = withV1ApiWrapper({
};
}
auditLog.oldObject = webhook;
if (auditLog) {
auditLog.oldObject = webhook;
}
// delete webhook from database
try {
@@ -69,7 +69,9 @@ export const DELETE = withV1ApiWrapper({
response: responses.successResponse(deletedWebhook),
};
} catch (e) {
auditLog.status = "failure";
if (auditLog) {
auditLog.status = "failure";
}
logger.error({ error: e, url: req.url }, "Error deleting webhook");
return {
response: responses.notFoundResponse("Webhook", params.webhookId),

View File

@@ -1,14 +1,17 @@
import { NextRequest } from "next/server";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
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 { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
export const GET = withV1ApiWrapper({
handler: async ({ authentication }: { authentication: NonNullable<TApiKeyAuthentication> }) => {
handler: async ({ authentication }: THandlerParams) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
try {
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
@@ -29,15 +32,11 @@ export const GET = withV1ApiWrapper({
});
export const POST = withV1ApiWrapper({
handler: async ({
req,
auditLog,
authentication,
}: {
req: NextRequest;
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
handler: async ({ req, auditLog, authentication }: THandlerParams) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
}
const webhookInput = await req.json();
const inputValidation = ZWebhookInput.safeParse(webhookInput);
@@ -66,8 +65,10 @@ export const POST = withV1ApiWrapper({
try {
const webhook = await createWebhook(inputValidation.data);
auditLog.targetId = webhook.id;
auditLog.newObject = webhook;
if (auditLog) {
auditLog.targetId = webhook.id;
auditLog.newObject = webhook;
}
return {
response: responses.successResponse(webhook),

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { doesContactExist } from "./contact";
@@ -16,7 +16,7 @@ vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn((fn) => fn), // Mock react's cache to just return the function
cache: vi.fn((fn: Function) => fn), // Mock react's cache to just return the function
};
});
@@ -28,7 +28,12 @@ describe("doesContactExist", () => {
});
test("should return true if contact exists", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: contactId });
vi.mocked(prisma.contact.findFirst).mockResolvedValue({
id: contactId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "test-env",
});
const result = await doesContactExist(contactId);

View File

@@ -8,7 +8,9 @@ import { doesContactExist } from "./contact";
import { createDisplay } from "./display";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn((inputs) => inputs.map((input) => input[0])), // Pass through validation for testing
validateInputs: vi.fn((inputs: [unknown, unknown][]) =>
inputs.map((input: [unknown, unknown]) => input[0])
), // Pass through validation for testing
}));
vi.mock("@formbricks/database", () => ({

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