Compare commits

..

14 Commits

Author SHA1 Message Date
Jakob Schott 74337df278 fixed some of sonarQube errors 2025-04-24 11:36:50 +02:00
victorvhs017 3f16291137 fix: updated encryption algorithm and added sort function (#5485)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-24 07:47:22 +00:00
victorvhs017 a5958d5653 chore: update sonar properties file (#5472) 2025-04-23 22:09:16 +02:00
victorvhs017 fdbdf8207a chore: add js-core to SonarQube check (#5466) 2025-04-23 22:08:31 +02:00
Piyush Gupta 630e5489ec feat: Implement v2 management api endpoint for contact attribute keys (#5316)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-04-23 15:48:18 +00:00
Anshuman Pandey 36943bb786 fix: android sdk callbacks, tweaks and fixes (#5487) 2025-04-23 13:37:22 +00:00
Dhruwang Jariwala e1bbb0a10f fix: billing (#5483) 2025-04-23 09:54:08 +00:00
Piyush Jain 27da540846 chore: fix buckets and iam for staging env (#5475) 2025-04-23 08:24:45 +00:00
Piyush Jain 7d7f6ed04a chore(terraform): add valkey and rds for staging env (#5471) 2025-04-22 16:11:16 +00:00
Vijay ff01bc342d fix: Some DoS (usage of regex) Sonar Security Hotspots (#5334) 2025-04-22 17:16:22 +02:00
Anshuman Pandey cd8b40b569 fix: cleanup issue in iOS package (#5473) 2025-04-22 14:50:28 +00:00
Anshuman Pandey 31c742f7a8 fix: setLanguage and icon issue for the iOS SDK (#5470) 2025-04-22 13:18:28 +00:00
Dhruwang Jariwala d6a7a2c21f fix: x button not visible when close on click outside is not allowed (#5464)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-22 07:13:04 +00:00
Dhruwang Jariwala 499ecab691 chore: update alpine version (#5465) 2025-04-22 06:48:52 +00:00
244 changed files with 3497 additions and 968 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS base
FROM node:22-alpine3.21 AS base
#
## step 1: Prune monorepo
@@ -101,17 +101,17 @@ export const OnboardingSetupInstructions = ({
<div>
{activeTab === "npm" ? (
<div className="prose prose-slate w-full">
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="sh">
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
npm install @formbricks/js
</CodeBlock>
<p>{t("common.or")}</p>
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="sh">
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
yarn add @formbricks/js
</CodeBlock>
<p className="text-sm text-slate-700">
{t("environments.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
</p>
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="js">
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
</CodeBlock>
<Button id="onboarding-inapp-connect-read-npm-docs" className="mt-3" variant="secondary" asChild>
@@ -125,11 +125,11 @@ export const OnboardingSetupInstructions = ({
</div>
) : activeTab === "html" ? (
<div className="prose prose-slate">
<p className="mt-6 -mb-1 text-sm text-slate-700">
<p className="-mb-1 mt-6 text-sm text-slate-700">
{t("environments.connect.insert_this_code_into_the_head_tag_of_your_website")}
</p>
<div>
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="js">
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
{channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys}
</CodeBlock>
</div>
@@ -44,7 +44,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel}
/>
<Button
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}`}>
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<Button
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>
@@ -80,7 +80,7 @@ export const LandingSidebar = ({
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-hidden">
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
<>
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>
@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>
@@ -218,7 +218,7 @@ export const ProjectSettings = ({
</FormProvider>
</div>
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow-sm">
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
{logoUrl && (
<Image
src={logoUrl}
@@ -65,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/>
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>
@@ -36,7 +36,7 @@ export const ActionClassesTable = ({
return (
<>
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
{TableHeading}
<div id="actionClassesWrapper" className="flex flex-col">
{actionClasses.length > 0 ? (
@@ -14,7 +14,9 @@ export const ActionClassDataRow = ({
<div className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex items-center">
<div className="h-5 w-5 shrink-0 text-slate-500">{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}</div>
<div className="h-5 w-5 flex-shrink-0 text-slate-500">
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
</div>
<div className="ml-4 text-left">
<div className="font-medium text-slate-900">{actionClass.name}</div>
<div className="text-xs text-slate-400">{actionClass.description}</div>
@@ -10,7 +10,7 @@ const Loading = () => {
<>
<PageContentWrapper>
<PageHeader pageTitle={t("common.actions")} />
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-6 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<span className="sr-only">{t("common.edit")}</span>
<div className="col-span-4 pl-6">{t("environments.actions.user_actions")}</div>
@@ -22,7 +22,7 @@ const Loading = () => {
className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex items-center">
<div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-slate-200 text-slate-500" />
<div className="h-6 w-6 flex-shrink-0 animate-pulse rounded-full bg-slate-200 text-slate-500" />
<div className="ml-4 text-left">
<div className="font-medium text-slate-900">
<div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-slate-200"></div>
@@ -33,7 +33,7 @@ const Loading = () => {
</div>
</div>
</div>
<div className="col-span-2 my-auto flex justify-center text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-2 my-auto flex justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
@@ -265,7 +265,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-hidden"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -332,7 +332,7 @@ export const MainNavigation = ({
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-hidden">
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div
tabIndex={0}
className={cn(
@@ -18,7 +18,7 @@ export const TopControlBar = ({
}: SideBarProps) => {
return (
<div className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
<div className="z-10 shadow-2xs">
<div className="shadow-xs z-10">
<div className="flex w-fit items-center space-x-2 py-2">
<TopControlButtons
environment={environment}
@@ -56,7 +56,7 @@ export const EditAlerts = ({
<TooltipTrigger>
<div className="col-span-1 flex cursor-default items-center justify-center space-x-2">
<span>{t("environments.settings.notifications.every_response")}</span>
<HelpCircleIcon className="h-4 w-4 shrink-0 text-slate-500" />
<HelpCircleIcon className="h-4 w-4 flex-shrink-0 text-slate-500" />
</div>
</TooltipTrigger>
<TooltipContent>
@@ -99,7 +99,7 @@ export const EditAlerts = ({
))}
</div>
) : (
<div className="m-2 flex h-16 items-center justify-center rounded-sm bg-slate-50 text-sm text-slate-500">
<div className="m-2 flex h-16 items-center justify-center rounded bg-slate-50 text-sm text-slate-500">
<p>{t("common.no_surveys_found")}</p>
</div>
)}
@@ -11,7 +11,7 @@ export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
const { t } = useTranslate();
return (
<div>
<div className="flex max-w-4xl items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-xs md:space-y-0 md:text-base">
<div className="flex max-w-4xl items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base">
<SlackIcon className="mr-3 h-4 w-4 text-blue-400" />
<p className="text-sm">
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
@@ -105,7 +105,7 @@ export const EditProfileAvatarForm = ({ session, environmentId, imageUrl }: Edit
<div>
<div className="relative h-10 w-10 overflow-hidden rounded-full">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
@@ -97,7 +97,7 @@ const Page = async (props) => {
</PageHeader>
{isEnterpriseEdition ? (
<div>
<div className="mt-8 max-w-4xl rounded-lg border border-slate-300 bg-slate-100 shadow-xs">
<div className="mt-8 max-w-4xl rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
<div className="space-y-4 p-8">
<div className="flex items-center gap-x-2">
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
@@ -152,7 +152,7 @@ const Page = async (props) => {
</p>
</div>
</div>
<div className="mt-8 rounded-lg border border-slate-300 bg-slate-100 shadow-xs">
<div className="mt-8 rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
<div className="p-8">
<h2 className="mr-2 inline-flex text-2xl font-bold text-slate-700">
{t("environments.settings.enterprise.enterprise_features")}
@@ -25,7 +25,7 @@ export const SettingsCard = ({
return (
<div
className={cn(
"relative my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 text-left shadow-xs",
"relative my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 text-left shadow-sm",
className
)}
id={title}>
@@ -36,7 +36,7 @@ export const ResponseTableCell = ({
// Conditional rendering of maximize icon
const renderMaximizeIcon = cell.column.id === "createdAt" && (
<div
className="hidden shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300"
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300"
onClick={handleCellClick}>
<Maximize2Icon className="h-4 w-4" />
</div>
@@ -20,7 +20,7 @@ interface AddressSummaryProps {
export const AddressSummary = ({ questionSummary, environmentId, survey, locale }: AddressSummaryProps) => {
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div>
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
@@ -16,7 +16,7 @@ export const CTASummary = ({ questionSummary, survey }: CTASummaryProps) => {
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
survey={survey}
questionSummary={questionSummary}
@@ -40,7 +40,7 @@ export const CTASummary = ({ questionSummary, survey }: CTASummaryProps) => {
</>
}
/>
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700">CTR</p>
@@ -16,9 +16,9 @@ export const CalSummary = ({ questionSummary, survey }: CalSummaryProps) => {
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
@@ -39,9 +39,9 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
},
];
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{summaryItems.map((summaryItem) => {
return (
<div
@@ -25,7 +25,7 @@ export const ContactInfoSummary = ({
}: ContactInfoSummaryProps) => {
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div>
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
@@ -46,7 +46,7 @@ export const DateQuestionSummary = ({
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
@@ -36,7 +36,7 @@ export const FileUploadSummary = ({
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
@@ -27,7 +27,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
);
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
<div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>
@@ -45,14 +45,14 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
: [];
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="overflow-x-auto p-6">
{/* Summary Table */}
<table className="mx-auto border-collapse cursor-default text-left">
<thead>
<tr>
<th className="p-4 pt-0 pb-3 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
<th className="p-4 pb-3 pt-0 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
{columns.map((column) => (
<th key={column} className="text-center font-medium">
<TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}>
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
<tbody>
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
<tr key={rowLabel}>
<td className="max-w-60 overflow-hidden p-4 text-ellipsis whitespace-nowrap">
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
</TooltipRenderer>
@@ -83,7 +83,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
)}>
<div
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded-sm p-4 text-sm text-slate-950 hover:outline"
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
onClick={() =>
setFilter(
questionSummary.question.id,
@@ -65,7 +65,7 @@ export const MultipleChoiceSummary = ({
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
@@ -60,16 +60,16 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<div className="cursor-pointer hover:opacity-80" key={group} onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold text-slate-700 capitalize ${group === "dismissed" ? "" : "text-slate-700"}`}>
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
@@ -91,7 +91,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
))}
</div>
<div className="flex justify-center pt-4 pb-4">
<div className="flex justify-center pb-4 pt-4">
<HalfCircle value={questionSummary.score} />
</div>
</div>
@@ -60,7 +60,7 @@ export const OpenTextSummary = ({
];
return (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
@@ -30,7 +30,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
const results = questionSummary.choices;
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
@@ -43,7 +43,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
) : undefined
}
/>
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, index) => (
<div
className="cursor-pointer hover:opacity-80"
@@ -1,6 +1,7 @@
"use client";
import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { useTranslate } from "@tolgee/react";
@@ -23,31 +24,15 @@ export const QuestionSummaryHeader = ({
}: HeadProps) => {
const { t } = useTranslate();
const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type);
// formats the text to highlight specific parts of the text with slashes
const formatTextWithSlashes = (text: string): (string | JSX.Element)[] => {
const regex = /\/(.*?)\\/g;
const parts = text.split(regex);
return parts.map((part, index) => {
// Check if the part was inside slashes
if (index % 2 !== 0) {
return (
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-lg">
@{part}
</span>
);
} else {
return part;
}
});
};
return (
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
<div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{formatTextWithSlashes(
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"]
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"],
"@",
["text-lg"]
)}
</h3>
</div>
@@ -17,16 +17,16 @@ export const RankingSummary = ({ questionSummary, surveyType, survey }: RankingS
});
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
<div key={result.value} className="group cursor-pointer">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<div className="flex w-full items-center">
<span className="mr-2 text-slate-400">#{resultsIdx + 1}</span>
<div className="rounded-sm bg-slate-100 px-2 py-1">{result.value}</div>
<div className="rounded bg-slate-100 px-2 py-1">{result.value}</div>
<span className="ml-auto flex items-center space-x-1">
<span className="font-bold text-slate-600">
#{convertFloatToNDecimal(result.avgRanking, 2)}
@@ -37,7 +37,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
}, [questionSummary]);
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
@@ -50,7 +50,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
</div>
}
/>
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((result) => (
<div
className="cursor-pointer hover:opacity-80"
@@ -43,7 +43,7 @@ const ScrollToTop: React.FC<ScrollToTopProps> = ({ containerId }) => {
return (
<button
onClick={scrollToTop}
className={`fixed right-4 bottom-4 z-1 flex h-10 w-10 justify-center rounded-md bg-slate-500 p-2 text-white transition-opacity ${
className={`fixed bottom-4 right-4 z-[1] flex h-10 w-10 justify-center rounded-md bg-slate-500 p-2 text-white transition-opacity ${
showButton ? "opacity-80" : "opacity-0"
}`}>
@@ -1,11 +1,11 @@
"use client";
import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionIcon } from "@/modules/survey/lib/questions";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { TimerIcon } from "lucide-react";
import { JSX } from "react";
import { TSurvey, TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types";
interface SummaryDropOffsProps {
@@ -20,26 +20,8 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
};
const formatTextWithSlashes = (text: string): (string | JSX.Element)[] => {
const regex = /\/(.*?)\\/g;
const parts = text.split(regex);
return parts.map((part, index) => {
// Check if the part was inside slashes
if (index % 2 !== 0) {
return (
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-lg">
@{part}
</span>
);
} else {
return part;
}
});
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="">
<div className="grid h-10 grid-cols-6 items-center border-y border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-3 pl-4 md:pl-6">{t("common.questions")}</div>
@@ -73,7 +55,9 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
survey,
true,
"default"
)["default"]
)["default"],
"@",
["text-lg"]
)}
</p>
</div>
@@ -17,7 +17,7 @@ const StatCard = ({ label, percentage, value, tooltipText, isLoading }) => {
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-xs">
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
<p className="flex items-center gap-1 text-sm text-slate-600">
{label}
{typeof percentage === "number" && !isNaN(percentage) && !isLoading && (
@@ -101,7 +101,7 @@ export const SummaryMetadata = ({
<TooltipTrigger>
<div
onClick={() => setShowDropOffs(!showDropOffs)}
className="group flex h-full w-full cursor-pointer flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-xs">
className="group flex h-full w-full cursor-pointer flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm">
<span className="text-sm text-slate-600">
{t("environments.surveys.summary.drop_offs")}
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
@@ -99,7 +99,7 @@ export const EmbedView = ({
className={cn(
"rounded-md px-4 py-2",
tab.id === activeId
? "bg-white text-slate-900 shadow-xs"
? "bg-white text-slate-900 shadow-sm"
: "border-transparent text-slate-700 hover:text-slate-900"
)}>
{tab.label}
@@ -389,7 +389,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
onOpenChange={(value) => {
value && handleDatePickerClose();
}}>
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-hidden">
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
<div className="h-auto min-w-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">{t("common.download")}</span>
@@ -166,7 +166,7 @@ export const QuestionFilterComboBox = ({
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-hidden">
<div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<div className="p-2">
<Input
@@ -177,7 +177,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in bg-popover absolute top-0 z-50 max-h-52 w-full overflow-auto rounded-md bg-white outline-hidden">
<div className="animate-in bg-popover absolute top-0 z-50 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
{options?.map((data) => (
@@ -199,7 +199,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded-sm border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
<span>
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
</span>
@@ -86,8 +86,8 @@ export const ResultsShareButton = ({ survey, webAppUrl }: ResultsShareButtonProp
<DropdownMenu>
<DropdownMenuTrigger
asChild
className="focus:bg-muted cursor-pointer border border-slate-200 outline-hidden hover:border-slate-300">
<div className="h-auto min-w-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[7rem] sm:px-6 sm:py-3">
className="focus:bg-muted cursor-pointer border border-slate-200 outline-none hover:border-slate-300">
<div className="min-w-auto h-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[7rem] sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">
{t("environments.surveys.summary.share_results")}
@@ -22,6 +22,10 @@ export const GET = async (
return responses.unauthorizedResponse();
}
if (survey.type !== "link") {
return responses.badRequestResponse("Single use links are only available for link surveys");
}
if (!survey.singleUse || !survey.singleUse.enabled) {
return responses.badRequestResponse("Single use links are not enabled for this survey");
}
+1 -1
View File
@@ -35,7 +35,7 @@ export const GET = async (req: NextRequest) => {
<div tw="flex rounded-2xl absolute -right-2 mt-2">
<a tw={`rounded-xl border border-transparent bg-[${brandColor}] h-18 w-38 opacity-50`}></a>
</div>
<div tw="flex rounded-2xl shadow-sm ">
<div tw="flex rounded-2xl shadow ">
<a
tw={`flex items-center justify-center rounded-xl border border-transparent bg-[${brandColor}] text-2xl text-white h-18 w-38`}>
Begin!
@@ -0,0 +1,7 @@
import {
DELETE,
GET,
PUT,
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route";
export { GET, PUT, DELETE };
@@ -0,0 +1,3 @@
import { GET, POST } from "@/modules/api/v2/management/contact-attribute-keys/route";
export { GET, POST };
@@ -7,7 +7,6 @@ import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUs
vi.mock("@/lib/crypto", () => ({
symmetricEncrypt: vi.fn(),
symmetricDecrypt: vi.fn(),
decryptAES128: vi.fn(),
}));
// Mock constants
+59
View File
@@ -0,0 +1,59 @@
import { createCipheriv, randomBytes } from "crypto";
import { describe, expect, test, vi } from "vitest";
import {
generateLocalSignedUrl,
getHash,
symmetricDecrypt,
symmetricEncrypt,
validateLocalSignedUrl,
} from "./crypto";
vi.mock("./constants", () => ({ ENCRYPTION_KEY: "0".repeat(32) }));
const key = "0".repeat(32);
const plain = "hello";
describe("crypto", () => {
test("encrypt + decrypt roundtrip", () => {
const cipher = symmetricEncrypt(plain, key);
expect(symmetricDecrypt(cipher, key)).toBe(plain);
});
test("decrypt V2 GCM payload", () => {
const iv = randomBytes(16);
const bufKey = Buffer.from(key, "utf8");
const cipher = createCipheriv("aes-256-gcm", bufKey, iv);
let enc = cipher.update(plain, "utf8", "hex");
enc += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
const payload = `${iv.toString("hex")}:${enc}:${tag}`;
expect(symmetricDecrypt(payload, key)).toBe(plain);
});
test("decrypt legacy (single-colon) payload", () => {
const iv = randomBytes(16);
const cipher = createCipheriv("aes256", Buffer.from(key, "utf8"), iv); // NOSONAR typescript:S5542 // We are testing backwards compatibility
let enc = cipher.update(plain, "utf8", "hex");
enc += cipher.final("hex");
const legacy = `${iv.toString("hex")}:${enc}`;
expect(symmetricDecrypt(legacy, key)).toBe(plain);
});
test("getHash returns a non-empty string", () => {
const h = getHash("abc");
expect(typeof h).toBe("string");
expect(h.length).toBeGreaterThan(0);
});
test("signed URL generation & validation", () => {
const { uuid, timestamp, signature } = generateLocalSignedUrl("f", "e", "t");
expect(uuid).toHaveLength(32);
expect(typeof timestamp).toBe("number");
expect(typeof signature).toBe("string");
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, signature, key)).toBe(true);
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, "bad", key)).toBe(false);
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp - 1000 * 60 * 6, signature, key)).toBe(
false
);
});
});
+58 -30
View File
@@ -1,11 +1,12 @@
import crypto from "crypto";
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "crypto";
import { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY } from "./constants";
const ALGORITHM = "aes256";
const ALGORITHM_V1 = "aes256";
const ALGORITHM_V2 = "aes-256-gcm";
const INPUT_ENCODING = "utf8";
const OUTPUT_ENCODING = "hex";
const BUFFER_ENCODING = ENCRYPTION_KEY!.length === 32 ? "latin1" : "hex";
const BUFFER_ENCODING = ENCRYPTION_KEY.length === 32 ? "latin1" : "hex";
const IV_LENGTH = 16; // AES blocksize
/**
@@ -17,15 +18,12 @@ const IV_LENGTH = 16; // AES blocksize
*/
export const symmetricEncrypt = (text: string, key: string) => {
const _key = Buffer.from(key, BUFFER_ENCODING);
const iv = crypto.randomBytes(IV_LENGTH);
// @ts-ignore -- the package needs to be built
const cipher = crypto.createCipheriv(ALGORITHM, _key, iv);
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM_V2, _key, iv);
let ciphered = cipher.update(text, INPUT_ENCODING, OUTPUT_ENCODING);
ciphered += cipher.final(OUTPUT_ENCODING);
const ciphertext = iv.toString(OUTPUT_ENCODING) + ":" + ciphered;
return ciphertext;
const tag = cipher.getAuthTag().toString(OUTPUT_ENCODING);
return `${iv.toString(OUTPUT_ENCODING)}:${ciphered}:${tag}`;
};
/**
@@ -33,38 +31,68 @@ export const symmetricEncrypt = (text: string, key: string) => {
* @param text Value to decrypt
* @param key Key used to decrypt value must be 32 bytes for AES256 encryption algorithm
*/
export const symmetricDecrypt = (text: string, key: string) => {
const symmetricDecryptV1 = (text: string, key: string): string => {
const _key = Buffer.from(key, BUFFER_ENCODING);
const components = text.split(":");
const iv_from_ciphertext = Buffer.from(components.shift() || "", OUTPUT_ENCODING);
// @ts-ignore -- the package needs to be built
const decipher = crypto.createDecipheriv(ALGORITHM, _key, iv_from_ciphertext);
const iv_from_ciphertext = Buffer.from(components.shift() ?? "", OUTPUT_ENCODING);
const decipher = createDecipheriv(ALGORITHM_V1, _key, iv_from_ciphertext);
let deciphered = decipher.update(components.join(":"), OUTPUT_ENCODING, INPUT_ENCODING);
deciphered += decipher.final(INPUT_ENCODING);
return deciphered;
};
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");
/**
*
* @param text Value to decrypt
* @param key Key used to decrypt value must be 32 bytes for AES256 encryption algorithm
*/
// create an aes128 encryption function
export const encryptAES128 = (encryptionKey: string, data: string): string => {
// @ts-ignore -- the package needs to be built
const cipher = createCipheriv("aes-128-ecb", Buffer.from(encryptionKey, "base64"), "");
let encrypted = cipher.update(data, "utf-8", "hex");
encrypted += cipher.final("hex");
return encrypted;
};
// create an aes128 decryption function
export const decryptAES128 = (encryptionKey: string, data: string): string => {
// @ts-ignore -- the package needs to be built
const cipher = createDecipheriv("aes-128-ecb", Buffer.from(encryptionKey, "base64"), "");
let decrypted = cipher.update(data, "hex", "utf-8");
decrypted += cipher.final("utf-8");
const symmetricDecryptV2 = (text: string, key: string): string => {
// split into [ivHex, encryptedHex, tagHex]
const [ivHex, encryptedHex, tagHex] = text.split(":");
const _key = Buffer.from(key, BUFFER_ENCODING);
const iv = Buffer.from(ivHex, OUTPUT_ENCODING);
const decipher = createDecipheriv(ALGORITHM_V2, _key, iv);
decipher.setAuthTag(Buffer.from(tagHex, OUTPUT_ENCODING));
let decrypted = decipher.update(encryptedHex, OUTPUT_ENCODING, INPUT_ENCODING);
decrypted += decipher.final(INPUT_ENCODING);
return decrypted;
};
/**
* Decrypts an encrypted payload, automatically handling multiple encryption versions.
*
* If the payload contains exactly one “:”, it is treated as a legacy V1 format
* and `symmetricDecryptV1` is invoked. Otherwise, it attempts a V2 GCM decryption
* via `symmetricDecryptV2`, falling back to V1 on failure (e.g., authentication
* errors or bad formats).
*
* @param payload - The encrypted string to decrypt.
* @param key - The secret key used for decryption.
* @returns The decrypted plaintext.
*/
export function symmetricDecrypt(payload: string, key: string): string {
// If it's clearly V1 (only one “:”), skip straight to V1
if (payload.split(":").length === 2) {
return symmetricDecryptV1(payload, key);
}
// Otherwise try GCM first, then fall back to CBC
try {
return symmetricDecryptV2(payload, key);
} catch (err) {
logger.warn("AES-GCM decryption failed; refusing to fall back to insecure CBC", err);
throw err;
}
}
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");
export const generateLocalSignedUrl = (
fileName: string,
environmentId: string,
@@ -73,7 +101,7 @@ export const generateLocalSignedUrl = (
const uuid = randomBytes(16).toString("hex");
const timestamp = Date.now();
const data = `${uuid}:${fileName}:${environmentId}:${fileType}:${timestamp}`;
const signature = createHmac("sha256", ENCRYPTION_KEY!).update(data).digest("hex");
const signature = createHmac("sha256", ENCRYPTION_KEY).update(data).digest("hex");
return { signature, uuid, timestamp };
};
@@ -0,0 +1,143 @@
import {
mockLanguage,
mockLanguageId,
mockLanguageInput,
mockLanguageUpdate,
mockProjectId,
mockUpdatedLanguage,
} from "./__mocks__/data.mock";
import { projectCache } from "@/lib/project/cache";
import { getProject } from "@/lib/project/service";
import { surveyCache } from "@/lib/survey/cache";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { TProject } from "@formbricks/types/project";
import { createLanguage, deleteLanguage, updateLanguage } from "../service";
vi.mock("@formbricks/database", () => ({
prisma: {
language: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
// stub out project/service and caches
vi.mock("@/lib/project/service", () => ({
getProject: vi.fn(),
}));
vi.mock("@/lib/project/cache", () => ({
projectCache: { revalidate: vi.fn() },
}));
vi.mock("@/lib/survey/cache", () => ({
surveyCache: { revalidate: vi.fn() },
}));
const fakeProject = {
id: mockProjectId,
environments: [{ id: "env1" }, { id: "env2" }],
} as TProject;
const testInputValidation = async (
service: (projectId: string, ...functionArgs: any[]) => Promise<any>,
...args: any[]
): Promise<void> => {
test("throws ValidationError on bad input", async () => {
await expect(service(...args)).rejects.toThrow(ValidationError);
});
};
describe("createLanguage", () => {
beforeEach(() => {
vi.mocked(getProject).mockResolvedValue(fakeProject);
});
test("happy path creates a new Language", async () => {
vi.mocked(prisma.language.create).mockResolvedValue(mockLanguage);
const result = await createLanguage(mockProjectId, mockLanguageInput);
expect(result).toEqual(mockLanguage);
// projectCache.revalidate called for each env
expect(projectCache.revalidate).toHaveBeenCalledTimes(2);
});
describe("sad path", () => {
testInputValidation(createLanguage, "bad-id", {});
test("throws DatabaseError when PrismaKnownRequestError", async () => {
const err = new Prisma.PrismaClientKnownRequestError("dup", {
code: "P2002",
clientVersion: "1",
});
vi.mocked(prisma.language.create).mockRejectedValue(err);
await expect(createLanguage(mockProjectId, mockLanguageInput)).rejects.toThrow(DatabaseError);
});
});
});
describe("updateLanguage", () => {
beforeEach(() => {
vi.mocked(getProject).mockResolvedValue(fakeProject);
});
test("happy path updates a language", async () => {
const mockUpdatedLanguageWithSurveyLanguage = {
...mockUpdatedLanguage,
surveyLanguages: [
{
id: "surveyLanguageId",
},
],
};
vi.mocked(prisma.language.update).mockResolvedValue(mockUpdatedLanguageWithSurveyLanguage);
const result = await updateLanguage(mockProjectId, mockLanguageId, mockLanguageUpdate);
expect(result).toEqual(mockUpdatedLanguage);
// caches revalidated
expect(projectCache.revalidate).toHaveBeenCalled();
expect(surveyCache.revalidate).toHaveBeenCalled();
});
describe("sad path", () => {
testInputValidation(updateLanguage, "bad-id", mockLanguageId, {});
test("throws DatabaseError on PrismaKnownRequestError", async () => {
const err = new Prisma.PrismaClientKnownRequestError("dup", {
code: "P2002",
clientVersion: "1",
});
vi.mocked(prisma.language.update).mockRejectedValue(err);
await expect(updateLanguage(mockProjectId, mockLanguageId, mockLanguageUpdate)).rejects.toThrow(
DatabaseError
);
});
});
});
describe("deleteLanguage", () => {
beforeEach(() => {
vi.mocked(getProject).mockResolvedValue(fakeProject);
});
test("happy path deletes a language", async () => {
vi.mocked(prisma.language.delete).mockResolvedValue(mockLanguage);
const result = await deleteLanguage(mockLanguageId, mockProjectId);
expect(result).toEqual(mockLanguage);
expect(projectCache.revalidate).toHaveBeenCalledTimes(2);
});
describe("sad path", () => {
testInputValidation(deleteLanguage, "bad-id", mockProjectId);
test("throws DatabaseError on PrismaKnownRequestError", async () => {
const err = new Prisma.PrismaClientKnownRequestError("dup", {
code: "P2002",
clientVersion: "1",
});
vi.mocked(prisma.language.delete).mockRejectedValue(err);
await expect(deleteLanguage(mockLanguageId, mockProjectId)).rejects.toThrow(DatabaseError);
});
});
});
@@ -1,136 +0,0 @@
import {
mockEnvironmentId,
mockLanguage,
mockLanguageId,
mockLanguageInput,
mockLanguageUpdate,
mockProjectId,
mockUpdatedLanguage,
} from "./__mocks__/data.mock";
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { createLanguage, deleteLanguage, updateLanguage } from "../service";
vi.mock("@formbricks/database", () => ({
prisma: {
language: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
const testInputValidation = async (service: Function, ...args: any[]): Promise<void> => {
test("it should throw a ValidationError if the inputs are invalid", async () => {
await expect(service(...args)).rejects.toThrow(ValidationError);
});
};
describe("Tests for createLanguage service", () => {
describe("Happy Path", () => {
test("Creates a new Language", async () => {
vi.mocked(prisma.language.create).mockResolvedValue(mockLanguage);
const language = await createLanguage(mockProjectId, mockLanguageInput);
expect(language).toEqual(mockLanguage);
});
});
describe("Sad Path", () => {
testInputValidation(createLanguage, "123");
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.language.create).mockRejectedValue(errToThrow);
await expect(createLanguage(mockProjectId, mockLanguageInput)).rejects.toThrow(DatabaseError);
});
test("Throws a generic Error for other exceptions", async () => {
const mockErrorMessage = "Mock error message";
vi.mocked(prisma.language.create).mockRejectedValue(new Error(mockErrorMessage));
await expect(createLanguage(mockProjectId, mockLanguageInput)).rejects.toThrow(Error);
});
});
});
describe("Tests for updateLanguage Service", () => {
describe("Happy Path", () => {
test("Updates a language", async () => {
vi.mocked(prisma.language.update).mockResolvedValue(mockUpdatedLanguage);
const language = await updateLanguage(mockEnvironmentId, mockLanguageId, mockLanguageUpdate);
expect(language).toEqual(mockUpdatedLanguage);
});
});
describe("Sad Path", () => {
testInputValidation(updateLanguage, "123", "123");
test("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.language.update).mockRejectedValue(errToThrow);
await expect(updateLanguage(mockEnvironmentId, mockLanguageId, mockLanguageUpdate)).rejects.toThrow(
DatabaseError
);
});
test("Throws a generic Error for other unexpected issues", async () => {
const mockErrorMessage = "Mock error message";
vi.mocked(prisma.language.update).mockRejectedValue(new Error(mockErrorMessage));
await expect(updateLanguage(mockEnvironmentId, mockLanguageId, mockLanguageUpdate)).rejects.toThrow(
Error
);
});
});
});
describe("Tests for deleteLanguage", () => {
describe("Happy Path", () => {
test("Deletes a Language", async () => {
vi.mocked(prisma.language.delete).mockResolvedValue(mockLanguage);
const language = await deleteLanguage(mockLanguageId, mockProjectId);
expect(language).toEqual(mockLanguage);
});
});
describe("Sad Path", () => {
testInputValidation(deleteLanguage, "123");
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.language.delete).mockRejectedValue(errToThrow);
await expect(deleteLanguage(mockLanguageId, mockProjectId)).rejects.toThrow(DatabaseError);
});
test("Throws a generic Error for other exceptions", async () => {
const mockErrorMessage = "Mock error message";
vi.mocked(prisma.language.delete).mockRejectedValue(new Error(mockErrorMessage));
await expect(deleteLanguage(mockLanguageId, mockProjectId)).rejects.toThrow(Error);
});
});
});
+3 -13
View File
@@ -3,6 +3,7 @@ import { cache } from "@/lib/cache";
import { BILLING_LIMITS, ITEMS_PER_PAGE, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { getProjects } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { getBillingPeriodStartDate } from "@/lib/utils/billing";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
@@ -337,19 +338,8 @@ export const getMonthlyOrganizationResponseCount = reactCache(
throw new ResourceNotFoundError("Organization", organizationId);
}
// Determine the start date based on the plan type
let startDate: Date;
if (organization.billing.plan === "free") {
// For free plans, use the first day of the current calendar month
const now = new Date();
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
} else {
// For other plans, use the periodStart from billing
if (!organization.billing.periodStart) {
throw new Error("Organization billing period start is not set");
}
startDate = organization.billing.periodStart;
}
// Use the utility function to calculate the start date
const startDate = getBillingPeriodStartDate(organization.billing);
// Get all environment IDs for the organization
const projects = await getProjects(organizationId);
+3 -3
View File
@@ -11,11 +11,11 @@ export const getOriginalFileNameFromUrl = (fileURL: string) => {
const fileId = fileNameFromURL?.split("--fid--")[1] ?? "";
if (!fileId) {
const fileName = originalFileName ? decodeURIComponent(originalFileName || "") : "";
const fileName = originalFileName ? decodeURIComponent(originalFileName) : "";
return fileName;
}
const fileName = originalFileName ? decodeURIComponent(`${originalFileName}.${fileExt}` || "") : "";
const fileName = originalFileName ? decodeURIComponent(`${originalFileName}.${fileExt}`) : "";
return fileName;
} catch (error) {
logger.error(error, "Error parsing file URL");
@@ -28,7 +28,7 @@ export const getFileNameWithIdFromUrl = (fileURL: string) => {
? fileURL.split("/").pop()
: new URL(fileURL).pathname.split("/").pop();
return fileNameFromURL ? decodeURIComponent(fileNameFromURL || "") : "";
return fileNameFromURL ? decodeURIComponent(fileNameFromURL) : "";
} catch (error) {
logger.error(error, "Error parsing file URL");
}
+176
View File
@@ -0,0 +1,176 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getBillingPeriodStartDate } from "./billing";
describe("getBillingPeriodStartDate", () => {
let originalDate: DateConstructor;
beforeEach(() => {
// Store the original Date constructor
originalDate = global.Date;
});
afterEach(() => {
// Restore the original Date constructor
global.Date = originalDate;
vi.useRealTimers();
});
test("returns first day of month for free plans", () => {
// Mock the current date to be 2023-03-15
vi.setSystemTime(new Date(2023, 2, 15));
const organization = {
billing: {
plan: "free",
periodStart: new Date("2023-01-15"),
period: "monthly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// For free plans, should return first day of current month
expect(result).toEqual(new Date(2023, 2, 1));
});
test("returns correct date for monthly plans", () => {
// Mock the current date to be 2023-03-15
vi.setSystemTime(new Date(2023, 2, 15));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2023-02-10"),
period: "monthly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// For monthly plans, should return periodStart directly
expect(result).toEqual(new Date("2023-02-10"));
});
test("returns current month's subscription day for yearly plans when today is after subscription day", () => {
// Mock the current date to be March 20, 2023
vi.setSystemTime(new Date(2023, 2, 20));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-05-15"), // Original subscription on 15th
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return March 15, 2023 (same day in current month)
expect(result).toEqual(new Date(2023, 2, 15));
});
test("returns previous month's subscription day for yearly plans when today is before subscription day", () => {
// Mock the current date to be March 10, 2023
vi.setSystemTime(new Date(2023, 2, 10));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-05-15"), // Original subscription on 15th
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return February 15, 2023 (same day in previous month)
expect(result).toEqual(new Date(2023, 1, 15));
});
test("handles subscription day that doesn't exist in current month (February edge case)", () => {
// Mock the current date to be February 15, 2023
vi.setSystemTime(new Date(2023, 1, 15));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-01-31"), // Original subscription on 31st
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return January 31, 2023 (previous month's subscription day)
// since today (Feb 15) is less than the subscription day (31st)
expect(result).toEqual(new Date(2023, 0, 31));
});
test("handles subscription day that doesn't exist in previous month (February to March transition)", () => {
// Mock the current date to be March 10, 2023
vi.setSystemTime(new Date(2023, 2, 10));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-01-30"), // Original subscription on 30th
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return February 28, 2023 (last day of February)
// since February 2023 doesn't have a 30th day
expect(result).toEqual(new Date(2023, 1, 28));
});
test("handles subscription day that doesn't exist in previous month (leap year)", () => {
// Mock the current date to be March 10, 2024 (leap year)
vi.setSystemTime(new Date(2024, 2, 10));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2023-01-30"), // Original subscription on 30th
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return February 29, 2024 (last day of February in leap year)
expect(result).toEqual(new Date(2024, 1, 29));
});
test("handles current month with fewer days than subscription day", () => {
// Mock the current date to be April 25, 2023 (April has 30 days)
vi.setSystemTime(new Date(2023, 3, 25));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-01-31"), // Original subscription on 31st
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return March 31, 2023 (since today is before April's adjusted subscription day)
expect(result).toEqual(new Date(2023, 2, 31));
});
test("throws error when periodStart is not set for non-free plans", () => {
const organization = {
billing: {
plan: "scale",
periodStart: null,
period: "monthly",
},
};
expect(() => {
getBillingPeriodStartDate(organization.billing);
}).toThrow("billing period start is not set");
});
});
+53
View File
@@ -0,0 +1,53 @@
import { TOrganizationBilling } from "@formbricks/types/organizations";
// Function to calculate billing period start date based on organization plan and billing period
export const getBillingPeriodStartDate = (billing: TOrganizationBilling): Date => {
const now = new Date();
if (billing.plan === "free") {
// For free plans, use the first day of the current calendar month
return new Date(now.getFullYear(), now.getMonth(), 1);
} else if (billing.period === "yearly" && billing.periodStart) {
// For yearly plans, use the same day of the month as the original subscription date
const periodStart = new Date(billing.periodStart);
const subscriptionDay = periodStart.getDate();
// Helper function to get the last day of a specific month
const getLastDayOfMonth = (year: number, month: number): number => {
// Create a date for the first day of the next month, then subtract one day
return new Date(year, month + 1, 0).getDate();
};
// Calculate the adjusted day for the current month
const lastDayOfCurrentMonth = getLastDayOfMonth(now.getFullYear(), now.getMonth());
const adjustedCurrentMonthDay = Math.min(subscriptionDay, lastDayOfCurrentMonth);
// Calculate the current month's adjusted subscription date
const currentMonthSubscriptionDate = new Date(now.getFullYear(), now.getMonth(), adjustedCurrentMonthDay);
// If today is before the subscription day in the current month (or its adjusted equivalent),
// we should use the previous month's subscription day as our start date
if (now.getDate() < adjustedCurrentMonthDay) {
// Calculate previous month and year
const prevMonth = now.getMonth() === 0 ? 11 : now.getMonth() - 1;
const prevYear = now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear();
// Calculate the adjusted day for the previous month
const lastDayOfPreviousMonth = getLastDayOfMonth(prevYear, prevMonth);
const adjustedPreviousMonthDay = Math.min(subscriptionDay, lastDayOfPreviousMonth);
// Return the adjusted previous month date
return new Date(prevYear, prevMonth, adjustedPreviousMonthDay);
} else {
return currentMonthSubscriptionDate;
}
} else if (billing.period === "monthly" && billing.periodStart) {
// For monthly plans with a periodStart, use that date
return new Date(billing.periodStart);
} else {
// For other plans, use the periodStart from billing
if (!billing.periodStart) {
throw new Error("billing period start is not set");
}
return new Date(billing.periodStart);
}
};
@@ -88,7 +88,7 @@ export const QuestionSkip = ({
{status === "aborted" && (
<div className="flex">
<div
className="flex w-0.5 grow items-start justify-center"
className="flex w-0.5 flex-grow items-start justify-center"
style={{
background:
"repeating-linear-gradient(to bottom, rgb(148 163 184), rgb(148 163 184) 2px, transparent 2px, transparent 10px)", // adjust the 2px to change dot size and 10px to change space between dots
@@ -100,14 +100,14 @@ export const ResponseNotes = ({
return (
<div
className={clsx(
"absolute w-1/4 rounded-lg border border-slate-200 shadow-xs transition-all",
"absolute w-1/4 rounded-lg border border-slate-200 shadow-sm transition-all",
!isOpen && unresolvedNotes.length && "group/hint cursor-pointer bg-white hover:-right-3",
!isOpen && !unresolvedNotes.length && "cursor-pointer bg-slate-50",
isOpen
? "top-0 -right-2 h-5/6 max-h-[600px] w-1/4 bg-white"
: unresolvedNotes.length
? "top-[8.33%] right-0 h-5/6 max-h-[600px] w-1/12"
: "top-[8.333%] right-[120px] h-5/6 max-h-[600px] w-1/12 group-hover:right-0"
: "top-[8.333%] right-[120px] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
)}
onClick={() => {
if (!isOpen) setIsOpen(true);
@@ -215,7 +215,7 @@ export const ResponseNotes = ({
<textarea
rows={2}
className={cn(
"block w-full resize-none rounded-md border border-slate-100 bg-slate-50 p-2 shadow-xs focus:border-slate-500 focus:ring-0 sm:text-sm",
"block w-full resize-none rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:ring-0 sm:text-sm",
!isTextAreaOpen && "scale-y-0 transition-all duration-1000",
!isTextAreaOpen && "translate-y-8 transition-all duration-300",
isTextAreaOpen && "scale-y-1 transition-all duration-1000",
@@ -116,7 +116,7 @@ export const SingleResponseCard = ({
<div className={clsx("group relative", isOpen && "min-h-[300px]")}>
<div
className={clsx(
"relative z-20 my-6 rounded-xl border border-slate-200 bg-white shadow-xs transition-all",
"relative z-20 my-6 rounded-xl border border-slate-200 bg-white shadow-sm transition-all",
pageType === "response" &&
(isOpen
? "w-3/4"
@@ -47,8 +47,5 @@ describe("isSubmissionTimeMoreThan5Minutes", () => {
const currentTime = new Date();
const recentTime = new Date(currentTime.getTime() - 4 * 60 * 1000); // 4 minutes ago
expect(isSubmissionTimeMoreThan5Minutes(recentTime)).toBe(false);
const exact5Minutes = new Date(currentTime.getTime() - 5 * 60 * 1000); // exactly 5 minutes ago
expect(isSubmissionTimeMoreThan5Minutes(exact5Minutes)).toBe(false);
});
});
+13 -5
View File
@@ -21,11 +21,19 @@ export type ExtendedSchemas = {
};
// Define a type that returns separate keys for each input type.
export type ParsedSchemas<S extends ExtendedSchemas | undefined> = {
body?: S extends { body: z.ZodObject<any> } ? z.infer<S["body"]> : undefined;
query?: S extends { query: z.ZodObject<any> } ? z.infer<S["query"]> : undefined;
params?: S extends { params: z.ZodObject<any> } ? z.infer<S["params"]> : undefined;
};
// It uses mapped types to create a new type based on the input schemas.
// It checks if each schema is defined and if it is a ZodObject, then infers the type from it.
// It also uses conditional types to ensure that the keys are only included if the schema is defined and valid.
// This allows for more flexibility and type safety when working with the input schemas.
export type ParsedSchemas<S extends ExtendedSchemas | undefined> = S extends object
? {
[K in keyof S as NonNullable<S[K]> extends z.ZodObject<any> ? K : never]: NonNullable<
S[K]
> extends z.ZodObject<any>
? z.infer<NonNullable<S[K]>>
: never;
}
: {};
export const apiWrapper = async <S extends ExtendedSchemas>({
request,
+29
View File
@@ -260,6 +260,34 @@ const successResponse = ({
);
};
export const createdResponse = ({
data,
meta,
cors = false,
cache = "private, no-store",
}: {
data: Object;
meta?: Record<string, unknown>;
cors?: boolean;
cache?: string;
}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
data,
meta,
} as ApiSuccessResponse,
{
status: 201,
headers,
}
);
};
export const multiStatusResponse = ({
data,
meta,
@@ -298,5 +326,6 @@ export const responses = {
tooManyRequestsResponse,
internalServerErrorResponse,
successResponse,
createdResponse,
multiStatusResponse,
};
@@ -120,7 +120,7 @@ describe("API Responses", () => {
});
test("include CORS headers when cors is true", () => {
const res = responses.unprocessableEntityResponse({ cors: true });
const res = responses.unprocessableEntityResponse({ cors: true, details: [] });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
@@ -182,4 +182,38 @@ describe("API Responses", () => {
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("createdResponse", () => {
test("return a success response with the provided data", async () => {
const data = { foo: "bar" };
const meta = { page: 1 };
const res = responses.createdResponse({ data, meta });
expect(res.status).toBe(201);
const body = await res.json();
expect(body.data).toEqual(data);
expect(body.meta).toEqual(meta);
});
test("include CORS headers when cors is true", () => {
const data = { foo: "bar" };
const res = responses.createdResponse({ data, cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("multiStatusResponse", () => {
test("return a 207 response with the provided data", async () => {
const data = { foo: "bar" };
const res = responses.multiStatusResponse({ data });
expect(res.status).toBe(207);
const body = await res.json();
expect(body.data).toEqual(data);
});
test("include CORS headers when cors is true", () => {
const data = { foo: "bar" };
const res = responses.multiStatusResponse({ data, cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
});
@@ -0,0 +1,183 @@
import { cache } from "@/lib/cache";
import { contactCache } from "@/lib/cache/contact";
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ContactAttributeKey } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getContactAttributeKey = reactCache(async (contactAttributeKeyId: string) =>
cache(
async (): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
try {
const contactAttributeKey = await prisma.contactAttributeKey.findUnique({
where: {
id: contactAttributeKeyId,
},
});
if (!contactAttributeKey) {
return err({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
return ok(contactAttributeKey);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: error.message }],
});
}
},
[`management-getContactAttributeKey-${contactAttributeKeyId}`],
{
tags: [contactAttributeKeyCache.tag.byId(contactAttributeKeyId)],
}
)()
);
export const updateContactAttributeKey = async (
contactAttributeKeyId: string,
contactAttributeKeyInput: TContactAttributeKeyUpdateSchema
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
try {
const updatedKey = await prisma.contactAttributeKey.update({
where: {
id: contactAttributeKeyId,
},
data: contactAttributeKeyInput,
});
const associatedContactAttributes = await prisma.contactAttribute.findMany({
where: {
attributeKeyId: updatedKey.id,
},
select: {
id: true,
contactId: true,
},
});
contactAttributeKeyCache.revalidate({
id: contactAttributeKeyId,
environmentId: updatedKey.environmentId,
key: updatedKey.key,
});
contactAttributeCache.revalidate({
key: updatedKey.key,
environmentId: updatedKey.environmentId,
});
contactCache.revalidate({
environmentId: updatedKey.environmentId,
});
associatedContactAttributes.forEach((contactAttribute) => {
contactAttributeCache.revalidate({
contactId: contactAttribute.contactId,
});
contactCache.revalidate({
id: contactAttribute.contactId,
});
});
return ok(updatedKey);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
return err({
type: "conflict",
details: [
{
field: "contactAttributeKey",
issue: `Contact attribute key with "${contactAttributeKeyInput.key}" already exists`,
},
],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: error.message }],
});
}
};
export const deleteContactAttributeKey = async (
contactAttributeKeyId: string
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
try {
const deletedKey = await prisma.contactAttributeKey.delete({
where: {
id: contactAttributeKeyId,
},
});
const associatedContactAttributes = await prisma.contactAttribute.findMany({
where: {
attributeKeyId: deletedKey.id,
},
select: {
id: true,
contactId: true,
},
});
contactAttributeKeyCache.revalidate({
id: contactAttributeKeyId,
environmentId: deletedKey.environmentId,
key: deletedKey.key,
});
contactAttributeCache.revalidate({
key: deletedKey.key,
environmentId: deletedKey.environmentId,
});
contactCache.revalidate({
environmentId: deletedKey.environmentId,
});
associatedContactAttributes.forEach((contactAttribute) => {
contactAttributeCache.revalidate({
contactId: contactAttribute.contactId,
});
contactCache.revalidate({
id: contactAttribute.contactId,
});
});
return ok(deletedKey);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: error.message }],
});
}
};
@@ -1,4 +1,8 @@
import { ZContactAttributeKeyInput } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import {
ZContactAttributeKeyIdSchema,
ZContactAttributeKeyUpdateSchema,
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
@@ -9,7 +13,7 @@ export const getContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
description: "Gets a contact attribute key from the database.",
requestParams: {
path: z.object({
contactAttributeKeyId: z.string().cuid2(),
id: ZContactAttributeKeyIdSchema,
}),
},
tags: ["Management API > Contact Attribute Keys"],
@@ -18,29 +22,7 @@ export const getContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
description: "Contact attribute key retrieved successfully.",
content: {
"application/json": {
schema: ZContactAttributeKey,
},
},
},
},
};
export const deleteContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContactAttributeKey",
summary: "Delete a contact attribute key",
description: "Deletes a contact attribute key from the database.",
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
}),
},
responses: {
"200": {
description: "Contact attribute key deleted successfully.",
content: {
"application/json": {
schema: ZContactAttributeKey,
schema: makePartialSchema(ZContactAttributeKey),
},
},
},
@@ -54,7 +36,7 @@ export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
path: z.object({
contactAttributeKeyId: z.string().cuid2(),
id: ZContactAttributeKeyIdSchema,
}),
},
requestBody: {
@@ -62,7 +44,7 @@ export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
description: "The contact attribute key to update",
content: {
"application/json": {
schema: ZContactAttributeKeyInput,
schema: ZContactAttributeKeyUpdateSchema,
},
},
},
@@ -71,7 +53,29 @@ export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
description: "Contact attribute key updated successfully.",
content: {
"application/json": {
schema: ZContactAttributeKey,
schema: makePartialSchema(ZContactAttributeKey),
},
},
},
},
};
export const deleteContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContactAttributeKey",
summary: "Delete a contact attribute key",
description: "Deletes a contact attribute key from the database.",
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
path: z.object({
id: ZContactAttributeKeyIdSchema,
}),
},
responses: {
"200": {
description: "Contact attribute key deleted successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZContactAttributeKey),
},
},
},
@@ -0,0 +1,222 @@
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { ContactAttributeKey } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import {
deleteContactAttributeKey,
getContactAttributeKey,
updateContactAttributeKey,
} from "../contact-attribute-key";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
contactAttributeKey: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findMany: vi.fn(),
},
contactAttribute: {
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/cache/contact-attribute-key", () => ({
contactAttributeKeyCache: {
tag: {
byId: () => "mockTag",
},
revalidate: vi.fn(),
},
}));
// Mock data
const mockContactAttributeKey: ContactAttributeKey = {
id: "cak123",
key: "email",
name: "Email",
description: "User's email address",
environmentId: "env123",
isUnique: true,
type: "default",
createdAt: new Date(),
updatedAt: new Date(),
};
const mockUpdateInput: TContactAttributeKeyUpdateSchema = {
key: "email",
name: "Email Address",
description: "User's verified email address",
};
const prismaNotFoundError = new PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "0.0.1",
});
const prismaUniqueConstraintError = new PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
describe("getContactAttributeKey", () => {
test("returns ok if contact attribute key is found", async () => {
vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValueOnce(mockContactAttributeKey);
const result = await getContactAttributeKey("cak123");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockContactAttributeKey);
}
});
test("returns err if contact attribute key not found", async () => {
vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValueOnce(null);
const result = await getContactAttributeKey("cak999");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
});
test("returns err on Prisma error", async () => {
vi.mocked(prisma.contactAttributeKey.findUnique).mockRejectedValueOnce(new Error("DB error"));
const result = await getContactAttributeKey("error");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: "DB error" }],
});
}
});
});
describe("updateContactAttributeKey", () => {
test("returns ok on successful update", async () => {
const updatedKey = { ...mockContactAttributeKey, ...mockUpdateInput };
vi.mocked(prisma.contactAttributeKey.update).mockResolvedValueOnce(updatedKey);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValueOnce([
{ id: "contact1", contactId: "contact1" },
{ id: "contact2", contactId: "contact2" },
]);
const result = await updateContactAttributeKey("cak123", mockUpdateInput);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(updatedKey);
}
expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({
id: "cak123",
environmentId: mockContactAttributeKey.environmentId,
key: mockUpdateInput.key,
});
});
test("returns not_found if record does not exist", async () => {
vi.mocked(prisma.contactAttributeKey.update).mockRejectedValueOnce(prismaNotFoundError);
const result = await updateContactAttributeKey("cak999", mockUpdateInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
});
test("returns conflict error if key already exists", async () => {
vi.mocked(prisma.contactAttributeKey.update).mockRejectedValueOnce(prismaUniqueConstraintError);
const result = await updateContactAttributeKey("cak123", mockUpdateInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "conflict",
details: [
{ field: "contactAttributeKey", issue: 'Contact attribute key with "email" already exists' },
],
});
}
});
test("returns internal_server_error if other error occurs", async () => {
vi.mocked(prisma.contactAttributeKey.update).mockRejectedValueOnce(new Error("Unknown error"));
const result = await updateContactAttributeKey("cak123", mockUpdateInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: "Unknown error" }],
});
}
});
});
describe("deleteContactAttributeKey", () => {
test("returns ok on successful delete", async () => {
vi.mocked(prisma.contactAttributeKey.delete).mockResolvedValueOnce(mockContactAttributeKey);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValueOnce([
{ id: "contact1", contactId: "contact1" },
{ id: "contact2", contactId: "contact2" },
]);
const result = await deleteContactAttributeKey("cak123");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockContactAttributeKey);
}
expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({
id: "cak123",
environmentId: mockContactAttributeKey.environmentId,
key: mockContactAttributeKey.key,
});
});
test("returns not_found if record does not exist", async () => {
vi.mocked(prisma.contactAttributeKey.delete).mockRejectedValueOnce(prismaNotFoundError);
const result = await deleteContactAttributeKey("cak999");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
});
test("returns internal_server_error on other errors", async () => {
vi.mocked(prisma.contactAttributeKey.delete).mockRejectedValueOnce(new Error("Delete error"));
const result = await deleteContactAttributeKey("cak123");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: "Delete error" }],
});
}
});
});
@@ -0,0 +1,131 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import {
deleteContactAttributeKey,
getContactAttributeKey,
updateContactAttributeKey,
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key";
import {
ZContactAttributeKeyIdSchema,
ZContactAttributeKeyUpdateSchema,
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { z } from "zod";
export const GET = async (
request: NextRequest,
props: { params: Promise<{ contactAttributeKeyId: string }> }
) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
const res = await getContactAttributeKey(params.contactAttributeKeyId);
if (!res.ok) {
return handleApiError(request, res.error);
}
if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "GET")) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "environment", issue: "unauthorized" }],
});
}
return responses.successResponse(res);
},
});
export const PUT = async (
request: NextRequest,
props: { params: Promise<{ contactAttributeKeyId: string }> }
) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }),
body: ZContactAttributeKeyUpdateSchema,
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
const { params, body } = parsedInput;
const res = await getContactAttributeKey(params.contactAttributeKeyId);
if (!res.ok) {
return handleApiError(request, res.error);
}
if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "PUT")) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "environment", issue: "unauthorized" }],
});
}
if (res.data.isUnique) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "contactAttributeKey", issue: "cannot update unique contact attribute key" }],
});
}
const updatedContactAttributeKey = await updateContactAttributeKey(params.contactAttributeKeyId, body);
if (!updatedContactAttributeKey.ok) {
return handleApiError(request, updatedContactAttributeKey.error);
}
return responses.successResponse(updatedContactAttributeKey);
},
});
export const DELETE = async (
request: NextRequest,
props: { params: Promise<{ contactAttributeKeyId: string }> }
) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
const res = await getContactAttributeKey(params.contactAttributeKeyId);
if (!res.ok) {
return handleApiError(request, res.error);
}
if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "DELETE")) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "environment", issue: "unauthorized" }],
});
}
if (res.data.isUnique) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "contactAttributeKey", issue: "cannot delete unique contact attribute key" }],
});
}
const deletedContactAttributeKey = await deleteContactAttributeKey(params.contactAttributeKeyId);
if (!deletedContactAttributeKey.ok) {
return handleApiError(request, deletedContactAttributeKey.error);
}
return responses.successResponse(deletedContactAttributeKey);
},
});
@@ -0,0 +1,28 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
extendZodWithOpenApi(z);
export const ZContactAttributeKeyIdSchema = z
.string()
.cuid2()
.openapi({
ref: "contactAttributeKeyId",
description: "The ID of the contact attribute key",
param: {
name: "id",
in: "path",
},
});
export const ZContactAttributeKeyUpdateSchema = ZContactAttributeKey.pick({
name: true,
description: true,
key: true,
}).openapi({
ref: "contactAttributeKeyUpdate",
description: "A contact attribute key to update.",
});
export type TContactAttributeKeyUpdateSchema = z.infer<typeof ZContactAttributeKeyUpdateSchema>;
@@ -0,0 +1,105 @@
import { cache } from "@/lib/cache";
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { getContactAttributeKeysQuery } from "@/modules/api/v2/management/contact-attribute-keys/lib/utils";
import {
TContactAttributeKeyInput,
TGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
import { ContactAttributeKey, Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getContactAttributeKeys = reactCache(
async (environmentIds: string[], params: TGetContactAttributeKeysFilter) =>
cache(
async (): Promise<Result<ApiResponseWithMeta<ContactAttributeKey[]>, ApiErrorResponseV2>> => {
try {
const query = getContactAttributeKeysQuery(environmentIds, params);
const [keys, count] = await prisma.$transaction([
prisma.contactAttributeKey.findMany({
...query,
}),
prisma.contactAttributeKey.count({
where: query.where,
}),
]);
return ok({ data: keys, meta: { total: count, limit: params.limit, offset: params.skip } });
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "contactAttributeKeys", issue: error.message }],
});
}
},
[`management-getContactAttributeKeys-${environmentIds.join(",")}-${JSON.stringify(params)}`],
{
tags: environmentIds.map((environmentId) =>
contactAttributeKeyCache.tag.byEnvironmentId(environmentId)
),
}
)()
);
export const createContactAttributeKey = async (
contactAttributeKey: TContactAttributeKeyInput
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
const { environmentId, name, description, key } = contactAttributeKey;
try {
const prismaData: Prisma.ContactAttributeKeyCreateInput = {
environment: {
connect: {
id: environmentId,
},
},
name,
description,
key,
};
const createdContactAttributeKey = await prisma.contactAttributeKey.create({
data: prismaData,
});
contactAttributeKeyCache.revalidate({
environmentId: createdContactAttributeKey.environmentId,
key: createdContactAttributeKey.key,
});
return ok(createdContactAttributeKey);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
return err({
type: "conflict",
details: [
{
field: "contactAttributeKey",
issue: `Contact attribute key with "${contactAttributeKey.key}" already exists`,
},
],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: error.message }],
});
}
};
@@ -8,9 +8,9 @@ import {
ZGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { z } from "zod";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttributeKeys",
@@ -18,14 +18,14 @@ export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
description: "Gets contact attribute keys from the database.",
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
query: ZGetContactAttributeKeysFilter,
query: ZGetContactAttributeKeysFilter.sourceType(),
},
responses: {
"200": {
description: "Contact attribute keys retrieved successfully.",
content: {
"application/json": {
schema: z.array(ZContactAttributeKey),
schema: responseWithMetaSchema(makePartialSchema(ZContactAttributeKey)),
},
},
},
@@ -49,6 +49,11 @@ export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
responses: {
"201": {
description: "Contact attribute key created successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZContactAttributeKey),
},
},
},
},
};
@@ -0,0 +1,166 @@
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import {
TContactAttributeKeyInput,
TGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { ContactAttributeKey } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { createContactAttributeKey, getContactAttributeKeys } from "../contact-attribute-key";
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: vi.fn(),
contactAttributeKey: {
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
},
},
}));
vi.mock("@/lib/cache/contact-attribute-key", () => ({
contactAttributeKeyCache: {
revalidate: vi.fn(),
tag: {
byEnvironmentId: vi.fn(),
},
},
}));
describe("getContactAttributeKeys", () => {
const environmentIds = ["env1", "env2"];
const params: TGetContactAttributeKeysFilter = {
limit: 10,
skip: 0,
order: "asc",
sortBy: "createdAt",
};
const fakeContactAttributeKeys = [
{ id: "key1", environmentId: "env1", name: "Key One", key: "keyOne" },
{ id: "key2", environmentId: "env1", name: "Key Two", key: "keyTwo" },
];
const count = fakeContactAttributeKeys.length;
test("returns ok response with contact attribute keys and meta", async () => {
vi.mocked(prisma.$transaction).mockResolvedValueOnce([fakeContactAttributeKeys, count]);
const result = await getContactAttributeKeys(environmentIds, params);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.data).toEqual(fakeContactAttributeKeys);
expect(result.data.meta).toEqual({
total: count,
limit: params.limit,
offset: params.skip,
});
}
});
test("returns error when prisma.$transaction throws", async () => {
vi.mocked(prisma.$transaction).mockRejectedValueOnce(new Error("Test error"));
const result = await getContactAttributeKeys(environmentIds, params);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toEqual("internal_server_error");
}
});
});
describe("createContactAttributeKey", () => {
const inputContactAttributeKey: TContactAttributeKeyInput = {
environmentId: "env1",
name: "New Contact Attribute Key",
key: "newKey",
description: "Description for new key",
};
const createdContactAttributeKey: ContactAttributeKey = {
id: "key100",
environmentId: inputContactAttributeKey.environmentId,
name: inputContactAttributeKey.name,
key: inputContactAttributeKey.key,
description: inputContactAttributeKey.description,
isUnique: false,
type: "custom",
createdAt: new Date(),
updatedAt: new Date(),
};
test("creates a contact attribute key and revalidates cache", async () => {
vi.mocked(prisma.contactAttributeKey.create).mockResolvedValueOnce(createdContactAttributeKey);
const result = await createContactAttributeKey(inputContactAttributeKey);
expect(prisma.contactAttributeKey.create).toHaveBeenCalled();
expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({
environmentId: createdContactAttributeKey.environmentId,
key: createdContactAttributeKey.key,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(createdContactAttributeKey);
}
});
test("returns error when creation fails", async () => {
vi.mocked(prisma.contactAttributeKey.create).mockRejectedValueOnce(new Error("Creation failed"));
const result = await createContactAttributeKey(inputContactAttributeKey);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("internal_server_error");
}
});
test("returns conflict error when key already exists", async () => {
const errToThrow = new PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.contactAttributeKey.create).mockRejectedValueOnce(errToThrow);
const result = await createContactAttributeKey(inputContactAttributeKey);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "conflict",
details: [
{
field: "contactAttributeKey",
issue: 'Contact attribute key with "newKey" already exists',
},
],
});
}
});
test("returns not found error when related record does not exist", async () => {
const errToThrow = new PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "0.0.1",
});
vi.mocked(prisma.contactAttributeKey.create).mockRejectedValueOnce(errToThrow);
const result = await createContactAttributeKey(inputContactAttributeKey);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "not_found",
details: [
{
field: "contactAttributeKey",
issue: "not found",
},
],
});
}
});
});
@@ -0,0 +1,106 @@
import { TGetContactAttributeKeysFilter } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getContactAttributeKeysQuery } from "../utils";
describe("getContactAttributeKeysQuery", () => {
const environmentId = "env-123";
const baseParams: TGetContactAttributeKeysFilter = {
limit: 10,
skip: 0,
order: "asc",
sortBy: "createdAt",
};
beforeEach(() => {
vi.clearAllMocks();
});
test("returns query with environmentId in array when no params are provided", () => {
const environmentIds = ["env-1", "env-2"];
const result = getContactAttributeKeysQuery(environmentIds);
expect(result).toEqual({
where: {
environmentId: {
in: environmentIds,
},
},
});
});
test("applies common filters when provided", () => {
const environmentIds = ["env-1", "env-2"];
const params: TGetContactAttributeKeysFilter = {
...baseParams,
environmentId,
};
const result = getContactAttributeKeysQuery(environmentIds, params);
expect(result).toEqual({
where: {
environmentId: {
in: environmentIds,
},
},
take: 10,
orderBy: {
createdAt: "asc",
},
});
});
test("applies date filters when provided", () => {
const environmentIds = ["env-1", "env-2"];
const startDate = new Date("2023-01-01");
const endDate = new Date("2023-12-31");
const params: TGetContactAttributeKeysFilter = {
...baseParams,
environmentId,
startDate,
endDate,
};
const result = getContactAttributeKeysQuery(environmentIds, params);
expect(result).toEqual({
where: {
environmentId: {
in: environmentIds,
},
createdAt: {
gte: startDate,
lte: endDate,
},
},
take: 10,
orderBy: {
createdAt: "asc",
},
});
});
test("handles multiple filter parameters correctly", () => {
const environmentIds = ["env-1", "env-2"];
const params: TGetContactAttributeKeysFilter = {
environmentId,
limit: 5,
skip: 10,
sortBy: "updatedAt",
order: "asc",
};
const result = getContactAttributeKeysQuery(environmentIds, params);
expect(result).toEqual({
where: {
environmentId: {
in: environmentIds,
},
},
take: 5,
skip: 10,
orderBy: {
updatedAt: "asc",
},
});
});
});
@@ -0,0 +1,26 @@
import { TGetContactAttributeKeysFilter } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { Prisma } from "@prisma/client";
export const getContactAttributeKeysQuery = (
environmentIds: string[],
params?: TGetContactAttributeKeysFilter
): Prisma.ContactAttributeKeyFindManyArgs => {
let query: Prisma.ContactAttributeKeyFindManyArgs = {
where: {
environmentId: {
in: environmentIds,
},
},
};
if (!params) return query;
const baseFilter = pickCommonFilter(params);
if (baseFilter) {
query = buildCommonFilterQuery<Prisma.ContactAttributeKeyFindManyArgs>(query, baseFilter);
}
return query;
};
@@ -0,0 +1,73 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import {
createContactAttributeKey,
getContactAttributeKeys,
} from "@/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key";
import {
ZContactAttributeKeyInput,
ZGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetContactAttributeKeysFilter.sourceType(),
},
handler: async ({ authentication, parsedInput }) => {
const { query } = parsedInput;
let environmentIds: string[] = [];
if (query.environmentId) {
if (!hasPermission(authentication.environmentPermissions, query.environmentId, "GET")) {
return handleApiError(request, {
type: "unauthorized",
});
}
environmentIds = [query.environmentId];
} else {
environmentIds = authentication.environmentPermissions.map((permission) => permission.environmentId);
}
const res = await getContactAttributeKeys(environmentIds, query);
if (!res.ok) {
return handleApiError(request, res.error);
}
return responses.successResponse(res.data);
},
});
export const POST = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
body: ZContactAttributeKeyInput,
},
handler: async ({ authentication, parsedInput }) => {
const { body } = parsedInput;
if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) {
return handleApiError(request, {
type: "forbidden",
details: [
{ field: "environmentId", issue: "does not have permission to create contact attribute key" },
],
});
}
const createContactAttributeKeyResult = await createContactAttributeKey(body);
if (!createContactAttributeKeyResult.ok) {
return handleApiError(request, createContactAttributeKeyResult.error);
}
return responses.createdResponse(createContactAttributeKeyResult);
},
});
@@ -1,18 +1,13 @@
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
extendZodWithOpenApi(z);
export const ZGetContactAttributeKeysFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
})
export const ZGetContactAttributeKeysFilter = ZGetFilter.extend({
environmentId: z.string().cuid2().optional().describe("The environment ID to filter by"),
})
.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
@@ -23,13 +18,15 @@ export const ZGetContactAttributeKeysFilter = z
{
message: "startDate must be before endDate",
}
);
)
.describe("Filter for retrieving contact attribute keys");
export type TGetContactAttributeKeysFilter = z.infer<typeof ZGetContactAttributeKeysFilter>;
export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
key: true,
name: true,
description: true,
type: true,
environmentId: true,
}).openapi({
ref: "contactAttributeKeyInput",
@@ -14,7 +14,8 @@ type HasFindMany =
| Prisma.ResponseFindManyArgs
| Prisma.TeamFindManyArgs
| Prisma.ProjectTeamFindManyArgs
| Prisma.UserFindManyArgs;
| Prisma.UserFindManyArgs
| Prisma.ContactAttributeKeyFindManyArgs;
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
const { limit, skip, sortBy, order, startDate, endDate } = params || {};
@@ -1,5 +1,6 @@
import { cache } from "@/lib/cache";
import { organizationCache } from "@/lib/organization/cache";
import { getBillingPeriodStartDate } from "@/lib/utils/billing";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Organization } from "@prisma/client";
import { cache as reactCache } from "react";
@@ -133,22 +134,7 @@ export const getMonthlyOrganizationResponseCount = reactCache(async (organizatio
}
// Determine the start date based on the plan type
let startDate: Date;
if (billing.data.plan === "free") {
// For free plans, use the first day of the current calendar month
const now = new Date();
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
} else {
// For other plans, use the periodStart from billing
if (!billing.data.periodStart) {
return err({
type: "internal_server_error",
details: [{ field: "organization", issue: "billing period start is not set" }],
});
}
startDate = billing.data.periodStart;
}
const startDate = getBillingPeriodStartDate(billing.data);
// Get all environment IDs for the organization
const environmentIdsResult = await getAllEnvironmentsFromOrganizationId(organizationId);
@@ -81,6 +81,6 @@ export const POST = async (request: Request) =>
return handleApiError(request, createResponseResult.error);
}
return responses.successResponse({ data: createResponseResult.data });
return responses.createdResponse({ data: createResponseResult.data });
},
});
@@ -72,6 +72,6 @@ export const POST = async (request: NextRequest) =>
return handleApiError(request, createWebhookResult.error);
}
return responses.successResponse(createWebhookResult);
return responses.createdResponse(createWebhookResult);
},
});
+2 -2
View File
@@ -1,4 +1,4 @@
// import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi";
import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi";
// import { contactAttributePaths } from "@/modules/api/v2/management/contact-attributes/lib/openapi";
// import { contactPaths } from "@/modules/api/v2/management/contacts/lib/openapi";
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
@@ -42,7 +42,7 @@ const document = createDocument({
...bulkContactPaths,
// ...contactPaths,
// ...contactAttributePaths,
// ...contactAttributeKeyPaths,
...contactAttributeKeyPaths,
...surveyPaths,
...surveyContactLinksBySegmentPaths,
...webhookPaths,
@@ -59,6 +59,6 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza
return handleApiError(request, createTeamResult.error);
}
return responses.successResponse({ data: createTeamResult.data });
return responses.createdResponse({ data: createTeamResult.data });
},
});
@@ -79,7 +79,7 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza
return handleApiError(request, createUserResult.error);
}
return responses.successResponse({ data: createUserResult.data });
return responses.createdResponse({ data: createUserResult.data });
},
});
@@ -7,7 +7,7 @@ import Image from "next/image";
export const Testimonial = async () => {
const t = await getTranslate();
return (
<div className="flex flex-col items-center justify-center bg-linear-to-tr from-slate-100 to-slate-300">
<div className="flex flex-col items-center justify-center bg-gradient-to-tr from-slate-100 to-slate-300">
<div className="3xl:w-2/3 mb-10 space-y-8 px-12 xl:px-20">
<div>
<h2 className="text-3xl font-bold text-slate-800">{t("auth.testimonial_title")}</h2>
@@ -31,13 +31,13 @@ export const Testimonial = async () => {
</div>
</div>
<div className="rounded-xl border border-slate-200 bg-linear-to-tr from-slate-100 to-slate-200 p-8">
<p className="text-slate-700 italic">{t("auth.testimonial_1")}</p>
<div className="rounded-xl border border-slate-200 bg-gradient-to-tr from-slate-100 to-slate-200 p-8">
<p className="italic text-slate-700">{t("auth.testimonial_1")}</p>
<div className="mt-4 flex items-center space-x-6">
<Image
src={Peer}
alt="Cal.com Co-Founder Peer Richelsen"
className="h-28 w-28 rounded-full border border-slate-200 shadow-xs"
className="h-28 w-28 rounded-full border border-slate-200 shadow-sm"
/>
<div>
<p className="mb-1.5 text-sm text-slate-500">Peer Richelsen, Co-Founder Cal.com</p>
@@ -60,7 +60,7 @@ export const ForgotPasswordForm = () => {
onChange={(e) => field.onChange(e)}
autoComplete="email"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-xs sm:text-sm"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
/>
</FormControl>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
@@ -26,7 +26,7 @@ const passwordInputProps = {
placeholder: "*******",
required: true,
className:
"focus:border-brand-dark focus:ring-brand-dark mt-2 block w-full rounded-md border-slate-300 shadow-xs sm:text-sm",
"focus:border-brand-dark focus:ring-brand-dark mt-2 block w-full rounded-md border-slate-300 shadow-sm sm:text-sm",
};
export const ResetPasswordForm = () => {
@@ -181,7 +181,7 @@ export const LoginForm = ({
value={field.value}
onChange={(email) => field.onChange(email)}
placeholder="work@email.com"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-xs sm:text-sm"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
@@ -204,7 +204,7 @@ export const LoginForm = ({
aria-label="password"
aria-required="true"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-xs sm:text-sm"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>
+1 -1
View File
@@ -35,7 +35,7 @@ export const LoginPage = async () => {
const samlSsoEnabled = isSamlSsoEnabled && SAML_OAUTH_ENABLED;
return (
<div className="grid min-h-screen w-full bg-linear-to-tr from-slate-100 to-slate-50 lg:grid-cols-5">
<div className="grid min-h-screen w-full bg-gradient-to-tr from-slate-100 to-slate-50 lg:grid-cols-5">
<div className="col-span-2 hidden lg:flex">
<Testimonial />
</div>
@@ -222,7 +222,7 @@ export const SignupForm = ({
placeholder="*******"
aria-placeholder="password"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md shadow-xs sm:text-sm"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md shadow-sm sm:text-sm"
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
+1 -1
View File
@@ -58,7 +58,7 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
const emailFromSearchParams = searchParams["email"];
return (
<div className="grid min-h-screen w-full bg-linear-to-tr from-slate-100 to-slate-50 lg:grid-cols-5">
<div className="grid min-h-screen w-full bg-gradient-to-tr from-slate-100 to-slate-50 lg:grid-cols-5">
<div className="col-span-2 hidden lg:flex">
<Testimonial />
</div>
@@ -164,7 +164,7 @@ export const PricingTable = ({
<Button
size="sm"
variant="secondary"
className="justify-center py-2 shadow-xs"
className="justify-center py-2 shadow-sm"
onClick={openCustomerPortal}>
{t("environments.settings.billing.manage_card_details")}
</Button>
@@ -172,7 +172,7 @@ export const PricingTable = ({
)}
</div>
<div className="mt-2 flex flex-col rounded-xl border border-slate-200 bg-white py-4 capitalize shadow-xs dark:bg-slate-800">
<div className="mt-2 flex flex-col rounded-xl border border-slate-200 bg-white py-4 capitalize shadow-sm dark:bg-slate-800">
<div
className={cn(
"relative mx-8 mb-8 flex flex-col gap-4",
@@ -293,7 +293,7 @@ export const UploadContactsCSVButton = ({
<div className="sticky top-0 flex h-full flex-col rounded-lg">
<button
className={cn(
"absolute top-0 right-0 hidden pt-4 pr-4 text-slate-400 hover:text-slate-500 focus:ring-0 focus:outline-hidden sm:block"
"absolute top-0 right-0 hidden pt-4 pr-4 text-slate-400 hover:text-slate-500 focus:ring-0 focus:outline-none sm:block"
)}
onClick={() => {
resetState(true);
@@ -33,7 +33,7 @@ export const SegmentTableDataRow = ({
onClick={() => setIsEditSegmentModalOpen(true)}>
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex items-center gap-4">
<div className="ph-no-capture w-8 shrink-0 text-slate-500">
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
<UsersIcon className="h-5 w-5" />
</div>
<div className="flex flex-col">
@@ -42,7 +42,7 @@ export const SegmentTableDataRow = ({
</div>
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-nowrap text-slate-500 sm:block">
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{surveys?.length}</div>
</div>
<div className="whitespace-wrap col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
@@ -52,7 +52,7 @@ export const SegmentTableDataRow = ({
}).replace("about", "")}
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-normal text-slate-500 sm:block">
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{format(createdAt, "do 'of' MMMM, yyyy")}</div>
</div>
</div>
@@ -18,7 +18,7 @@ export const SegmentTable = async ({
}: TSegmentTableProps) => {
const t = await getTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 pl-6">{t("common.title")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.surveys")}</div>
@@ -12,7 +12,7 @@ const Loading = async () => {
<PageHeader pageTitle="Contacts">
<ContactsSecondaryNavigation activeId="segments" loading />
</PageHeader>
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 pl-6">{t("common.title")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.surveys")}</div>
@@ -26,7 +26,7 @@ const Loading = async () => {
className="m-2 grid h-16 grid-cols-7 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex items-center gap-4">
<UsersIcon className="h-5 w-5 shrink-0 animate-pulse text-slate-500" />
<UsersIcon className="h-5 w-5 flex-shrink-0 animate-pulse text-slate-500" />
<div className="flex flex-col">
<div className="font-medium text-slate-900">
<div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-slate-200"></div>
@@ -37,13 +37,13 @@ const Loading = async () => {
</div>
</div>
</div>
<div className="col-span-1 my-auto text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-1 my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="m-4 h-4 animate-pulse rounded-full bg-slate-200"></div>
</div>
<div className="whitespace-wrap col-span-1 my-auto text-center text-sm text-slate-500">
<div className="m-4 h-4 animate-pulse rounded-full bg-slate-200"></div>
</div>
<div className="col-span-1 my-auto text-center text-sm whitespace-normal text-slate-500">
<div className="col-span-1 my-auto whitespace-normal text-center text-sm text-slate-500">
<div className="m-4 h-4 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
@@ -64,7 +64,7 @@ export function LanguageIndicator({
language.language.code !== languageToBeDisplayed?.language.code &&
language.enabled && (
<button
className="block w-full rounded-xs p-1 text-left hover:bg-slate-700"
className="block w-full rounded-sm p-1 text-left hover:bg-slate-700"
key={language.language.id}
onClick={() => {
changeLanguage(language);
@@ -15,17 +15,17 @@ export const TeamsLoading = () => {
</PageHeader>
<div className="p-4">
<div className="mb-4">
<div className="h-6 w-1/3 animate-pulse rounded-sm bg-slate-200" />
<div className="h-6 w-1/3 animate-pulse rounded bg-slate-200" />
</div>
<div className="space-y-4">
{[...Array(3)].map((_, idx) => (
<div
key={idx}
className="flex animate-pulse items-center space-x-4 rounded-sm border border-slate-200 p-4">
className="flex animate-pulse items-center space-x-4 rounded border border-slate-200 p-4">
<div className="h-10 w-10 rounded-full bg-slate-300" />
<div className="flex-1 space-y-2">
<div className="h-4 w-3/4 rounded-sm bg-slate-200" />
<div className="h-4 w-1/2 rounded-sm bg-slate-200" />
<div className="h-4 w-3/4 rounded bg-slate-200" />
<div className="h-4 w-1/2 rounded bg-slate-200" />
</div>
</div>
))}
@@ -207,7 +207,7 @@ export const TeamSettingsModal = ({
<div className="sticky top-0 flex h-full flex-col rounded-lg">
<button
className={cn(
"absolute top-0 right-0 hidden pt-4 pr-4 text-slate-400 hover:text-slate-500 focus:ring-0 focus:outline-hidden sm:block"
"absolute top-0 right-0 hidden pt-4 pr-4 text-slate-400 hover:text-slate-500 focus:ring-0 focus:outline-none sm:block"
)}
onClick={closeSettingsModal}>
<XIcon className="h-6 w-6 rounded-md bg-white" />
@@ -86,7 +86,7 @@ export const ConfirmPasswordForm = ({
required
onChange={(password) => field.onChange(password)}
value={field.value}
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-xs sm:text-sm"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>

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