mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-20 11:38:38 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b77df0f36 |
-4
@@ -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,
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,120 +9,46 @@ cube(`FeedbackRecords`, {
|
||||
description: `Total number of feedback responses`,
|
||||
},
|
||||
|
||||
uniqueRespondents: {
|
||||
type: `countDistinct`,
|
||||
sql: `${CUBE}.user_id`,
|
||||
description: `Number of unique users who provided feedback`,
|
||||
},
|
||||
|
||||
uniqueResponses: {
|
||||
type: `countDistinct`,
|
||||
sql: `${CUBE}.submission_id`,
|
||||
description: `Number of unique survey submissions (a submission can produce multiple feedback records)`,
|
||||
},
|
||||
|
||||
promoterCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number >= 9` }],
|
||||
description: `Number of NPS promoters (score 9-10)`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 9` }],
|
||||
description: `Number of promoters (NPS score 9-10)`,
|
||||
},
|
||||
|
||||
detractorCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 0 AND 6` }],
|
||||
description: `Number of NPS detractors (score 0-6)`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6` }],
|
||||
description: `Number of detractors (NPS score 0-6)`,
|
||||
},
|
||||
|
||||
passiveCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 7 AND 8` }],
|
||||
description: `Number of NPS passives (score 7-8)`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
|
||||
description: `Number of passives (NPS score 7-8)`,
|
||||
},
|
||||
|
||||
npsScore: {
|
||||
type: `number`,
|
||||
sql: `
|
||||
CASE
|
||||
WHEN COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number IS NOT NULL THEN 1 END) = 0 THEN NULL
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
(
|
||||
(COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
|
||||
COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 0 AND 6 THEN 1 END)::numeric)
|
||||
/ COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number IS NOT NULL THEN 1 END)::numeric
|
||||
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
|
||||
COUNT(CASE WHEN ${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
|
||||
/ COUNT(*)::numeric
|
||||
) * 100,
|
||||
2
|
||||
)
|
||||
END
|
||||
`,
|
||||
description: `Net Promoter Score: ((Promoters - Detractors) / Answered NPS responses) * 100. NULL when there are no answered NPS responses.`,
|
||||
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
|
||||
},
|
||||
|
||||
npsAverage: {
|
||||
averageScore: {
|
||||
type: `avg`,
|
||||
sql: `${CUBE}.value_number`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'nps'` }],
|
||||
description: `Average NPS rating (0-10)`,
|
||||
},
|
||||
|
||||
csatCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL` }],
|
||||
description: `Number of answered CSAT responses (dismissed responses excluded).`,
|
||||
},
|
||||
|
||||
csatSatisfiedCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number >= 4` }],
|
||||
description: `Number of satisfied CSAT responses (top-2-box on the 1-5 scale)`,
|
||||
},
|
||||
|
||||
csatDissatisfiedCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number BETWEEN 1 AND 2` }],
|
||||
description: `Number of dissatisfied CSAT responses (bottom-2-box on the 1-5 scale)`,
|
||||
},
|
||||
|
||||
csatNeutralCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number = 3` }],
|
||||
description: `Number of neutral CSAT responses (middle box on the 1-5 scale)`,
|
||||
},
|
||||
|
||||
csatScore: {
|
||||
type: `number`,
|
||||
sql: `
|
||||
CASE
|
||||
WHEN COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL THEN 1 END) = 0 THEN NULL
|
||||
ELSE ROUND(
|
||||
(
|
||||
COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number >= 4 THEN 1 END)::numeric
|
||||
/ COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL THEN 1 END)::numeric
|
||||
) * 100,
|
||||
2
|
||||
)
|
||||
END
|
||||
`,
|
||||
description: `CSAT Score: % of answered CSAT responses rated 4 or 5 (top-2-box on the 1-5 scale). NULL when there are no answered CSAT responses.`,
|
||||
},
|
||||
|
||||
csatAverage: {
|
||||
type: `avg`,
|
||||
sql: `${CUBE}.value_number`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat'` }],
|
||||
description: `Average CSAT rating (1-5)`,
|
||||
},
|
||||
|
||||
cesCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'ces' AND ${CUBE}.value_number IS NOT NULL` }],
|
||||
description: `Number of answered CES responses (dismissed responses excluded).`,
|
||||
},
|
||||
|
||||
cesAverage: {
|
||||
type: `avg`,
|
||||
sql: `${CUBE}.value_number`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'ces'` }],
|
||||
description: `Average CES rating (scale is 1-5 or 1-7 depending on the question)`,
|
||||
description: `Average NPS score`,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -151,70 +77,22 @@ cube(`FeedbackRecords`, {
|
||||
description: `Type of feedback field (e.g., nps, text, rating)`,
|
||||
},
|
||||
|
||||
fieldLabel: {
|
||||
sql: `field_label`,
|
||||
type: `string`,
|
||||
description: `Human-readable label of the question/field (e.g., "How satisfied are you with support?")`,
|
||||
},
|
||||
|
||||
fieldGroupLabel: {
|
||||
sql: `field_group_label`,
|
||||
type: `string`,
|
||||
description: `Label of the parent composite question for matrix/ranking rows`,
|
||||
},
|
||||
|
||||
language: {
|
||||
sql: `language`,
|
||||
type: `string`,
|
||||
description: `Response language code (e.g., "en", "de"). NULL when language is "default".`,
|
||||
},
|
||||
|
||||
collectedAt: {
|
||||
sql: `collected_at`,
|
||||
type: `time`,
|
||||
description: `Timestamp when the feedback was collected`,
|
||||
},
|
||||
|
||||
createdAt: {
|
||||
sql: `created_at`,
|
||||
type: `time`,
|
||||
description: `Timestamp when the feedback record was created in Hub`,
|
||||
},
|
||||
|
||||
updatedAt: {
|
||||
sql: `updated_at`,
|
||||
type: `time`,
|
||||
description: `Timestamp when the feedback record was last updated in Hub`,
|
||||
},
|
||||
|
||||
valueNumber: {
|
||||
npsValue: {
|
||||
sql: `value_number`,
|
||||
type: `number`,
|
||||
description: `Numeric answer value (NPS 0-10, CSAT 1-5, CES 1-5 or 1-7, rating, generic number). Pair with a fieldType filter to keep scales consistent.`,
|
||||
},
|
||||
|
||||
valueText: {
|
||||
sql: `value_text`,
|
||||
type: `string`,
|
||||
description: `Text answer value (open text, or the label of a multiple-choice / categorical answer). Pair with a fieldType filter to keep types consistent.`,
|
||||
},
|
||||
|
||||
valueBoolean: {
|
||||
sql: `value_boolean`,
|
||||
type: `boolean`,
|
||||
description: `Boolean answer value (yes/no questions). Pair with a fieldType filter.`,
|
||||
},
|
||||
|
||||
valueDate: {
|
||||
sql: `value_date`,
|
||||
type: `time`,
|
||||
description: `Date answer value (e.g., "preferred meeting date"). Pair with a fieldType filter.`,
|
||||
description: `Raw NPS score value (0-10)`,
|
||||
},
|
||||
|
||||
responseId: {
|
||||
sql: `submission_id`,
|
||||
sql: `response_id`,
|
||||
type: `string`,
|
||||
description: `Unique identifier linking related feedback records (submission_id in Hub)`,
|
||||
description: `Unique identifier linking related feedback records`,
|
||||
},
|
||||
|
||||
userId: {
|
||||
|
||||
Reference in New Issue
Block a user