fix: switch Hub purge to DELETE /v1/tenants/{tenant_id}/data

Hub#77 ships the proper tenant-data purge endpoint that nukes feedback
records, derived embeddings, and webhooks in one idempotent call. Swap
the placeholder bulkDelete cast for a direct client.delete<>() against
the new path and surface the per-resource counts.

Renames deleteFeedbackRecordsByTenant → deleteHubTenantData to reflect
that it deletes more than feedback records. Org-deletion call site and
tests updated accordingly. Best-effort error handling unchanged.
This commit is contained in:
Dhruwang
2026-05-21 15:04:21 +05:30
parent 8b77df0f36
commit 3a7a132ef1
3 changed files with 46 additions and 29 deletions
+9 -6
View File
@@ -47,7 +47,10 @@ vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
}));
vi.mock("@/modules/hub/service", () => ({
deleteFeedbackRecordsByTenant: vi.fn().mockResolvedValue({ data: { deletedCount: 0 }, error: null }),
deleteHubTenantData: vi.fn().mockResolvedValue({
data: { deletedFeedbackRecords: 0, deletedEmbeddings: 0, deletedWebhooks: 0 },
error: null,
}),
}));
describe("Organization Service", () => {
@@ -369,8 +372,8 @@ describe("Organization Service", () => {
}
});
test("should purge Hub feedback records for each feedback directory", async () => {
const { deleteFeedbackRecordsByTenant } = await import("@/modules/hub/service");
test("should purge Hub-owned data for each feedback directory", async () => {
const { deleteHubTenantData } = await import("@/modules/hub/service");
vi.mocked(prisma.organization.delete).mockResolvedValue({
id: "org1",
name: "Test Org",
@@ -382,9 +385,9 @@ describe("Organization Service", () => {
await deleteOrganization("org1");
expect(deleteFeedbackRecordsByTenant).toHaveBeenCalledTimes(2);
expect(deleteFeedbackRecordsByTenant).toHaveBeenCalledWith("frd_1");
expect(deleteFeedbackRecordsByTenant).toHaveBeenCalledWith("frd_2");
expect(deleteHubTenantData).toHaveBeenCalledTimes(2);
expect(deleteHubTenantData).toHaveBeenCalledWith("frd_1");
expect(deleteHubTenantData).toHaveBeenCalledWith("frd_2");
});
});
});
+5 -4
View File
@@ -19,7 +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 { deleteHubTenantData } from "@/modules/hub/service";
import { validateInputs } from "../utils/validate";
export const select = {
@@ -308,10 +308,11 @@ export const deleteOrganization = async (organizationId: string) => {
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.
// Best-effort: purge Hub-owned data (feedback records, embeddings, webhooks) 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);
await deleteHubTenantData(directory.id);
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
+32 -19
View File
@@ -129,38 +129,51 @@ export const deleteFeedbackRecord = async (id: string): Promise<HubFeedbackRecor
}
};
export type HubFeedbackRecordsByTenantDeleteResult = {
data: { deletedCount: number } | null;
export type HubTenantDataDeleteResult = {
data: {
deletedFeedbackRecords: number;
deletedEmbeddings: number;
deletedWebhooks: number;
} | null;
error: HubError | null;
};
type TenantDataDeleteResponse = {
tenant_id: string;
deleted_feedback_records: number;
deleted_embeddings: number;
deleted_webhooks: number;
message?: string;
};
/**
* 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.
* Purge all Hub-owned data (feedback records, derived embeddings, webhooks) for a tenant.
* Called when the owning organization is deleted so Hub-side rows don't become orphaned.
* Idempotent on the Hub side; the caller treats failures as best-effort.
*
* 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.
* Hits `DELETE /v1/tenants/{tenant_id}/data` directly because the SDK doesn't yet expose
* a typed method for this endpoint.
*/
export const deleteFeedbackRecordsByTenant = async (
tenantId: string
): Promise<HubFeedbackRecordsByTenantDeleteResult> => {
export const deleteHubTenantData = async (tenantId: string): Promise<HubTenantDataDeleteResult> => {
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 };
const data = await client.delete<TenantDataDeleteResponse>(
`/v1/tenants/${encodeURIComponent(tenantId)}/data`
);
return {
data: {
deletedFeedbackRecords: data.deleted_feedback_records,
deletedEmbeddings: data.deleted_embeddings,
deletedWebhooks: data.deleted_webhooks,
},
error: null,
};
} catch (err) {
logger.warn({ err, tenantId }, "Hub: deleteFeedbackRecordsByTenant failed");
logger.warn({ err, tenantId }, "Hub: deleteHubTenantData failed");
const status = getErrorStatus(err);
const message = getErrorMessage(err);
return { data: null, error: { status, message, detail: message } };