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
3 changed files with 73 additions and 0 deletions
+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;