Compare commits

...

43 Commits

Author SHA1 Message Date
Piyush Gupta
93c72df4d9 fix: changes 2025-06-30 19:04:50 +05:30
Piyush Gupta
49560ccba8 fix: reset password email enumeration 2025-06-30 18:30:07 +05:30
Piyush Gupta
3f98283d4d fix: review changes 2025-06-30 17:10:30 +05:30
Piyush Gupta
7b64422a3f Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-06-30 17:09:32 +05:30
Aditya
5f02ad49c1 fix: allow dynamic height for action cards to show full text (#6106)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-30 02:29:06 -07:00
Dhruwang Jariwala
6644bba6ea fix: formatted databse error message for response endpoint (#6111) 2025-06-30 06:15:50 +00:00
Piyush Gupta
0b7734f725 fix: optional fields in update response API (#6113) 2025-06-30 06:13:42 +00:00
Dhruwang Jariwala
1536bf6907 fix: question change issue (#6091) 2025-06-29 11:10:30 -07:00
Varun Singh
e81190214f feat: Enable recall for welcome cards. (#5963)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-06-29 10:24:54 -07:00
Romit
48c8906a89 fix: Preview in Email embed is broken (#6120) 2025-06-29 09:31:26 -07:00
Johannes
717b30115b fix: align settings card height plus border radius (#6119) 2025-06-27 07:20:52 -07:00
victorvhs017
1f3962d2d5 fix: updated url validation (#6096)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-06-27 13:01:36 +00:00
Piyush Gupta
619f6e408f fix: /api/v2/management/contact-attribute-keys returns 500 instead of 409 on duplicate record (#6100) 2025-06-27 12:50:35 +00:00
Dhruwang Jariwala
4a8719abaa fix: auto subscribe (#6114) 2025-06-27 12:33:08 +00:00
Dhruwang Jariwala
7b59eb3b26 fix: name and description updation in contact attribute key via api (#6089) 2025-06-27 12:09:41 +00:00
Piyush Gupta
8ac280268d fix: update preview URL construction in survey dropdown menu (#6117) 2025-06-27 11:42:14 +00:00
Dhruwang Jariwala
34e8f4931d chore: simplified sharing modal access (#6103) 2025-06-27 11:39:15 +00:00
Piyush Gupta
ac46850a24 fix: unformatted db errors in contact attribute keys management v1 API (#6102) 2025-06-27 05:48:08 +00:00
victorvhs017
6328be220a fix: updated api docs to use - instead of > (#6107) 2025-06-26 09:54:34 -07:00
Dhruwang Jariwala
882ad99ed7 fix: templates page back button (#6088)
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-26 10:38:45 +00:00
Piyush Gupta
ce47b4c2d8 fix: improper zod validation in action classes management API (#6084) 2025-06-26 10:21:01 +00:00
Matti Nannt
ce8f9de8ec fix: confetti animation display issue (#6085)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-26 06:35:19 +00:00
Anshuman Pandey
ed3c2d2b58 fix: fixes shrinking checkbox (#6092) 2025-06-26 05:14:54 +00:00
Anshuman Pandey
9ae226329b fix: decreases environment ttl to 5 minutes (#6087) 2025-06-25 10:30:36 +00:00
Piyush Gupta
12c3899b85 fix: input validation in management v2 webhooks API (#6078) 2025-06-25 09:49:56 +00:00
Piyush Gupta
ccb1353eb5 fix: split domain docs (#6086) 2025-06-25 00:50:23 -07:00
Johannes
22eb0b79ee chore: update issue templates (#6081) 2025-06-24 13:42:10 -07:00
Piyush Gupta
a7ee1f189f fix: docker build validation workflow 2025-05-13 17:04:41 +05:30
Piyush Gupta
46a590311b Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-05-13 17:03:50 +05:30
Piyush Gupta
0faeffb624 Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-05-12 17:10:02 +05:30
Piyush Gupta
d9727a336a Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-05-12 13:50:29 +05:30
Piyush Gupta
330e0db668 Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-05-12 10:58:53 +05:30
Piyush Gupta
f5b7f73199 test: enhance EditProfileDetailsForm tests with password reset functionality 2025-05-09 16:02:39 +05:30
Piyush Gupta
c02f070307 fix: functionality 2025-05-09 15:41:00 +05:30
Piyush Gupta
bc489e050a Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-05-09 11:41:59 +05:30
Kunal Garg
3062059ed5 feat: added description and logout flow 2025-04-19 13:45:22 +05:30
Johannes
f27ede6b2c fix button 2025-04-15 08:48:31 +07:00
Piyush Gupta
e460ff5100 fix: error handling 2025-04-08 19:02:41 +05:30
Piyush Gupta
4699c0014b fix: reset password 2025-04-08 18:45:24 +05:30
Piyush Gupta
52f69be05d Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-04-08 18:37:31 +05:30
Kunal Garg
619c0983a4 fix: input type fixed 2025-04-04 12:09:17 +05:30
Kunal Garg
964fb8d4f4 fix: html tag type 2025-04-03 15:44:52 +05:30
Kunal Garg
5391c60bba feat: reset password in accounts page 2025-04-03 15:29:58 +05:30
151 changed files with 1530 additions and 1081 deletions

View File

@@ -1,6 +1,7 @@
name: Feature request
description: "Suggest an idea for this project \U0001F680"
type: feature
projects: "formbricks/21"
body:
- type: textarea
id: problem-description

View File

@@ -1,11 +0,0 @@
name: Task (internal)
description: "Template for creating a task. Used by the Formbricks Team only \U0001f4e5"
type: task
body:
- type: textarea
id: task-summary
attributes:
label: Task description
description: A clear detailed-rich description of the task.
validations:
required: true

View File

@@ -11,22 +11,21 @@ export const ActionClassDataRow = ({
locale: TUserLocale;
}) => {
return (
<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 flex-shrink-0 text-slate-500">
<div className="m-2 grid grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-4 flex items-start py-3 pl-6 text-sm">
<div className="flex w-full items-center gap-4">
<div className="mt-1 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>
<div className="text-left">
<div className="break-words font-medium text-slate-900">{actionClass.name}</div>
<div className="break-words text-xs text-slate-400">{actionClass.description}</div>
</div>
</div>
</div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
{timeSince(actionClass.createdAt.toString(), locale)}
</div>
<div className="text-center"></div>
</div>
);
};

View File

@@ -20,7 +20,7 @@ vi.mock("@/modules/ui/components/switch", () => ({
}));
vi.mock("../actions", () => ({
updateNotificationSettingsAction: vi.fn(() => Promise.resolve()),
updateNotificationSettingsAction: vi.fn(() => Promise.resolve({ data: true })),
}));
const surveyId = "survey1";
@@ -246,4 +246,204 @@ describe("NotificationSwitch", () => {
});
expect(updateNotificationSettingsAction).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction fails for 'alert' type", async () => {
const mockErrorResponse = { serverError: "Failed to update notification settings" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("Failed to update notification settings", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction fails for 'weeklySummary' type", async () => {
const mockErrorResponse = { serverError: "Database connection failed" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
renderSwitch({
surveyOrProjectOrOrganizationId: projectId,
notificationSettings: initialSettings,
notificationType: "weeklySummary",
});
const switchInput = screen.getByLabelText("toggle notification settings for weeklySummary");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, weeklySummary: { [projectId]: false } },
});
expect(toast.error).toHaveBeenCalledWith("Database connection failed", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction fails for 'unsubscribedOrganizationIds' type", async () => {
const mockErrorResponse = { serverError: "Permission denied" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
renderSwitch({
surveyOrProjectOrOrganizationId: organizationId,
notificationSettings: initialSettings,
notificationType: "unsubscribedOrganizationIds",
});
const switchInput = screen.getByLabelText("toggle notification settings for unsubscribedOrganizationIds");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, unsubscribedOrganizationIds: [organizationId] },
});
expect(toast.error).toHaveBeenCalledWith("Permission denied", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction returns null", async () => {
const mockErrorResponse = { serverError: "An error occurred" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("An error occurred", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction returns undefined", async () => {
const mockErrorResponse = { serverError: "An error occurred" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("An error occurred", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction returns response without data property", async () => {
const mockErrorResponse = { validationErrors: { _errors: ["Invalid input"] } };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("Invalid input", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction throws an exception", async () => {
const mockErrorResponse = { serverError: "Network error" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("Network error", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("switch remains enabled after error occurs", async () => {
const mockErrorResponse = { serverError: "Failed to update" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(toast.error).toHaveBeenCalledWith("Failed to update", {
id: "notification-switch",
});
expect(switchInput).toBeEnabled(); // Switch should be re-enabled after error
});
test("shows error toast with validation errors for specific fields", async () => {
const mockErrorResponse = {
validationErrors: {
notificationSettings: {
_errors: ["Invalid notification settings"],
},
},
};
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("notificationSettingsInvalid notification settings", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,9 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Switch } from "@/modules/ui/components/switch";
import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { TUserNotificationSettings } from "@formbricks/types/user";
@@ -24,6 +26,7 @@ export const NotificationSwitch = ({
}: NotificationSwitchProps) => {
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslate();
const router = useRouter();
const isChecked =
notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
@@ -50,7 +53,20 @@ export const NotificationSwitch = ({
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
}
await updateNotificationSettingsAction({ notificationSettings: updatedNotificationSettings });
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
notificationSettings: updatedNotificationSettings,
});
if (updatedNotificationSettingsActionResponse?.data) {
toast.success(t("environments.settings.notifications.notification_settings_updated"), {
id: "notification-switch",
});
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updatedNotificationSettingsActionResponse);
toast.error(errorMessage, {
id: "notification-switch",
});
}
setIsLoading(false);
};
@@ -104,9 +120,6 @@ export const NotificationSwitch = ({
disabled={isLoading}
onCheckedChange={async () => {
await handleSwitchChange();
toast.success(t("environments.settings.notifications.notification_settings_updated"), {
id: "notification-switch",
});
}}
/>
);

View File

@@ -1,3 +1,4 @@
import { forgotPasswordAction } from "@/modules/auth/forgot-password/actions";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
@@ -24,6 +25,8 @@ const mockUser = {
objective: "other",
} as unknown as TUser;
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
// Mock window.location.reload
const originalLocation = window.location;
beforeEach(() => {
@@ -37,6 +40,10 @@ vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/act
updateUserAction: vi.fn(),
}));
vi.mock("@/modules/auth/forgot-password/actions", () => ({
forgotPasswordAction: vi.fn(),
}));
afterEach(() => {
vi.unstubAllGlobals();
});
@@ -50,7 +57,13 @@ describe("EditProfileDetailsForm", () => {
test("renders with initial user data and updates successfully", async () => {
vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any);
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={true} />);
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={true}
isPasswordResetEnabled={false}
/>
);
const nameInput = screen.getByPlaceholderText("common.full_name");
expect(nameInput).toHaveValue(mockUser.name);
@@ -91,7 +104,13 @@ describe("EditProfileDetailsForm", () => {
const errorMessage = "Update failed";
vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage));
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={false}
/>
);
const nameInput = screen.getByPlaceholderText("common.full_name");
await userEvent.clear(nameInput);
@@ -109,7 +128,13 @@ describe("EditProfileDetailsForm", () => {
});
test("update button is disabled initially and enables on change", async () => {
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={false}
/>
);
const updateButton = screen.getByText("common.update");
expect(updateButton).toBeDisabled();
@@ -117,4 +142,63 @@ describe("EditProfileDetailsForm", () => {
await userEvent.type(nameInput, " updated");
expect(updateButton).toBeEnabled();
});
test("reset password button works", async () => {
vi.mocked(forgotPasswordAction).mockResolvedValue(undefined);
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={true}
/>
);
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
await userEvent.click(resetButton);
await waitFor(() => {
expect(forgotPasswordAction).toHaveBeenCalledWith({ email: mockUser.email });
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("auth.forgot-password.email-sent.heading");
});
});
test("reset password button handles error correctly", async () => {
const errorMessage = "Reset failed";
vi.mocked(forgotPasswordAction).mockRejectedValue(new Error(errorMessage));
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={true}
/>
);
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
await userEvent.click(resetButton);
await waitFor(() => {
expect(forgotPasswordAction).toHaveBeenCalledWith({ email: mockUser.email });
});
});
test("reset password button shows loading state", async () => {
vi.mocked(forgotPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={true}
/>
);
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
await userEvent.click(resetButton);
expect(resetButton).toBeDisabled();
});
});

View File

@@ -3,6 +3,7 @@
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { forgotPasswordAction } from "@/modules/auth/forgot-password/actions";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { Button } from "@/modules/ui/components/button";
import {
@@ -14,6 +15,7 @@ import {
} from "@/modules/ui/components/dropdown-menu";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react";
@@ -30,13 +32,17 @@ const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email:
});
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
interface IEditProfileDetailsFormProps {
user: TUser;
isPasswordResetEnabled?: boolean;
emailVerificationDisabled: boolean;
}
export const EditProfileDetailsForm = ({
user,
isPasswordResetEnabled,
emailVerificationDisabled,
}: {
user: TUser;
emailVerificationDisabled: boolean;
}) => {
}: IEditProfileDetailsFormProps) => {
const { t } = useTranslate();
const form = useForm<TEditProfileNameForm>({
@@ -50,6 +56,8 @@ export const EditProfileDetailsForm = ({
});
const { isSubmitting, isDirty } = form.formState;
const [isResettingPassword, setIsResettingPassword] = useState(false);
const [showModal, setShowModal] = useState(false);
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
@@ -121,6 +129,24 @@ export const EditProfileDetailsForm = ({
}
};
const handleResetPassword = async () => {
if (!user.email) return;
setIsResettingPassword(true);
await forgotPasswordAction({ email: user.email });
toast.success(t("auth.forgot-password.email-sent.heading"));
await signOutWithAudit({
reason: "password_reset",
redirectUrl: "/auth/login",
redirect: true,
callbackUrl: "/auth/login",
});
setIsResettingPassword(false);
};
return (
<>
<FormProvider {...form}>
@@ -205,6 +231,26 @@ export const EditProfileDetailsForm = ({
)}
/>
{isPasswordResetEnabled && (
<div className="mt-4 space-y-2">
<Label htmlFor="reset-password">{t("auth.forgot-password.reset_password")}</Label>
<p className="mt-1 text-sm text-slate-500">
{t("auth.forgot-password.reset_password_description")}
</p>
<div className="flex items-center justify-between gap-2">
<Input type="email" id="reset-password" defaultValue={user.email} disabled />
<Button
onClick={handleResetPassword}
loading={isResettingPassword}
disabled={isResettingPassword}
size="default"
variant="secondary">
{t("auth.forgot-password.reset_password")}
</Button>
</div>
</div>
)}
<Button
type="submit"
className="mt-4"

View File

@@ -12,7 +12,8 @@ import Page from "./page";
// Mock services and utils
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
IS_FORMBRICKS_CLOUD: 1,
PASSWORD_RESET_DISABLED: 1,
EMAIL_VERIFICATION_DISABLED: true,
}));
vi.mock("@/lib/organization/service", () => ({

View File

@@ -1,6 +1,6 @@
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -32,6 +32,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
throw new Error(t("common.user_not_found"));
}
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.account_settings")}>
@@ -42,7 +44,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
<SettingsCard
title={t("environments.settings.profile.personal_information")}
description={t("environments.settings.profile.update_personal_info")}>
<EditProfileDetailsForm emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED} user={user} />
<EditProfileDetailsForm
user={user}
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
isPasswordResetEnabled={isPasswordResetEnabled}
/>
</SettingsCard>
<SettingsCard
title={t("common.avatar")}

View File

@@ -176,8 +176,8 @@ describe("ShareEmbedSurvey", () => {
));
});
test("renders initial 'start' view correctly when open and modalView is 'start'", () => {
render(<ShareEmbedSurvey {...defaultProps} />);
test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} />);
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.getByText("ShareSurveyLinkMock")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
@@ -188,6 +188,18 @@ describe("ShareEmbedSurvey", () => {
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
});
test("renders initial 'start' view correctly when open and modalView is 'start' for app survey", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} />);
// For app surveys, ShareSurveyLink should not be rendered
expect(screen.queryByText("ShareSurveyLinkMock")).not.toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.embed_survey")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.configure_alerts")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.setup_integrations")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
});
test("switches to 'embed' view when 'Embed survey' button is clicked", async () => {
render(<ShareEmbedSurvey {...defaultProps} />);
const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
@@ -205,7 +217,7 @@ describe("ShareEmbedSurvey", () => {
});
test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="embed" />);
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
@@ -219,7 +231,7 @@ describe("ShareEmbedSurvey", () => {
});
test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="panel" />);
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
@@ -257,8 +269,8 @@ describe("ShareEmbedSurvey", () => {
};
expect(embedViewProps.tabs.length).toBe(3);
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
expect(embedViewProps.tabs[0].id).toBe("email");
expect(embedViewProps.activeId).toBe("email");
expect(embedViewProps.tabs[0].id).toBe("link");
expect(embedViewProps.activeId).toBe("link");
});
test("correctly configures for 'web' survey type in embed view", () => {

View File

@@ -47,13 +47,14 @@ export const ShareEmbedSurvey = ({
const tabs = useMemo(
() =>
[
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
{
id: "link",
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
icon: LinkIcon,
},
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
{ id: "app", label: t("environments.surveys.summary.embed_in_app"), icon: SmartphoneIcon },
].filter((tab) => !(survey.type === "link" && tab.id === "app")),
[t, isSingleUseLinkSurvey, survey.type]
@@ -108,24 +109,26 @@ export const ShareEmbedSurvey = ({
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
{showView === "start" ? (
<div className="h-full max-w-full overflow-hidden">
<div className="flex h-[200px] w-full flex-col items-center justify-center space-y-6 p-8 text-center lg:h-2/5">
<DialogTitle>
<p className="pt-2 text-xl font-semibold text-slate-800">
{t("environments.surveys.summary.your_survey_is_public")} 🎉
</p>
</DialogTitle>
<DialogDescription className="hidden" />
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
</div>
<div className="flex h-[300px] flex-col items-center justify-center gap-8 rounded-b-lg bg-slate-50 px-8 lg:h-3/5">
<p className="-mt-8 text-sm text-slate-500">{t("environments.surveys.summary.whats_next")}</p>
<div className="flex h-full max-w-full flex-col overflow-hidden">
{survey.type === "link" && (
<div className="flex h-2/5 w-full flex-col items-center justify-center space-y-6 p-8 text-center">
<DialogTitle>
<p className="pt-2 text-xl font-semibold text-slate-800">
{t("environments.surveys.summary.your_survey_is_public")} 🎉
</p>
</DialogTitle>
<DialogDescription className="hidden" />
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
</div>
)}
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-b-lg bg-slate-50 px-8">
<p className="text-sm text-slate-500">{t("environments.surveys.summary.whats_next")}</p>
<div className="grid grid-cols-4 gap-2">
<button
type="button"

View File

@@ -1,5 +1,6 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
@@ -117,13 +118,13 @@ export const SummaryMetadata = ({
)}
</span>
{!isLoading && (
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
<Button variant="secondary" className="h-6 w-6">
{showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</span>
</Button>
)}
</div>
</div>

View File

@@ -69,13 +69,13 @@ vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
const mockSearchParams = new URLSearchParams();
const mockPush = vi.fn();
const mockReplace = vi.fn();
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
useRouter: () => ({ push: mockPush, replace: mockReplace }),
useSearchParams: () => mockSearchParams,
usePathname: () => "/current",
useParams: () => ({ environmentId: "env123", surveyId: "survey123" }),
usePathname: () => "/current-path",
}));
// Mock copySurveyLink to return a predictable string
@@ -131,280 +131,281 @@ const dummySurvey = {
id: "survey123",
type: "link",
environmentId: "env123",
status: "active",
status: "inProgress",
resultShareKey: null,
} as unknown as TSurvey;
const dummyAppSurvey = {
id: "survey123",
type: "app",
environmentId: "env123",
status: "inProgress",
} as unknown as TSurvey;
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
const dummyUser = { id: "user123", name: "Test User" } as TUser;
describe("SurveyAnalysisCTA - handleCopyLink", () => {
afterEach(() => {
cleanup();
});
test("calls copySurveyLink and clipboard.writeText on success", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
const copyButton = screen.getByRole("button", { name: "common.copy_link" });
fireEvent.click(copyButton);
await waitFor(() => {
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
expect(writeTextMock).toHaveBeenCalledWith("https://public-domain.com/s/survey123?suId=newSingleUseId");
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
});
test("shows error toast on failure", async () => {
refreshSingleUseIdSpy.mockImplementationOnce(() => Promise.reject(new Error("fail")));
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
const copyButton = screen.getByRole("button", { name: "common.copy_link" });
fireEvent.click(copyButton);
await waitFor(() => {
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
expect(writeTextMock).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.surveys.summary.failed_to_copy_link");
});
});
});
// New tests for squarePenIcon and edit functionality
describe("SurveyAnalysisCTA - Edit functionality", () => {
describe("SurveyAnalysisCTA", () => {
beforeEach(() => {
vi.resetAllMocks();
mockSearchParams.delete("share"); // reset params
});
afterEach(() => {
cleanup();
});
test("opens EditPublicSurveyAlertDialog when edit icon is clicked and response count > 0", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
describe("Edit functionality", () => {
test("opens EditPublicSurveyAlertDialog when edit icon is clicked and response count > 0", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Find the edit button
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Find the edit button
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Check if dialog is shown
const dialogTitle = screen.getByText("environments.surveys.edit.caution_edit_published_survey");
expect(dialogTitle).toBeInTheDocument();
// Check if dialog is shown
const dialogTitle = screen.getByText("environments.surveys.edit.caution_edit_published_survey");
expect(dialogTitle).toBeInTheDocument();
});
test("navigates directly to edit page when response count = 0", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={0}
/>
);
// Find the edit button
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Should navigate directly to edit page
expect(mockPush).toHaveBeenCalledWith(
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
);
});
test("doesn't show edit button when isReadOnly is true", () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={true}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
const editButton = screen.queryByRole("button", { name: "common.edit" });
expect(editButton).not.toBeInTheDocument();
});
});
test("navigates directly to edit page when response count = 0", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={0}
/>
);
describe("Duplicate functionality", () => {
test("duplicates survey and redirects on primary button click", async () => {
mockCopySurveyToOtherEnvironmentAction.mockResolvedValue({
data: { id: "newSurvey456" },
});
// Find the edit button
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Should navigate directly to edit page
expect(mockPush).toHaveBeenCalledWith(
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
);
const editButton = screen.getByRole("button", { name: "common.edit" });
fireEvent.click(editButton);
const primaryButton = await screen.findByText("environments.surveys.edit.caution_edit_duplicate");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(mockCopySurveyToOtherEnvironmentAction).toHaveBeenCalledWith({
environmentId: "env123",
surveyId: "survey123",
targetEnvironmentId: "env123",
});
expect(mockPush).toHaveBeenCalledWith("/environments/env123/surveys/newSurvey456/edit");
expect(toast.success).toHaveBeenCalledWith("environments.surveys.survey_duplicated_successfully");
});
});
test("shows error toast on duplication failure", async () => {
const error = { error: "Duplication failed" };
mockCopySurveyToOtherEnvironmentAction.mockResolvedValue(error);
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
const editButton = screen.getByRole("button", { name: "common.edit" });
fireEvent.click(editButton);
const primaryButton = await screen.findByText("environments.surveys.edit.caution_edit_duplicate");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Duplication failed");
});
});
});
test("doesn't show edit button when isReadOnly is true", () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={true}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
describe("Share button and modal", () => {
test("opens share modal when 'Share survey' button is clicked", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Try to find the edit button (it shouldn't exist)
const editButton = screen.queryByRole("button", { name: "common.edit" });
expect(editButton).not.toBeInTheDocument();
});
});
const shareButton = screen.getByText("environments.surveys.summary.share_survey");
fireEvent.click(shareButton);
// Updated test description to mention EditPublicSurveyAlertDialog
describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertDialog", () => {
afterEach(() => {
cleanup();
// The share button opens the embed modal, not a URL
// We can verify this by checking that the ShareEmbedSurvey component is rendered
// with the embed modal open
expect(screen.getByText("environments.surveys.summary.share_survey")).toBeInTheDocument();
});
test("renders ShareEmbedSurvey component when share modal is open", async () => {
mockSearchParams.set("share", "true");
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Assuming ShareEmbedSurvey renders a dialog with a specific title when open
const dialog = await screen.findByRole("dialog");
expect(dialog).toBeInTheDocument();
});
});
test("duplicates survey successfully and navigates to edit page", async () => {
// Mock the API response
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
data: { id: "duplicated-survey-456" },
describe("General UI and visibility", () => {
test("shows public results badge when resultShareKey is present", () => {
const surveyWithShareKey = { ...dummySurvey, resultShareKey: "someKey" } as TSurvey;
render(
<SurveyAnalysisCTA
survey={surveyWithShareKey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
expect(screen.getByText("environments.surveys.summary.results_are_public")).toBeInTheDocument();
});
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
test("shows SurveyStatusDropdown for non-draft surveys", () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Find and click the edit button to show dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Find and click the duplicate button in dialog
const duplicateButton = screen.getByRole("button", {
name: "environments.surveys.edit.caution_edit_duplicate",
});
await fireEvent.click(duplicateButton);
// Verify the API was called with correct parameters
expect(mockCopySurveyToOtherEnvironmentAction).toHaveBeenCalledWith({
environmentId: dummyEnvironment.id,
surveyId: dummySurvey.id,
targetEnvironmentId: dummyEnvironment.id,
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
// Verify success toast was shown
expect(toast.success).toHaveBeenCalledWith("environments.surveys.survey_duplicated_successfully");
// Verify navigation to edit page
expect(mockPush).toHaveBeenCalledWith(
`/environments/${dummyEnvironment.id}/surveys/duplicated-survey-456/edit`
);
});
test("shows error toast when duplication fails with error object", async () => {
// Mock API failure with error object
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
error: "Test error message",
test("does not show SurveyStatusDropdown for draft surveys", () => {
const draftSurvey = { ...dummySurvey, status: "draft" } as TSurvey;
render(
<SurveyAnalysisCTA
survey={draftSurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
});
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
test("hides status dropdown and edit actions when isReadOnly is true", () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={true}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Open dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Click duplicate
const duplicateButton = screen.getByRole("button", {
name: "environments.surveys.edit.caution_edit_duplicate",
});
await fireEvent.click(duplicateButton);
// Verify error toast
expect(toast.error).toHaveBeenCalledWith("Test error message");
});
test("navigates to edit page when cancel button is clicked in dialog", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Open dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Click edit (cancel) button
const editButtonInDialog = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButtonInDialog);
// Verify navigation
expect(mockPush).toHaveBeenCalledWith(
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
);
});
test("shows loading state when duplicating survey", async () => {
// Create a promise that we can resolve manually
let resolvePromise: (value: any) => void;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "common.edit" })).not.toBeInTheDocument();
});
mockCopySurveyToOtherEnvironmentAction.mockImplementation(() => promise);
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Open dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Click duplicate
const duplicateButton = screen.getByRole("button", {
name: "environments.surveys.edit.caution_edit_duplicate",
});
await fireEvent.click(duplicateButton);
// Button should now be in loading state
// expect(duplicateButton).toHaveAttribute("data-state", "loading");
// Resolve the promise
resolvePromise!({
data: { id: "duplicated-survey-456" },
test("shows preview button for link surveys", () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
expect(screen.getByRole("button", { name: "common.preview" })).toBeInTheDocument();
});
// Wait for the promise to resolve
await waitFor(() => {
expect(mockPush).toHaveBeenCalled();
test("hides preview button for app surveys", () => {
render(
<SurveyAnalysisCTA
survey={dummyAppSurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
expect(screen.queryByRole("button", { name: "common.preview" })).not.toBeInTheDocument();
});
});
});

View File

@@ -5,13 +5,12 @@ import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { IconBar } from "@/modules/ui/components/iconbar";
import { useTranslate } from "@tolgee/react";
import { BellRing, Code2Icon, Eye, LinkIcon, SquarePenIcon, UsersRound } from "lucide-react";
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
@@ -57,7 +56,6 @@ export const SurveyAnalysisCTA = ({
});
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
const { refreshSingleUseId } = useSingleUseId(survey);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -79,22 +77,6 @@ export const SurveyAnalysisCTA = ({
setModalState((prev) => ({ ...prev, share: open }));
};
const handleCopyLink = () => {
refreshSingleUseId()
.then((newId) => {
const linkToCopy = copySurveyLink(surveyUrl, newId);
return navigator.clipboard.writeText(linkToCopy);
})
.then(() => {
toast.success(t("common.copied_to_clipboard"));
})
.catch((err) => {
toast.error(t("environments.surveys.summary.failed_to_copy_link"));
console.error(err);
});
setModalState((prev) => ({ ...prev, dropdown: false }));
};
const duplicateSurveyAndRoute = async (surveyId: string) => {
setLoading(true);
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
@@ -134,24 +116,6 @@ export const SurveyAnalysisCTA = ({
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const iconActions = [
{
icon: Eye,
tooltip: t("common.preview"),
onClick: () => window.open(getPreviewUrl(), "_blank"),
isVisible: survey.type === "link",
},
{
icon: LinkIcon,
tooltip: t("common.copy_link"),
onClick: handleCopyLink,
isVisible: survey.type === "link",
},
{
icon: Code2Icon,
tooltip: t("common.embed"),
onClick: () => handleModalState("embed")(true),
isVisible: !isReadOnly,
},
{
icon: BellRing,
tooltip: t("environments.surveys.summary.configure_alerts"),
@@ -159,13 +123,10 @@ export const SurveyAnalysisCTA = ({
isVisible: !isReadOnly,
},
{
icon: UsersRound,
tooltip: t("environments.surveys.summary.send_to_panel"),
onClick: () => {
handleModalState("panel")(true);
setModalState((prev) => ({ ...prev, dropdown: false }));
},
isVisible: !isReadOnly,
icon: Eye,
tooltip: t("common.preview"),
onClick: () => window.open(getPreviewUrl(), "_blank"),
isVisible: survey.type === "link",
},
{
icon: SquarePenIcon,
@@ -195,6 +156,13 @@ export const SurveyAnalysisCTA = ({
)}
<IconBar actions={iconActions} />
<Button
className="h-10"
onClick={() => {
setModalState((prev) => ({ ...prev, embed: true }));
}}>
{t("environments.surveys.summary.share_survey")}
</Button>
{user && (
<>

View File

@@ -274,7 +274,7 @@ describe("getEnvironmentState", () => {
expect(withCache).toHaveBeenCalledWith(expect.any(Function), {
key: `fb:env:${environmentId}:state`,
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
});
});
});

View File

@@ -83,9 +83,8 @@ export const getEnvironmentState = async (
{
// Use enterprise-grade cache key pattern
key: createCacheKey.environment.state(environmentId),
// 30 minutes TTL ensures fresh data for hourly SDK checks
// Balances performance with freshness requirements
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
// This is a temporary fix for the invalidation issues, will be changed later with a proper solution
ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
}
);

View File

@@ -52,14 +52,6 @@ export const POST = withApiLogging(
}
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
const environmentId = actionClassInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
@@ -70,6 +62,14 @@ export const POST = withApiLogging(
};
}
const environmentId = inputValidation.data.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
auditLog.targetId = actionClass.id;
auditLog.newObject = actionClass;

View File

@@ -186,6 +186,18 @@ describe("Response Lib Tests", () => {
expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError
});
test("should handle RelatedRecordDoesNotExist error with specific message", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Related record does not exist", {
code: "P2025", // PrismaErrorType.RelatedRecordDoesNotExist
clientVersion: "2.0",
});
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
await expect(createResponse(mockResponseInput)).rejects.toThrow("Display ID does not exist");
});
test("should handle generic errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);

View File

@@ -12,6 +12,7 @@ import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
@@ -176,6 +177,9 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.RelatedRecordDoesNotExist) {
throw new DatabaseError("Display ID does not exist");
}
throw new DatabaseError(error.message);
}

View File

@@ -149,6 +149,10 @@ export const POST = withApiLogging(
return {
response: responses.badRequestResponse(error.message),
};
} else if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return {
@@ -158,7 +162,7 @@ export const POST = withApiLogging(
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
response: responses.badRequestResponse("An unexpected error occurred while creating the response"),
};
}
throw error;

View File

@@ -65,7 +65,8 @@ export const validateSingleFile = (
return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension);
};
export const validateFileUploads = (data: TResponseData, questions?: TSurveyQuestion[]): boolean => {
export const validateFileUploads = (data?: TResponseData, questions?: TSurveyQuestion[]): boolean => {
if (!data) return true;
for (const key of Object.keys(data)) {
const question = questions?.find((q) => q.id === key);
if (!question || question.type !== TSurveyQuestionTypeEnum.FileUpload) continue;

View File

@@ -1,9 +1,16 @@
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { updateUser } from "@/lib/user/service";
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { createOrganization, getOrganization, getOrganizationsByUserId, updateOrganization } from "./service";
import {
createOrganization,
getOrganization,
getOrganizationsByUserId,
subscribeOrganizationMembersToSurveyResponses,
updateOrganization,
} from "./service";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -13,9 +20,16 @@ vi.mock("@formbricks/database", () => ({
create: vi.fn(),
update: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/user/service", () => ({
updateUser: vi.fn(),
}));
describe("Organization Service", () => {
afterEach(() => {
vi.clearAllMocks();
@@ -252,4 +266,62 @@ describe("Organization Service", () => {
});
});
});
describe("subscribeOrganizationMembersToSurveyResponses", () => {
test("should subscribe user to survey responses when not unsubscribed", async () => {
const mockUser = {
id: "user-123",
notificationSettings: {
alert: { "existing-survey-id": true },
weeklySummary: {},
unsubscribedOrganizationIds: [], // User is subscribed to all organizations
},
} as any;
const surveyId = "survey-123";
const userId = "user-123";
const organizationId = "org-123";
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(mockUser);
vi.mocked(updateUser).mockResolvedValueOnce({} as any);
await subscribeOrganizationMembersToSurveyResponses(surveyId, userId, organizationId);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: userId },
});
expect(updateUser).toHaveBeenCalledWith(userId, {
notificationSettings: {
alert: {
"existing-survey-id": true,
"survey-123": true,
},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
});
});
test("should not subscribe user when unsubscribed from organization", async () => {
const mockUser = {
id: "user-123",
notificationSettings: {
alert: { "existing-survey-id": true },
weeklySummary: {},
unsubscribedOrganizationIds: ["org-123"], // User has unsubscribed from this organization
},
} as any;
const surveyId = "survey-123";
const userId = "user-123";
const organizationId = "org-123";
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(mockUser);
await subscribeOrganizationMembersToSurveyResponses(surveyId, userId, organizationId);
// Should not call updateUser because user is unsubscribed from this organization
expect(updateUser).not.toHaveBeenCalled();
});
});
});

View File

@@ -34,7 +34,8 @@
"text": "Du kannst Dich jetzt mit deinem neuen Passwort einloggen"
}
},
"reset_password": "Passwort zurücksetzen"
"reset_password": "Passwort zurücksetzen",
"reset_password_description": "Du wirst abgemeldet, um dein Passwort zurückzusetzen."
},
"invite": {
"create_account": "Konto erstellen",
@@ -191,7 +192,6 @@
"e_commerce": "E-Commerce",
"edit": "Bearbeiten",
"email": "E-Mail",
"embed": "Einbetten",
"enterprise_license": "Enterprise Lizenz",
"environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
@@ -1786,6 +1786,7 @@
"setup_instructions": "Einrichtung",
"setup_integrations": "Integrationen einrichten",
"share_results": "Ergebnisse teilen",
"share_survey": "Umfrage teilen",
"share_the_link": "Teile den Link",
"share_the_link_to_get_responses": "Teile den Link, um Antworten einzusammeln",
"show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen",

View File

@@ -34,7 +34,8 @@
"text": "You can now log in with your new password"
}
},
"reset_password": "Reset password"
"reset_password": "Reset password",
"reset_password_description": "You will be logged out to reset your password."
},
"invite": {
"create_account": "Create an account",
@@ -191,7 +192,6 @@
"e_commerce": "E-Commerce",
"edit": "Edit",
"email": "Email",
"embed": "Embed",
"enterprise_license": "Enterprise License",
"environment_not_found": "Environment not found",
"environment_notice": "You're currently in the {environment} environment.",
@@ -1786,6 +1786,7 @@
"setup_instructions": "Setup instructions",
"setup_integrations": "Setup integrations",
"share_results": "Share results",
"share_survey": "Share survey",
"share_the_link": "Share the link",
"share_the_link_to_get_responses": "Share the link to get responses",
"show_all_responses_that_match": "Show all responses that match",

View File

@@ -34,7 +34,8 @@
"text": "Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
}
},
"reset_password": "Réinitialiser le mot de passe"
"reset_password": "Réinitialiser le mot de passe",
"reset_password_description": "Vous serez déconnecté pour réinitialiser votre mot de passe."
},
"invite": {
"create_account": "Créer un compte",
@@ -191,7 +192,6 @@
"e_commerce": "E-commerce",
"edit": "Modifier",
"email": "Email",
"embed": "Intégrer",
"enterprise_license": "Licence d'entreprise",
"environment_not_found": "Environnement non trouvé",
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
@@ -1786,6 +1786,7 @@
"setup_instructions": "Instructions d'installation",
"setup_integrations": "Configurer les intégrations",
"share_results": "Partager les résultats",
"share_survey": "Partager l'enquête",
"share_the_link": "Partager le lien",
"share_the_link_to_get_responses": "Partagez le lien pour obtenir des réponses",
"show_all_responses_that_match": "Afficher toutes les réponses correspondantes",

View File

@@ -34,7 +34,8 @@
"text": "Agora você pode fazer login com sua nova senha"
}
},
"reset_password": "Redefinir senha"
"reset_password": "Redefinir senha",
"reset_password_description": "Você será desconectado para redefinir sua senha."
},
"invite": {
"create_account": "Cria uma conta",
@@ -191,7 +192,6 @@
"e_commerce": "comércio eletrônico",
"edit": "Editar",
"email": "Email",
"embed": "incorporar",
"enterprise_license": "Licença Empresarial",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Você está atualmente no ambiente {environment}.",
@@ -356,7 +356,7 @@
"start_free_trial": "Iniciar Teste Grátis",
"status": "status",
"step_by_step_manual": "Manual passo a passo",
"styling": "estilização",
"styling": "Estilização",
"submit": "Enviar",
"summary": "Resumo",
"survey": "Pesquisa",
@@ -368,7 +368,7 @@
"survey_paused": "Pesquisa pausada.",
"survey_scheduled": "Pesquisa agendada.",
"survey_type": "Tipo de Pesquisa",
"surveys": "pesquisas",
"surveys": "Pesquisas",
"switch_organization": "Mudar organização",
"switch_to": "Mudar para {environment}",
"table_items_deleted_successfully": "{type}s deletados com sucesso",
@@ -1786,6 +1786,7 @@
"setup_instructions": "Instruções de configuração",
"setup_integrations": "Configurar integrações",
"share_results": "Compartilhar resultados",
"share_survey": "Compartilhar pesquisa",
"share_the_link": "Compartilha o link",
"share_the_link_to_get_responses": "Compartilha o link pra receber respostas",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",

View File

@@ -34,7 +34,8 @@
"text": "Pode agora iniciar sessão com a sua nova palavra-passe"
}
},
"reset_password": "Redefinir palavra-passe"
"reset_password": "Redefinir palavra-passe",
"reset_password_description": "Será desconectado para redefinir a sua palavra-passe."
},
"invite": {
"create_account": "Criar uma conta",
@@ -191,7 +192,6 @@
"e_commerce": "Comércio Eletrónico",
"edit": "Editar",
"email": "Email",
"embed": "Incorporar",
"enterprise_license": "Licença Enterprise",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Está atualmente no ambiente {environment}.",
@@ -1786,6 +1786,7 @@
"setup_instructions": "Instruções de configuração",
"setup_integrations": "Configurar integrações",
"share_results": "Partilhar resultados",
"share_survey": "Partilhar inquérito",
"share_the_link": "Partilhar o link",
"share_the_link_to_get_responses": "Partilhe o link para obter respostas",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",

View File

@@ -34,7 +34,8 @@
"text": "您現在可以使用新密碼登入"
}
},
"reset_password": "重設密碼"
"reset_password": "重設密碼",
"reset_password_description": "您將被登出以重設您的密碼。"
},
"invite": {
"create_account": "建立帳戶",
@@ -191,7 +192,6 @@
"e_commerce": "電子商務",
"edit": "編輯",
"email": "電子郵件",
"embed": "嵌入",
"enterprise_license": "企業授權",
"environment_not_found": "找不到環境",
"environment_notice": "您目前在 '{'environment'}' 環境中。",
@@ -1786,6 +1786,7 @@
"setup_instructions": "設定說明",
"setup_integrations": "設定整合",
"share_results": "分享結果",
"share_survey": "分享問卷",
"share_the_link": "分享連結",
"share_the_link_to_get_responses": "分享連結以取得回應",
"show_all_responses_that_match": "顯示所有相符的回應",

View File

@@ -33,10 +33,11 @@ export const validateOtherOptionLengthForMultipleChoice = ({
surveyQuestions,
responseLanguage,
}: {
responseData: TResponseData;
responseData?: TResponseData;
surveyQuestions: TSurveyQuestion[];
responseLanguage?: string;
}): string | undefined => {
if (!responseData) return undefined;
for (const [questionId, answer] of Object.entries(responseData)) {
const question = surveyQuestions.find((q) => q.id === questionId);
if (!question) continue;

View File

@@ -1,7 +1,6 @@
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 { ContactAttributeKey, Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -55,7 +54,7 @@ export const updateContactAttributeKey = async (
return ok(updatedKey);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
@@ -106,7 +105,7 @@ export const deleteContactAttributeKey = async (
return ok(deletedKey);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist

View File

@@ -16,7 +16,7 @@ export const getContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
id: ZContactAttributeKeyIdSchema,
}),
},
tags: ["Management API > Contact Attribute Keys"],
tags: ["Management API - Contact Attribute Keys"],
responses: {
"200": {
description: "Contact attribute key retrieved successfully.",
@@ -33,7 +33,7 @@ export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContactAttributeKey",
summary: "Update a contact attribute key",
description: "Updates a contact attribute key in the database.",
tags: ["Management API > Contact Attribute Keys"],
tags: ["Management API - Contact Attribute Keys"],
requestParams: {
path: z.object({
id: ZContactAttributeKeyIdSchema,
@@ -64,7 +64,7 @@ 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"],
tags: ["Management API - Contact Attribute Keys"],
requestParams: {
path: z.object({
id: ZContactAttributeKeyIdSchema,

View File

@@ -1,6 +1,5 @@
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 { ContactAttributeKey, Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -44,12 +43,12 @@ const mockUpdateInput: TContactAttributeKeyUpdateSchema = {
description: "User's verified email address",
};
const prismaNotFoundError = new PrismaClientKnownRequestError("Mock error message", {
const prismaNotFoundError = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "0.0.1",
});
const prismaUniqueConstraintError = new PrismaClientKnownRequestError("Mock error message", {
const prismaUniqueConstraintError = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});

View File

@@ -5,7 +5,6 @@ import {
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
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";
@@ -58,7 +57,7 @@ export const createContactAttributeKey = async (
return ok(createdContactAttributeKey);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist

View File

@@ -16,7 +16,7 @@ export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttributeKeys",
summary: "Get contact attribute keys",
description: "Gets contact attribute keys from the database.",
tags: ["Management API > Contact Attribute Keys"],
tags: ["Management API - Contact Attribute Keys"],
requestParams: {
query: ZGetContactAttributeKeysFilter.sourceType(),
},
@@ -36,7 +36,7 @@ export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "createContactAttributeKey",
summary: "Create a contact attribute key",
description: "Creates a contact attribute key in the database.",
tags: ["Management API > Contact Attribute Keys"],
tags: ["Management API - Contact Attribute Keys"],
requestBody: {
required: true,
description: "The contact attribute key to create",

View File

@@ -2,8 +2,7 @@ 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 { ContactAttributeKey, Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -106,7 +105,7 @@ describe("createContactAttributeKey", () => {
});
test("returns conflict error when key already exists", async () => {
const errToThrow = new PrismaClientKnownRequestError("Mock error message", {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
@@ -129,7 +128,7 @@ describe("createContactAttributeKey", () => {
});
test("returns not found error when related record does not exist", async () => {
const errToThrow = new PrismaClientKnownRequestError("Mock error message", {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "0.0.1",
});

View File

@@ -12,7 +12,7 @@ export const getContactAttributeEndpoint: ZodOpenApiOperationObject = {
contactAttributeId: z.string().cuid2(),
}),
},
tags: ["Management API > Contact Attributes"],
tags: ["Management API - Contact Attributes"],
responses: {
"200": {
description: "Contact retrieved successfully.",
@@ -29,7 +29,7 @@ export const deleteContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContactAttribute",
summary: "Delete a contact attribute",
description: "Deletes a contact attribute from the database.",
tags: ["Management API > Contact Attributes"],
tags: ["Management API - Contact Attributes"],
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
@@ -51,7 +51,7 @@ export const updateContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContactAttribute",
summary: "Update a contact attribute",
description: "Updates a contact attribute in the database.",
tags: ["Management API > Contact Attributes"],
tags: ["Management API - Contact Attributes"],
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),

View File

@@ -16,7 +16,7 @@ export const getContactAttributesEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttributes",
summary: "Get contact attributes",
description: "Gets contact attributes from the database.",
tags: ["Management API > Contact Attributes"],
tags: ["Management API - Contact Attributes"],
requestParams: {
query: ZGetContactAttributesFilter,
},
@@ -36,7 +36,7 @@ export const createContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "createContactAttribute",
summary: "Create a contact attribute",
description: "Creates a contact attribute in the database.",
tags: ["Management API > Contact Attributes"],
tags: ["Management API - Contact Attributes"],
requestBody: {
required: true,
description: "The contact attribute to create",

View File

@@ -12,7 +12,7 @@ export const getContactEndpoint: ZodOpenApiOperationObject = {
contactId: z.string().cuid2(),
}),
},
tags: ["Management API > Contacts"],
tags: ["Management API - Contacts"],
responses: {
"200": {
description: "Contact retrieved successfully.",
@@ -29,7 +29,7 @@ export const deleteContactEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContact",
summary: "Delete a contact",
description: "Deletes a contact from the database.",
tags: ["Management API > Contacts"],
tags: ["Management API - Contacts"],
requestParams: {
path: z.object({
contactId: z.string().cuid2(),
@@ -51,7 +51,7 @@ export const updateContactEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContact",
summary: "Update a contact",
description: "Updates a contact in the database.",
tags: ["Management API > Contacts"],
tags: ["Management API - Contacts"],
requestParams: {
path: z.object({
contactId: z.string().cuid2(),

View File

@@ -16,7 +16,7 @@ export const getContactsEndpoint: ZodOpenApiOperationObject = {
requestParams: {
query: ZGetContactsFilter,
},
tags: ["Management API > Contacts"],
tags: ["Management API - Contacts"],
responses: {
"200": {
description: "Contacts retrieved successfully.",
@@ -33,7 +33,7 @@ export const createContactEndpoint: ZodOpenApiOperationObject = {
operationId: "createContact",
summary: "Create a contact",
description: "Creates a contact in the database.",
tags: ["Management API > Contacts"],
tags: ["Management API - Contacts"],
requestBody: {
required: true,
description: "The contact to create",

View File

@@ -25,7 +25,9 @@ export const getEnvironmentId = async (
*/
export const getEnvironmentIdFromSurveyIds = async (
surveyIds: string[]
): Promise<Result<string, ApiErrorResponseV2>> => {
): Promise<Result<string | null, ApiErrorResponseV2>> => {
if (surveyIds.length === 0) return ok(null);
const result = await fetchEnvironmentIdFromSurveyIds(surveyIds);
if (!result.ok) {

View File

@@ -1,5 +1,5 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { Result, err, ok } from "@formbricks/types/error-handlers";
@@ -19,7 +19,7 @@ export const deleteDisplay = async (displayId: string): Promise<Result<boolean,
return ok(true);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist

View File

@@ -14,7 +14,7 @@ export const getResponseEndpoint: ZodOpenApiOperationObject = {
id: ZResponseIdSchema,
}),
},
tags: ["Management API > Responses"],
tags: ["Management API - Responses"],
responses: {
"200": {
description: "Response retrieved successfully.",
@@ -31,7 +31,7 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteResponse",
summary: "Delete a response",
description: "Deletes a response from the database.",
tags: ["Management API > Responses"],
tags: ["Management API - Responses"],
requestParams: {
path: z.object({
id: ZResponseIdSchema,
@@ -53,7 +53,7 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = {
operationId: "updateResponse",
summary: "Update a response",
description: "Updates a response in the database.",
tags: ["Management API > Responses"],
tags: ["Management API - Responses"],
requestParams: {
path: z.object({
id: ZResponseIdSchema,

View File

@@ -3,8 +3,7 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
import { findAndDeleteUploadedFilesInResponse } from "@/modules/api/v2/management/responses/[responseId]/lib/utils";
import { ZResponseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Response } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma, Response } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
@@ -56,7 +55,7 @@ export const deleteResponse = async (responseId: string): Promise<Result<Respons
return ok(deletedResponse);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
@@ -89,7 +88,7 @@ export const updateResponse = async (
return ok(updatedResponse);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist

View File

@@ -1,5 +1,5 @@
import { displayId, mockDisplay } from "./__mocks__/display.mock";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -39,7 +39,7 @@ describe("Display Lib", () => {
test("return a not_found error when the display is not found", async () => {
vi.mocked(prisma.display.delete).mockRejectedValue(
new PrismaClientKnownRequestError("Display not found", {
new Prisma.PrismaClientKnownRequestError("Display not found", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "1.0.0",
meta: {

View File

@@ -1,5 +1,5 @@
import { response, responseId, responseInput, survey } from "./__mocks__/response.mock";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -154,7 +154,7 @@ describe("Response Lib", () => {
test("handle prisma client error code P2025", async () => {
vi.mocked(prisma.response.delete).mockRejectedValue(
new PrismaClientKnownRequestError("Response not found", {
new Prisma.PrismaClientKnownRequestError("Response not found", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "1.0.0",
meta: {
@@ -208,7 +208,7 @@ describe("Response Lib", () => {
test("return a not_found error when the response is not found", async () => {
vi.mocked(prisma.response.update).mockRejectedValue(
new PrismaClientKnownRequestError("Response not found", {
new Prisma.PrismaClientKnownRequestError("Response not found", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "1.0.0",
meta: {

View File

@@ -16,7 +16,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
requestParams: {
query: ZGetResponsesFilter.sourceType(),
},
tags: ["Management API > Responses"],
tags: ["Management API - Responses"],
responses: {
"200": {
description: "Responses retrieved successfully.",
@@ -33,7 +33,7 @@ export const createResponseEndpoint: ZodOpenApiOperationObject = {
operationId: "createResponse",
summary: "Create a response",
description: "Creates a response in the database.",
tags: ["Management API > Responses"],
tags: ["Management API - Responses"],
requestBody: {
required: true,
description: "The response to create",

View File

@@ -10,7 +10,7 @@ export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
requestParams: {
path: ZContactLinkParams,
},
tags: ["Management API > Surveys > Contact Links"],
tags: ["Management API - Surveys - Contact Links"],
responses: {
"200": {
description: "Personalized survey link retrieved successfully.",

View File

@@ -10,7 +10,7 @@ export const getContactLinksBySegmentEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactLinksBySegment",
summary: "Get survey links for contacts in a segment",
description: "Generates personalized survey links for contacts in a segment.",
tags: ["Management API > Surveys > Contact Links"],
tags: ["Management API - Surveys - Contact Links"],
requestParams: {
path: ZContactLinksBySegmentParams,
query: ZContactLinksBySegmentQuery,

View File

@@ -13,7 +13,7 @@ export const getSurveyEndpoint: ZodOpenApiOperationObject = {
id: surveyIdSchema,
}),
},
tags: ["Management API > Surveys"],
tags: ["Management API - Surveys"],
responses: {
"200": {
description: "Response retrieved successfully.",
@@ -30,7 +30,7 @@ export const deleteSurveyEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteSurvey",
summary: "Delete a survey",
description: "Deletes a survey from the database.",
tags: ["Management API > Surveys"],
tags: ["Management API - Surveys"],
requestParams: {
path: z.object({
id: surveyIdSchema,
@@ -52,7 +52,7 @@ export const updateSurveyEndpoint: ZodOpenApiOperationObject = {
operationId: "updateSurvey",
summary: "Update a survey",
description: "Updates a survey in the database.",
tags: ["Management API > Surveys"],
tags: ["Management API - Surveys"],
requestParams: {
path: z.object({
id: surveyIdSchema,

View File

@@ -1,8 +1,3 @@
// import {
// deleteSurveyEndpoint,
// getSurveyEndpoint,
// updateSurveyEndpoint,
// } from "@/modules/api/v2/management/surveys/[surveyId]/lib/openapi";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { getPersonalizedSurveyLink } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/openapi";
import { ZGetSurveysFilter, ZSurveyInput } from "@/modules/api/v2/management/surveys/types/surveys";
@@ -17,7 +12,7 @@ export const getSurveysEndpoint: ZodOpenApiOperationObject = {
requestParams: {
query: ZGetSurveysFilter,
},
tags: ["Management API > Surveys"],
tags: ["Management API - Surveys"],
responses: {
"200": {
description: "Surveys retrieved successfully.",
@@ -34,7 +29,7 @@ export const createSurveyEndpoint: ZodOpenApiOperationObject = {
operationId: "createSurvey",
summary: "Create a survey",
description: "Creates a survey in the database.",
tags: ["Management API > Surveys"],
tags: ["Management API - Surveys"],
requestBody: {
required: true,
description: "The survey to create",

View File

@@ -14,7 +14,7 @@ export const getWebhookEndpoint: ZodOpenApiOperationObject = {
id: ZWebhookIdSchema,
}),
},
tags: ["Management API > Webhooks"],
tags: ["Management API - Webhooks"],
responses: {
"200": {
description: "Webhook retrieved successfully.",
@@ -31,7 +31,7 @@ export const deleteWebhookEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteWebhook",
summary: "Delete a webhook",
description: "Deletes a webhook from the database.",
tags: ["Management API > Webhooks"],
tags: ["Management API - Webhooks"],
requestParams: {
path: z.object({
id: ZWebhookIdSchema,
@@ -53,7 +53,7 @@ export const updateWebhookEndpoint: ZodOpenApiOperationObject = {
operationId: "updateWebhook",
summary: "Update a webhook",
description: "Updates a webhook in the database.",
tags: ["Management API > Webhooks"],
tags: ["Management API - Webhooks"],
requestParams: {
path: z.object({
id: ZWebhookIdSchema,

View File

@@ -1,5 +1,4 @@
import { WebhookSource } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma, WebhookSource } from "@prisma/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
export const mockedPrismaWebhookUpdateReturn = {
@@ -14,7 +13,7 @@ export const mockedPrismaWebhookUpdateReturn = {
surveyIds: [],
};
export const prismaNotFoundError = new PrismaClientKnownRequestError("Record does not exist", {
export const prismaNotFoundError = new Prisma.PrismaClientKnownRequestError("Record does not exist", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "PrismaClient 4.0.0",
});

View File

@@ -1,7 +1,6 @@
import { ZWebhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Webhook } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma, Webhook } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -45,7 +44,7 @@ export const updateWebhook = async (
return ok(updatedWebhook);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
@@ -73,7 +72,7 @@ export const deleteWebhook = async (webhookId: string): Promise<Result<Webhook,
return ok(deletedWebhook);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist

View File

@@ -75,13 +75,14 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
);
}
// get surveys environment
const surveysEnvironmentId = await getEnvironmentIdFromSurveyIds(body.surveyIds);
const surveysEnvironmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
if (!surveysEnvironmentId.ok) {
return handleApiError(request, surveysEnvironmentId.error, auditLog);
if (!surveysEnvironmentIdResult.ok) {
return handleApiError(request, surveysEnvironmentIdResult.error, auditLog);
}
const surveysEnvironmentId = surveysEnvironmentIdResult.data;
// get webhook environment
const webhook = await getWebhook(params.webhookId);
@@ -101,7 +102,7 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
}
// check if webhook environment matches the surveys environment
if (webhook.data.environmentId !== surveysEnvironmentId.data) {
if (surveysEnvironmentId && webhook.data.environmentId !== surveysEnvironmentId) {
return handleApiError(
request,
{

View File

@@ -16,7 +16,7 @@ export const getWebhooksEndpoint: ZodOpenApiOperationObject = {
requestParams: {
query: ZGetWebhooksFilter.sourceType(),
},
tags: ["Management API > Webhooks"],
tags: ["Management API - Webhooks"],
responses: {
"200": {
description: "Webhooks retrieved successfully.",
@@ -33,7 +33,7 @@ export const createWebhookEndpoint: ZodOpenApiOperationObject = {
operationId: "createWebhook",
summary: "Create a webhook",
description: "Creates a webhook in the database.",
tags: ["Management API > Webhooks"],
tags: ["Management API - Webhooks"],
requestBody: {
required: true,
description: "The webhook to create",

View File

@@ -57,10 +57,12 @@ export const POST = async (request: NextRequest) =>
);
}
const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
if (body.surveyIds && body.surveyIds.length > 0) {
const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error, auditLog);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error, auditLog);
}
}
if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) {

View File

@@ -66,43 +66,43 @@ const document = createDocument({
description: "Operations for managing your API key.",
},
{
name: "Management API > Responses",
name: "Management API - Responses",
description: "Operations for managing responses.",
},
{
name: "Management API > Contacts",
name: "Management API - Contacts",
description: "Operations for managing contacts.",
},
{
name: "Management API > Contact Attributes",
name: "Management API - Contact Attributes",
description: "Operations for managing contact attributes.",
},
{
name: "Management API > Contact Attributes Keys",
description: "Operations for managing contact attributes keys.",
name: "Management API - Contact Attribute Keys",
description: "Operations for managing contact attribute keys.",
},
{
name: "Management API > Surveys",
name: "Management API - Surveys",
description: "Operations for managing surveys.",
},
{
name: "Management API > Surveys > Contact Links",
name: "Management API - Surveys - Contact Links",
description: "Operations for generating personalized survey links for contacts.",
},
{
name: "Management API > Webhooks",
name: "Management API - Webhooks",
description: "Operations for managing webhooks.",
},
{
name: "Organizations API > Teams",
name: "Organizations API - Teams",
description: "Operations for managing teams.",
},
{
name: "Organizations API > Project Teams",
name: "Organizations API - Project Teams",
description: "Operations for managing project teams.",
},
{
name: "Organizations API > Users",
name: "Organizations API - Users",
description: "Operations for managing users.",
},
],

View File

@@ -20,7 +20,7 @@ export const getProjectTeamsEndpoint: ZodOpenApiOperationObject = {
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Project Teams"],
tags: ["Organizations API - Project Teams"],
responses: {
"200": {
description: "Project teams retrieved successfully.",
@@ -42,7 +42,7 @@ export const createProjectTeamEndpoint: ZodOpenApiOperationObject = {
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Project Teams"],
tags: ["Organizations API - Project Teams"],
requestBody: {
required: true,
description: "The project team to create",
@@ -68,7 +68,7 @@ export const deleteProjectTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteProjectTeam",
summary: "Delete a project team",
description: "Deletes a project team from the database.",
tags: ["Organizations API > Project Teams"],
tags: ["Organizations API - Project Teams"],
requestParams: {
query: ZGetProjectTeamUpdateFilter.required(),
path: z.object({
@@ -91,7 +91,7 @@ export const updateProjectTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "updateProjectTeam",
summary: "Update a project team",
description: "Updates a project team in the database.",
tags: ["Organizations API > Project Teams"],
tags: ["Organizations API - Project Teams"],
requestParams: {
path: z.object({
organizationId: ZOrganizationIdSchema,

View File

@@ -16,7 +16,7 @@ export const getTeamEndpoint: ZodOpenApiOperationObject = {
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Teams"],
tags: ["Organizations API - Teams"],
responses: {
"200": {
description: "Team retrieved successfully.",
@@ -33,7 +33,7 @@ export const deleteTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteTeam",
summary: "Delete a team",
description: "Deletes a team from the database.",
tags: ["Organizations API > Teams"],
tags: ["Organizations API - Teams"],
requestParams: {
path: z.object({
id: ZTeamIdSchema,
@@ -56,7 +56,7 @@ export const updateTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "updateTeam",
summary: "Update a team",
description: "Updates a team in the database.",
tags: ["Organizations API > Teams"],
tags: ["Organizations API - Teams"],
requestParams: {
path: z.object({
id: ZTeamIdSchema,

View File

@@ -1,7 +1,6 @@
import { ZTeamUpdateSchema } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Team } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma, Team } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
@@ -51,7 +50,7 @@ export const deleteTeam = async (
return ok(deletedTeam);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
@@ -89,7 +88,7 @@ export const updateTeam = async (
return ok(updatedTeam);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist

View File

@@ -1,4 +1,4 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -74,7 +74,7 @@ describe("Teams Lib", () => {
test("returns not_found error on known prisma error", async () => {
(prisma.team.delete as any).mockRejectedValueOnce(
new PrismaClientKnownRequestError("Not found", {
new Prisma.PrismaClientKnownRequestError("Not found", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "1.0.0",
meta: {},
@@ -120,7 +120,7 @@ describe("Teams Lib", () => {
test("returns not_found error when update fails due to missing team", async () => {
(prisma.team.update as any).mockRejectedValueOnce(
new PrismaClientKnownRequestError("Not found", {
new Prisma.PrismaClientKnownRequestError("Not found", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "1.0.0",
meta: {},

View File

@@ -24,7 +24,7 @@ export const getTeamsEndpoint: ZodOpenApiOperationObject = {
}),
query: ZGetTeamsFilter.sourceType(),
},
tags: ["Organizations API > Teams"],
tags: ["Organizations API - Teams"],
responses: {
"200": {
description: "Teams retrieved successfully.",
@@ -46,7 +46,7 @@ export const createTeamEndpoint: ZodOpenApiOperationObject = {
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Teams"],
tags: ["Organizations API - Teams"],
requestBody: {
required: true,
description: "The team to create",

View File

@@ -20,7 +20,7 @@ export const getUsersEndpoint: ZodOpenApiOperationObject = {
}),
query: ZGetUsersFilter.sourceType(),
},
tags: ["Organizations API > Users"],
tags: ["Organizations API - Users"],
responses: {
"200": {
description: "Users retrieved successfully.",
@@ -42,7 +42,7 @@ export const createUserEndpoint: ZodOpenApiOperationObject = {
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Users"],
tags: ["Organizations API - Users"],
requestBody: {
required: true,
description: "The user to create",
@@ -73,7 +73,7 @@ export const updateUserEndpoint: ZodOpenApiOperationObject = {
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Users"],
tags: ["Organizations API - Users"],
requestBody: {
required: true,
description: "The user to update",

View File

@@ -13,7 +13,13 @@ export const logSignOutAction = async (
userId: string,
userEmail: string,
context: {
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
reason?:
| "user_initiated"
| "account_deletion"
| "email_change"
| "session_timeout"
| "forced_logout"
| "password_reset";
redirectUrl?: string;
organizationId?: string;
}

View File

@@ -1,9 +1,11 @@
"use server";
import { PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { actionClient } from "@/lib/utils/action-client";
import { getUserByEmail } from "@/modules/auth/lib/user";
import { sendForgotPasswordEmail } from "@/modules/email";
import { z } from "zod";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZUserEmail } from "@formbricks/types/user";
const ZForgotPasswordAction = z.object({
@@ -13,9 +15,15 @@ const ZForgotPasswordAction = z.object({
export const forgotPasswordAction = actionClient
.schema(ZForgotPasswordAction)
.action(async ({ parsedInput }) => {
const user = await getUserByEmail(parsedInput.email);
if (user) {
await sendForgotPasswordEmail(user);
if (PASSWORD_RESET_DISABLED) {
throw new OperationNotAllowedError("Password reset is disabled");
}
return { success: true };
const user = await getUserByEmail(parsedInput.email);
if (!user || user.identityProvider !== "email") {
return;
}
await sendForgotPasswordEmail(user);
});

View File

@@ -3,7 +3,13 @@ import { signOut } from "next-auth/react";
import { logger } from "@formbricks/logger";
interface UseSignOutOptions {
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
reason?:
| "user_initiated"
| "account_deletion"
| "email_change"
| "session_timeout"
| "forced_logout"
| "password_reset";
redirectUrl?: string;
organizationId?: string;
redirect?: boolean;

View File

@@ -78,6 +78,7 @@ export const getUserByEmail = reactCache(async (email: string) => {
email: true,
emailVerified: true,
isActive: true,
identityProvider: true,
},
});

View File

@@ -283,7 +283,13 @@ export const logSignOut = (
userId: string,
userEmail: string,
context?: {
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
reason?:
| "user_initiated"
| "account_deletion"
| "email_change"
| "session_timeout"
| "forced_logout"
| "password_reset";
redirectUrl?: string;
organizationId?: string;
}

View File

@@ -48,7 +48,9 @@ describe("EmailChangeSignIn", () => {
expect(screen.getByText("auth.email-change.email_change_success_description")).toBeInTheDocument();
});
expect(signOut).toHaveBeenCalledWith({ redirect: false });
await waitFor(() => {
expect(signOut).toHaveBeenCalledWith({ redirect: false });
});
});
test("handles failed email change verification", async () => {

View File

@@ -34,8 +34,8 @@ export const getSegments = reactCache((environmentId: string) =>
},
{
key: createCacheKey.environment.segments(environmentId),
// 30 minutes TTL - segment definitions change infrequently
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
// This is a temporary fix for the invalidation issues, will be changed later with a proper solution
ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
}
)()
);

View File

@@ -2,10 +2,9 @@ import { ContactAttributeKey, Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributeKey, TContactAttributeKeyType } from "@formbricks/types/contact-attribute-key";
import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
import { DatabaseError } from "@formbricks/types/errors";
import { TContactAttributeKeyUpdateInput } from "../types/contact-attribute-keys";
import {
createContactAttributeKey,
deleteContactAttributeKey,
getContactAttributeKey,
updateContactAttributeKey,
@@ -101,79 +100,6 @@ describe("getContactAttributeKey", () => {
});
});
describe("createContactAttributeKey", () => {
const type: TContactAttributeKeyType = "custom";
beforeEach(() => {
vi.clearAllMocks();
});
test("should create and return a new contact attribute key", async () => {
const createdAttributeKey = { ...mockContactAttributeKey, id: "new_cak_id", key: mockKey, type };
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(5); // Below limit
vi.mocked(prisma.contactAttributeKey.create).mockResolvedValue(createdAttributeKey);
const result = await createContactAttributeKey(mockEnvironmentId, mockKey, type);
expect(result).toEqual(createdAttributeKey);
expect(prisma.contactAttributeKey.count).toHaveBeenCalledWith({
where: { environmentId: mockEnvironmentId },
});
expect(prisma.contactAttributeKey.create).toHaveBeenCalledWith({
data: {
key: mockKey,
name: mockKey, // As per implementation
type,
environment: { connect: { id: mockEnvironmentId } },
},
});
});
test("should throw OperationNotAllowedError if max attribute classes reached", async () => {
// MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT is mocked to 10
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(10);
await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow(
OperationNotAllowedError
);
expect(prisma.contactAttributeKey.count).toHaveBeenCalledWith({
where: { environmentId: mockEnvironmentId },
});
expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled();
});
test("should throw Prisma error if prisma.contactAttributeKey.count fails", async () => {
const errorMessage = "Prisma count error";
const prismaError = new Prisma.PrismaClientKnownRequestError(errorMessage, {
code: "P1000",
clientVersion: "test",
});
vi.mocked(prisma.contactAttributeKey.count).mockRejectedValue(prismaError);
await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow(prismaError);
});
test("should throw DatabaseError if Prisma create fails", async () => {
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(5); // Below limit
const errorMessage = "Prisma create error";
vi.mocked(prisma.contactAttributeKey.create).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError(errorMessage, { code: "P2000", clientVersion: "test" })
);
await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow(DatabaseError);
await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow(errorMessage);
});
test("should throw generic error if non-Prisma error occurs during create", async () => {
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(5);
const errorMessage = "Some other error during create";
vi.mocked(prisma.contactAttributeKey.create).mockRejectedValue(new Error(errorMessage));
await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow(Error);
await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow(errorMessage);
});
});
describe("deleteContactAttributeKey", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -1,15 +1,10 @@
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId, ZString } from "@formbricks/types/common";
import {
TContactAttributeKey,
TContactAttributeKeyType,
ZContactAttributeKeyType,
} from "@formbricks/types/contact-attribute-key";
import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
import { ZId } from "@formbricks/types/common";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { DatabaseError } from "@formbricks/types/errors";
import {
TContactAttributeKeyUpdateInput,
ZContactAttributeKeyUpdateInput,
@@ -34,48 +29,6 @@ export const getContactAttributeKey = reactCache(
}
);
export const createContactAttributeKey = async (
environmentId: string,
key: string,
type: TContactAttributeKeyType
): Promise<TContactAttributeKey | null> => {
validateInputs([environmentId, ZId], [key, ZString], [type, ZContactAttributeKeyType]);
const contactAttributeKeysCount = await prisma.contactAttributeKey.count({
where: {
environmentId,
},
});
if (contactAttributeKeysCount >= MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT) {
throw new OperationNotAllowedError(
`Maximum number of attribute classes (${MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT}) reached for environment ${environmentId}`
);
}
try {
const contactAttributeKey = await prisma.contactAttributeKey.create({
data: {
key,
name: key,
type,
environment: {
connect: {
id: environmentId,
},
},
},
});
return contactAttributeKey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteContactAttributeKey = async (
contactAttributeKeyId: string
): Promise<TContactAttributeKey> => {
@@ -110,6 +63,8 @@ export const updateContactAttributeKey = async (
},
data: {
description: data.description,
name: data.name,
key: data.key,
},
});

View File

@@ -5,12 +5,14 @@ export const ZContactAttributeKeyCreateInput = z.object({
description: z.string().optional(),
type: z.enum(["custom"]),
environmentId: z.string(),
name: z.string().optional(),
});
export type TContactAttributeKeyCreateInput = z.infer<typeof ZContactAttributeKeyCreateInput>;
export const ZContactAttributeKeyUpdateInput = z.object({
description: z.string().optional(),
name: z.string().optional(),
key: z.string().optional(),
});
export type TContactAttributeKeyUpdateInput = z.infer<typeof ZContactAttributeKeyUpdateInput>;

View File

@@ -1,4 +1,5 @@
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { TContactAttributeKeyCreateInput } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
@@ -27,8 +28,28 @@ describe("getContactAttributeKeys", () => {
test("should return contact attribute keys when found", async () => {
const mockEnvironmentIds = ["env1", "env2"];
const mockAttributeKeys = [
{ id: "key1", environmentId: "env1", name: "Key One", key: "keyOne", type: "custom" },
{ id: "key2", environmentId: "env2", name: "Key Two", key: "keyTwo", type: "custom" },
{
id: "key1",
environmentId: "env1",
name: "Key One",
key: "keyOne",
type: "custom" as TContactAttributeKeyType,
createdAt: new Date(),
updatedAt: new Date(),
description: null,
isUnique: false,
},
{
id: "key2",
environmentId: "env2",
name: "Key Two",
key: "keyTwo",
type: "custom" as TContactAttributeKeyType,
createdAt: new Date(),
updatedAt: new Date(),
description: null,
isUnique: false,
},
];
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(mockAttributeKeys);
@@ -79,25 +100,31 @@ describe("createContactAttributeKey", () => {
description: null,
};
const createInput: TContactAttributeKeyCreateInput = {
key,
type,
environmentId,
name: key,
description: "",
};
beforeEach(() => {
vi.clearAllMocks();
});
test("should create and return a new contact attribute key", async () => {
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(0);
vi.mocked(prisma.contactAttributeKey.create).mockResolvedValue({
...mockCreatedAttributeKey,
description: null, // ensure description is explicitly null if that's the case
});
vi.mocked(prisma.contactAttributeKey.create).mockResolvedValue(mockCreatedAttributeKey);
const result = await createContactAttributeKey(environmentId, key, type);
const result = await createContactAttributeKey(environmentId, createInput);
expect(prisma.contactAttributeKey.count).toHaveBeenCalledWith({ where: { environmentId } });
expect(prisma.contactAttributeKey.create).toHaveBeenCalledWith({
data: {
key,
name: key,
type,
key: createInput.key,
name: createInput.name || createInput.key,
type: createInput.type,
description: createInput.description || "",
environment: { connect: { id: environmentId } },
},
});
@@ -107,7 +134,7 @@ describe("createContactAttributeKey", () => {
test("should throw OperationNotAllowedError if max attribute classes reached", async () => {
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT);
await expect(createContactAttributeKey(environmentId, key, type)).rejects.toThrow(
await expect(createContactAttributeKey(environmentId, createInput)).rejects.toThrow(
OperationNotAllowedError
);
expect(prisma.contactAttributeKey.count).toHaveBeenCalledWith({ where: { environmentId } });
@@ -121,8 +148,8 @@ describe("createContactAttributeKey", () => {
new Prisma.PrismaClientKnownRequestError(errorMessage, { code: "P2000", clientVersion: "test" })
);
await expect(createContactAttributeKey(environmentId, key, type)).rejects.toThrow(DatabaseError);
await expect(createContactAttributeKey(environmentId, key, type)).rejects.toThrow(errorMessage);
await expect(createContactAttributeKey(environmentId, createInput)).rejects.toThrow(DatabaseError);
await expect(createContactAttributeKey(environmentId, createInput)).rejects.toThrow(errorMessage);
});
test("should throw generic error if non-Prisma error occurs during create", async () => {
@@ -130,7 +157,55 @@ describe("createContactAttributeKey", () => {
const errorMessage = "Some other create error";
vi.mocked(prisma.contactAttributeKey.create).mockRejectedValue(new Error(errorMessage));
await expect(createContactAttributeKey(environmentId, key, type)).rejects.toThrow(Error);
await expect(createContactAttributeKey(environmentId, key, type)).rejects.toThrow(errorMessage);
await expect(createContactAttributeKey(environmentId, createInput)).rejects.toThrow(Error);
await expect(createContactAttributeKey(environmentId, createInput)).rejects.toThrow(errorMessage);
});
test("should use key as name when name is not provided", async () => {
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(0);
vi.mocked(prisma.contactAttributeKey.create).mockResolvedValue(mockCreatedAttributeKey);
const inputWithoutName: TContactAttributeKeyCreateInput = {
key,
type,
environmentId,
description: "",
};
await createContactAttributeKey(environmentId, inputWithoutName);
expect(prisma.contactAttributeKey.create).toHaveBeenCalledWith({
data: {
key: inputWithoutName.key,
name: inputWithoutName.key, // Should fall back to key when name is not provided
type: inputWithoutName.type,
description: inputWithoutName.description || "",
environment: { connect: { id: environmentId } },
},
});
});
test("should use empty string for description when description is not provided", async () => {
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(0);
vi.mocked(prisma.contactAttributeKey.create).mockResolvedValue(mockCreatedAttributeKey);
const inputWithoutDescription: TContactAttributeKeyCreateInput = {
key,
type,
environmentId,
name: "Test Name",
};
await createContactAttributeKey(environmentId, inputWithoutDescription);
expect(prisma.contactAttributeKey.create).toHaveBeenCalledWith({
data: {
key: inputWithoutDescription.key,
name: inputWithoutDescription.name,
type: inputWithoutDescription.type,
description: "", // Should fall back to empty string when description is not provided
environment: { connect: { id: environmentId } },
},
});
});
});

View File

@@ -1,14 +1,10 @@
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { validateInputs } from "@/lib/utils/validate";
import { TContactAttributeKeyCreateInput } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId, ZString } from "@formbricks/types/common";
import {
TContactAttributeKey,
TContactAttributeKeyType,
ZContactAttributeKeyType,
} from "@formbricks/types/contact-attribute-key";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
export const getContactAttributeKeys = reactCache(
@@ -30,11 +26,8 @@ export const getContactAttributeKeys = reactCache(
export const createContactAttributeKey = async (
environmentId: string,
key: string,
type: TContactAttributeKeyType
data: TContactAttributeKeyCreateInput
): Promise<TContactAttributeKey | null> => {
validateInputs([environmentId, ZId], [key, ZString], [type, ZContactAttributeKeyType]);
const contactAttributeKeysCount = await prisma.contactAttributeKey.count({
where: {
environmentId,
@@ -50,9 +43,10 @@ export const createContactAttributeKey = async (
try {
const contactAttributeKey = await prisma.contactAttributeKey.create({
data: {
key,
name: key,
type,
key: data.key,
name: data.name ?? data.key,
type: data.type,
description: data.description ?? "",
environment: {
connect: {
id: environmentId,
@@ -64,6 +58,10 @@ export const createContactAttributeKey = async (
return contactAttributeKey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new DatabaseError("Attribute key already exists");
}
throw new DatabaseError(error.message);
}
throw error;

View File

@@ -75,7 +75,7 @@ export const POST = withApiLogging(
),
};
}
const environmentId = contactAttibuteKeyInput.environmentId;
const environmentId = inputValidation.data.environmentId;
auditLog.organizationId = authentication.organizationId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
@@ -84,11 +84,7 @@ export const POST = withApiLogging(
};
}
const contactAttributeKey = await createContactAttributeKey(
environmentId,
inputValidation.data.key,
inputValidation.data.type
);
const contactAttributeKey = await createContactAttributeKey(environmentId, inputValidation.data);
if (!contactAttributeKey) {
return {

View File

@@ -73,7 +73,7 @@ const bulkContactEndpoint: ZodOpenApiOperationObject = {
},
},
},
tags: ["Management API > Contacts"],
tags: ["Management API - Contacts"],
responses: {
"200": {
description: "Contacts uploaded successfully.",

View File

@@ -170,7 +170,7 @@ export function TargetingCard({
<Collapsible.CollapsibleTrigger
asChild
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
<div className="inline-flex px-4 py-6">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"

View File

@@ -60,11 +60,7 @@ const getRatingContent = (scale: string, i: number, range: number, isColorCoding
);
}
if (scale === "number") {
return (
<Text className="m-0 h-[44px] text-center text-[14px] leading-[44px]">
{i + 1}
</Text>
);
return <Text className="m-0 h-[44px] text-center text-[14px] leading-[44px]">{i + 1}</Text>;
}
if (scale === "star") {
return <Text className="m-auto text-3xl"></Text>;
@@ -232,8 +228,8 @@ export async function PreviewEmailTemplate({
{ "rounded-l-lg border-l": i === 0 },
{ "rounded-r-lg": i === firstQuestion.range - 1 },
firstQuestion.isColorCodingEnabled &&
firstQuestion.scale === "number" &&
`border border-t-[6px] border-t-${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`,
firstQuestion.scale === "number" &&
`border border-t-[6px] border-t-${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`,
firstQuestion.scale === "star" && "border-transparent"
)}
href={`${urlWithPrefilling}${firstQuestion.id}=${(i + 1).toString()}`}

View File

@@ -89,6 +89,31 @@ describe("RecallItemSelect", () => {
expect(screen.queryByText("_File Upload Question_")).not.toBeInTheDocument();
});
test("do not render questions if questionId is 'start' (welcome card)", async () => {
render(
<RecallItemSelect
localSurvey={mockSurvey}
questionId="start"
addRecallItem={mockAddRecallItem}
setShowRecallItemSelect={mockSetShowRecallItemSelect}
recallItems={mockRecallItems}
selectedLanguageCode="en"
hiddenFields={mockSurvey.hiddenFields}
/>
);
expect(screen.queryByText("_Question 1_")).not.toBeInTheDocument();
expect(screen.queryByText("_Question 2_")).not.toBeInTheDocument();
expect(screen.getByText("_hidden1_")).toBeInTheDocument();
expect(screen.getByText("_hidden2_")).toBeInTheDocument();
expect(screen.getByText("_Variable 1_")).toBeInTheDocument();
expect(screen.getByText("_Variable 2_")).toBeInTheDocument();
expect(screen.queryByText("_Current Question_")).not.toBeInTheDocument();
expect(screen.queryByText("_File Upload Question_")).not.toBeInTheDocument();
});
test("filters recall items based on search input", async () => {
const user = userEvent.setup();
render(

View File

@@ -109,6 +109,9 @@ export const RecallItemSelect = ({
}, [localSurvey.variables, recallItemIds]);
const surveyQuestionRecallItems = useMemo(() => {
const isWelcomeCard = questionId === "start";
if (isWelcomeCard) return [];
const isEndingCard = !localSurvey.questions.map((question) => question.id).includes(questionId);
const idx = isEndingCard
? localSurvey.questions.length

View File

@@ -309,7 +309,7 @@ export const QuestionFormInput = ({
onAddFallback={() => {
inputRef.current?.focus();
}}
isRecallAllowed={!isWelcomeCard && (id === "headline" || id === "subheader")}
isRecallAllowed={id === "headline" || id === "subheader"}
usedLanguageCode={usedLanguageCode}
render={({
value,

View File

@@ -1,105 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { subscribeOrganizationMembersToSurveyResponses } from "./organization";
import { updateUser } from "./user";
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
},
}));
vi.mock("./user", () => ({
updateUser: vi.fn(),
}));
describe("subscribeOrganizationMembersToSurveyResponses", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("subscribes user to survey responses successfully", async () => {
const mockUser = {
id: "user-123",
notificationSettings: {
alert: { "existing-survey-id": true },
weeklySummary: {},
},
};
const surveyId = "survey-123";
const userId = "user-123";
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(mockUser);
vi.mocked(updateUser).mockResolvedValueOnce({} as any);
await subscribeOrganizationMembersToSurveyResponses(surveyId, userId);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: userId },
});
expect(updateUser).toHaveBeenCalledWith(userId, {
notificationSettings: {
alert: {
"existing-survey-id": true,
"survey-123": true,
},
weeklySummary: {},
},
});
});
test("creates notification settings if user doesn't have any", async () => {
const mockUser = {
id: "user-123",
notificationSettings: null,
};
const surveyId = "survey-123";
const userId = "user-123";
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(mockUser);
vi.mocked(updateUser).mockResolvedValueOnce({} as any);
await subscribeOrganizationMembersToSurveyResponses(surveyId, userId);
expect(updateUser).toHaveBeenCalledWith(userId, {
notificationSettings: {
alert: {
"survey-123": true,
},
weeklySummary: {},
},
});
});
test("throws ResourceNotFoundError if user is not found", async () => {
const surveyId = "survey-123";
const userId = "nonexistent-user";
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(null);
await expect(subscribeOrganizationMembersToSurveyResponses(surveyId, userId)).rejects.toThrow(
new ResourceNotFoundError("User", userId)
);
expect(updateUser).not.toHaveBeenCalled();
});
test("propagates errors from database operations", async () => {
const surveyId = "survey-123";
const userId = "user-123";
const dbError = new Error("Database error");
vi.mocked(prisma.user.findUnique).mockRejectedValueOnce(dbError);
await expect(subscribeOrganizationMembersToSurveyResponses(surveyId, userId)).rejects.toThrow(dbError);
expect(updateUser).not.toHaveBeenCalled();
});
});

View File

@@ -1,35 +0,0 @@
import { updateUser } from "@/modules/survey/components/template-list/lib/user";
import { prisma } from "@formbricks/database";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TUserNotificationSettings } from "@formbricks/types/user";
export const subscribeOrganizationMembersToSurveyResponses = async (
surveyId: string,
createdBy: string
): Promise<void> => {
try {
const surveyCreator = await prisma.user.findUnique({
where: {
id: createdBy,
},
});
if (!surveyCreator) {
throw new ResourceNotFoundError("User", createdBy);
}
const defaultSettings = { alert: {}, weeklySummary: {} };
const updatedNotificationSettings: TUserNotificationSettings = {
...defaultSettings,
...surveyCreator.notificationSettings,
};
updatedNotificationSettings.alert[surveyId] = true;
await updateUser(surveyCreator.id, {
notificationSettings: updatedNotificationSettings,
});
} catch (error) {
throw error;
}
};

View File

@@ -1,7 +1,9 @@
import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { subscribeOrganizationMembersToSurveyResponses } from "@/modules/survey/components/template-list/lib/organization";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { selectSurvey } from "@/modules/survey/lib/survey";
import { ActionClass, Prisma } from "@prisma/client";
import "@testing-library/jest-dom/vitest";
@@ -21,19 +23,15 @@ vi.mock("@/lib/survey/utils", () => ({
checkForInvalidImagesInQuestions: vi.fn(),
}));
vi.mock("@/modules/survey/components/template-list/lib/organization", () => ({
vi.mock("@/lib/organization/service", () => ({
subscribeOrganizationMembersToSurveyResponses: vi.fn(),
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@/modules/survey/lib/action-class", () => ({
getActionClasses: vi.fn(),
}));
vi.mock("@/modules/survey/lib/organization", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
getOrganizationAIKeys: vi.fn(),
}));
vi.mock("@/modules/survey/lib/survey", () => ({
selectSurvey: {
id: true,
@@ -86,8 +84,7 @@ describe("survey module", () => {
// Mock dependencies
const mockActionClasses: ActionClass[] = [];
vi.mocked(getActionClasses).mockResolvedValue(mockActionClasses);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("org-123");
vi.mocked(getOrganizationAIKeys).mockResolvedValue({ id: "org-123", name: "Org" } as any);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({ id: "org-123", name: "Org" } as any);
const mockCreatedSurvey = {
id: "survey-123",
@@ -109,8 +106,7 @@ describe("survey module", () => {
// Verify results
expect(getActionClasses).toHaveBeenCalledWith(environmentId);
expect(getOrganizationIdFromEnvironmentId).toHaveBeenCalledWith(environmentId);
expect(getOrganizationAIKeys).toHaveBeenCalledWith("org-123");
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
expect(prisma.survey.create).toHaveBeenCalledWith({
data: expect.objectContaining({
name: surveyBody.name,
@@ -122,7 +118,11 @@ describe("survey module", () => {
});
expect(prisma.segment.create).toHaveBeenCalled();
expect(prisma.survey.update).toHaveBeenCalled();
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalledWith("survey-123", "user-123");
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalledWith(
"survey-123",
"user-123",
"org-123"
);
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(
environmentId,
"survey created",
@@ -143,8 +143,7 @@ describe("survey module", () => {
};
vi.mocked(getActionClasses).mockResolvedValue([]);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("org-123");
vi.mocked(getOrganizationAIKeys).mockResolvedValue({ id: "org-123" } as any);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({ id: "org-123" } as any);
vi.mocked(prisma.survey.create).mockResolvedValue({
id: "survey-123",
environmentId,
@@ -172,8 +171,7 @@ describe("survey module", () => {
};
vi.mocked(getActionClasses).mockResolvedValue([]);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("org-123");
vi.mocked(getOrganizationAIKeys).mockResolvedValue({ id: "org-123" } as any);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({ id: "org-123" } as any);
vi.mocked(prisma.survey.create).mockResolvedValue({
id: "survey-123",
environmentId,
@@ -204,8 +202,7 @@ describe("survey module", () => {
};
vi.mocked(getActionClasses).mockResolvedValue([]);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("org-123");
vi.mocked(getOrganizationAIKeys).mockResolvedValue(null);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createSurvey(environmentId, surveyBody)).rejects.toThrow(ResourceNotFoundError);
});
@@ -220,12 +217,11 @@ describe("survey module", () => {
};
vi.mocked(getActionClasses).mockResolvedValue([]);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("org-123");
vi.mocked(getOrganizationAIKeys).mockResolvedValue({ id: "org-123" } as any);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({ id: "org-123" } as any);
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "4.8.0",
clientVersion: "5.0.0",
});
vi.mocked(prisma.survey.create).mockRejectedValue(prismaError);

View File

@@ -1,9 +1,11 @@
import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
import { subscribeOrganizationMembersToSurveyResponses } from "@/modules/survey/components/template-list/lib/organization";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { selectSurvey } from "@/modules/survey/lib/survey";
import { ActionClass, Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
@@ -43,8 +45,7 @@ export const createSurvey = async (
};
}
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const organization = await getOrganizationAIKeys(organizationId);
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
@@ -118,7 +119,7 @@ export const createSurvey = async (
};
if (createdBy) {
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy);
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
}
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {

View File

@@ -23,7 +23,7 @@ export const TargetingLockedCard = ({ isFormbricksCloud, environmentId }: Target
<Collapsible.CollapsibleTrigger
asChild
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
<div className="inline-flex px-4 py-6">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<div className="rounded-full border border-slate-300 bg-slate-100 p-1">
<LockIcon className="h-4 w-4 text-slate-500" strokeWidth={3} />

View File

@@ -195,9 +195,8 @@ export const SurveyDropDownMenu = ({
e.preventDefault();
setIsDropDownOpen(false);
const newId = await refreshSingleUseId();
const previewUrl = newId
? `/s/${survey.id}?suId=${newId}&preview=true`
: `/s/${survey.id}?preview=true`;
const previewUrl =
surveyLink + (newId ? `?suId=${newId}&preview=true` : "?preview=true");
window.open(previewUrl, "_blank");
}}>
<EyeIcon className="mr-2 h-4 w-4" />

View File

@@ -5,7 +5,11 @@ import { useTranslate } from "@tolgee/react";
import { ArrowLeftIcon } from "lucide-react";
import { useRouter } from "next/navigation";
export const BackButton = () => {
interface BackButtonProps {
path?: string;
}
export const BackButton = ({ path }: BackButtonProps) => {
const router = useRouter();
const { t } = useTranslate();
return (
@@ -13,7 +17,11 @@ export const BackButton = () => {
variant="secondary"
size="sm"
onClick={() => {
router.back();
if (path) {
router.push(path);
} else {
router.back();
}
}}>
<ArrowLeftIcon />
{t("common.back")}

View File

@@ -7,7 +7,7 @@ export const MenuBar = () => {
<>
<div className="border-b border-slate-200 bg-white px-5 py-2.5 sm:flex sm:items-center sm:justify-between">
<div className="flex items-center space-x-2 whitespace-nowrap">
<BackButton />
<BackButton path="/" />
</div>
</div>
</>

View File

@@ -12,6 +12,7 @@ vi.mock("react-confetti", () => ({
data-colors={JSON.stringify(props.colors)}
data-number-of-pieces={props.numberOfPieces}
data-recycle={props.recycle}
style={props.style}
/>
)),
}));
@@ -36,6 +37,10 @@ describe("Confetti", () => {
expect(confettiElement).toHaveAttribute("data-colors", JSON.stringify(["#00C4B8", "#eee"]));
expect(confettiElement).toHaveAttribute("data-number-of-pieces", "400");
expect(confettiElement).toHaveAttribute("data-recycle", "false");
expect(confettiElement).toHaveAttribute(
"style",
"position: fixed; top: 0px; left: 0px; z-index: 9999; pointer-events: none;"
);
});
test("renders with custom colors", () => {

View File

@@ -13,5 +13,20 @@ export const Confetti: React.FC<ConfettiProps> = ({
colors?: string[];
}) => {
const { width, height } = useWindowSize();
return <ReactConfetti width={width} height={height} colors={colors} numberOfPieces={400} recycle={false} />;
return (
<ReactConfetti
width={width}
height={height}
colors={colors}
numberOfPieces={400}
recycle={false}
style={{
position: "fixed",
top: 0,
left: 0,
zIndex: 9999,
pointerEvents: "none",
}}
/>
);
};

View File

@@ -397,7 +397,10 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
editor.registerUpdateListener(({ editorState, prevEditorState }) => {
editorState.read(() => {
const textInHtml = $generateHtmlFromNodes(editor).replace(/&lt;/g, "<").replace(/&gt;/g, ">");
const textInHtml = $generateHtmlFromNodes(editor)
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/white-space:\s*pre-wrap;?/g, "");
setText.current(textInHtml);
});
if (!prevEditorState._selection) editor.blur();

View File

@@ -19,7 +19,7 @@ export const IconBar = ({ actions }: IconBarProps) => {
return (
<div
className="flex items-center justify-center divide-x rounded-lg border border-slate-300 bg-white"
className="flex items-center justify-center divide-x rounded-md border border-slate-300 bg-white"
role="toolbar"
aria-label="Action buttons">
{actions

View File

@@ -62,7 +62,7 @@ const v1ClientEndpoints = {
},
},
summary: "Update Response",
tags: ["Client API > Response"],
tags: ["Client API - Response"],
servers: [
{
url: "https://app.formbricks.com/api/v2",
@@ -95,7 +95,7 @@ const v1ClientEndpoints = {
},
},
summary: "Create Response",
tags: ["Client API > Response"],
tags: ["Client API - Response"],
servers: [
{
url: "https://app.formbricks.com/api/v2",
@@ -145,7 +145,7 @@ const v1ClientEndpoints = {
},
},
summary: "Update Contact (Attributes)",
tags: ["Client API > Contacts"],
tags: ["Client API - Contacts"],
servers: [
{
url: "https://app.formbricks.com/api/v2",
@@ -175,7 +175,7 @@ const v1ClientEndpoints = {
},
},
summary: "Get Contact State",
tags: ["Client API > Contacts"],
tags: ["Client API - Contacts"],
servers: [
{
url: "https://app.formbricks.com/api/v2",
@@ -208,7 +208,7 @@ const v1ClientEndpoints = {
},
},
summary: "Create Display",
tags: ["Client API > Display"],
tags: ["Client API - Display"],
servers: [
{
url: "https://app.formbricks.com/api/v2",
@@ -220,7 +220,8 @@ const v1ClientEndpoints = {
"/client/{environmentId}/environment": {
get: {
security: [],
description: "Retrieves the environment state to be used in Formbricks SDKs",
description:
"Retrieves the environment state to be used in Formbricks SDKs. **Cache Behavior**: This endpoint uses server-side caching with a **5-minute TTL (Time To Live)**. Any changes to surveys, action classes, project settings, or other environment data will take up to 5 minutes to reflect in the API response. This caching is implemented to improve performance for high-frequency SDK requests.",
parameters: [
{
in: "path",
@@ -242,7 +243,7 @@ const v1ClientEndpoints = {
},
},
summary: "Get Environment State",
tags: ["Client API > Environment"],
tags: ["Client API - Environment"],
servers: [
{
url: "https://app.formbricks.com/api/v2",
@@ -275,7 +276,7 @@ const v1ClientEndpoints = {
},
},
summary: "Create or Identify User",
tags: ["Client API > User"],
tags: ["Client API - User"],
servers: [
{
url: "https://app.formbricks.com/api/v2",
@@ -290,7 +291,7 @@ const v1ClientEndpoints = {
summary: "Upload Private File",
description:
"API endpoint for uploading private files. Uploaded files are kept private so that only users with access to the specified environment can retrieve them. The endpoint validates the survey ID, file name, and file type from the request body, and returns a signed URL for S3 uploads along with a local upload URL.",
tags: ["Client API > File Upload"],
tags: ["Client API - File Upload"],
parameters: [
{
in: "path",
@@ -445,7 +446,7 @@ const v1ClientEndpoints = {
summary: "Upload Private File to Local Storage",
description:
'API endpoint for uploading private files to local storage. The request must include a valid signature, UUID, and timestamp to verify the upload. The file is provided as a Base64 encoded string in the request body. The "Content-Type" header must be set to a valid MIME type, and the file data must be a valid file object (buffer).',
tags: ["Client API > File Upload"],
tags: ["Client API - File Upload"],
parameters: [
{
in: "path",
@@ -471,7 +472,7 @@ const v1ClientEndpoints = {
fileName: {
type: "string",
description:
"This must be the `fileName` returned from the [Upload Private File](/api-v2-reference/client-api->-file-upload/upload-private-file) endpoint (Step 1).",
"This must be the `fileName` returned from the [Upload Private File](/api-v2-reference/client-api--file-upload/upload-private-file) endpoint (Step 1).",
},
fileType: {
type: "string",

View File

@@ -35,4 +35,4 @@ The documentation will be available at `http://localhost:3000`.
- If Mintlify dev isn't running, try `mintlify install` to reinstall dependencies
- If a page loads as a 404, ensure you're in the `docs` folder with the `mint.json` file
- For other issues, please check our [Contributing Guidelines](./CONTRIBUTING.md)
- For other issues, please check our [Contributing Guidelines](https://github.com/formbricks/formbricks/blob/main/CONTRIBUTING.md)

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