mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-26 08:20:29 -06:00
Compare commits
43 Commits
devin/1735
...
feat-reset
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93c72df4d9 | ||
|
|
49560ccba8 | ||
|
|
3f98283d4d | ||
|
|
7b64422a3f | ||
|
|
5f02ad49c1 | ||
|
|
6644bba6ea | ||
|
|
0b7734f725 | ||
|
|
1536bf6907 | ||
|
|
e81190214f | ||
|
|
48c8906a89 | ||
|
|
717b30115b | ||
|
|
1f3962d2d5 | ||
|
|
619f6e408f | ||
|
|
4a8719abaa | ||
|
|
7b59eb3b26 | ||
|
|
8ac280268d | ||
|
|
34e8f4931d | ||
|
|
ac46850a24 | ||
|
|
6328be220a | ||
|
|
882ad99ed7 | ||
|
|
ce47b4c2d8 | ||
|
|
ce8f9de8ec | ||
|
|
ed3c2d2b58 | ||
|
|
9ae226329b | ||
|
|
12c3899b85 | ||
|
|
ccb1353eb5 | ||
|
|
22eb0b79ee | ||
|
|
a7ee1f189f | ||
|
|
46a590311b | ||
|
|
0faeffb624 | ||
|
|
d9727a336a | ||
|
|
330e0db668 | ||
|
|
f5b7f73199 | ||
|
|
c02f070307 | ||
|
|
bc489e050a | ||
|
|
3062059ed5 | ||
|
|
f27ede6b2c | ||
|
|
e460ff5100 | ||
|
|
4699c0014b | ||
|
|
52f69be05d | ||
|
|
619c0983a4 | ||
|
|
964fb8d4f4 | ||
|
|
5391c60bba |
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -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
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/task.yml
vendored
11
.github/ISSUE_TEMPLATE/task.yml
vendored
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "顯示所有相符的回應",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -78,6 +78,7 @@ export const getUserByEmail = reactCache(async (email: string) => {
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
isActive: true,
|
||||
identityProvider: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -73,7 +73,7 @@ const bulkContactEndpoint: ZodOpenApiOperationObject = {
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["Management API > Contacts"],
|
||||
tags: ["Management API - Contacts"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contacts uploaded successfully.",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()}`}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -397,7 +397,10 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
|
||||
|
||||
editor.registerUpdateListener(({ editorState, prevEditorState }) => {
|
||||
editorState.read(() => {
|
||||
const textInHtml = $generateHtmlFromNodes(editor).replace(/</g, "<").replace(/>/g, ">");
|
||||
const textInHtml = $generateHtmlFromNodes(editor)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/white-space:\s*pre-wrap;?/g, "");
|
||||
setText.current(textInHtml);
|
||||
});
|
||||
if (!prevEditorState._selection) editor.blur();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user