Compare commits

..

1 Commits

Author SHA1 Message Date
Dhruwang 8b77df0f36 feat: cascade delete Hub feedback records on org deletion (ENG-973)
Add a tenant-scoped purge wrapper in the Hub gateway and call it for
each FeedbackDirectory after an organization is deleted, so Hub records
do not become orphaned when the local cascade clears the directory rows.

Depends on a Hub-side change to accept a tenant-only bulkDelete payload;
the call is best-effort and failures are logged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:43:53 +05:30
5 changed files with 78 additions and 47 deletions
@@ -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 */}
+23
View File
@@ -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");
});
});
});
+12
View File
@@ -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);
+38
View File
@@ -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 />