Compare commits

..

1 Commits

Author SHA1 Message Date
Bhagya Amarasinghe e7e1e6cc32 fix: return 404 when response is deleted mid-update 2026-05-19 22:19:44 +05:30
23 changed files with 91 additions and 173 deletions
@@ -194,7 +194,7 @@ export const MainNavigation = ({
const settingsNavigationItem = useMemo(
() => ({
name: t("common.settings"),
href: `/workspaces/${workspace.id}/settings/workspace/general`,
href: `/workspaces/${workspace.id}/settings`,
icon: SettingsIcon,
isActive: isSettingsMode,
disabled: isMembershipPending || isBilling,
@@ -467,7 +467,7 @@ export const MainNavigation = ({
{isSettingsMode ? (
<div className="flex flex-col overflow-hidden">
<div className="mb-2 px-3">
<GoBackButton url={`/workspaces/${workspace.id}/surveys`} />
<GoBackButton />
</div>
{/* Settings sidebar content */}
@@ -335,7 +335,6 @@ export const SettingsSidebarContent = ({
href: `${basePath}/organization/feedback-directories`,
icon: <FoldersIcon className={iconClassName} />,
hidden: isMember,
disabled: !isOwnerOrManager,
},
{
id: "org-api-keys",
@@ -374,14 +373,12 @@ export const SettingsSidebarContent = ({
label: t("common.your_profile"),
href: `${basePath}/account/profile`,
icon: <UserCircleIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "notifications",
label: t("common.notifications"),
href: `${basePath}/account/notifications`,
icon: <BellIcon className={iconClassName} />,
disabled: isBilling,
},
];
@@ -1,11 +1,4 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const AccountSettingsLayout = async (props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const AccountSettingsLayout = (props: { children: React.ReactNode }) => {
return <>{props.children}</>;
};
@@ -1,54 +0,0 @@
import { redirect } from "next/navigation";
import { describe, expect, test, vi } from "vitest";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { redirectBillingRoleFromRestrictedSettings } from "./redirect-billing-role";
const mocks = vi.hoisted(() => ({
getBillingFallbackPath: vi.fn(),
getWorkspaceAuth: vi.fn(),
isFormbricksCloud: false,
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: mocks.isFormbricksCloud,
}));
vi.mock("@/lib/membership/navigation", () => ({
getBillingFallbackPath: mocks.getBillingFallbackPath,
}));
vi.mock("@/modules/workspaces/lib/utils", () => ({
getWorkspaceAuth: mocks.getWorkspaceAuth,
}));
const workspaceId = "workspace-1";
const billingFallbackPath = `/workspaces/${workspaceId}/settings/organization/billing`;
const getWorkspaceAuthResponse = (isBilling: boolean) =>
({
isBilling,
}) as Awaited<ReturnType<typeof getWorkspaceAuth>>;
describe("redirectBillingRoleFromRestrictedSettings", () => {
test("does not redirect non-billing workspace members", async () => {
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(false));
await expect(redirectBillingRoleFromRestrictedSettings(workspaceId)).resolves.toBeUndefined();
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
expect(getBillingFallbackPath).not.toHaveBeenCalled();
expect(redirect).not.toHaveBeenCalled();
});
test("redirects billing users to the billing fallback path", async () => {
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(true));
vi.mocked(getBillingFallbackPath).mockReturnValue(billingFallbackPath);
await redirectBillingRoleFromRestrictedSettings(workspaceId);
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
expect(getBillingFallbackPath).toHaveBeenCalledWith(workspaceId, mocks.isFormbricksCloud);
expect(redirect).toHaveBeenCalledWith(billingFallbackPath);
});
});
@@ -1,12 +0,0 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
export const redirectBillingRoleFromRestrictedSettings = async (workspaceId: string): Promise<void> => {
const { isBilling } = await getWorkspaceAuth(workspaceId);
if (isBilling) {
redirect(getBillingFallbackPath(workspaceId, IS_FORMBRICKS_CLOUD));
}
};
@@ -1,11 +1,3 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return APIKeysPage(props);
};
export default Page;
export default APIKeysPage;
@@ -1,18 +1,3 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { PricingPage } from "@/modules/ee/billing/page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
const { isBilling } = await getWorkspaceAuth(params.workspaceId);
if (isBilling && !IS_FORMBRICKS_CLOUD) {
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
}
return PricingPage(props);
};
export default Page;
export default PricingPage;
@@ -1,7 +1,6 @@
import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { PrettyUrlsTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/domain/components/pretty-urls-table";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
@@ -13,9 +12,8 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {
@@ -1,10 +1,9 @@
import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable";
import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseStatus";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getTranslate } from "@/lingodotdev/server";
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { Button } from "@/modules/ui/components/button";
@@ -12,19 +11,15 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const { isBilling, isMember } = await getWorkspaceAuth(params.workspaceId);
if (isBilling && IS_FORMBRICKS_CLOUD) {
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
}
if (IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { isMember } = await getWorkspaceAuth(params.workspaceId);
const isPricingDisabled = isMember;
if (isPricingDisabled) {
@@ -1,11 +1 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { FeedbackDirectoriesPage } from "@/modules/ee/feedback-directory/page";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return FeedbackDirectoriesPage(props);
};
export default Page;
export { FeedbackDirectoriesPage as default } from "@/modules/ee/feedback-directory/page";
@@ -1,4 +1,3 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
@@ -27,9 +26,8 @@ import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const t = await getTranslate();
const { session, currentUserMembership, organization, isOwner, isManager } = await getWorkspaceAuth(
@@ -1,11 +1,3 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { TeamsPage } from "@/modules/organization/settings/teams/page";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return TeamsPage(props);
};
export default Page;
export default TeamsPage;
@@ -1,9 +1,7 @@
import { redirect } from "next/navigation";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return redirect(`/workspaces/${params.workspaceId}/settings/workspace/general`);
};
@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { getEmailTemplateHtml } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { capturePostHogEvent } from "@/lib/posthog";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
@@ -176,7 +176,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
);
if (!contactsResult || contactsResult.length === 0) {
throw new InvalidInputError("No contacts found for the selected segment");
throw new UnknownError("No contacts found for the selected segment");
}
capturePostHogEvent(
@@ -11,7 +11,6 @@ import {
ContactIcon,
EyeOff,
FlagIcon,
GaugeIcon,
GlobeIcon,
GridIcon,
HashIcon,
@@ -26,7 +25,6 @@ import {
NetworkIcon,
PieChartIcon,
Rows3Icon,
SmilePlusIcon,
SmartphoneIcon,
StarIcon,
User,
@@ -105,8 +103,6 @@ const elementIcons = {
[TSurveyElementTypeEnum.PictureSelection]: ImageIcon,
[TSurveyElementTypeEnum.Matrix]: GridIcon,
[TSurveyElementTypeEnum.Ranking]: ListOrderedIcon,
[TSurveyElementTypeEnum.CSAT]: SmilePlusIcon,
[TSurveyElementTypeEnum.CES]: GaugeIcon,
[TSurveyElementTypeEnum.Address]: HomeIcon,
[TSurveyElementTypeEnum.ContactInfo]: ContactIcon,
+11 -2
View File
@@ -313,9 +313,18 @@ describe("handleErrorResponse", () => {
expect(body.message).toBe("bad input");
});
test("returns 400 badRequest for ResourceNotFoundError", async () => {
test("returns 404 notFound for ResourceNotFoundError", async () => {
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
expect(response.status).toBe(400);
expect(response.status).toBe(404);
const body = await response.json();
expect(body).toEqual({
code: "not_found",
message: "Survey not found",
details: {
resource_id: "id-1",
resource_type: "Survey",
},
});
});
test("returns 500 internalServerError for unknown errors", async () => {
+4 -5
View File
@@ -29,11 +29,10 @@ export const handleErrorResponse = (error: any): Response => {
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message);
}
if (
error instanceof DatabaseError ||
error instanceof InvalidInputError ||
error instanceof ResourceNotFoundError
) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse(error.resourceType, error.resourceId);
}
if (error instanceof DatabaseError || error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
return responses.internalServerErrorResponse("Some error occurred");
@@ -104,11 +104,7 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
const prismaData = buildPrismaResponseData(
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
contact,
ttc
);
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const prismaClient = tx ?? prisma;
@@ -49,7 +49,18 @@ const buildPrismaResponseData = (
contact: { id: string; attributes: TContactAttributes } | null,
ttc: Record<string, number>
): Prisma.ResponseCreateInput => {
const { surveyId, displayId, finished, data, language, meta, singleUseId, variables } = responseInput;
const {
surveyId,
displayId,
finished,
data,
language,
meta,
singleUseId,
variables,
createdAt,
updatedAt,
} = responseInput;
return {
survey: {
@@ -73,6 +84,8 @@ const buildPrismaResponseData = (
singleUseId,
...(variables && { variables }),
ttc: ttc,
createdAt,
updatedAt,
};
};
+31
View File
@@ -1,6 +1,7 @@
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";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseUpdateInput } from "@formbricks/types/responses";
import { updateResponse } from "./service";
@@ -324,5 +325,35 @@ describe("updateResponse", () => {
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(DatabaseError);
});
test("should throw ResourceNotFoundError when response is deleted during update", async () => {
const currentResponse = createMockCurrentResponse();
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record to update not found", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "5.0.0",
})
);
const responseInput = createMockResponseInput();
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw ResourceNotFoundError when Prisma reports a missing response record", async () => {
const currentResponse = createMockCurrentResponse();
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record does not exist", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "5.0.0",
})
);
const responseInput = createMockResponseInput();
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
});
});
});
+8
View File
@@ -3,6 +3,7 @@ import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -569,6 +570,13 @@ export const updateResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
throw new ResourceNotFoundError("Response", responseId);
}
throw new DatabaseError(error.message);
}
@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
@@ -49,7 +49,7 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
const surveyWorkspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId);
if (surveyWorkspaceId !== parsedInput.workspaceId) {
throw new InvalidInputError("Survey and segment are not in the same workspace");
throw new Error("Survey and segment are not in the same workspace");
}
}
@@ -82,7 +82,7 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters";
throw new InvalidInputError(errMsg);
throw new Error(errMsg);
}
const segment = await createSegment(parsedInput);
@@ -139,7 +139,7 @@ export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdate
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters";
throw new InvalidInputError(errMsg);
throw new Error(errMsg);
}
await checkForRecursiveSegmentFilter(parsedFilters.data, parsedInput.segmentId);
@@ -169,7 +169,7 @@ export const loadNewSegmentAction = authenticatedActionClient
const segmentWorkspaceId = await getWorkspaceIdFromSegmentId(parsedInput.segmentId);
if (surveyWorkspaceId !== segmentWorkspaceId) {
throw new InvalidInputError("Segment and survey are not in the same workspace");
throw new Error("Segment and survey are not in the same workspace");
}
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
@@ -5,14 +5,9 @@ import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
interface GoBackButtonProps {
url?: string;
}
export const GoBackButton = ({ url }: Readonly<GoBackButtonProps>) => {
export const GoBackButton = ({ url }: { url?: string }) => {
const router = useRouter();
const { t } = useTranslation();
return (
<Button
size="sm"
@@ -22,7 +17,6 @@ export const GoBackButton = ({ url }: Readonly<GoBackButtonProps>) => {
router.push(url);
return;
}
router.back();
}}>
<ArrowLeftIcon />