mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-20 19:48:52 -05:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e65278af7 | |||
| 13c9677edd | |||
| c0bf2ab7cc | |||
| 65d0f4ac0e | |||
| 655c0b5e47 |
@@ -194,7 +194,7 @@ export const MainNavigation = ({
|
||||
const settingsNavigationItem = useMemo(
|
||||
() => ({
|
||||
name: t("common.settings"),
|
||||
href: `/workspaces/${workspace.id}/settings`,
|
||||
href: `/workspaces/${workspace.id}/settings/workspace/general`,
|
||||
icon: SettingsIcon,
|
||||
isActive: isSettingsMode,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
@@ -467,7 +467,7 @@ export const MainNavigation = ({
|
||||
{isSettingsMode ? (
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="mb-2 px-3">
|
||||
<GoBackButton />
|
||||
<GoBackButton url={`/workspaces/${workspace.id}/surveys`} />
|
||||
</div>
|
||||
|
||||
{/* Settings sidebar content */}
|
||||
|
||||
@@ -335,6 +335,7 @@ export const SettingsSidebarContent = ({
|
||||
href: `${basePath}/organization/feedback-directories`,
|
||||
icon: <FoldersIcon className={iconClassName} />,
|
||||
hidden: isMember,
|
||||
disabled: !isOwnerOrManager,
|
||||
},
|
||||
{
|
||||
id: "org-api-keys",
|
||||
@@ -373,12 +374,14 @@ export const SettingsSidebarContent = ({
|
||||
label: t("common.your_profile"),
|
||||
href: `${basePath}/account/profile`,
|
||||
icon: <UserCircleIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
label: t("common.notifications"),
|
||||
href: `${basePath}/account/notifications`,
|
||||
icon: <BellIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
const AccountSettingsLayout = (props: { children: React.ReactNode }) => {
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
|
||||
const AccountSettingsLayout = async (props: Readonly<{
|
||||
params: Promise<{ workspaceId: string }>;
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
return <>{props.children}</>;
|
||||
};
|
||||
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "./redirect-billing-role";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getBillingFallbackPath: vi.fn(),
|
||||
getWorkspaceAuth: vi.fn(),
|
||||
isFormbricksCloud: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: mocks.isFormbricksCloud,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/navigation", () => ({
|
||||
getBillingFallbackPath: mocks.getBillingFallbackPath,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/workspaces/lib/utils", () => ({
|
||||
getWorkspaceAuth: mocks.getWorkspaceAuth,
|
||||
}));
|
||||
|
||||
const workspaceId = "workspace-1";
|
||||
const billingFallbackPath = `/workspaces/${workspaceId}/settings/organization/billing`;
|
||||
|
||||
const getWorkspaceAuthResponse = (isBilling: boolean) =>
|
||||
({
|
||||
isBilling,
|
||||
}) as Awaited<ReturnType<typeof getWorkspaceAuth>>;
|
||||
|
||||
describe("redirectBillingRoleFromRestrictedSettings", () => {
|
||||
test("does not redirect non-billing workspace members", async () => {
|
||||
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(false));
|
||||
|
||||
await expect(redirectBillingRoleFromRestrictedSettings(workspaceId)).resolves.toBeUndefined();
|
||||
|
||||
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
|
||||
expect(getBillingFallbackPath).not.toHaveBeenCalled();
|
||||
expect(redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("redirects billing users to the billing fallback path", async () => {
|
||||
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(true));
|
||||
vi.mocked(getBillingFallbackPath).mockReturnValue(billingFallbackPath);
|
||||
|
||||
await redirectBillingRoleFromRestrictedSettings(workspaceId);
|
||||
|
||||
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
|
||||
expect(getBillingFallbackPath).toHaveBeenCalledWith(workspaceId, mocks.isFormbricksCloud);
|
||||
expect(redirect).toHaveBeenCalledWith(billingFallbackPath);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
export const redirectBillingRoleFromRestrictedSettings = async (workspaceId: string): Promise<void> => {
|
||||
const { isBilling } = await getWorkspaceAuth(workspaceId);
|
||||
|
||||
if (isBilling) {
|
||||
redirect(getBillingFallbackPath(workspaceId, IS_FORMBRICKS_CLOUD));
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,11 @@
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
|
||||
|
||||
export default APIKeysPage;
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
|
||||
return APIKeysPage(props);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { PricingPage } from "@/modules/ee/billing/page";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
export default PricingPage;
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
const { isBilling } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (isBilling && !IS_FORMBRICKS_CLOUD) {
|
||||
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
|
||||
}
|
||||
|
||||
return PricingPage(props);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { PrettyUrlsTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/domain/components/pretty-urls-table";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -12,8 +13,9 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
const t = await getTranslate();
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
|
||||
+9
-4
@@ -1,9 +1,10 @@
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable";
|
||||
import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseStatus";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -11,15 +12,19 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
const { isBilling, isMember } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (isBilling && IS_FORMBRICKS_CLOUD) {
|
||||
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
|
||||
}
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { isMember } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
const isPricingDisabled = isMember;
|
||||
|
||||
if (isPricingDisabled) {
|
||||
|
||||
+11
-1
@@ -1 +1,11 @@
|
||||
export { FeedbackDirectoriesPage as default } from "@/modules/ee/feedback-directory/page";
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { FeedbackDirectoriesPage } from "@/modules/ee/feedback-directory/page";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
|
||||
return FeedbackDirectoriesPage(props);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
||||
import {
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
@@ -26,8 +27,9 @@ import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||
import { SecurityListTip } from "./components/SecurityListTip";
|
||||
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
const t = await getTranslate();
|
||||
|
||||
const { session, currentUserMembership, organization, isOwner, isManager } = await getWorkspaceAuth(
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { TeamsPage } from "@/modules/organization/settings/teams/page";
|
||||
|
||||
export default TeamsPage;
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
|
||||
return TeamsPage(props);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
return redirect(`/workspaces/${params.workspaceId}/settings/workspace/general`);
|
||||
};
|
||||
|
||||
|
||||
@@ -104,7 +104,11 @@ export const createResponse = async (
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
|
||||
const prismaData = buildPrismaResponseData(
|
||||
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
|
||||
contact,
|
||||
ttc
|
||||
);
|
||||
|
||||
const prismaClient = tx ?? prisma;
|
||||
|
||||
|
||||
@@ -49,18 +49,7 @@ const buildPrismaResponseData = (
|
||||
contact: { id: string; attributes: TContactAttributes } | null,
|
||||
ttc: Record<string, number>
|
||||
): Prisma.ResponseCreateInput => {
|
||||
const {
|
||||
surveyId,
|
||||
displayId,
|
||||
finished,
|
||||
data,
|
||||
language,
|
||||
meta,
|
||||
singleUseId,
|
||||
variables,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
} = responseInput;
|
||||
const { surveyId, displayId, finished, data, language, meta, singleUseId, variables } = responseInput;
|
||||
|
||||
return {
|
||||
survey: {
|
||||
@@ -84,8 +73,6 @@ const buildPrismaResponseData = (
|
||||
singleUseId,
|
||||
...(variables && { variables }),
|
||||
ttc: ttc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -5,9 +5,14 @@ import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
export const GoBackButton = ({ url }: { url?: string }) => {
|
||||
interface GoBackButtonProps {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export const GoBackButton = ({ url }: Readonly<GoBackButtonProps>) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -17,6 +22,7 @@ export const GoBackButton = ({ url }: { url?: string }) => {
|
||||
router.push(url);
|
||||
return;
|
||||
}
|
||||
|
||||
router.back();
|
||||
}}>
|
||||
<ArrowLeftIcon />
|
||||
|
||||
@@ -55,7 +55,8 @@ Cube is part of the baseline Formbricks v5 stack and is deployed by this chart b
|
||||
when using the default release name.
|
||||
- For an external Cube, set `cube.enabled: false` and point `deployment.env.CUBEJS_API_URL` at your
|
||||
endpoint.
|
||||
- Provide `CUBEJS_API_SECRET` through your existing secret management flow, such as the generated app secret override or `deployment.envFrom`.
|
||||
- The generated app secret supplies `CUBEJS_API_SECRET` by default. If you disable generated secrets,
|
||||
provide it through your existing secret management flow.
|
||||
- Provide `CUBEJS_DB_*` connection variables to the Cube deployment through `cube.envFrom` or `cube.env`.
|
||||
- Keep `cube.replicas=1` while `cube.env.CUBEJS_CACHE_AND_QUEUE_DRIVER` is `memory`. Configure Cube Store before running multiple Cube replicas.
|
||||
- Keep Hub enabled. Cube should point at the same feedback records database that Hub writes to, unless you intentionally split that storage.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -289,6 +289,15 @@ true
|
||||
{{- randAlphaNum 32 -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.cubejsApiSecret" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
{{- if and $secret (index $secret.data "CUBEJS_API_SECRET") }}
|
||||
{{- index $secret.data "CUBEJS_API_SECRET" | b64dec -}}
|
||||
{{- else }}
|
||||
{{- randAlphaNum 32 -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
{{- define "formbricks.envoy.gatewayClassName" -}}
|
||||
{{- if .Values.envoy.formbricks.gatewayClass.name -}}
|
||||
{{- .Values.envoy.formbricks.gatewayClass.name | trunc 63 | trimSuffix "-" -}}
|
||||
|
||||
@@ -70,8 +70,12 @@ spec:
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.cube.envFrom }}
|
||||
{{- if or .Values.cube.envFrom (or (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) .Values.secret.enabled) }}
|
||||
envFrom:
|
||||
{{- if or .Values.secret.enabled (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) }}
|
||||
- secretRef:
|
||||
name: {{ template "formbricks.name" . }}-app-secrets
|
||||
{{- end }}
|
||||
{{- range $value := .Values.cube.envFrom }}
|
||||
{{- if (eq .type "configmap") }}
|
||||
- configMapRef:
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{{- $redisPassword := include "formbricks.redisPassword" . }}
|
||||
{{- $webappUrl := required "formbricks.webappUrl is required. Set it to your Formbricks instance URL (e.g., https://formbricks.example.com)" .Values.formbricks.webappUrl }}
|
||||
{{- $hubApiKey := include "formbricks.hubApiKey" . }}
|
||||
{{- $cubejsApiSecret := include "formbricks.cubejsApiSecret" . }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
@@ -31,6 +32,7 @@ data:
|
||||
{{- end }}
|
||||
HUB_API_KEY: {{ $hubApiKey | b64enc }}
|
||||
credential: {{ printf "Bearer %s" $hubApiKey | b64enc }}
|
||||
CUBEJS_API_SECRET: {{ $cubejsApiSecret | b64enc }}
|
||||
CRON_SECRET: {{ include "formbricks.cronSecret" . | b64enc }}
|
||||
ENCRYPTION_KEY: {{ include "formbricks.encryptionKey" . | b64enc }}
|
||||
NEXTAUTH_SECRET: {{ include "formbricks.nextAuthSecret" . | b64enc }}
|
||||
|
||||
@@ -580,8 +580,9 @@ cube:
|
||||
type: ClusterIP
|
||||
port: 4000
|
||||
|
||||
# Secret values such as CUBEJS_API_SECRET and CUBEJS_DB_* should be supplied
|
||||
# through envFrom or another secret-management flow.
|
||||
# The generated app secret supplies CUBEJS_API_SECRET when secret.enabled=true.
|
||||
# Secret values such as CUBEJS_DB_* should be supplied through envFrom or another secret-management
|
||||
# flow.
|
||||
envFrom: []
|
||||
|
||||
env:
|
||||
|
||||
@@ -116,7 +116,8 @@ Cube is part of the baseline Formbricks v5 stack and is bundled with the chart b
|
||||
|
||||
- set `cube.enabled: false` to skip the bundled Cube deployment
|
||||
- point the app at your external endpoint via `deployment.env.CUBEJS_API_URL`
|
||||
- supply `CUBEJS_API_SECRET` via `deployment.env` or `deployment.envFrom`
|
||||
- supply `CUBEJS_API_SECRET` via `deployment.env` or `deployment.envFrom` if you disable generated
|
||||
secrets
|
||||
|
||||
## 4. Upgrade The Deployment
|
||||
|
||||
@@ -134,8 +135,8 @@ For a Formbricks 4.x to 5.0 migration, confirm the following before running the
|
||||
- `HUB_API_KEY` is present
|
||||
- your edge rate-limiting plan is in place
|
||||
- any required `AI_*` variables are added
|
||||
- `CUBEJS_API_SECRET` is configured (Cube is bundled by default; provide an external endpoint if you set
|
||||
`cube.enabled: false`)
|
||||
- `CUBEJS_API_SECRET` is configured (the generated app secret supplies it by default; provide an external
|
||||
endpoint if you set `cube.enabled: false`)
|
||||
|
||||
## 5. Key Values
|
||||
|
||||
|
||||
Reference in New Issue
Block a user