mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-20 03:07:53 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b77df0f36 |
@@ -22,7 +22,7 @@ import {
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
@@ -110,8 +110,6 @@ export const MainNavigation = ({
|
||||
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
const isSettingsMode = pathname?.includes("/settings");
|
||||
const [previousPathname, setPreviousPathname] = useState<string | null>(null);
|
||||
const currentPathRef = useRef<string | null>(pathname ?? null);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
@@ -123,15 +121,6 @@ export const MainNavigation = ({
|
||||
setIsCollapsed(isCollapsedValueFromLocalStorage);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname || currentPathRef.current === pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviousPathname(currentPathRef.current);
|
||||
currentPathRef.current = pathname;
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const toggleTextOpacity = () => {
|
||||
setIsTextVisible(isCollapsed);
|
||||
@@ -205,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,
|
||||
@@ -478,11 +467,7 @@ export const MainNavigation = ({
|
||||
{isSettingsMode ? (
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="mb-2 px-3">
|
||||
<GoBackButton
|
||||
previousPath={previousPathname}
|
||||
settingsPathPrefix={`/workspaces/${workspace.id}/settings`}
|
||||
settingsFallbackUrl={`/workspaces/${workspace.id}/surveys`}
|
||||
/>
|
||||
<GoBackButton />
|
||||
</div>
|
||||
|
||||
{/* Settings sidebar content */}
|
||||
|
||||
@@ -46,6 +46,10 @@ vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
|
||||
cleanupStripeCustomer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/hub/service", () => ({
|
||||
deleteFeedbackRecordsByTenant: vi.fn().mockResolvedValue({ data: { deletedCount: 0 }, error: null }),
|
||||
}));
|
||||
|
||||
describe("Organization Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(ensureCloudStripeSetupForOrganization).mockResolvedValue(undefined);
|
||||
@@ -355,6 +359,7 @@ describe("Organization Service", () => {
|
||||
billing: { stripeCustomerId: "cus_123" },
|
||||
memberships: [],
|
||||
workspaces: [],
|
||||
feedbackDirectories: [],
|
||||
} as any);
|
||||
|
||||
await deleteOrganization("org1");
|
||||
@@ -363,5 +368,23 @@ describe("Organization Service", () => {
|
||||
expect(cleanupStripeCustomer).toHaveBeenCalledWith("cus_123");
|
||||
}
|
||||
});
|
||||
|
||||
test("should purge Hub feedback records for each feedback directory", async () => {
|
||||
const { deleteFeedbackRecordsByTenant } = await import("@/modules/hub/service");
|
||||
vi.mocked(prisma.organization.delete).mockResolvedValue({
|
||||
id: "org1",
|
||||
name: "Test Org",
|
||||
billing: null,
|
||||
memberships: [],
|
||||
workspaces: [],
|
||||
feedbackDirectories: [{ id: "frd_1" }, { id: "frd_2" }],
|
||||
} as any);
|
||||
|
||||
await deleteOrganization("org1");
|
||||
|
||||
expect(deleteFeedbackRecordsByTenant).toHaveBeenCalledTimes(2);
|
||||
expect(deleteFeedbackRecordsByTenant).toHaveBeenCalledWith("frd_1");
|
||||
expect(deleteFeedbackRecordsByTenant).toHaveBeenCalledWith("frd_2");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import { updateUser } from "@/lib/user/service";
|
||||
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
|
||||
import { getWorkspaces } from "@/lib/workspace/service";
|
||||
import { cleanupStripeCustomer } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { deleteFeedbackRecordsByTenant } from "@/modules/hub/service";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
export const select = {
|
||||
@@ -294,6 +295,11 @@ export const deleteOrganization = async (organizationId: string) => {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
feedbackDirectories: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -301,6 +307,12 @@ export const deleteOrganization = async (organizationId: string) => {
|
||||
if (IS_FORMBRICKS_CLOUD && stripeCustomerId) {
|
||||
await cleanupStripeCustomer(stripeCustomerId);
|
||||
}
|
||||
|
||||
// Best-effort: purge feedback records in the Hub for each directory tenant.
|
||||
// Failures are logged inside the gateway and do not roll back the local delete.
|
||||
for (const directory of deletedOrganization.feedbackDirectories) {
|
||||
await deleteFeedbackRecordsByTenant(directory.id);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -129,6 +129,44 @@ export const deleteFeedbackRecord = async (id: string): Promise<HubFeedbackRecor
|
||||
}
|
||||
};
|
||||
|
||||
export type HubFeedbackRecordsByTenantDeleteResult = {
|
||||
data: { deletedCount: number } | null;
|
||||
error: HubError | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete all feedback records in the Hub for a given tenant.
|
||||
* Used when an organization (and its feedback directories) is deleted, so that
|
||||
* Hub-side records do not become orphaned.
|
||||
*
|
||||
* NOTE: depends on the Hub `bulkDelete` endpoint accepting a `tenant_id`-only
|
||||
* payload (no `user_id`). Until that ships, this call will fail with a 4xx and
|
||||
* be logged as a warning — caller treats this as best-effort.
|
||||
*/
|
||||
export const deleteFeedbackRecordsByTenant = async (
|
||||
tenantId: string
|
||||
): Promise<HubFeedbackRecordsByTenantDeleteResult> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return { data: null, error: { ...NO_CONFIG_ERROR } };
|
||||
}
|
||||
|
||||
try {
|
||||
// Cast: SDK currently requires `user_id`. Hub-side change will accept a
|
||||
// tenant-only payload; until the SDK types catch up we go through `unknown`.
|
||||
const bulkDelete = client.feedbackRecords.bulkDelete as unknown as (params: {
|
||||
tenant_id: string;
|
||||
}) => Promise<{ deleted_count: number }>;
|
||||
const data = await bulkDelete({ tenant_id: tenantId });
|
||||
return { data: { deletedCount: data.deleted_count }, error: null };
|
||||
} catch (err) {
|
||||
logger.warn({ err, tenantId }, "Hub: deleteFeedbackRecordsByTenant failed");
|
||||
const status = getErrorStatus(err);
|
||||
const message = getErrorMessage(err);
|
||||
return { data: null, error: { status, message, detail: message } };
|
||||
}
|
||||
};
|
||||
|
||||
export type ListFeedbackRecordsResult = {
|
||||
data: FeedbackRecordListResponse | null;
|
||||
error: HubError | null;
|
||||
|
||||
@@ -1,34 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface GoBackButtonProps {
|
||||
url?: string;
|
||||
previousPath?: string | null;
|
||||
settingsPathPrefix?: string;
|
||||
settingsFallbackUrl?: string;
|
||||
}
|
||||
|
||||
export const GoBackButton = ({
|
||||
url,
|
||||
previousPath,
|
||||
settingsPathPrefix,
|
||||
settingsFallbackUrl,
|
||||
}: Readonly<GoBackButtonProps>) => {
|
||||
export const GoBackButton = ({ url }: { url?: string }) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const shouldRedirectToSettingsFallback =
|
||||
!!settingsFallbackUrl &&
|
||||
!!settingsPathPrefix &&
|
||||
!!previousPath &&
|
||||
previousPath.startsWith(settingsPathPrefix) &&
|
||||
previousPath !== pathname;
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -38,12 +17,6 @@ export const GoBackButton = ({
|
||||
router.push(url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldRedirectToSettingsFallback) {
|
||||
router.replace(settingsFallbackUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
router.back();
|
||||
}}>
|
||||
<ArrowLeftIcon />
|
||||
|
||||
Reference in New Issue
Block a user