mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-02 11:30:31 -05:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74337df278 | |||
| 3f16291137 | |||
| a5958d5653 | |||
| fdbdf8207a | |||
| 630e5489ec | |||
| 36943bb786 | |||
| e1bbb0a10f | |||
| 27da540846 | |||
| 7d7f6ed04a | |||
| ff01bc342d | |||
| cd8b40b569 | |||
| 31c742f7a8 | |||
| d6a7a2c21f | |||
| 499ecab691 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS base
|
||||
FROM node:22-alpine3.21 AS base
|
||||
|
||||
#
|
||||
## step 1: Prune monorepo
|
||||
|
||||
+5
-5
@@ -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`}>
|
||||
|
||||
+1
-1
@@ -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} />
|
||||
<>
|
||||
|
||||
+1
-1
@@ -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={"/"}>
|
||||
|
||||
+1
-1
@@ -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={"/"}>
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+1
-1
@@ -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={"/"}>
|
||||
|
||||
+1
-1
@@ -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 ? (
|
||||
|
||||
+3
-1
@@ -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}
|
||||
|
||||
+2
-2
@@ -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>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -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")}?
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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")}
|
||||
|
||||
+1
-1
@@ -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}>
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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">
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+4
-4
@@ -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,
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+4
-4
@@ -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>
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
+4
-19
@@ -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>
|
||||
|
||||
+3
-3
@@ -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)}
|
||||
|
||||
+2
-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"
|
||||
|
||||
+1
-1
@@ -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"
|
||||
}`}>
|
||||
↑
|
||||
|
||||
+5
-21
@@ -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>
|
||||
|
||||
+2
-2
@@ -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 && (
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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) => (
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+2
-2
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("*");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+183
@@ -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 }],
|
||||
});
|
||||
}
|
||||
};
|
||||
+32
-28
@@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+222
@@ -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" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
+131
@@ -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);
|
||||
},
|
||||
});
|
||||
+28
@@ -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>;
|
||||
+105
@@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+166
@@ -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);
|
||||
},
|
||||
});
|
||||
+8
-11
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user