Compare commits

...

56 Commits

Author SHA1 Message Date
pandeymangg 756d058001 skips same survey responses from being reported as failures 2026-05-13 12:58:47 +05:30
Dhruwang Jariwala ae9c1e499a fix: add missing title in feedback directory (#7983) 2026-05-13 12:04:15 +05:30
Dhruwang Jariwala 5b70c99eb3 fix(dashboards): chart removal flow + add-charts dialog focus ring (ENG-901, ENG-903) (#7981) 2026-05-12 16:55:48 +05:30
Dhruwang 10c09f00a8 refactor(dashboards): address review on removeWidgetFromDashboard
- Drop the prisma.$transaction wrapper; find + delete is two sequential
  steps, doesn't need a transaction.
- Drop the redundant ResourceNotFoundError catch branch; the trailing
  `throw error` already lets it bubble.
- Let action-client infer ctx / parsedInput types.

Tests: cover the two catch branches (Prisma -> DatabaseError, unknown
rethrow) so the new function is fully line-covered.
2026-05-12 16:11:45 +05:30
Javi Aguilar 5f4f133dcb fix: add missing title in feedback directory 2026-05-12 11:07:02 +02:00
Dhruwang Jariwala 037b005d48 fix(charts): pie tooltip spacing + scroll to chart-name on empty save (ENG-914, ENG-916) (#7970) 2026-05-12 13:22:00 +05:30
Dhruwang ddd2d5e983 fix(dashboards): chart removal flow + add-charts dialog focus ring (ENG-901, ENG-903)
ENG-901: clicking Remove on a chart widget no longer enters dashboard edit mode
(which surfaced the rename input) and saving with the last widget removed no longer
surfaces a raw "widgets: Too small" zod error. Out of edit mode, Remove now goes
through a DeleteDialog -> dedicated removeWidgetFromDashboardAction. The batched
update path also allows an empty widgets array now that the lib already supports
deleting all widgets correctly.

ENG-903: add p-1 to AddExistingChartsDialog DialogBody so the focus ring on the
inner search input is no longer clipped by the body's overflow boundary.
2026-05-12 12:51:46 +05:30
Dhruwang Jariwala 6777b284b3 fix(a11y): large selects are not scrollable (#7963) 2026-05-12 12:08:38 +05:30
Anshuman Pandey c6282632e0 fix: fixes read only issues in feedback sources UI (#7974) 2026-05-12 10:20:30 +04:00
Dhruwang Jariwala f84c409bc4 feat: add beta badge to unify feedback navigation section (#7968) 2026-05-12 11:38:09 +05:30
Dhruwang Jariwala 98b475a2a4 fix(charts): time-range crash + sentiment/emotion/response-id cube column mismatches (ENG-907, ENG-906, ENG-915) (#7973) 2026-05-12 10:02:50 +04:00
Dhruwang c48474b943 chore(charts): drop sonar S6478 JSDoc on PieTooltipRow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:31:38 +05:30
Anshuman Pandey 3c0d1e3fd7 fix: fixes connector modal UI errors (#7971) 2026-05-11 16:40:35 +04:00
Bhagya Amarasinghe 1f7a496967 fix(helm): stop forcing TEI float16 dtype (#7967) 2026-05-11 17:24:57 +05:30
Dhruwang 99e378ae2e chore: drop verbose doc comments on ChartDialogFooter props
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:44:15 +05:30
Dhruwang c6e39c3103 refactor(charts): hoist pie tooltip formatter to module scope (sonar S6478)
Same pattern as CartesianChart's ChartTooltipRow — a module-level
component that calls useTranslation internally, plus a thin formatter
function passed to ChartTooltipContent. Resolves the nested-component
warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:41:18 +05:30
Dhruwang 19472ca9d3 chore: inline form onSubmit to avoid deprecated FormEvent type alias
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:37:20 +05:30
Dhruwang 43feff0009 fix(charts): inline error styling for empty chart-name on submit
Switch from the native browser tooltip to the in-app inline error pattern:
red Label, isInvalid Input border, and helper text below the field. The
onInvalid handler suppresses the default tooltip and scrolls + focuses the
field via event.currentTarget — no ref needed. Typing clears the error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:36:45 +05:30
Dhruwang 507cec6958 feat(charts): show translated message on empty chart-name validation
Browsers default to "Please fill in this field"; use the existing
workspace.analysis.charts.please_enter_chart_name key so the message
matches the toast we surface elsewhere. Clear custom validity on change
so the form unblocks once the user types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:27:52 +05:30
Dhruwang d874b65be9 refactor(charts): use native form validation for chart-name field (ENG-916)
Replace the imperative scrollIntoView + focus on Save click with a tiny
<form> wrapping the required Input. The Save button becomes type=submit
with form="create-chart-form", so the browser handles scroll, focus, and
the "Please fill in this field" tooltip natively when the name is empty.

ChartDialogFooter now accepts either onSaveClick (button mode) or formId
(submit-button mode). All sibling buttons in the footer are explicitly
type="button" so they don't accidentally submit the form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:25:17 +05:30
Dhruwang Jariwala c159af1a26 fix: format toast (ENG-893) (#7966) 2026-05-11 16:22:24 +05:30
Dhruwang 3ce2ef6cf4 fix(charts): scroll/focus chart-name input when save is blocked by empty name (ENG-916)
Clicking Save Chart with the name field empty fired a toast but left the
modal scrolled wherever the user was (e.g. on the chart preview at the
bottom), so they couldn't see what was wrong. Scroll the name field into
view and focus it before delegating to the existing save handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:20:57 +05:30
Dhruwang Jariwala baae6335c9 fix: validate workspace assignment on feedback directory create (ENG-900) (#7965)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-11 14:50:05 +04:00
Dhruwang 8e443db1f6 revert: drop YAxis integer-tick handling
The fractional-tick bar/line/area scenario isn't the bug we're shipping in
this PR. Keep only the pie tooltip spacing/formatting change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:13:40 +05:30
Dhruwang 18cd9afc2c fix(charts): space between value and name in pie tooltip
Returning [value, name] from the tooltip formatter rendered as two adjacent text nodes ("3Nps"). Return a fragment with an explicit space so it reads "3 Nps".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:08:22 +05:30
Dhruwang 680e1e1593 fix(charts): show integer y-axis ticks for COUNT measures and format pie tooltips
ENG-914

When a chart's measures are all whole numbers (e.g. COUNT grouped by a
dimension), Recharts was auto-generating fractional y-axis ticks like
0.5, 1.5, 2 — nonsensical for a discrete count. The pie tooltip also
rendered values via raw String() with no thousand separators.

- Add allValuesAreIntegers() + formatYAxisTick() to chart-utils.
- CartesianChart now passes allowDecimals={false} and a number-aware
  tickFormatter to YAxis when every measure value is an integer.
- ChartRenderer's pie tooltip routes the value through formatCellValue,
  matching the Cartesian tooltip behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:49:57 +05:30
Dhruwang 04bfdeef69 removed stale comment 2026-05-11 15:09:51 +05:30
Dhruwang 8a1db7b6aa reverted errorcode transform change 2026-05-11 15:08:58 +05:30
Dhruwang 1d22fe2da6 fix: surface translated inline form errors and prefer .at() over slice
- FormError ignored its children when an error was set, rendering the raw
  zod message instead of the translated text passed in. Prefer explicit
  children, fall back to the field error message.
- Use .at(-1) in getTranslatedFeedbackDirectoryError per SonarQube S7755.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:03:00 +05:30
Dhruwang d8a119712c revert: drop zero-workspace requirement on feedback directory create
Per Johannes on ENG-893: zero-workspace creation is acceptable — admins
move workspaces between directories and forcing at least one is an
arbitrary limitation. The unformatted-error fix
(getTranslatedFeedbackDirectoryError prefix stripping) is retained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:54:54 +05:30
Cursor Agent 50089eeca4 feat: add beta badge to unify feedback section
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-11 08:20:06 +00:00
Bhagya Amarasinghe d8fe832f5f fix(helm): stop forcing TEI float16 dtype 2026-05-11 13:24:49 +05:30
Dhruwang 1c904e2243 fix: require at least one workspace when creating a feedback directory
ENG-893

Why: A feedback directory with zero workspaces is useless — it groups
nothing. The create path allowed it, and any validation error that did
fire surfaced as a raw "field: CODE" string in the toast.

How:
- createFeedbackDirectory now rejects empty/missing workspaceIds with
  DIRECTORY_WORKSPACES_REQUIRED before touching the database.
- getTranslatedFeedbackDirectoryError strips the "<field>: " prefix that
  next-safe-action prepends to validation errors, so machine codes always
  map to a translated message.
- Add the new translation key across all 15 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:04:47 +05:30
Javi Aguilar 4dbecc2d58 fix/a11y-select-scroll 2026-05-11 05:52:55 +02:00
Dhruwang Jariwala 72f4e93432 fix: support Redis Cluster for BullMQ jobs (#7960) 2026-05-08 15:28:10 +05:30
Dhruwang Jariwala 9007502804 feat(feedback-sources): add a Create Survey CTA if there are none (#7943) 2026-05-08 15:10:28 +05:30
Tiago Farto d84589452c Merge branch 'epic/v5' of https://github.com/formbricks/formbricks into fix/investigate-bullmq-issue
# Conflicts:
#	docs/development/technical-handbook/background-job-processing.mdx
2026-05-08 09:38:53 +00:00
Tiago Farto 43aaed3923 fix: support Redis Cluster for BullMQ jobs 2026-05-08 09:19:49 +00:00
Bhagya Amarasinghe 550bfc6a6c fix: update Hub runtime defaults for v5 staging (#7959) 2026-05-07 19:25:20 +02:00
Bhagya Amarasinghe 2c22b00ec6 fix: address Cube chart review feedback (#7956) 2026-05-07 17:27:55 +02:00
Bhagya Amarasinghe d64fb546d3 feat: add internal cube helm deployment (#7955) 2026-05-07 16:06:24 +02:00
Dhruwang Jariwala f4ca7c46ef fix: add Hub and Cube env vars to Docker build secrets (#7950) 2026-05-07 17:22:05 +05:30
Dhruwang c252d8c4c9 fix: update tests for required Cube and Hub env vars
Tests now expect validation failures when CUBEJS_API_URL, CUBEJS_API_SECRET,
or HUB_API_KEY are missing, and all test env helpers provide HUB_API_KEY.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 17:14:01 +05:30
Dhruwang 2bec3b040d fix: remove unused ZOptionalUrl variable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 17:06:23 +05:30
Dhruwang 3c49b33dad feat: make HUB_API_KEY required and add to Docker build secrets
Hub is mandatory in v5, so HUB_API_KEY should fail fast at startup
if not configured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 16:56:55 +05:30
Dhruwang 0f2f3d337e fix: restore CUBEJS_JWT_AUDIENCE and CUBEJS_JWT_ISSUER in env schema
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 16:48:57 +05:30
Dhruwang 4d1df795ad feat: make CUBEJS_API_SECRET and CUBEJS_API_URL required
Makes Cube env vars mandatory in env.ts (per PR #7913) and adds them
as Docker build secrets with fallback values, following the same pattern
as DATABASE_URL, REDIS_URL, and HUB_API_URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 16:45:46 +05:30
Bhagya Amarasinghe 3ce2998d0d feat(helm): add Hub worker and embeddings runtime (#7945) 2026-05-07 16:35:32 +05:30
Bhagya Amarasinghe b9a6520e10 fix(helm): address embeddings review feedback 2026-05-07 16:21:42 +05:30
Dhruwang 55bb9a525e fix: use secrets.DUMMY_HUB_API_URL instead of hardcoded value
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 16:20:48 +05:30
Dhruwang 11055f812e fix: add HUB_API_URL to Docker build secrets
HUB_API_URL is required by the Zod env validation at build time but was
not provided as a Docker secret, causing the release build to fail.

Adds HUB_API_URL with a dummy fallback (http://localhost:4000) to the
build pipeline, following the same pattern as DATABASE_URL/REDIS_URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 16:18:35 +05:30
Bhagya Amarasinghe a276aa6d34 fix(helm): default embeddings model to gte multilingual 2026-05-07 13:46:29 +05:30
Javi Aguilar d192fbf839 add CR changes 2026-05-07 10:12:06 +02:00
Javi Aguilar c5d52df9b7 use i18n interpolation properly 2026-05-07 10:12:06 +02:00
Javi Aguilar 550e859a2d feat(unify): add CTA to create a survey before using it as feedback source if there are none 2026-05-07 10:12:06 +02:00
Bhagya Amarasinghe e6b6f5e6d3 feat(helm): add Hub worker and embeddings runtime 2026-05-07 01:45:45 +05:30
76 changed files with 2651 additions and 239 deletions
@@ -284,6 +284,10 @@ runs:
database_url=${{ env.DUMMY_DATABASE_URL }}
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
redis_url=${{ env.DUMMY_REDIS_URL }}
hub_api_url=${{ env.DUMMY_HUB_API_URL }}
hub_api_key=${{ env.DUMMY_HUB_API_KEY }}
cubejs_api_url=${{ env.DUMMY_CUBEJS_API_URL }}
cubejs_api_secret=${{ env.DUMMY_CUBEJS_API_SECRET }}
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
posthog_key=${{ env.POSTHOG_KEY }}
env:
@@ -291,6 +295,10 @@ runs:
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ env.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ env.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ env.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ env.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
+4
View File
@@ -91,5 +91,9 @@ jobs:
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
@@ -73,6 +73,10 @@ jobs:
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
redis_url=redis://localhost:6379
hub_api_url=http://localhost:4000
hub_api_key=build-time-placeholder
cubejs_api_url=http://localhost:4000
cubejs_api_secret=build-time-placeholder
- name: Verify and Initialize PostgreSQL
run: |
@@ -47,4 +47,8 @@ jobs:
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
@@ -105,4 +105,8 @@ jobs:
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+4
View File
@@ -66,6 +66,10 @@ RUN pnpm build --filter=@formbricks/database
RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=encryption_key \
--mount=type=secret,id=redis_url \
--mount=type=secret,id=hub_api_url \
--mount=type=secret,id=hub_api_key \
--mount=type=secret,id=cubejs_api_url \
--mount=type=secret,id=cubejs_api_secret \
--mount=type=secret,id=sentry_auth_token \
--mount=type=secret,id=posthog_key \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
@@ -43,6 +43,7 @@ import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
@@ -151,7 +152,17 @@ export const MainNavigation = ({
},
{
id: "unify-feedback",
name: t("workspace.unify.unify_feedback"),
name: (
<span className="inline-flex items-center gap-2">
<span>{t("workspace.unify.unify_feedback")}</span>
<Badge
text="Beta"
type="gray"
size="tiny"
className="normal-case text-[10px] font-semibold tracking-normal"
/>
</span>
),
items: [
{
name: t("workspace.unify.feedback_records"),
+8
View File
@@ -1743,6 +1743,7 @@ checksums:
workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190
workspace/analysis/charts/update_chart: 7d9223335d9f0c5938ec30356d7034a9
workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa
workspace/analysis/dashboards/chart_removed: 1ce20b8ee0b56bcd7d6fea2b5c1ae9fd
workspace/analysis/dashboards/charts_add_failed: c4fda79ede798ab6747a989f083a0947
workspace/analysis/dashboards/charts_add_partial_failure: b1a9fc6fe18ab20fe16c16e91a05c195
workspace/analysis/dashboards/charts_added_to_dashboard: 917abab80231adf5af51e812352fc77b
@@ -3529,6 +3530,12 @@ checksums:
workspace/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3
workspace/unify/enter_value: 4f068bb59617975c1e546218373122cd
workspace/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
workspace/unify/error_connector_field_mapping_duplicate: 4e507ae4a99f53dfa75d849d32566bf2
workspace/unify/error_connector_formbricks_mapping_duplicate: 48524e22fa33bd0b2829f7aea49c711b
workspace/unify/error_connector_name_duplicate: 56a1a05212e87dff21261d1db90fdb11
workspace/unify/error_connector_name_required: b2d5f79f6126e23128d7bef0c1736ff2
workspace/unify/error_connector_questions_required: d7d8e388959ab83a9195ba63bebf6516
workspace/unify/error_connector_survey_required: 1f49086dfb874307aae1136e88c3d514
workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8
workspace/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
workspace/unify/feedback_directory: 156fa9957e1ee5eadf1f44226a2365e4
@@ -3576,6 +3583,7 @@ checksums:
workspace/unify/no_feedback_directory_linked_member_description: e799b17da03899cabd125c3cf672c10d
workspace/unify/no_feedback_directory_linked_title: b01a53d508aeeb1c8ad080ee07efcd04
workspace/unify/no_feedback_records: 16a905c40f6d47a5e8f93b3d8c6f6693
workspace/unify/no_formbricks_surveys_available_description: 2218334806910168bdfb6f283f31b963
workspace/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
workspace/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
workspace/unify/optional: 396fb9a0472daf401c392bdc3e248943
+22
View File
@@ -114,6 +114,28 @@ describe("importHistoricalResponses", () => {
expect(result.failures).toBe(1);
});
test("counts 409 duplicates as skipped, not failures", async () => {
const mockResponses = [{ id: "r1" }, { id: "r2" }, { id: "r3" }];
getResponses.mockResolvedValueOnce(mockResponses as never);
getResponses.mockResolvedValueOnce([]);
transformResponseToFeedbackRecords.mockReturnValue([{ field: "record" }] as never);
createFeedbackRecordsBatch.mockResolvedValue({
results: [
{ data: { id: "fb1" }, error: null },
{ data: null, error: { status: 409, message: "Conflict", detail: "duplicate" } },
{ data: null, error: { status: 500, message: "Server error", detail: "boom" } },
],
} as never);
const result = await importHistoricalResponses(mockConnector, mockSurvey);
expect(result.successes).toBe(1);
expect(result.failures).toBe(1);
expect(result.skipped).toBe(1);
});
test("paginates through responses in batches", async () => {
const batch1 = Array.from({ length: 50 }, (_, i) => ({ id: `r${i}` }));
const batch2 = [{ id: "r50" }];
+5 -2
View File
@@ -18,6 +18,7 @@ const processBatch = async (
): Promise<TImportResult> => {
let successes = 0;
let failures = 0;
let duplicates = 0;
const expectedRecords = responses.length * mappings.length;
const allRecords = responses.flatMap((response) =>
@@ -27,10 +28,12 @@ const processBatch = async (
if (allRecords.length > 0) {
const { results } = await createFeedbackRecordsBatch(allRecords);
successes = results.filter((r) => r.data !== null).length;
failures = results.filter((r) => r.error !== null).length;
duplicates = results.filter((r) => r.error?.status === 409).length;
failures = results.filter((r) => r.error !== null && r.error.status !== 409).length;
}
return { successes, failures, skipped: expectedRecords - allRecords.length };
const unmappedSkipped = expectedRecords - allRecords.length;
return { successes, failures, skipped: unmappedSkipped + duplicates };
};
export const importHistoricalResponses = async (
+81 -2
View File
@@ -386,11 +386,12 @@ describe("createConnectorWithMappings", () => {
);
});
test("throws InvalidInputError on unique constraint violation", async () => {
test("throws CONNECTOR_NAME_DUPLICATE on Connector name unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "name"] },
})
);
@@ -400,7 +401,43 @@ describe("createConnectorWithMappings", () => {
type: "formbricks_survey",
feedbackDirectoryId: FRD_ID,
})
).rejects.toThrow(InvalidInputError);
).rejects.toThrow(new InvalidInputError("CONNECTOR_NAME_DUPLICATE"));
});
test("throws CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE on mapping unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "connectorId", "surveyId", "elementId"] },
})
);
await expect(
createConnectorWithMappings(ENV_ID, {
name: "Dup mapping",
type: "formbricks_survey",
feedbackDirectoryId: FRD_ID,
})
).rejects.toThrow(new InvalidInputError("CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE"));
});
test("throws CONNECTOR_FIELD_MAPPING_DUPLICATE on field mapping unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "connectorId", "sourceFieldId", "targetFieldId"] },
})
);
await expect(
createConnectorWithMappings(ENV_ID, {
name: "Dup field mapping",
type: "csv",
feedbackDirectoryId: FRD_ID,
})
).rejects.toThrow(new InvalidInputError("CONNECTOR_FIELD_MAPPING_DUPLICATE"));
});
test("throws DatabaseError on generic Prisma error", async () => {
@@ -526,6 +563,48 @@ describe("updateConnectorWithMappings", () => {
);
});
test("throws CONNECTOR_NAME_DUPLICATE on Connector name unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "name"] },
})
);
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "Dup" })).rejects.toThrow(
new InvalidInputError("CONNECTOR_NAME_DUPLICATE")
);
});
test("throws CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE on formbricks mapping unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "connectorId", "surveyId", "elementId"] },
})
);
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(
new InvalidInputError("CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE")
);
});
test("throws CONNECTOR_FIELD_MAPPING_DUPLICATE on field mapping unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "connectorId", "sourceFieldId", "targetFieldId"] },
})
);
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(
new InvalidInputError("CONNECTOR_FIELD_MAPPING_DUPLICATE")
);
});
test("throws DatabaseError on generic Prisma error", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("DB error", {
+16 -1
View File
@@ -212,6 +212,18 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
// -- Composite functions --
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
const target = error.meta?.target;
const targetFields = Array.isArray(target) ? (target as string[]) : [];
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
return new InvalidInputError("CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE");
}
if (targetFields.includes("sourceFieldId") || targetFields.includes("targetFieldId")) {
return new InvalidInputError("CONNECTOR_FIELD_MAPPING_DUPLICATE");
}
return new InvalidInputError("CONNECTOR_NAME_DUPLICATE");
};
export type TFormbricksMappingsInput = {
type: "formbricks_survey";
mappings: TConnectorFormbricksMappingCreateInput[];
@@ -284,7 +296,7 @@ export const createConnectorWithMappings = async (
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError(`Connector with name ${data.name} already exists`);
throw mapUniqueConstraintError(error);
}
throw new DatabaseError(error.message);
}
@@ -359,6 +371,9 @@ export const updateConnectorWithMappings = async (
return mapConnectorWithMappings(result);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw mapUniqueConstraintError(error);
}
if (error.code === PrismaErrorType.RecordDoesNotExist) {
throw new ResourceNotFoundError("Connector", connectorId);
}
+9 -16
View File
@@ -9,6 +9,7 @@ const setTestEnv = (overrides: Record<string, string | undefined> = {}) => {
DATABASE_URL: "https://example.com/db",
ENCRYPTION_KEY: "12345678901234567890123456789012",
HUB_API_URL: "https://hub.formbricks.local",
HUB_API_KEY: "test-hub-api-key",
CUBEJS_API_URL: "https://cube.formbricks.local",
CUBEJS_API_SECRET: "cube-secret",
...overrides,
@@ -128,44 +129,36 @@ describe("env", () => {
expect(env.CUBEJS_JWT_ISSUER).toBe("formbricks-web");
});
test("allows the Cube API secret to be omitted until analytics is used", async () => {
test("fails to load when the Cube API secret is missing", async () => {
setTestEnv({
CUBEJS_API_SECRET: undefined,
});
const { env } = await import("./env");
expect(env.CUBEJS_API_SECRET).toBeUndefined();
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("treats an empty Cube API secret from Docker Compose as omitted", async () => {
test("fails to load when the Cube API secret is empty", async () => {
setTestEnv({
CUBEJS_API_SECRET: "",
});
const { env } = await import("./env");
expect(env.CUBEJS_API_SECRET).toBeUndefined();
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("allows the Cube API URL to be omitted until analytics is used", async () => {
test("fails to load when the Cube API URL is missing", async () => {
setTestEnv({
CUBEJS_API_URL: undefined,
});
const { env } = await import("./env");
expect(env.CUBEJS_API_URL).toBeUndefined();
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("treats an empty Cube API URL as omitted", async () => {
test("fails to load when the Cube API URL is empty", async () => {
setTestEnv({
CUBEJS_API_URL: "",
});
const { env } = await import("./env");
expect(env.CUBEJS_API_URL).toBeUndefined();
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("fails to load when the Cube API URL is invalid", async () => {
+3 -4
View File
@@ -136,7 +136,6 @@ const ZSurveySchedulingLocalMinute = z.coerce.number().int().min(0).max(59);
const emptyStringToUndefined = (value: unknown) =>
typeof value === "string" && value.trim() === "" ? undefined : value;
const ZOptionalNonEmptyString = z.preprocess(emptyStringToUndefined, z.string().trim().min(1).optional());
const ZOptionalUrl = z.preprocess(emptyStringToUndefined, z.url().optional());
const parsedEnv = createEnv({
/*
@@ -190,14 +189,14 @@ const parsedEnv = createEnv({
AI_AZURE_API_KEY: z.string().optional(),
AI_AZURE_API_VERSION: z.string().optional(),
AI_AZURE_RESOURCE_NAME: z.string().optional(),
CUBEJS_API_SECRET: ZOptionalNonEmptyString,
CUBEJS_API_URL: ZOptionalUrl,
CUBEJS_API_SECRET: z.string().trim().min(1),
CUBEJS_API_URL: z.url(),
CUBEJS_JWT_AUDIENCE: ZOptionalNonEmptyString,
CUBEJS_JWT_ISSUER: ZOptionalNonEmptyString,
HTTP_PROXY: z.url().optional(),
HTTPS_PROXY: z.url().optional(),
HUB_API_URL: z.url(),
HUB_API_KEY: z.string().optional(),
HUB_API_KEY: z.string().trim().min(1),
IMPRINT_URL: z
.url()
.optional()
+8
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "{count} Diagramm(e) hinzufügen",
"chart_removed": "Diagramm vom Dashboard entfernt",
"charts_add_failed": "Diagramme konnten nicht zum Dashboard hinzugefügt werden",
"charts_add_partial_failure": "{count} Diagramm(e) konnten nicht hinzugefügt werden",
"charts_added_to_dashboard": "Diagramme zum Dashboard hinzugefügt",
@@ -3689,6 +3690,12 @@
"enter_name_for_source": "Gib einen Namen für diese Quelle ein",
"enter_value": "Wert eingeben...",
"enum": "Aufzählung",
"error_connector_field_mapping_duplicate": "Doppelte Feldzuordnung für diese Quelle",
"error_connector_formbricks_mapping_duplicate": "Doppelte Fragenzuordnung für diese Quelle",
"error_connector_name_duplicate": "Eine Quelle mit diesem Namen existiert bereits",
"error_connector_name_required": "Quellenname ist erforderlich",
"error_connector_questions_required": "Wähle mindestens eine Frage aus",
"error_connector_survey_required": "Wähle eine Umfrage aus",
"failed_to_load_feedback_records": "Feedback-Einträge konnten nicht geladen werden",
"feedback_date": "Aktuelles Datum",
"feedback_directory": "Feedback-Verzeichnis",
@@ -3736,6 +3743,7 @@
"no_feedback_directory_linked_member_description": "Für diesen Workspace muss ein Feedback-Verzeichnis eingerichtet werden, bevor diese Funktion verfügbar ist. Bitte einen Organisationsinhaber oder Manager, eins zuzuweisen.",
"no_feedback_directory_linked_title": "Kein Feedback-Verzeichnis verknüpft",
"no_feedback_records": "Noch keine Feedback-Einträge vorhanden. Einträge erscheinen hier, sobald deine Konnektoren Daten senden.",
"no_formbricks_surveys_available_description": "In diesem Workspace gibt es noch keine Umfragen. <surveyLink>Erstelle eine neue Umfrage</surveyLink>, um eine als Feedback-Quelle zu verwenden.",
"no_source_fields_loaded": "Noch keine Quellfelder geladen",
"no_sources_connected": "Noch keine Quellen verbunden. Füge eine Quelle hinzu, um loszulegen.",
"optional": "Optional",
+8
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Add {count} chart(s)",
"chart_removed": "Chart removed from dashboard",
"charts_add_failed": "Failed to add charts to dashboard",
"charts_add_partial_failure": "Failed to add {count} chart(s)",
"charts_added_to_dashboard": "Charts added to dashboard",
@@ -3689,6 +3690,12 @@
"enter_name_for_source": "Enter a name for this source",
"enter_value": "Enter value...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Duplicate field mapping for this source",
"error_connector_formbricks_mapping_duplicate": "Duplicate question mapping for this source",
"error_connector_name_duplicate": "A source with this name already exists",
"error_connector_name_required": "Source name is required",
"error_connector_questions_required": "Select at least one question",
"error_connector_survey_required": "Select a survey",
"failed_to_load_feedback_records": "Failed to load feedback records",
"feedback_date": "Current date",
"feedback_directory": "Feedback Directory",
@@ -3736,6 +3743,7 @@
"no_feedback_directory_linked_member_description": "A feedback directory needs to be set up for this workspace before this functionality is available. Ask an organization owner or manager to assign one.",
"no_feedback_directory_linked_title": "No feedback directory linked",
"no_feedback_records": "No feedback records yet. Records will appear here once your connectors start sending data.",
"no_formbricks_surveys_available_description": "There are no surveys in this workspace yet. <surveyLink>Create a new survey</surveyLink> to use it as a feedback source.",
"no_source_fields_loaded": "No source fields loaded yet",
"no_sources_connected": "No sources connected yet. Add a source to get started.",
"optional": "Optional",
+8
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Añadir {count} gráfico(s)",
"chart_removed": "Gráfico eliminado del panel",
"charts_add_failed": "Error al añadir gráficos al panel",
"charts_add_partial_failure": "Error al añadir {count} gráfico(s)",
"charts_added_to_dashboard": "Gráficos añadidos al panel",
@@ -3689,6 +3690,12 @@
"enter_name_for_source": "Introduce un nombre para este origen",
"enter_value": "Introduce un valor...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Mapeo de campo duplicado para esta fuente",
"error_connector_formbricks_mapping_duplicate": "Mapeo de pregunta duplicado para esta fuente",
"error_connector_name_duplicate": "Ya existe una fuente con este nombre",
"error_connector_name_required": "El nombre de origen es obligatorio",
"error_connector_questions_required": "Selecciona al menos una pregunta",
"error_connector_survey_required": "Selecciona una encuesta",
"failed_to_load_feedback_records": "Error al cargar los registros de comentarios",
"feedback_date": "Fecha actual",
"feedback_directory": "Directorio de feedback",
@@ -3736,6 +3743,7 @@
"no_feedback_directory_linked_member_description": "Es necesario configurar un directorio de feedback para este espacio de trabajo antes de que esta funcionalidad esté disponible. Solicita a un propietario o gestor de la organización que asigne uno.",
"no_feedback_directory_linked_title": "No hay ningún directorio de feedback vinculado",
"no_feedback_records": "Aún no hay registros de comentarios. Los registros aparecerán aquí una vez que tus conectores empiecen a enviar datos.",
"no_formbricks_surveys_available_description": "Todavía no hay encuestas en este espacio de trabajo. <surveyLink>Crea una nueva encuesta</surveyLink> para usar una como fuente de feedback.",
"no_source_fields_loaded": "Aún no se han cargado campos de origen",
"no_sources_connected": "Aún no hay fuentes conectadas. Añade una fuente para empezar.",
"optional": "Opcional",
+8
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Ajouter {count} graphique(s)",
"chart_removed": "Graphique retiré du tableau de bord",
"charts_add_failed": "Échec de l'ajout des graphiques au tableau de bord",
"charts_add_partial_failure": "Échec de l'ajout de {count} graphique(s)",
"charts_added_to_dashboard": "Graphiques ajoutés au tableau de bord",
@@ -3689,6 +3690,12 @@
"enter_name_for_source": "Entrez un nom pour cette source",
"enter_value": "Saisir une valeur...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Mappage de champ en double pour cette source",
"error_connector_formbricks_mapping_duplicate": "Mappage de question en double pour cette source",
"error_connector_name_duplicate": "Une source avec ce nom existe déjà",
"error_connector_name_required": "Le nom de la source est requis",
"error_connector_questions_required": "Sélectionnez au moins une question",
"error_connector_survey_required": "Sélectionnez une enquête",
"failed_to_load_feedback_records": "Échec du chargement des enregistrements de feedback",
"feedback_date": "Date actuelle",
"feedback_directory": "Répertoire de retours",
@@ -3736,6 +3743,7 @@
"no_feedback_directory_linked_member_description": "Un répertoire de feedback doit être configuré pour cet espace de travail avant que cette fonctionnalité ne soit disponible. Demande à un propriétaire ou un gestionnaire de l'organisation d'en attribuer un.",
"no_feedback_directory_linked_title": "Aucun répertoire de feedback lié",
"no_feedback_records": "Aucun enregistrement de feedback pour le moment. Les enregistrements apparaîtront ici une fois que vos connecteurs commenceront à envoyer des données.",
"no_formbricks_surveys_available_description": "Il ny a pas encore de sondages dans cet espace de travail. <surveyLink>Créez une nouvelle enquête</surveyLink> pour en utiliser une comme source de feedback.",
"no_source_fields_loaded": "Aucun champ source chargé pour le moment",
"no_sources_connected": "Aucune source connectée pour le moment. Ajoutez une source pour commencer.",
"optional": "Facultatif",
+8
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "{count} diagram hozzáadása",
"chart_removed": "A diagram eltávolítva a műszerfalról",
"charts_add_failed": "A diagramok műszerfalhoz való hozzáadása sikertelen",
"charts_add_partial_failure": "{count} diagram hozzáadása sikertelen",
"charts_added_to_dashboard": "Diagramok hozzáadva a műszerfalhoz",
@@ -3689,6 +3690,12 @@
"enter_name_for_source": "Adj nevet ennek a forrásnak",
"enter_value": "Érték megadása...",
"enum": "felsorolás",
"error_connector_field_mapping_duplicate": "Duplikált mezőleképezés ehhez a forráshoz",
"error_connector_formbricks_mapping_duplicate": "Duplikált kérdésleképezés ehhez a forráshoz",
"error_connector_name_duplicate": "Már létezik forrás ezzel a névvel",
"error_connector_name_required": "A forrás neve kötelező",
"error_connector_questions_required": "Válasszon ki legalább egy kérdést",
"error_connector_survey_required": "Válasszon ki egy felmérést",
"failed_to_load_feedback_records": "Nem sikerült betölteni a visszajelzési rekordokat",
"feedback_date": "Aktuális dátum",
"feedback_directory": "Visszajelzési könyvtár",
@@ -3736,6 +3743,7 @@
"no_feedback_directory_linked_member_description": "Ehhez a munkaterülethez be kell állítani egy visszajelzési könyvtárat, mielőtt ez a funkció elérhetővé válna. Kérje meg a szervezet tulajdonosát vagy vezetőjét, hogy rendeljen hozzá egyet.",
"no_feedback_directory_linked_title": "Nincs visszajelzési könyvtár kapcsolva",
"no_feedback_records": "Még nincsenek visszajelzési rekordok. A rekordok itt fognak megjelenni, amint a csatlakozók elkezdik küldeni az adatokat.",
"no_formbricks_surveys_available_description": "Ebben a munkaterületen még nincsenek kérdőívek. <surveyLink>Hozz létre egy új kérdőívet</surveyLink>, hogy visszajelzési forrásként használhass egyet.",
"no_source_fields_loaded": "Még nincsenek forrás mezők betöltve",
"no_sources_connected": "Még nincsenek források csatlakoztatva. Adj hozzá egy forrást a kezdéshez.",
"optional": "Elhagyható",
+8
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "{count}個のグラフを追加",
"chart_removed": "チャートがダッシュボードから削除されました",
"charts_add_failed": "ダッシュボードへのグラフの追加に失敗しました",
"charts_add_partial_failure": "{count}個のグラフの追加に失敗しました",
"charts_added_to_dashboard": "グラフをダッシュボードに追加しました",
@@ -3689,6 +3690,12 @@
"enter_name_for_source": "このソースの名前を入力",
"enter_value": "値を入力...",
"enum": "列挙型",
"error_connector_field_mapping_duplicate": "このソースのフィールドマッピングが重複しています",
"error_connector_formbricks_mapping_duplicate": "このソースの質問マッピングが重複しています",
"error_connector_name_duplicate": "この名前のソースは既に存在します",
"error_connector_name_required": "ソース名は必須です",
"error_connector_questions_required": "少なくとも1つの質問を選択してください",
"error_connector_survey_required": "アンケートを選択してください",
"failed_to_load_feedback_records": "フィードバックレコードの読み込みに失敗しました",
"feedback_date": "現在の日付",
"feedback_directory": "フィードバックディレクトリ",
@@ -3736,6 +3743,7 @@
"no_feedback_directory_linked_member_description": "この機能を利用するには、このワークスペースにフィードバックディレクトリを設定する必要があります。組織のオーナーまたはマネージャーに割り当てを依頼してください。",
"no_feedback_directory_linked_title": "フィードバックディレクトリが未リンク",
"no_feedback_records": "フィードバックレコードはまだありません。コネクタがデータの送信を開始すると、ここにレコードが表示されます。",
"no_formbricks_surveys_available_description": "このワークスペースにはまだフォームがありません。フィードバックソースとして使用するには<surveyLink>新しいフォームを作成</surveyLink>してください。",
"no_source_fields_loaded": "ソースフィールドがまだ読み込まれていません",
"no_sources_connected": "ソースがまだ接続されていません。開始するにはソースを追加してください。",
"optional": "任意",
+8
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "{count} grafiek(en) toevoegen",
"chart_removed": "Grafiek verwijderd van dashboard",
"charts_add_failed": "Grafieken toevoegen aan dashboard mislukt",
"charts_add_partial_failure": "{count} grafiek(en) toevoegen mislukt",
"charts_added_to_dashboard": "Grafieken toegevoegd aan dashboard",
@@ -3689,6 +3690,12 @@
"enter_name_for_source": "Voer een naam in voor deze bron",
"enter_value": "Voer waarde in...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Dubbele veldkoppeling voor deze bron",
"error_connector_formbricks_mapping_duplicate": "Dubbele vraagkoppeling voor deze bron",
"error_connector_name_duplicate": "Er bestaat al een bron met deze naam",
"error_connector_name_required": "Bronnaam is verplicht",
"error_connector_questions_required": "Selecteer minimaal één vraag",
"error_connector_survey_required": "Selecteer een enquête",
"failed_to_load_feedback_records": "Kan feedbackrecords niet laden",
"feedback_date": "Huidige datum",
"feedback_directory": "Feedbackmap",
@@ -3736,6 +3743,7 @@
"no_feedback_directory_linked_member_description": "Er moet eerst een feedbackmap worden ingesteld voor deze werkruimte voordat deze functionaliteit beschikbaar is. Vraag een organisatie-eigenaar of manager om er een toe te wijzen.",
"no_feedback_directory_linked_title": "Geen feedbackmap gekoppeld",
"no_feedback_records": "Nog geen feedbackrecords. Records verschijnen hier zodra je connectoren gegevens beginnen te verzenden.",
"no_formbricks_surveys_available_description": "Er zijn nog geen enquêtes in deze werkruimte. <surveyLink>Maak een nieuwe enquête</surveyLink> om er een als feedbackbron te gebruiken.",
"no_source_fields_loaded": "Nog geen bronvelden geladen",
"no_sources_connected": "Nog geen bronnen verbonden. Voeg een bron toe om te beginnen.",
"optional": "Optioneel",
+8
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Adicionar {count} gráfico(s)",
"chart_removed": "Gráfico removido do painel",
"charts_add_failed": "Falha ao adicionar gráficos ao painel",
"charts_add_partial_failure": "Falha ao adicionar {count} gráfico(s)",
"charts_added_to_dashboard": "Gráficos adicionados ao painel",
@@ -3689,6 +3690,12 @@
"enter_name_for_source": "Digite um nome para esta origem",
"enter_value": "Digite o valor...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Mapeamento de campo duplicado para esta origem",
"error_connector_formbricks_mapping_duplicate": "Mapeamento de pergunta duplicado para esta origem",
"error_connector_name_duplicate": "Uma fonte com este nome já existe",
"error_connector_name_required": "O nome da fonte é obrigatório",
"error_connector_questions_required": "Selecione pelo menos uma pergunta",
"error_connector_survey_required": "Selecione uma pesquisa",
"failed_to_load_feedback_records": "Falha ao carregar registros de feedback",
"feedback_date": "Data atual",
"feedback_directory": "Diretório de Feedback",
@@ -3736,6 +3743,7 @@
"no_feedback_directory_linked_member_description": "Um diretório de feedback precisa ser configurado para este workspace antes que esta funcionalidade esteja disponível. Peça a um proprietário ou gerente da organização para configurar um.",
"no_feedback_directory_linked_title": "Nenhum diretório de feedback vinculado",
"no_feedback_records": "Nenhum registro de feedback ainda. Os registros aparecerão aqui assim que seus conectores começarem a enviar dados.",
"no_formbricks_surveys_available_description": "Ainda não há pesquisas neste workspace. <surveyLink>Crie uma nova pesquisa</surveyLink> para usar uma como fonte de feedback.",
"no_source_fields_loaded": "Nenhum campo de origem carregado ainda",
"no_sources_connected": "Nenhuma origem conectada ainda. Adicione uma origem para começar.",
"optional": "Opcional",
+8
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Adicionar {count} gráfico(s)",
"chart_removed": "Gráfico removido do painel",
"charts_add_failed": "Falha ao adicionar gráficos ao painel",
"charts_add_partial_failure": "Falha ao adicionar {count} gráfico(s)",
"charts_added_to_dashboard": "Gráficos adicionados ao painel",
@@ -3689,6 +3690,12 @@
"enter_name_for_source": "Introduz um nome para esta origem",
"enter_value": "Introduzir valor...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Mapeamento de campo duplicado para esta origem",
"error_connector_formbricks_mapping_duplicate": "Mapeamento de pergunta duplicado para esta origem",
"error_connector_name_duplicate": "Já existe uma origem com este nome",
"error_connector_name_required": "O nome da origem é obrigatório",
"error_connector_questions_required": "Seleciona pelo menos uma pergunta",
"error_connector_survey_required": "Seleciona um inquérito",
"failed_to_load_feedback_records": "Falha ao carregar registos de feedback",
"feedback_date": "Data atual",
"feedback_directory": "Diretório de Feedback",
@@ -3736,6 +3743,7 @@
"no_feedback_directory_linked_member_description": "É necessário configurar um diretório de feedback para este workspace antes de esta funcionalidade estar disponível. Pede a um proprietário ou gestor da organização para atribuir um.",
"no_feedback_directory_linked_title": "Nenhum diretório de feedback vinculado",
"no_feedback_records": "Ainda não há registos de feedback. Os registos aparecerão aqui assim que os teus conectores começarem a enviar dados.",
"no_formbricks_surveys_available_description": "Ainda não há inquéritos neste workspace. <surveyLink>Cria um novo inquérito</surveyLink> para usar um como fonte de feedback.",
"no_source_fields_loaded": "Ainda não foram carregados campos de origem",
"no_sources_connected": "Ainda não há origens ligadas. Adicione uma origem para começar.",
"optional": "Opcional",
+8
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Adaugă {count} grafic(e)",
"chart_removed": "Graficul a fost eliminat din tabloul de bord",
"charts_add_failed": "Nu s-au putut adăuga graficele la panoul de control",
"charts_add_partial_failure": "Nu s-au putut adăuga {count} grafic(e)",
"charts_added_to_dashboard": "Grafice adăugate la panoul de control",
@@ -3689,6 +3690,12 @@
"enter_name_for_source": "Introdu un nume pentru această sursă",
"enter_value": "Introdu valoarea...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Mapare duplicată a câmpului pentru această sursă",
"error_connector_formbricks_mapping_duplicate": "Mapare duplicată a întrebării pentru această sursă",
"error_connector_name_duplicate": "Există deja o sursă cu acest nume",
"error_connector_name_required": "Numele sursei este obligatoriu",
"error_connector_questions_required": "Selectează cel puțin o întrebare",
"error_connector_survey_required": "Selectează un sondaj",
"failed_to_load_feedback_records": "Nu s-au putut încărca înregistrările de feedback",
"feedback_date": "Data curentă",
"feedback_directory": "Director de Feedback",
@@ -3736,6 +3743,7 @@
"no_feedback_directory_linked_member_description": "Trebuie configurat un director de feedback pentru acest spațiu de lucru înainte ca această funcționalitate să fie disponibilă. Solicită unui proprietar sau manager al organizației să atribuie unul.",
"no_feedback_directory_linked_title": "Niciun director de feedback conectat",
"no_feedback_records": "Nu există încă înregistrări de feedback. Înregistrările vor apărea aici după ce conectorii tăi vor începe să trimită date.",
"no_formbricks_surveys_available_description": "Nu există încă chestionare în acest spațiu de lucru. <surveyLink>Creează un chestionar nou</surveyLink> pentru a folosi unul ca sursă de feedback.",
"no_source_fields_loaded": "Nu au fost încă încărcate câmpuri sursă",
"no_sources_connected": "Nicio sursă conectată încă. Adaugă o sursă pentru a începe.",
"optional": "Opțional",
+8
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Добавить {count} график(ов)",
"chart_removed": "График удалён с панели",
"charts_add_failed": "Не удалось добавить графики на дашборд",
"charts_add_partial_failure": "Не удалось добавить {count} график(ов)",
"charts_added_to_dashboard": "Графики добавлены на дашборд",
@@ -3689,6 +3690,12 @@
"enter_name_for_source": "Введи имя для этого источника",
"enter_value": "Введите значение...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Дублирующееся сопоставление полей для этого источника",
"error_connector_formbricks_mapping_duplicate": "Дублирующееся сопоставление вопросов для этого источника",
"error_connector_name_duplicate": "Источник с таким именем уже существует",
"error_connector_name_required": "Необходимо указать название источника",
"error_connector_questions_required": "Выберите хотя бы один вопрос",
"error_connector_survey_required": "Выберите опрос",
"failed_to_load_feedback_records": "Не удалось загрузить отзывы",
"feedback_date": "Текущая дата",
"feedback_directory": "Директория обратной связи",
@@ -3736,6 +3743,7 @@
"no_feedback_directory_linked_member_description": "Для этого рабочего пространства нужно настроить директорию обратной связи, прежде чем эта функция станет доступна. Попроси владельца или менеджера организации назначить её.",
"no_feedback_directory_linked_title": "Директория обратной связи не привязана",
"no_feedback_records": "Пока нет записей отзывов. Они появятся здесь, когда коннекторы начнут отправлять данные.",
"no_formbricks_surveys_available_description": "В этом рабочем пространстве пока нет опросов. <surveyLink>Создайте новый опрос</surveyLink>, чтобы использовать один как источник обратной связи.",
"no_source_fields_loaded": "Поля источника ещё не загружены",
"no_sources_connected": "Нет подключённых источников. Добавьте источник, чтобы начать.",
"optional": "Необязательно",
+8
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Lägg till {count} diagram",
"chart_removed": "Diagram borttaget från instrumentpanelen",
"charts_add_failed": "Misslyckades med att lägga till diagram i instrumentpanelen",
"charts_add_partial_failure": "Misslyckades med att lägga till {count} diagram",
"charts_added_to_dashboard": "Diagram tillagda i instrumentpanelen",
@@ -3689,6 +3690,12 @@
"enter_name_for_source": "Ange ett namn för denna källa",
"enter_value": "Ange värde...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Duplicerad fältmappning för denna källa",
"error_connector_formbricks_mapping_duplicate": "Duplicerad frågemappning för denna källa",
"error_connector_name_duplicate": "En källa med det här namnet finns redan",
"error_connector_name_required": "Källnamn krävs",
"error_connector_questions_required": "Välj minst en fråga",
"error_connector_survey_required": "Välj en undersökning",
"failed_to_load_feedback_records": "Det gick inte att ladda feedbackposter",
"feedback_date": "Aktuellt datum",
"feedback_directory": "Feedback-katalog",
@@ -3736,6 +3743,7 @@
"no_feedback_directory_linked_member_description": "En feedbackkatalog måste konfigureras för denna arbetsyta innan den här funktionen blir tillgänglig. Be en organisationsägare eller chef att tilldela en.",
"no_feedback_directory_linked_title": "Ingen feedbackkatalog länkad",
"no_feedback_records": "Inga feedbackposter ännu. Poster visas här när dina connectors börjar skicka data.",
"no_formbricks_surveys_available_description": "Det finns inga enkäter i denna arbetsyta ännu. <surveyLink>Skapa en ny enkät</surveyLink> för att använda en som feedbackkälla.",
"no_source_fields_loaded": "Inga källfält har laddats än",
"no_sources_connected": "Inga källor är anslutna än. Lägg till en källa för att komma igång.",
"optional": "Valfritt",
+8
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "{count} grafik ekle",
"chart_removed": "Grafik gösterge panosundan kaldırıldı",
"charts_add_failed": "Grafikler panoya eklenemedi",
"charts_add_partial_failure": "{count} grafik eklenemedi",
"charts_added_to_dashboard": "Grafikler panoya eklendi",
@@ -3689,6 +3690,12 @@
"enter_name_for_source": "Bu kaynak için bir ad girin",
"enter_value": "Değer girin...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Bu kaynak için yinelenen alan eşlemesi",
"error_connector_formbricks_mapping_duplicate": "Bu kaynak için yinelenen soru eşlemesi",
"error_connector_name_duplicate": "Bu isimde bir kaynak zaten mevcut",
"error_connector_name_required": "Kaynak adı gereklidir",
"error_connector_questions_required": "En az bir soru seçin",
"error_connector_survey_required": "Bir anket seçin",
"failed_to_load_feedback_records": "Geri bildirim kayıtları yüklenemedi",
"feedback_date": "Geçerli tarih",
"feedback_directory": "Geri Bildirim Dizini",
@@ -3736,6 +3743,7 @@
"no_feedback_directory_linked_member_description": "Bu işlevin kullanılabilmesi için önce bu çalışma alanı için bir geri bildirim dizini kurulması gerekiyor. Bir organizasyon sahibinden veya yöneticisinden bir tane atamasını iste.",
"no_feedback_directory_linked_title": "Bağlı geri bildirim dizini yok",
"no_feedback_records": "Henüz geri bildirim kaydı yok. Bağlayıcıların veri göndermeye başlamasıyla kayıtlar burada görünecek.",
"no_formbricks_surveys_available_description": "Bu çalışma alanında henüz anket yok. Geri bildirim kaynağı olarak kullanmak için <surveyLink>Yeni bir anket oluştur</surveyLink>.",
"no_source_fields_loaded": "Henüz kaynak alan yüklenmedi",
"no_sources_connected": "Henüz bağlı kaynak yok. Başlamak için bir kaynak ekle.",
"optional": "İsteğe bağlı",
+8
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "添加 {count} 个图表",
"chart_removed": "图表已从仪表板中移除",
"charts_add_failed": "添加图表到仪表板失败",
"charts_add_partial_failure": "添加 {count} 个图表失败",
"charts_added_to_dashboard": "图表已添加到仪表板",
@@ -3689,6 +3690,12 @@
"enter_name_for_source": "为此来源输入名称",
"enter_value": "请输入值...",
"enum": "枚举",
"error_connector_field_mapping_duplicate": "此来源存在重复的字段映射",
"error_connector_formbricks_mapping_duplicate": "此来源存在重复的问题映射",
"error_connector_name_duplicate": "该名称的数据源已存在",
"error_connector_name_required": "数据源名称为必填项",
"error_connector_questions_required": "请至少选择一个问题",
"error_connector_survey_required": "请选择一个调查问卷",
"failed_to_load_feedback_records": "加载反馈记录失败",
"feedback_date": "当前日期",
"feedback_directory": "反馈目录",
@@ -3736,6 +3743,7 @@
"no_feedback_directory_linked_member_description": "在该功能可用前,需要先为此工作区设置反馈目录。请联系组织所有者或管理员进行分配。",
"no_feedback_directory_linked_title": "未关联反馈目录",
"no_feedback_records": "暂无反馈记录。当你的连接器开始发送数据后,记录会显示在这里。",
"no_formbricks_surveys_available_description": "此工作区还没有调查。<surveyLink>创建新调查</surveyLink>,以将其用作反馈来源。",
"no_source_fields_loaded": "尚未加载源字段",
"no_sources_connected": "还没有连接数据源。添加一个数据源开始吧。",
"optional": "可选",
+8
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "新增 {count} 個圖表",
"chart_removed": "圖表已從儀表板移除",
"charts_add_failed": "無法將圖表新增至儀表板",
"charts_add_partial_failure": "無法新增 {count} 個圖表",
"charts_added_to_dashboard": "圖表已新增至儀表板",
@@ -3689,6 +3690,12 @@
"enter_name_for_source": "請輸入此來源的名稱",
"enter_value": "請輸入值……",
"enum": "enum",
"error_connector_field_mapping_duplicate": "此來源的欄位對應重複",
"error_connector_formbricks_mapping_duplicate": "此來源的問題對應重複",
"error_connector_name_duplicate": "已存在使用此名稱的來源",
"error_connector_name_required": "來源名稱為必填項目",
"error_connector_questions_required": "請至少選擇一個問題",
"error_connector_survey_required": "請選擇一個調查問卷",
"failed_to_load_feedback_records": "載入回饋紀錄失敗",
"feedback_date": "目前日期",
"feedback_directory": "意見回饋目錄",
@@ -3736,6 +3743,7 @@
"no_feedback_directory_linked_member_description": "此工作區需要先設定意見回饋目錄,才能使用此功能。請請組織擁有者或管理員指定一個目錄。",
"no_feedback_directory_linked_title": "未連結意見回饋目錄",
"no_feedback_records": "目前尚無回饋紀錄。當你的連接器開始傳送資料時,紀錄會顯示在這裡。",
"no_formbricks_surveys_available_description": "此工作區尚無問卷。<surveyLink>建立新問卷</surveyLink>,以將其用作回饋來源。",
"no_source_fields_loaded": "尚未載入來源欄位",
"no_sources_connected": "尚未連接任何來源。請新增來源以開始使用。",
"optional": "選填",
@@ -57,6 +57,7 @@ describe("executeTenantScopedQuery", () => {
vi.stubEnv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public");
vi.stubEnv("ENCRYPTION_KEY", "12345678901234567890123456789012");
vi.stubEnv("HUB_API_URL", "https://hub.formbricks.local");
vi.stubEnv("HUB_API_KEY", "test-hub-api-key");
vi.stubEnv("CUBEJS_API_URL", "https://cube.example.com");
vi.stubEnv("CUBEJS_API_SECRET", "cube-secret");
vi.stubEnv("CUBEJS_JWT_AUDIENCE", "formbricks-cube-test");
@@ -156,32 +157,17 @@ describe("executeTenantScopedQuery", () => {
expect(cubejs).toHaveBeenCalledWith(expect.any(String), { apiUrl: fullUrl });
});
test("throws a configuration error when Cube env is missing", async () => {
test("fails at env validation when Cube env is missing", async () => {
vi.unstubAllEnvs();
vi.stubEnv("NODE_ENV", "test");
vi.stubEnv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public");
vi.stubEnv("ENCRYPTION_KEY", "12345678901234567890123456789012");
vi.stubEnv("HUB_API_URL", "https://hub.formbricks.local");
vi.stubEnv("HUB_API_KEY", "test-hub-api-key");
vi.stubEnv("CUBEJS_API_URL", undefined);
vi.stubEnv("CUBEJS_API_SECRET", undefined);
const { CUBE_CONFIGURATION_ERROR_MESSAGE } = await import("./cube-config");
const { executeTenantScopedQuery } = await import("./cube-client");
await expect(executeTenantScopedQuery(scopedInput)).rejects.toThrow(CUBE_CONFIGURATION_ERROR_MESSAGE);
expect(mockLoggerError).toHaveBeenCalledWith(expect.any(Error), "Cube query configuration failed");
expect(mockQueueAuditEventWithoutRequest).toHaveBeenCalledWith(
expect.objectContaining({
action: "queried",
targetType: "cubeQuery",
status: "failure",
newObject: expect.objectContaining({
tenantId: "frd-1",
feedbackDirectoryId: "frd-1",
workspaceId: "workspace-1",
errorName: "ConfigurationError",
}),
})
);
await expect(import("./cube-client")).rejects.toThrow("Invalid environment variables");
});
test("logs Cube runtime failures and returns a generic query execution error", async () => {
@@ -12,6 +12,7 @@ const setTestEnv = (overrides: Record<string, string | undefined> = {}) => {
DATABASE_URL: "https://example.com/db",
ENCRYPTION_KEY: "12345678901234567890123456789012",
HUB_API_URL: "https://hub.formbricks.local",
HUB_API_KEY: "test-hub-api-key",
CUBEJS_API_URL: "https://cube.formbricks.local",
CUBEJS_API_SECRET: "cube-secret",
CUBEJS_JWT_AUDIENCE: "formbricks-cube-test",
@@ -96,23 +97,19 @@ describe("cube-config", () => {
expect(getCubeApiCredentials().apiUrl).toBe("https://cube.formbricks.local/cubejs-api/v1");
});
test("throws a configuration error when CUBEJS_API_URL is missing", async () => {
test("fails at env validation when CUBEJS_API_URL is missing", async () => {
setTestEnv({
CUBEJS_API_URL: undefined,
});
const { CUBE_CONFIGURATION_ERROR_MESSAGE, getCubeApiCredentials } = await import("./cube-config");
expect(() => getCubeApiCredentials()).toThrow(CUBE_CONFIGURATION_ERROR_MESSAGE);
await expect(import("./cube-config")).rejects.toThrow("Invalid environment variables");
});
test("throws a configuration error when CUBEJS_API_SECRET is missing", async () => {
test("fails at env validation when CUBEJS_API_SECRET is missing", async () => {
setTestEnv({
CUBEJS_API_SECRET: undefined,
});
const { CUBE_CONFIGURATION_ERROR_MESSAGE, getCubeApiCredentials } = await import("./cube-config");
expect(() => getCubeApiCredentials()).toThrow(CUBE_CONFIGURATION_ERROR_MESSAGE);
await expect(import("./cube-config")).rejects.toThrow("Invalid environment variables");
});
});
@@ -36,6 +36,16 @@ describe("cube queryRewrite", () => {
expect(() => queryRewrite({ measures: ["FeedbackRecords.count"] }, {})).toThrow(
/missing tenantId security context/
);
const logPayload = vi.mocked(console.log).mock.calls[0][0];
const parsed = JSON.parse(logPayload);
expect(parsed).toMatchObject({
type: "audit",
event: "cube.query",
status: "failure",
errorName: "Error",
errorMessage: "Cube query rejected: missing tenantId security context",
});
});
test("rejects Cube startup without an API secret", () => {
@@ -97,6 +107,18 @@ describe("cube queryRewrite", () => {
).toThrow(/tenant filters are enforced by Cube/);
});
test("rejects caller-supplied TopicsUnnested tenant filters", () => {
expect(() =>
queryRewrite(
{
measures: ["TopicsUnnested.count"],
filters: [{ member: "TopicsUnnested.tenantId", operator: "equals", values: ["workspace-2"] }],
},
{ securityContext }
)
).toThrow(/tenant filters are enforced by Cube/);
});
test("logs sanitized failure audit metadata for rejected tenant filters", () => {
expect(() =>
queryRewrite(
@@ -197,6 +219,19 @@ describe("cube queryRewrite", () => {
expect(query.filters).toHaveLength(1);
});
test("appends only the TopicsUnnested tenant filter for TopicsUnnested queries", () => {
const query = {
measures: ["TopicsUnnested.count"],
dimensions: ["TopicsUnnested.topic"],
};
const rewrittenQuery = queryRewrite(query, { securityContext });
expect(rewrittenQuery.filters).toEqual([
{ member: "TopicsUnnested.tenantId", operator: "equals", values: ["frd-1"] },
]);
});
test("logs sanitized Cube audit metadata without raw filter values", () => {
queryRewrite(
{
@@ -221,6 +256,7 @@ describe("cube queryRewrite", () => {
source: "charts.executeQueryAction",
});
expect(parsed.members).toContain("FeedbackRecords.tenantId");
expect(parsed.members).not.toContain("TopicsUnnested.tenantId");
expect(logPayload).not.toContain("secret-value");
});
});
@@ -6,7 +6,8 @@ import { Button } from "@/modules/ui/components/button";
import { DialogFooter } from "@/modules/ui/components/dialog";
interface ChartDialogFooterProps {
onSaveClick: () => void;
onSaveClick?: () => void;
formId?: string;
onAddToDashboardClick?: () => void;
isSaving: boolean;
saveLabel?: string;
@@ -15,6 +16,7 @@ interface ChartDialogFooterProps {
export function ChartDialogFooter({
onSaveClick,
formId,
onAddToDashboardClick,
isSaving,
saveLabel,
@@ -24,12 +26,16 @@ export function ChartDialogFooter({
return (
<DialogFooter>
{showAddToDashboard && onAddToDashboardClick && (
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
<Button variant="outline" type="button" onClick={onAddToDashboardClick} disabled={isSaving}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("workspace.analysis.charts.add_to_dashboard")}
</Button>
)}
<Button onClick={onSaveClick} disabled={isSaving}>
<Button
type={formId ? "submit" : "button"}
form={formId}
onClick={formId ? undefined : onSaveClick}
disabled={isSaving}>
<SaveIcon className="mr-2 h-4 w-4" />
{saveLabel ?? t("workspace.analysis.charts.save_chart")}
</Button>
@@ -7,6 +7,7 @@ import { CartesianChart } from "@/modules/ee/analysis/charts/components/cartesia
import {
CHART_BRAND_DARK,
CHART_MEASURE_COLORS,
formatCellValue,
formatXAxisTick,
preparePieData,
} from "@/modules/ee/analysis/charts/lib/chart-utils";
@@ -20,6 +21,19 @@ const formatPieLabel = ({ name, percent }: { name: string; percent?: number }):
return `${formatXAxisTick(name)}: ${(percent * 100).toFixed(0)}%`;
};
const PieTooltipRow = ({ value, name }: Readonly<{ value: unknown; name: string }>) => {
const { t } = useTranslation();
return (
<>
{formatCellValue(value)} {formatCubeColumnHeader(name, t)}
</>
);
};
const pieTooltipFormatter = (value: unknown, name: string | number) => (
<PieTooltipRow value={value} name={String(name)} />
);
interface ChartRendererProps {
chartType: TChartType;
data: TChartDataRow[];
@@ -165,13 +179,7 @@ export function ChartRenderer({ chartType, data, query }: Readonly<ChartRenderer
return <Cell key={uniqueKey} fill={colors[index] || CHART_BRAND_DARK} />;
})}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name) => [String(value), formatCubeColumnHeader(String(name), t)]}
/>
}
/>
<ChartTooltip content={<ChartTooltipContent formatter={pieTooltipFormatter} />} />
</PieChart>
</ChartContainer>
</div>
@@ -1,8 +1,9 @@
"use client";
import Link from "next/link";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
import { AIQuerySection } from "@/modules/ee/analysis/charts/components/ai-query-section";
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
@@ -79,6 +80,8 @@ export function CreateChartView({
});
const chartPreviewRef = useRef<HTMLDivElement>(null);
const CREATE_CHART_FORM_ID = "create-chart-form";
const [chartNameError, setChartNameError] = useState<string | null>(null);
useEffect(() => {
if (chartData) {
@@ -136,17 +139,38 @@ export function CreateChartView({
<div className="grid gap-4">
{hasSelectedDirectory ? (
<>
<div className="space-y-2">
<Label htmlFor="create-chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
<form
id={CREATE_CHART_FORM_ID}
onSubmit={(event) => {
event.preventDefault();
setChartNameError(null);
return handleSaveChart();
}}
className="space-y-2">
<Label htmlFor="create-chart-name" className={cn(chartNameError && "text-red-500")}>
{t("workspace.analysis.charts.chart_name")}
</Label>
<Input
id="create-chart-name"
value={chartName}
onChange={(event) => setChartName(event.target.value)}
onChange={(event) => {
if (chartNameError) setChartNameError(null);
setChartName(event.target.value);
}}
onInvalid={(event) => {
// Suppress the browser tooltip and render our inline message instead.
event.preventDefault();
event.currentTarget.scrollIntoView({ behavior: "smooth", block: "center" });
event.currentTarget.focus();
setChartNameError(t("workspace.analysis.charts.please_enter_chart_name"));
}}
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
maxLength={255}
required
isInvalid={!!chartNameError}
/>
</div>
{chartNameError && <p className="text-sm text-red-500">{chartNameError}</p>}
</form>
{!isEditing && (
<>
@@ -212,7 +236,7 @@ export function CreateChartView({
{chartData && (
<ChartDialogFooter
onSaveClick={handleSaveChart}
formId={CREATE_CHART_FORM_ID}
isSaving={isSaving}
showAddToDashboard={false}
saveLabel={
@@ -18,6 +18,7 @@ import {
duplicateDashboard,
getDashboard,
getDashboards,
removeWidgetFromDashboard,
updateDashboard,
updateWidgetLayouts,
} from "./lib/dashboards";
@@ -111,15 +112,13 @@ export const updateDashboardAction = authenticatedActionClient.inputSchema(ZUpda
const ZUpdateWidgetLayoutsAction = z.object({
workspaceId: ZId,
dashboardId: ZId,
widgets: z
.array(
z.object({
id: ZId,
layout: ZWidgetLayout,
order: z.number().int().nonnegative(),
})
)
.min(1),
widgets: z.array(
z.object({
id: ZId,
layout: ZWidgetLayout,
order: z.number().int().nonnegative(),
})
),
});
export const updateWidgetLayoutsAction = authenticatedActionClient
@@ -325,3 +324,34 @@ export const addChartToDashboardAction = authenticatedActionClient
}
)
);
const ZRemoveWidgetFromDashboardAction = z.object({
workspaceId: ZId,
dashboardId: ZId,
widgetId: ZId,
});
export const removeWidgetFromDashboardAction = authenticatedActionClient
.inputSchema(ZRemoveWidgetFromDashboardAction)
.action(
withAuditLogging("deleted", "dashboardWidget", async ({ ctx, parsedInput }) => {
const { organizationId, workspaceId } = await checkWorkspaceAccess(
ctx.user.id,
parsedInput.workspaceId,
"readWrite"
);
await checkDashboardsEnabled(organizationId);
const widget = await removeWidgetFromDashboard(
parsedInput.dashboardId,
workspaceId,
parsedInput.widgetId
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.workspaceId = workspaceId;
ctx.auditLoggingCtx.dashboardWidgetId = widget.id;
ctx.auditLoggingCtx.oldObject = widget;
return { success: true };
})
);
@@ -134,7 +134,7 @@ export function AddExistingChartsDialog({
<DialogTitle>{t("common.add_charts")}</DialogTitle>
<DialogDescription>{t("common.add_existing_chart_description")}</DialogDescription>
</DialogHeader>
<DialogBody>
<DialogBody className="p-1">
{isLoading ? (
<div className="flex items-center justify-center rounded-md border px-3 py-2">
<Loader2Icon className="h-5 w-5 animate-spin text-slate-400" />
@@ -17,10 +17,15 @@ import { DashboardWidget } from "@/modules/ee/analysis/dashboards/components/das
import { DashboardWidgetData } from "@/modules/ee/analysis/dashboards/components/dashboard-widget-data";
import { DashboardWidgetSkeleton } from "@/modules/ee/analysis/dashboards/components/dashboard-widget-skeleton";
import type { TChartDataRow, TDashboardDetail, TDashboardWidget } from "@/modules/ee/analysis/types/analysis";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { updateDashboardAction, updateWidgetLayoutsAction } from "../actions";
import {
removeWidgetFromDashboardAction,
updateDashboardAction,
updateWidgetLayoutsAction,
} from "../actions";
import type { TDashboardWidgetError } from "../lib/widget-errors";
const ROW_HEIGHT = 80;
@@ -163,6 +168,8 @@ export function DashboardDetailClient({
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [editingChartId, setEditingChartId] = useState<string | null>(null);
const [widgetIdToRemove, setWidgetIdToRemove] = useState<string | null>(null);
const [isRemovingWidget, setIsRemovingWidget] = useState(false);
const [, startTransition] = useTransition();
const [name, setName] = useState(dashboard.name);
@@ -207,17 +214,36 @@ export function DashboardDetailClient({
const handleRemoveWidgetFromMenu = useCallback(
(widgetId: string) => {
if (!isEditing) {
setDraftWidgets((current) => (current ?? dashboard.widgets).filter((w) => w.id !== widgetId));
setIsEditing(true);
if (isEditing) {
handleRemoveWidget(widgetId);
return;
}
handleRemoveWidget(widgetId);
setWidgetIdToRemove(widgetId);
},
[dashboard.widgets, handleRemoveWidget, isEditing]
[handleRemoveWidget, isEditing]
);
const handleConfirmRemoveWidget = useCallback(async () => {
if (!widgetIdToRemove) return;
setIsRemovingWidget(true);
try {
const result = await removeWidgetFromDashboardAction({
workspaceId,
dashboardId: dashboard.id,
widgetId: widgetIdToRemove,
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("workspace.analysis.dashboards.chart_removed"));
setWidgetIdToRemove(null);
startTransition(() => router.refresh());
} finally {
setIsRemovingWidget(false);
}
}, [widgetIdToRemove, workspaceId, dashboard.id, router, t, startTransition]);
const handleCancel = useCallback(() => {
setName(dashboard.name);
setDraftWidgets(null);
@@ -373,6 +399,17 @@ export function DashboardDetailClient({
aiUnavailableReason={aiUnavailableReason}
/>
)}
{!isReadOnly && (
<DeleteDialog
open={widgetIdToRemove !== null}
setOpen={(open) => {
if (!open) setWidgetIdToRemove(null);
}}
deleteWhat={t("common.chart")}
onDelete={handleConfirmRemoveWidget}
isDeleting={isRemovingWidget}
/>
)}
</PageContentWrapper>
);
}
@@ -18,9 +18,11 @@ var mockTxChart: { findFirst: ReturnType<typeof vi.fn> }; // NOSONAR / test code
var mockTxWidget: {
// NOSONAR / test code
aggregate: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
deleteMany: ReturnType<typeof vi.fn>;
};
@@ -29,9 +31,11 @@ vi.mock("@formbricks/database", () => {
const txChart = { findFirst: vi.fn() };
const txWidget = {
aggregate: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
};
mockTxDashboard = txDash;
@@ -44,6 +48,7 @@ vi.mock("@formbricks/database", () => {
findFirst: vi.fn(),
findMany: vi.fn(),
},
dashboardWidget: txWidget,
$transaction: vi.fn((cb: any) => cb({ dashboard: txDash, chart: txChart, dashboardWidget: txWidget })),
},
};
@@ -672,4 +677,52 @@ describe("Dashboard Service", () => {
});
});
});
describe("removeWidgetFromDashboard", () => {
const mockWidgetId = "widget-abc-123";
test("deletes a widget that belongs to the dashboard", async () => {
const mockWidget = { id: mockWidgetId, dashboardId: mockDashboardId, chartId: mockChartId };
mockTxWidget.findFirst.mockResolvedValue(mockWidget);
mockTxWidget.delete.mockResolvedValue(mockWidget);
const { removeWidgetFromDashboard } = await import("./dashboards");
const result = await removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId);
expect(result).toEqual(mockWidget);
expect(mockTxWidget.findFirst).toHaveBeenCalledWith({
where: { id: mockWidgetId, dashboard: { id: mockDashboardId, workspaceId: mockWorkspaceId } },
});
expect(mockTxWidget.delete).toHaveBeenCalledWith({ where: { id: mockWidgetId } });
});
test("throws ResourceNotFoundError when the widget is not on the dashboard", async () => {
mockTxWidget.findFirst.mockResolvedValue(null);
const { removeWidgetFromDashboard } = await import("./dashboards");
await expect(
removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId)
).rejects.toMatchObject({ name: "ResourceNotFoundError", resourceType: "DashboardWidget" });
expect(mockTxWidget.delete).not.toHaveBeenCalled();
});
test("wraps Prisma errors in DatabaseError", async () => {
mockTxWidget.findFirst.mockRejectedValue(makePrismaError("P9999"));
const { removeWidgetFromDashboard } = await import("./dashboards");
await expect(
removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId)
).rejects.toMatchObject({ name: "DatabaseError" });
});
test("rethrows unknown errors", async () => {
const error = new Error("boom");
mockTxWidget.findFirst.mockRejectedValue(error);
const { removeWidgetFromDashboard } = await import("./dashboards");
await expect(removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId)).rejects.toBe(
error
);
});
});
});
@@ -301,6 +301,31 @@ export const updateWidgetLayouts = async (
}
};
export const removeWidgetFromDashboard = async (
dashboardId: string,
workspaceId: string,
widgetId: string
) => {
validateInputs([dashboardId, ZId], [workspaceId, ZId], [widgetId, ZId]);
try {
const widget = await prisma.dashboardWidget.findFirst({
where: { id: widgetId, dashboard: { id: dashboardId, workspaceId } },
});
if (!widget) {
throw new ResourceNotFoundError("DashboardWidget", widgetId);
}
return await prisma.dashboardWidget.delete({ where: { id: widgetId } });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const addChartToDashboard = async (data: TAddWidgetInput) => {
validateInputs([data, ZAddWidgetInput]);
@@ -64,10 +64,13 @@ export function buildCubeQuery(config: ChartBuilderState): TChartQuery {
timeDim.dateRange = config.timeDimension.dateRange;
} else if (Array.isArray(config.timeDimension.dateRange)) {
const [startDate, endDate] = config.timeDimension.dateRange;
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const formatDate = (date: Date | string) => {
// dateRange round-trips through JSON (saved chart → parseQueryToState), so the array
// elements may already be ISO strings — coerce before formatting.
const d = date instanceof Date ? date : new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
timeDim.dateRange = [formatDate(startDate), formatDate(endDate)];
@@ -137,7 +140,12 @@ export function parseQueryToState(query: TChartQuery): Partial<ChartBuilderState
config.granularity = timeDim.granularity;
}
if (timeDim.dateRange) {
config.dateRange = timeDim.dateRange as TimeDimensionConfig["dateRange"];
if (typeof timeDim.dateRange === "string") {
config.dateRange = timeDim.dateRange;
} else if (Array.isArray(timeDim.dateRange) && timeDim.dateRange.length === 2) {
// Stored as [isoString, isoString]; lift back into Date objects for the date-picker UI.
config.dateRange = [new Date(timeDim.dateRange[0]), new Date(timeDim.dateRange[1])];
}
}
state.timeDimension = config;
}
@@ -289,6 +289,47 @@ describe("FeedbackDirectory Service", () => {
).rejects.toThrow(new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG"));
});
test("throws InvalidInputError when a workspace is already assigned to another active directory", async () => {
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce({
workspaceId: mockWorkspaceId1,
} as any);
await expect(
createFeedbackDirectory(mockOrganizationId, "Conflicting", [mockWorkspaceId1])
).rejects.toThrow(new InvalidInputError("WORKSPACE_ALREADY_ASSIGNED_TO_DIFFERENT_DIRECTORY"));
expect(prisma.feedbackDirectoryWorkspace.findFirst).toHaveBeenCalledWith({
where: {
workspaceId: { in: [mockWorkspaceId1] },
feedbackDirectory: { isArchived: false },
},
select: { workspaceId: true },
});
expect(prisma.feedbackDirectory.create).not.toHaveBeenCalled();
});
test("allows creation when workspace is only assigned to archived directory", async () => {
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce(null);
vi.mocked(prisma.feedbackDirectory.create).mockResolvedValueOnce({
id: mockDirectoryId,
} as any);
const result = await createFeedbackDirectory(mockOrganizationId, "ArchivedOnly", [mockWorkspaceId1]);
expect(prisma.feedbackDirectoryWorkspace.findFirst).toHaveBeenCalledWith({
where: {
workspaceId: { in: [mockWorkspaceId1] },
feedbackDirectory: { isArchived: false },
},
select: { workspaceId: true },
});
expect(prisma.feedbackDirectory.create).toHaveBeenCalled();
expect(result).toBe(mockDirectoryId);
});
test("throws InvalidInputError on duplicate name (unique constraint violation)", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
@@ -279,6 +279,7 @@ export const createFeedbackDirectory = async (
if (count !== workspaceIds.length) {
throw new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG");
}
await assertWorkspacesNotAssignedElsewhere(undefined, workspaceIds);
}
const directory = await prisma.feedbackDirectory.create({
@@ -440,9 +441,12 @@ const pauseConnectorsInWorkspaces = async (
* Enforces the single-active-FRD-per-workspace invariant. The client UI prevents
* assigning a workspace to multiple active directories, but the server must also
* reject such payloads to keep this guarantee under direct API access.
*
* Pass `directoryId` when updating an existing directory to exclude it from the
* conflict check. Omit it on create every active directory is a conflict.
*/
const assertWorkspacesNotAssignedElsewhere = async (
directoryId: string,
directoryId: string | undefined,
workspaceIds: string[]
): Promise<void> => {
if (workspaceIds.length === 0) return;
@@ -450,7 +454,7 @@ const assertWorkspacesNotAssignedElsewhere = async (
const conflicting = await prisma.feedbackDirectoryWorkspace.findFirst({
where: {
workspaceId: { in: workspaceIds },
feedbackDirectoryId: { not: directoryId },
...(directoryId === undefined ? {} : { feedbackDirectoryId: { not: directoryId } }),
feedbackDirectory: { isArchived: false },
},
select: { workspaceId: true },
@@ -4,6 +4,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { FeedbackDirectoryView } from "@/modules/ee/feedback-directory/components/feedback-directory-view";
import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
@@ -16,9 +17,12 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ workspa
const { isOwner, isManager } = getAccessFlags(currentUserMembership.role);
const isFeedbackDirectoriesAllowed = await getIsFeedbackDirectoriesEnabled(organization.id);
const pageTitle = t("workspace.settings.feedback_directories.title");
if (!isFeedbackDirectoriesAllowed) {
return (
<PageContentWrapper>
<PageHeader pageTitle={pageTitle} />
<div className="flex items-center justify-center">
<UpgradePrompt
title={t("workspace.settings.feedback_directories.upgrade_prompt_title")}
@@ -47,6 +51,7 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ workspa
if (!isOwner && !isManager) {
return (
<PageContentWrapper>
<PageHeader pageTitle={pageTitle} />
<p className="text-sm text-slate-500">{t("workspace.settings.feedback_directories.no_access")}</p>
</PageContentWrapper>
);
@@ -54,6 +59,7 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ workspa
return (
<PageContentWrapper>
<PageHeader pageTitle={pageTitle} />
<FeedbackDirectoryView organizationId={organization.id} membershipRole={currentUserMembership.role} />
</PageContentWrapper>
);
@@ -1,14 +1,16 @@
"use client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Trans, useTranslation } from "react-i18next";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { TConnectorOptionId, getConnectorOptions } from "../utils";
interface ConnectorTypeSelectorProps {
selectedType: TConnectorOptionId | null;
onSelectType: (type: TConnectorOptionId) => void;
workspaceId: string;
surveyCount: number;
}
const getOptionClassName = (
@@ -27,43 +29,54 @@ const getOptionClassName = (
return "border-slate-200 hover:border-slate-300 hover:bg-slate-50";
};
export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<ConnectorTypeSelectorProps>) {
export function ConnectorTypeSelector({
selectedType,
onSelectType,
workspaceId,
surveyCount,
}: Readonly<ConnectorTypeSelectorProps>) {
const { t } = useTranslation();
const connectorOptions = getConnectorOptions(t);
return (
<div className="space-y-3">
<div className="space-y-2">
{connectorOptions.map((option) => (
<button
key={option.id}
type="button"
disabled={option.disabled}
onClick={() => onSelectType(option.id)}
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
selectedType,
option.id,
option.disabled
)}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
</div>
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
</div>
<div
className={`ml-3 h-4 w-4 rounded-full border-2 ${
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
}`}>
{selectedType === option.id && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-1.5 w-1.5 rounded-full bg-white" />
{connectorOptions.map((option) => {
const showNoSurveysAlert =
surveyCount === 0 && option.id === "formbricks_survey" && selectedType === "formbricks_survey";
return (
<div key={option.id} className="space-y-2">
<button
type="button"
disabled={option.disabled}
onClick={() => onSelectType(option.id)}
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
selectedType,
option.id,
option.disabled
)}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
</div>
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
</div>
)}
<div
className={`ml-3 h-4 w-4 rounded-full border-2 ${
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
}`}>
{selectedType === option.id && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-1.5 w-1.5 rounded-full bg-white" />
</div>
)}
</div>
</button>
{showNoSurveysAlert && <NoFormbricksSurveysAlert workspaceId={workspaceId} />}
</div>
</button>
))}
);
})}
</div>
<Alert variant="outbound" size="small">
<AlertTitle>{t("workspace.unify.missing_feedback_source_title")}</AlertTitle>
@@ -80,3 +93,20 @@ export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<C
</div>
);
}
const NoFormbricksSurveysAlert = ({ workspaceId }: Readonly<{ workspaceId: string }>) => {
return (
<Alert variant="info" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
<Trans
i18nKey="workspace.unify.no_formbricks_surveys_available_description"
components={{
surveyLink: (
<Link href={`/workspaces/${workspaceId}/surveys/templates`} className="font-medium underline" />
),
}}
/>
</AlertDescription>
</Alert>
);
};
@@ -17,7 +17,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { TFieldMapping, TUnifySurvey } from "../types";
import { TFieldMapping, TUnifySurvey, getTranslatedConnectorError } from "../types";
import { ConnectorsTable } from "./connectors-table";
import { CreateConnectorModal } from "./create-connector-modal";
import { CsvImportModal } from "./csv-import-modal";
@@ -28,6 +28,7 @@ interface ConnectorsSectionProps {
initialConnectors: TConnectorWithMappings[];
initialSurveys: TUnifySurvey[];
directories: { id: string; name: string }[];
isReadOnly: boolean;
}
export function ConnectorsSection({
@@ -35,6 +36,7 @@ export function ConnectorsSection({
initialConnectors,
initialSurveys,
directories,
isReadOnly,
}: Readonly<ConnectorsSectionProps>) {
const { t } = useTranslation();
const router = useRouter();
@@ -78,7 +80,7 @@ export function ConnectorsSection({
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return undefined;
}
@@ -93,7 +95,7 @@ export function ConnectorsSection({
name: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => {
}): Promise<boolean> => {
const result = await updateConnectorWithMappingsAction({
connectorId: data.connectorId,
workspaceId: workspaceId,
@@ -111,19 +113,20 @@ export function ConnectorsSection({
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return false;
}
toast.success(t("workspace.unify.connector_updated_successfully"));
router.refresh();
return true;
};
const handleDeleteConnector = async (connectorId: string): Promise<void> => {
const result = await deleteConnectorAction({ connectorId, workspaceId: workspaceId });
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return;
}
@@ -138,7 +141,7 @@ export function ConnectorsSection({
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return;
}
@@ -155,7 +158,7 @@ export function ConnectorsSection({
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return;
}
@@ -170,11 +173,15 @@ export function ConnectorsSection({
<SettingsCard
title={t("workspace.unify.feedback_sources")}
description={t("workspace.unify.feedback_sources_settings_description")}
buttonInfo={{
text: t("workspace.unify.add_source"),
onClick: () => setIsCreateModalOpen(true),
variant: "default",
}}>
buttonInfo={
isReadOnly
? undefined
: {
text: t("workspace.unify.add_source"),
onClick: () => setIsCreateModalOpen(true),
variant: "default",
}
}>
<ConnectorsTable
connectors={initialConnectors}
onConnectorClick={setEditingConnector}
@@ -183,15 +190,18 @@ export function ConnectorsSection({
onToggleStatus={handleToggleStatus}
onDelete={handleDeleteConnector}
isLoading={false}
isReadOnly={isReadOnly}
/>
{directories.length > 0 && (
<Alert size="small" className="mt-4">
<AlertDescription>{feedbackDirectoryAccessText}</AlertDescription>
<AlertButton asChild>
<Link href={`/workspaces/${workspaceId}/settings/organization/feedback-directories`}>
{t("workspace.unify.manage_directories")}
</Link>
</AlertButton>
{!isReadOnly && (
<AlertButton asChild>
<Link href={`/workspaces/${workspaceId}/settings/organization/feedback-directories`}>
{t("workspace.unify.manage_directories")}
</Link>
</AlertButton>
)}
</Alert>
)}
</SettingsCard>
@@ -208,6 +218,7 @@ export function ConnectorsSection({
<EditConnectorModal
connector={editingConnector}
isReadOnly={isReadOnly}
open={editingConnector !== null}
onOpenChange={(open) => !open && setEditingConnector(null)}
onUpdateConnector={handleUpdateConnector}
@@ -37,6 +37,7 @@ interface ConnectorsTableDataRowProps {
onDuplicate: () => Promise<void>;
onToggleStatus: () => Promise<void>;
onDelete: () => Promise<void>;
isReadOnly?: boolean;
}
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
@@ -52,10 +53,11 @@ export function ConnectorsTableDataRow({
onDuplicate,
onToggleStatus,
onDelete,
isReadOnly = false,
}: Readonly<ConnectorsTableDataRowProps>) {
const { t, i18n } = useTranslation();
const handleRowClick = () => {
if (connector.type === "csv" && onCsvImport) {
if (!isReadOnly && connector.type === "csv" && onCsvImport) {
onCsvImport();
return;
}
@@ -93,10 +95,10 @@ export function ConnectorsTableDataRow({
title={t(getConnectorTypeLabelKey(connector.type))}>
{getConnectorIcon(connector.type, "h-4 w-4 text-slate-500")}
</div>
<div className="col-span-5 flex items-center">
<div className="col-span-4 flex items-center">
<span className="truncate text-sm font-medium text-slate-900">{connector.name}</span>
</div>
<div className="col-span-1 hidden items-center justify-center sm:flex">
<div className="col-span-2 hidden items-center justify-center sm:flex">
<Badge
text={getStatusLabel(connector.status, connector.type)}
type={STATUS_BADGE_TYPE[connector.status]}
@@ -110,14 +112,16 @@ export function ConnectorsTableDataRow({
<span className="truncate">{connector.creatorName ?? "—"}</span>
</div>
<div className="col-span-1 flex items-center justify-end pr-2">
<ConnectorRowDropdown
connector={connector}
onEdit={onEdit}
onCsvImport={onCsvImport}
onDuplicate={onDuplicate}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
/>
{!isReadOnly && (
<ConnectorRowDropdown
connector={connector}
onEdit={onEdit}
onCsvImport={onCsvImport}
onDuplicate={onDuplicate}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
/>
)}
</div>
</div>
);
@@ -9,6 +9,7 @@ interface ConnectorsTableRowsContainerProps {
onDuplicate: (connector: TConnectorWithMappings) => Promise<void>;
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
onDelete: (connectorId: string) => Promise<void>;
isReadOnly?: boolean;
}
export const ConnectorsTableRowsContainer = ({
@@ -18,6 +19,7 @@ export const ConnectorsTableRowsContainer = ({
onDuplicate,
onToggleStatus,
onDelete,
isReadOnly = false,
}: ConnectorsTableRowsContainerProps) => {
const { t } = useTranslation();
@@ -40,6 +42,7 @@ export const ConnectorsTableRowsContainer = ({
onDuplicate={() => onDuplicate(connector)}
onToggleStatus={() => onToggleStatus(connector)}
onDelete={() => onDelete(connector.id)}
isReadOnly={isReadOnly}
/>
))}
</div>
@@ -13,6 +13,7 @@ interface ConnectorsTableProps {
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
onDelete: (connectorId: string) => Promise<void>;
isLoading?: boolean;
isReadOnly?: boolean;
}
export function ConnectorsTable({
@@ -23,6 +24,7 @@ export function ConnectorsTable({
onToggleStatus,
onDelete,
isLoading = false,
isReadOnly = false,
}: Readonly<ConnectorsTableProps>) {
const { t } = useTranslation();
@@ -30,8 +32,8 @@ export function ConnectorsTable({
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">{t("common.type")}</div>
<div className="col-span-5">{t("common.name")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.status")}</div>
<div className="col-span-4">{t("common.name")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.status")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.updated_at")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.created_by")}</div>
<div className="col-span-1" />
@@ -48,6 +50,7 @@ export function ConnectorsTable({
onDuplicate={onDuplicate}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
isReadOnly={isReadOnly}
/>
)}
</div>
@@ -49,6 +49,7 @@ import {
TSourceField,
TUnifySurvey,
ZFormbricksConnectorForm,
getTranslatedConnectorError,
} from "../types";
import {
TConnectorOptionId,
@@ -339,7 +340,12 @@ export const CreateConnectorModal = ({
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
});
if (connectorId && values.importHistorical) {
if (!connectorId) {
setIsCreating(false);
return;
}
if (values.importHistorical) {
await handleHistoricalImport(connectorId, values.surveyId);
}
@@ -368,7 +374,12 @@ export const CreateConnectorModal = ({
fieldMappings: mappings.length > 0 ? mappings : undefined,
});
if (connectorId && csvParsedData.length > 0) {
if (!connectorId) {
setIsCreating(false);
return;
}
if (csvParsedData.length > 0) {
await handleCsvImport(connectorId);
}
@@ -416,16 +427,23 @@ export const CreateConnectorModal = ({
<div className="py-4">
{currentStep === "selectType" && (
<ConnectorTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
<ConnectorTypeSelector
selectedType={selectedType}
onSelectType={setSelectedType}
surveyCount={surveys.length}
workspaceId={workspaceId}
/>
)}
{currentStep === "mapping" && selectedType === "formbricks_survey" && (
<FormProvider {...formbricksForm}>
<form className="space-y-4">
<form
className="space-y-4"
onSubmit={formbricksForm.handleSubmit(handleCreateFormbricksConnector)}>
<FormField
control={formbricksForm.control}
name="sourceName"
render={({ field }) => (
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
<FormControl>
@@ -435,7 +453,9 @@ export const CreateConnectorModal = ({
placeholder={t("workspace.unify.enter_name_for_source")}
/>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -445,7 +465,7 @@ export const CreateConnectorModal = ({
<FormField
control={formbricksForm.control}
name="surveyId"
render={({ field }) => (
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
<FormControl>
@@ -462,7 +482,9 @@ export const CreateConnectorModal = ({
</SelectContent>
</Select>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -470,7 +492,7 @@ export const CreateConnectorModal = ({
<FormField
control={formbricksForm.control}
name="selectedQuestionIds"
render={() => (
render={({ fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
<FormControl>
@@ -482,7 +504,9 @@ export const CreateConnectorModal = ({
/>
</div>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -578,7 +602,9 @@ export const CreateConnectorModal = ({
</Button>
)}
{currentStep === "selectType" ? (
<Button onClick={handleNextStep} disabled={!selectedType}>
<Button
onClick={handleNextStep}
disabled={!selectedType || (selectedType === "formbricks_survey" && surveys.length === 0)}>
{getNextStepButtonLabel(selectedType, t)}
</Button>
) : (
@@ -38,6 +38,7 @@ import {
TSourceField,
TUnifySurvey,
ZFormbricksConnectorForm,
getTranslatedConnectorError,
} from "../types";
import {
areAllRequiredFieldsMapped,
@@ -59,9 +60,10 @@ interface EditConnectorModalProps {
name: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => Promise<void>;
}) => Promise<boolean>;
surveys: TUnifySurvey[];
onOpenCsvImport?: () => void;
isReadOnly?: boolean;
}
export const EditConnectorModal = ({
@@ -71,6 +73,7 @@ export const EditConnectorModal = ({
onUpdateConnector,
surveys,
onOpenCsvImport,
isReadOnly = false,
}: EditConnectorModalProps) => {
const { t } = useTranslation();
const [csvConnectorName, setCsvConnectorName] = useState("");
@@ -174,7 +177,7 @@ export const EditConnectorModal = ({
const handleUpdateFormbricksConnector = async (values: TFormbricksConnectorForm) => {
if (connector?.type !== "formbricks_survey") return;
setIsUpdating(true);
await onUpdateConnector({
const success = await onUpdateConnector({
connectorId: connector.id,
workspaceId: connector.workspaceId,
name: values.sourceName.trim(),
@@ -182,13 +185,15 @@ export const EditConnectorModal = ({
fieldMappings: undefined,
});
setIsUpdating(false);
handleOpenChange(false);
if (success) {
handleOpenChange(false);
}
};
const handleUpdateCsvConnector = async () => {
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
setIsUpdating(true);
await onUpdateConnector({
const success = await onUpdateConnector({
connectorId: connector.id,
workspaceId: connector.workspaceId,
name: csvConnectorName.trim(),
@@ -196,7 +201,9 @@ export const EditConnectorModal = ({
fieldMappings: mappings.length > 0 ? mappings : undefined,
});
setIsUpdating(false);
handleOpenChange(false);
if (success) {
handleOpenChange(false);
}
};
const handleFormbricksQuestionToggle = (questionId: string) => {
@@ -239,11 +246,13 @@ export const EditConnectorModal = ({
<div className="space-y-4 py-4">
{connector.type === "formbricks_survey" ? (
<FormProvider {...formbricksForm}>
<form className="space-y-4">
<form
className="space-y-4"
onSubmit={formbricksForm.handleSubmit(handleUpdateFormbricksConnector)}>
<FormField
control={formbricksForm.control}
name="sourceName"
render={({ field }) => (
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
<FormControl>
@@ -251,9 +260,12 @@ export const EditConnectorModal = ({
value={field.value}
onChange={field.onChange}
placeholder={t("workspace.unify.enter_name_for_source")}
disabled={isReadOnly}
/>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -261,7 +273,7 @@ export const EditConnectorModal = ({
<FormField
control={formbricksForm.control}
name="surveyId"
render={({ field }) => (
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
<FormControl>
@@ -281,7 +293,9 @@ export const EditConnectorModal = ({
</SelectContent>
</Select>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -289,19 +303,21 @@ export const EditConnectorModal = ({
<FormField
control={formbricksForm.control}
name="selectedQuestionIds"
render={() => (
render={({ fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
<FormControl>
<div>
<fieldset className={isReadOnly ? "opacity-70" : undefined} disabled={isReadOnly}>
<FormbricksQuestionList
survey={selectedSurvey}
selectedQuestionIds={selectedQuestionIds}
onQuestionToggle={handleFormbricksQuestionToggle}
/>
</div>
</fieldset>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -328,41 +344,54 @@ export const EditConnectorModal = ({
value={csvConnectorName}
onChange={(event) => setCsvConnectorName(event.target.value)}
placeholder={t("workspace.unify.enter_name_for_source")}
disabled={isReadOnly}
/>
</div>
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
<fieldset
disabled={isReadOnly}
className={`max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4 ${
isReadOnly ? "opacity-70" : ""
}`}>
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
connectorType={connector.type}
/>
</div>
</fieldset>
</>
)}
</div>
<DialogFooter>
{connector.type === "csv" && (
<Button
variant="secondary"
onClick={() => {
handleOpenChange(false);
onOpenCsvImport?.();
}}>
{t("workspace.unify.import_feedback")}
{isReadOnly ? (
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
{t("common.close")}
</Button>
) : (
<>
{connector.type === "csv" && (
<Button
variant="secondary"
onClick={() => {
handleOpenChange(false);
onOpenCsvImport?.();
}}>
{t("workspace.unify.import_feedback")}
</Button>
)}
<Button
onClick={
connector.type === "formbricks_survey"
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
: handleUpdateCsvConnector
}
disabled={saveChangesDisabled}>
{t("workspace.unify.save_changes")}
</Button>
</>
)}
<Button
onClick={
connector.type === "formbricks_survey"
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
: handleUpdateCsvConnector
}
disabled={saveChangesDisabled}>
{t("workspace.unify.save_changes")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -17,8 +17,16 @@ export const WorkspaceFeedbackSourcesPage = async (
const t = await getTranslate();
const params = await props.params;
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session, organization } =
await getWorkspaceAuth(params.workspaceId);
const {
isOwner,
isManager,
hasReadAccess,
hasReadWriteAccess,
hasManageAccess,
isReadOnly,
session,
organization,
} = await getWorkspaceAuth(params.workspaceId);
if (!session) {
throw new Error(t("common.session_not_found"));
@@ -72,6 +80,7 @@ export const WorkspaceFeedbackSourcesPage = async (
initialConnectors={connectors}
initialSurveys={unifySurveys}
directories={directories}
isReadOnly={isReadOnly}
/>
);
};
@@ -220,10 +220,29 @@ export type TFeedbackCSVData = z.infer<ReturnType<typeof createFeedbackCSVDataSc
export type TCreateConnectorStep = "selectType" | "mapping";
export const ZFormbricksConnectorForm = z.object({
sourceName: z.string().trim().min(1),
surveyId: z.string().min(1),
selectedQuestionIds: z.array(z.string()).min(1),
sourceName: z.string().trim().min(1, "CONNECTOR_NAME_REQUIRED"),
surveyId: z.string().min(1, "CONNECTOR_SURVEY_REQUIRED"),
selectedQuestionIds: z.array(z.string()).min(1, "CONNECTOR_QUESTIONS_REQUIRED"),
importHistorical: z.boolean(),
});
export type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
export const getTranslatedConnectorError = (errorCode: string, t: TFunction): string => {
switch (errorCode) {
case "CONNECTOR_NAME_DUPLICATE":
return t("workspace.unify.error_connector_name_duplicate");
case "CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE":
return t("workspace.unify.error_connector_formbricks_mapping_duplicate");
case "CONNECTOR_FIELD_MAPPING_DUPLICATE":
return t("workspace.unify.error_connector_field_mapping_duplicate");
case "CONNECTOR_NAME_REQUIRED":
return t("workspace.unify.error_connector_name_required");
case "CONNECTOR_SURVEY_REQUIRED":
return t("workspace.unify.error_connector_survey_required");
case "CONNECTOR_QUESTIONS_REQUIRED":
return t("workspace.unify.error_connector_questions_required");
default:
return errorCode;
}
};
@@ -126,7 +126,8 @@ const FormError = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const errorMessage = error?.message || error?.root?.message;
const body = error ? String(errorMessage) : children;
// Explicit children win — they're typically a translated/formatted version of the raw error.
const body = children ?? (error ? String(errorMessage) : null);
if (!body) {
return null;
@@ -1,7 +1,7 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { ChevronDown } from "lucide-react";
import { ChevronDown, ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/cn";
@@ -28,6 +28,32 @@ const SelectTrigger = React.forwardRef<
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
{...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
{...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
@@ -39,18 +65,16 @@ const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = R
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-100 bg-white text-slate-700 shadow-md animate-in fade-in-80 dark:bg-slate-700 dark:text-slate-300",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
"max-h-[var(--radix-select-content-available-height)] data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}>
className={cn("p-1", position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
@@ -98,6 +122,8 @@ export {
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
+44
View File
@@ -7,6 +7,10 @@ set -eu
DEFAULT_DATABASE_URL="postgresql://test:test@localhost:5432/formbricks"
DEFAULT_ENCRYPTION_KEY="0123456789abcdef0123456789abcdef"
DEFAULT_REDIS_URL="redis://localhost:6379"
DEFAULT_HUB_API_URL="http://localhost:4000"
DEFAULT_HUB_API_KEY="build-time-placeholder"
DEFAULT_CUBEJS_API_URL="http://localhost:4000"
DEFAULT_CUBEJS_API_SECRET="build-time-placeholder"
if [ -f "/run/secrets/database_url" ]; then
IFS= read -r DATABASE_URL < /run/secrets/database_url || true
@@ -35,6 +39,42 @@ if [ -z "${REDIS_URL:-}" ]; then
fi
export REDIS_URL
if [ -f "/run/secrets/hub_api_url" ]; then
IFS= read -r HUB_API_URL < /run/secrets/hub_api_url || true
fi
if [ -z "${HUB_API_URL:-}" ]; then
HUB_API_URL="${DEFAULT_HUB_API_URL}"
echo "⚠️ HUB_API_URL secret not found or empty. Using build-time fallback value."
fi
export HUB_API_URL
if [ -f "/run/secrets/hub_api_key" ]; then
IFS= read -r HUB_API_KEY < /run/secrets/hub_api_key || true
fi
if [ -z "${HUB_API_KEY:-}" ]; then
HUB_API_KEY="${DEFAULT_HUB_API_KEY}"
echo "⚠️ HUB_API_KEY secret not found or empty. Using build-time fallback value."
fi
export HUB_API_KEY
if [ -f "/run/secrets/cubejs_api_url" ]; then
IFS= read -r CUBEJS_API_URL < /run/secrets/cubejs_api_url || true
fi
if [ -z "${CUBEJS_API_URL:-}" ]; then
CUBEJS_API_URL="${DEFAULT_CUBEJS_API_URL}"
echo "⚠️ CUBEJS_API_URL secret not found or empty. Using build-time fallback value."
fi
export CUBEJS_API_URL
if [ -f "/run/secrets/cubejs_api_secret" ]; then
IFS= read -r CUBEJS_API_SECRET < /run/secrets/cubejs_api_secret || true
fi
if [ -z "${CUBEJS_API_SECRET:-}" ]; then
CUBEJS_API_SECRET="${DEFAULT_CUBEJS_API_SECRET}"
echo "⚠️ CUBEJS_API_SECRET secret not found or empty. Using build-time fallback value."
fi
export CUBEJS_API_SECRET
if [ -f "/run/secrets/posthog_key" ]; then
IFS= read -r POSTHOG_KEY < /run/secrets/posthog_key || true
fi
@@ -68,6 +108,10 @@ fi
echo " DATABASE_URL: $([ -n "${DATABASE_URL:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " ENCRYPTION_KEY: $([ -n "${ENCRYPTION_KEY:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " REDIS_URL: $([ -n "${REDIS_URL:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " HUB_API_URL: $([ -n "${HUB_API_URL:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " HUB_API_KEY: $([ -n "${HUB_API_KEY:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " CUBEJS_API_URL: $([ -n "${CUBEJS_API_URL:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " CUBEJS_API_SECRET: $([ -n "${CUBEJS_API_SECRET:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " SENTRY_AUTH_TOKEN: $([ -n "${SENTRY_AUTH_TOKEN:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " POSTHOG_KEY: $([ -n "${POSTHOG_KEY:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " TARGETARCH: $([ -n "${TARGETARCH:-}" ] && printf '%s' "${TARGETARCH}" || printf '[NOT SET]')"
+83 -2
View File
@@ -48,12 +48,47 @@ The intended defaults are:
## Cube.js for XM Suite v5
This chart does not deploy Cube.js. XM Suite v5 dashboard and analysis features require an external Cube instance.
XM Suite v5 dashboard and analysis features require Cube.js. Set `cube.enabled=true` to deploy an
internal Cube service from this chart, or provide an external Cube endpoint.
- Set `deployment.env.CUBEJS_API_URL` to your Cube endpoint.
- For chart-managed Cube, set `deployment.env.CUBEJS_API_URL` to `http://formbricks-cube:4000`
when using the default release name.
- For external Cube, set `deployment.env.CUBEJS_API_URL` to your Cube endpoint.
- Provide `CUBEJS_API_SECRET` through your existing secret management flow, such as the generated app secret override or `deployment.envFrom`.
- 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.
## Hub worker and self-hosted embeddings
The chart deploys Hub API and, by default, a `hub-worker` deployment. Hub API is insert-only for River jobs; webhook dispatch and embedding jobs are processed by `hub-worker`.
Self-hosted embeddings are disabled by default. Set `hub.embeddings.enabled=true` to deploy an internal Hugging Face Text Embeddings Inference (TEI) service and wire Hub API plus Hub worker to it through the OpenAI-compatible endpoint added in Hub:
```yaml
hub:
worker:
enabled: true
embeddings:
enabled: true
model: Alibaba-NLP/gte-multilingual-base
servedModelName: Alibaba-NLP/gte-multilingual-base
```
The generated Hub embedding configuration is:
- `EMBEDDING_PROVIDER=openai`
- `EMBEDDING_MODEL=<hub.embeddings.servedModelName or hub.embeddings.model>`
- `EMBEDDING_BASE_URL=http://<release>-hub-embeddings:8080/v1`
- `EMBEDDING_PROVIDER_API_KEY` from a dedicated embeddings Secret
The TEI service is internal-only (`ClusterIP`) and not exposed through ingress. For private or gated models, provide `hub.embeddings.huggingFace.token` or set `hub.embeddings.huggingFace.existingSecret`.
When TEI auth is enabled, configure the shared key through `hub.embeddings.auth.apiKey` or `hub.embeddings.auth.existingSecret`; the chart manages both TEI `API_KEY` and Hub `EMBEDDING_PROVIDER_API_KEY` from that source.
Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If you scale the embeddings runtime above one replica while persistence is enabled, the cache PVC must support `ReadWriteMany`; otherwise set `hub.embeddings.persistence.enabled=false` or provide a compatible `existingClaim`.
## Values
| Key | Type | Default | Description |
@@ -139,7 +174,40 @@ This chart does not deploy Cube.js. XM Suite v5 dashboard and analysis features
| externalSecret.secretStore.name | string | `"aws-secrets-manager"` | |
| formbricks.publicUrl | string | `""` | |
| formbricks.webappUrl | string | `""` | |
| hub.autoscaling.enabled | bool | `false` | |
| hub.autoscaling.maxReplicas | int | `3` | |
| hub.autoscaling.minReplicas | int | `1` | |
| hub.enabled | bool | `true` | |
| hub.embeddings.auth.enabled | bool | `true` | |
| hub.embeddings.auth.existingSecret | string | `""` | |
| hub.embeddings.auth.secretKey | string | `"EMBEDDING_PROVIDER_API_KEY"` | |
| hub.embeddings.autoscaling.enabled | bool | `false` | |
| hub.embeddings.autoscaling.maxReplicas | int | `2` | |
| hub.embeddings.autoscaling.minReplicas | int | `1` | |
| hub.embeddings.baseUrl | string | `""` | Defaults to the internal TEI service URL ending in `/v1`. |
| hub.embeddings.enabled | bool | `false` | |
| hub.embeddings.huggingFace.existingSecret | string | `""` | |
| hub.embeddings.huggingFace.token | string | `""` | |
| hub.embeddings.huggingFace.tokenKey | string | `"HF_TOKEN"` | |
| hub.embeddings.image.pullPolicy | string | `"IfNotPresent"` | |
| hub.embeddings.image.repository | string | `"ghcr.io/huggingface/text-embeddings-inference"` | |
| hub.embeddings.image.tag | string | `"cpu-1.9"` | |
| hub.embeddings.maxConcurrent | string | `"5"` | |
| hub.embeddings.model | string | `"Alibaba-NLP/gte-multilingual-base"` | |
| hub.embeddings.persistence.enabled | bool | `true` | |
| hub.embeddings.persistence.mountPath | string | `"/data"` | |
| hub.embeddings.persistence.size | string | `"10Gi"` | |
| hub.embeddings.pdb.enabled | bool | `false` | |
| hub.embeddings.port | int | `8080` | |
| hub.embeddings.prometheusPort | int | `9000` | |
| hub.embeddings.replicas | int | `1` | |
| hub.embeddings.resources.limits.memory | string | `"8Gi"` | |
| hub.embeddings.resources.requests.cpu | string | `"4"` | |
| hub.embeddings.resources.requests.memory | string | `"8Gi"` | |
| hub.embeddings.runtime | string | `"tei"` | |
| hub.embeddings.servedModelName | string | `""` | Defaults to `hub.embeddings.model`. |
| hub.embeddings.service.port | int | `8080` | |
| hub.embeddings.service.type | string | `"ClusterIP"` | |
| hub.env | object | `{}` | |
| hub.existingSecret | string | `""` | |
| hub.image.digest | string | `"sha256:14db7b3d285b6e9165b55693f9b83d08beff840a255fd77dd12882ee0a62f5cb"` | When set, takes precedence over tag (immutable pin). |
@@ -149,10 +217,23 @@ This chart does not deploy Cube.js. XM Suite v5 dashboard and analysis features
| hub.migration.activeDeadlineSeconds | int | `900` | |
| hub.migration.backoffLimit | int | `3` | |
| hub.migration.ttlSecondsAfterFinished | int | `300` | |
| hub.pdb.enabled | bool | `false` | |
| hub.replicas | int | `1` | |
| hub.resources.limits.memory | string | `"512Mi"` | |
| hub.resources.requests.cpu | string | `"100m"` | |
| hub.resources.requests.memory | string | `"256Mi"` | |
| hub.worker.autoscaling.enabled | bool | `false` | |
| hub.worker.autoscaling.maxReplicas | int | `5` | |
| hub.worker.autoscaling.minReplicas | int | `1` | |
| hub.worker.enabled | bool | `true` | |
| hub.worker.env | object | `{}` | |
| hub.worker.pdb.enabled | bool | `false` | |
| hub.worker.replicas | int | `1` | |
| hub.worker.resources.limits.memory | string | `"512Mi"` | |
| hub.worker.resources.requests.cpu | string | `"100m"` | |
| hub.worker.resources.requests.memory | string | `"256Mi"` | |
| hub.worker.waitForApi.enabled | bool | `true` | |
| hub.worker.waitForApi.maxAttempts | int | `120` | 120 attempts at 5s intervals = 10 minutes. |
| ingress.annotations | object | `{}` | |
| ingress.enabled | bool | `false` | |
| ingress.hosts[0].host | string | `"k8s.formbricks.com"` | |
+200
View File
@@ -0,0 +1,200 @@
/* eslint-env es2022 */
const TENANT_MEMBERS = ["FeedbackRecords.tenantId", "TopicsUnnested.tenantId"];
const REQUIRED_SCOPE = "xm:cube:query";
function assertRequiredEnvironmentVariable(name) {
const value = process.env[name];
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${name} is required to run Cube`);
}
}
assertRequiredEnvironmentVariable("CUBEJS_API_SECRET");
function getStringClaim(securityContext, claim) {
const value = securityContext?.[claim];
if (typeof value !== "string") {
return null;
}
const trimmedValue = value.trim();
return trimmedValue.length > 0 ? trimmedValue : null;
}
function getRequiredStringClaim(securityContext, claim) {
const value = getStringClaim(securityContext, claim);
if (!value) {
throw new Error(`Cube query rejected: missing ${claim} security context`);
}
return value;
}
function collectFilterMembers(filters) {
if (!Array.isArray(filters)) {
return [];
}
return filters.flatMap((filter) => [
...(typeof filter?.member === "string" ? [filter.member] : []),
...(typeof filter?.dimension === "string" ? [filter.dimension] : []),
...collectFilterMembers(filter?.and),
...collectFilterMembers(filter?.or),
]);
}
function collectOrderMembers(order) {
if (!order) {
return [];
}
if (Array.isArray(order)) {
return order
.map((orderEntry) => (Array.isArray(orderEntry) ? orderEntry[0] : null))
.filter((member) => typeof member === "string");
}
if (typeof order === "object") {
return Object.keys(order);
}
return [];
}
function collectTimeDimensionMembers(timeDimensions) {
if (!Array.isArray(timeDimensions)) {
return [];
}
return timeDimensions
.map((timeDimension) => timeDimension?.dimension)
.filter((dimension) => typeof dimension === "string");
}
function collectQueryMembers(query) {
const cubeQuery = query ?? {};
const members = [
...(Array.isArray(cubeQuery.measures) ? cubeQuery.measures : []),
...(Array.isArray(cubeQuery.dimensions) ? cubeQuery.dimensions : []),
...(Array.isArray(cubeQuery.segments) ? cubeQuery.segments : []),
...collectTimeDimensionMembers(cubeQuery.timeDimensions),
...collectFilterMembers(cubeQuery.filters),
...collectOrderMembers(cubeQuery.order),
].filter((member) => typeof member === "string");
return Array.from(new Set(members)).sort((a, b) => a.localeCompare(b));
}
function assertValidSecurityContext(securityContext) {
const tenantId = getRequiredStringClaim(securityContext, "tenantId");
const feedbackDirectoryId = getRequiredStringClaim(securityContext, "feedbackDirectoryId");
const workspaceId = getRequiredStringClaim(securityContext, "workspaceId");
const scope = getRequiredStringClaim(securityContext, "scope");
if (scope !== REQUIRED_SCOPE) {
throw new Error("Cube query rejected: invalid Cube query scope");
}
if (tenantId !== feedbackDirectoryId) {
throw new Error("Cube query rejected: tenantId/feedbackDirectoryId mismatch");
}
return {
tenantId,
feedbackDirectoryId,
workspaceId,
organizationId: getRequiredStringClaim(securityContext, "organizationId"),
userId: getRequiredStringClaim(securityContext, "userId"),
requestId: getRequiredStringClaim(securityContext, "jti"),
source: getRequiredStringClaim(securityContext, "source"),
};
}
function assertNoCallerTenantMember(query) {
for (const member of collectQueryMembers(query)) {
if (TENANT_MEMBERS.includes(member)) {
throw new Error("Cube query rejected: tenant filters are enforced by Cube");
}
}
}
function logCubeQueryAuditEvent(context, query, { error, status = "success" } = {}) {
const errorName = error instanceof Error ? error.name : undefined;
const errorMessage = error instanceof Error ? error.message : error ? String(error) : undefined;
console.log(
JSON.stringify({
type: "audit",
event: "cube.query",
status,
timestamp: new Date().toISOString(),
tenantId: context.tenantId,
feedbackDirectoryId: context.feedbackDirectoryId,
workspaceId: context.workspaceId,
organizationId: context.organizationId,
userId: context.userId,
requestId: context.requestId,
source: context.source,
members: collectQueryMembers(query),
...(errorName ? { errorName } : {}),
...(errorMessage ? { errorMessage } : {}),
})
);
}
function logCubeQuerySecurityContextFailure(query, error) {
console.log(
JSON.stringify({
type: "audit",
event: "cube.query",
status: "failure",
timestamp: new Date().toISOString(),
members: collectQueryMembers(query),
errorName: error instanceof Error ? error.name : undefined,
errorMessage: error instanceof Error ? error.message : String(error),
})
);
}
function queryRewrite(query, rewriteContext) {
const cubeQuery = query ?? {};
let context;
try {
context = assertValidSecurityContext(rewriteContext?.securityContext);
} catch (error) {
logCubeQuerySecurityContextFailure(cubeQuery, error);
throw error;
}
try {
assertNoCallerTenantMember(cubeQuery);
} catch (error) {
logCubeQueryAuditEvent(context, cubeQuery, { error, status: "failure" });
throw error;
}
const queriedCubePrefixes = new Set(collectQueryMembers(cubeQuery).map((member) => member.split(".")[0]));
const rewrittenQuery = {
...cubeQuery,
filters: [
...(Array.isArray(cubeQuery.filters) ? cubeQuery.filters : []),
...TENANT_MEMBERS.filter((member) => queriedCubePrefixes.has(member.split(".")[0])).map(
(member) => ({
member,
operator: "equals",
values: [context.tenantId],
})
),
],
};
logCubeQueryAuditEvent(context, rewrittenQuery);
return rewrittenQuery;
}
module.exports = {
queryRewrite,
};
@@ -0,0 +1,172 @@
// This schema maps to the `feedback_records` table owned by the Formbricks Hub Postgres.
// If the Hub changes column names, types, or the metadata JSONB shape (e.g. the `topics` array),
// this schema must be updated to match.
cube(`FeedbackRecords`, {
sql: `SELECT * FROM feedback_records`,
measures: {
count: {
type: `count`,
description: `Total number of feedback responses`,
},
promoterCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 9` }],
description: `Number of promoters (NPS score 9-10)`,
},
detractorCount: {
type: `count`,
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}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
description: `Number of passives (NPS score 7-8)`,
},
npsScore: {
type: `number`,
sql: `
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(
(
(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) / Total) * 100`,
},
averageScore: {
type: `avg`,
sql: `${CUBE}.value_number`,
description: `Average NPS score`,
},
},
dimensions: {
id: {
sql: `id`,
type: `string`,
primaryKey: true,
},
sentiment: {
sql: `sentiment`,
type: `string`,
description: `Sentiment extracted from metadata JSONB field`,
},
sourceType: {
sql: `source_type`,
type: `string`,
description: `Source type of the feedback (e.g., nps_campaign, survey)`,
},
sourceName: {
sql: `source_name`,
type: `string`,
description: `Human-readable name of the source`,
},
fieldType: {
sql: `field_type`,
type: `string`,
description: `Type of feedback field (e.g., nps, text, rating)`,
},
collectedAt: {
sql: `collected_at`,
type: `time`,
description: `Timestamp when the feedback was collected`,
},
npsValue: {
sql: `value_number`,
type: `number`,
description: `Raw NPS score value (0-10)`,
},
responseId: {
sql: `response_id`,
type: `string`,
description: `Unique identifier linking related feedback records`,
},
userId: {
sql: `user_id`,
type: `string`,
description: `Identifier of the user who provided feedback`,
},
emotion: {
sql: `emotion`,
type: `string`,
description: `Emotion extracted from metadata JSONB field`,
},
tenantId: {
sql: `tenant_id`,
type: `string`,
description: `Tenant ID linking to FeedbackDirectory`,
},
},
joins: {
TopicsUnnested: {
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
relationship: `hasMany`,
},
},
});
cube(`TopicsUnnested`, {
sql: `
SELECT
fr.id as feedback_record_id,
fr.tenant_id,
topic_elem.topic
FROM feedback_records fr
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
`,
measures: {
count: {
type: `count`,
},
},
dimensions: {
id: {
sql: `md5(feedback_record_id || '::' || topic)`,
type: `string`,
primaryKey: true,
},
feedbackRecordId: {
sql: `feedback_record_id`,
type: `string`,
},
tenantId: {
sql: `tenant_id`,
type: `string`,
description: `Tenant ID for row-level security scoping`,
},
topic: {
sql: `topic`,
type: `string`,
description: `Individual topic from the topics array`,
},
},
});
+116 -7
View File
@@ -15,6 +15,14 @@ Hub resource name: base name truncated to 59 chars then "-hub" so the suffix is
{{- printf "%s-hub" $base | trimSuffix "-" }}
{{- end }}
{{/*
Cube.js resource name.
*/}}
{{- define "formbricks.cubeName" -}}
{{- $base := include "formbricks.name" . | trunc 58 | trimSuffix "-" }}
{{- printf "%s-cube" $base | trimSuffix "-" }}
{{- end }}
{{/*
Define the application version to be used in labels.
@@ -114,6 +122,105 @@ hub-worker) must use this helper so they cannot drift apart.
{{- end -}}
{{- end }}
{{/*
Hub worker resource name.
*/}}
{{- define "formbricks.hubWorkerName" -}}
{{- $base := include "formbricks.name" . | trunc 52 | trimSuffix "-" }}
{{- printf "%s-hub-worker" $base | trimSuffix "-" }}
{{- end }}
{{/*
Hub embeddings runtime resource name.
*/}}
{{- define "formbricks.hubEmbeddingsName" -}}
{{- $base := include "formbricks.name" . | trunc 48 | trimSuffix "-" }}
{{- printf "%s-hub-embeddings" $base | trimSuffix "-" }}
{{- end }}
{{/*
Secret used by Hub and the embeddings runtime for the embeddings API key.
*/}}
{{- define "formbricks.hubEmbeddingsSecretName" -}}
{{- default (printf "%s-secret" (include "formbricks.hubEmbeddingsName" .)) .Values.hub.embeddings.auth.existingSecret -}}
{{- end }}
{{/*
Secret used by the embeddings runtime for Hugging Face access.
*/}}
{{- define "formbricks.hubEmbeddingsHuggingFaceSecretName" -}}
{{- default (include "formbricks.hubEmbeddingsSecretName" .) .Values.hub.embeddings.huggingFace.existingSecret -}}
{{- end }}
{{/*
Model name Hub sends to the OpenAI-compatible embeddings endpoint.
*/}}
{{- define "formbricks.hubEmbeddingsServedModelName" -}}
{{- default .Values.hub.embeddings.model .Values.hub.embeddings.servedModelName -}}
{{- end }}
{{/*
OpenAI-compatible embeddings base URL used by Hub.
*/}}
{{- define "formbricks.hubEmbeddingsBaseURL" -}}
{{- if .Values.hub.embeddings.baseUrl -}}
{{- .Values.hub.embeddings.baseUrl -}}
{{- else -}}
{{- printf "http://%s:%v/v1" (include "formbricks.hubEmbeddingsName" .) (.Values.hub.embeddings.service.port | default .Values.hub.embeddings.port) -}}
{{- end -}}
{{- end }}
{{/*
Embedding API key value for the generated embeddings secret.
*/}}
{{- define "formbricks.hubEmbeddingsApiKey" -}}
{{- $secretName := include "formbricks.hubEmbeddingsSecretName" . }}
{{- $secretKey := .Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" }}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace $secretName) }}
{{- if and $secret (index $secret.data $secretKey) }}
{{- index $secret.data $secretKey | b64dec -}}
{{- else if .Values.hub.embeddings.auth.apiKey }}
{{- .Values.hub.embeddings.auth.apiKey -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end }}
{{/*
Shared Hub embedding env. These values are managed from hub.embeddings when the
self-hosted runtime is enabled so Hub API and Hub worker cannot drift.
*/}}
{{- define "formbricks.hubEmbeddingEnv" -}}
{{- $root := .root -}}
{{- if $root.Values.hub.embeddings.enabled }}
- name: EMBEDDING_PROVIDER
value: "openai"
- name: EMBEDDING_MODEL
value: {{ include "formbricks.hubEmbeddingsServedModelName" $root | quote }}
- name: EMBEDDING_BASE_URL
value: {{ include "formbricks.hubEmbeddingsBaseURL" $root | quote }}
- name: EMBEDDING_PROVIDER_API_KEY
valueFrom:
secretKeyRef:
name: {{ include "formbricks.hubEmbeddingsSecretName" $root }}
key: {{ $root.Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" }}
- name: EMBEDDING_MAX_CONCURRENT
value: {{ $root.Values.hub.embeddings.maxConcurrent | quote }}
- name: EMBEDDING_NORMALIZE
value: {{ $root.Values.hub.embeddings.normalize | quote }}
{{- end }}
{{- end }}
{{/*
Returns true when an env var is managed by hub.embeddings and should not be rendered from hub.env/worker.env.
*/}}
{{- define "formbricks.hubEmbeddingEnvManaged" -}}
{{- $key := .key -}}
{{- if has $key (list "EMBEDDING_PROVIDER" "EMBEDDING_MODEL" "EMBEDDING_BASE_URL" "EMBEDDING_PROVIDER_API_KEY" "EMBEDDING_MAX_CONCURRENT" "EMBEDDING_NORMALIZE") -}}
true
{{- end -}}
{{- end }}
{{- define "formbricks.postgresAdminPassword" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
@@ -142,13 +249,15 @@ hub-worker) must use this helper so they cannot drift apart.
{{- end -}}
{{- end }}
{{- define "formbricks.cronSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- if $secret }}
{{- index $secret.data "CRON_SECRET" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- define "formbricks.cronSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- if and $secret (index $secret.data "CRON_SECRET") }}
{{- index $secret.data "CRON_SECRET" | b64dec -}}
{{- else if $secret }}
{{- fail (printf "Secret %q exists in namespace %q but is missing CRON_SECRET" (include "formbricks.appSecretName" .) .Release.Namespace) -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end }}
{{- define "formbricks.encryptionKey" -}}
@@ -0,0 +1,19 @@
{{- if .Values.cube.enabled }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "formbricks.cubeName" . }}-config
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: cube
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
data:
cube.js: |-
{{ .Files.Get "cube/cube.js" | indent 4 }}
FeedbackRecords.js: |-
{{ .Files.Get "cube/schema/FeedbackRecords.js" | indent 4 }}
{{- end }}
@@ -0,0 +1,124 @@
{{- $cubeCacheDriver := get (.Values.cube.env | default dict) "CUBEJS_CACHE_AND_QUEUE_DRIVER" | default "" | toString | trim | lower }}
{{- if and .Values.cube.enabled (gt (int .Values.cube.replicas) 1) (eq $cubeCacheDriver "memory") }}
{{- fail "cube.env.CUBEJS_CACHE_AND_QUEUE_DRIVER=memory is only supported when cube.replicas=1. Use Cube Store for multiple Cube replicas." }}
{{- end }}
{{- if .Values.cube.enabled }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "formbricks.cubeName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: cube
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
replicas: {{ .Values.cube.replicas }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: cube
annotations:
checksum/config: {{ include (print $.Template.BasePath "/cube-configmap.yaml") . | sha256sum }}
spec:
{{- if .Values.cube.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.cube.imagePullSecrets | nindent 8 }}
{{- end }}
{{- with .Values.cube.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.cube.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.cube.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.cube.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: cube
image: "{{ .Values.cube.image.repository }}:{{ .Values.cube.image.tag }}"
imagePullPolicy: {{ .Values.cube.image.pullPolicy }}
{{- with .Values.cube.containerSecurityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.cube.port }}
protocol: TCP
{{- with .Values.cube.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.cube.readinessProbe }}
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.cube.envFrom }}
envFrom:
{{- range $value := .Values.cube.envFrom }}
{{- if (eq .type "configmap") }}
- configMapRef:
{{- if .name }}
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
{{- else if .nameSuffix }}
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
{{- else }}
name: {{ template "formbricks.name" $ }}
{{- end }}
{{- end }}
{{- if (eq .type "secret") }}
- secretRef:
{{- if .name }}
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
{{- else if .nameSuffix }}
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
{{- else }}
name: {{ template "formbricks.name" $ }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
env:
{{- range $key, $value := .Values.cube.env }}
- name: {{ include "formbricks.tplvalues.render" ( dict "value" $key "context" $ ) }}
{{- if kindIs "string" $value }}
value: {{ include "formbricks.tplvalues.render" ( dict "value" $value "context" $ ) | quote }}
{{- else }}
{{- toYaml $value | nindent 14 }}
{{- end }}
{{- end }}
volumeMounts:
- name: cube-config
mountPath: /cube/conf/cube.js
subPath: cube.js
readOnly: true
- name: cube-config
mountPath: /cube/conf/model/FeedbackRecords.js
subPath: FeedbackRecords.js
readOnly: true
{{- if .Values.cube.resources }}
resources:
{{- toYaml .Values.cube.resources | nindent 12 }}
{{- end }}
volumes:
- name: cube-config
configMap:
name: {{ include "formbricks.cubeName" . }}-config
{{- end }}
@@ -0,0 +1,24 @@
{{- if .Values.cube.enabled }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "formbricks.cubeName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: cube
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
type: {{ .Values.cube.service.type }}
selector:
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
ports:
- name: http
port: {{ .Values.cube.service.port }}
targetPort: http
protocol: TCP
{{- end }}
@@ -14,7 +14,9 @@ metadata:
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
{{- if not .Values.hub.autoscaling.enabled }}
replicas: {{ .Values.hub.replicas | default 1 }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
@@ -37,6 +39,7 @@ spec:
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
command:
- sh
- -c
@@ -53,6 +56,7 @@ spec:
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
ports:
- name: http
containerPort: 8080
@@ -66,10 +70,13 @@ spec:
secretKeyRef:
name: {{ include "formbricks.hubSecretName" . }}
key: HUB_API_KEY
{{- include "formbricks.hubEmbeddingEnv" (dict "root" $ "env" .Values.hub.env) | nindent 12 }}
{{- range $key, $value := .Values.hub.env }}
{{- if not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key))) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
{{- if .Values.hub.resources }}
resources:
{{- toYaml .Values.hub.resources | nindent 12 }}
@@ -0,0 +1,168 @@
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled }}
{{- $embeddingsReplicas := int (.Values.hub.embeddings.replicas | default 1) -}}
{{- $embeddingsMaxReplicas := int (.Values.hub.embeddings.autoscaling.maxReplicas | default 1) -}}
{{- if and .Values.hub.embeddings.persistence.enabled (not .Values.hub.embeddings.persistence.existingClaim) (not (has "ReadWriteMany" .Values.hub.embeddings.persistence.accessModes)) (or (gt $embeddingsReplicas 1) (and .Values.hub.embeddings.autoscaling.enabled (gt $embeddingsMaxReplicas 1))) }}
{{- fail "hub.embeddings persistence with multiple replicas requires persistence.accessModes to include ReadWriteMany, or set hub.embeddings.persistence.enabled=false/use a ReadWriteMany existingClaim" }}
{{- end }}
{{- if and .Values.hub.embeddings.auth.existingSecret .Values.hub.embeddings.huggingFace.token (not .Values.hub.embeddings.huggingFace.existingSecret) }}
{{- fail "hub.embeddings.huggingFace.token cannot be stored when hub.embeddings.auth.existingSecret is set; put HF_TOKEN in the existing auth secret or set hub.embeddings.huggingFace.existingSecret" }}
{{- end }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "formbricks.hubEmbeddingsName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
{{- if and .Values.hub.embeddings.persistence.enabled (not (has "ReadWriteMany" .Values.hub.embeddings.persistence.accessModes)) }}
strategy:
type: Recreate
{{- else }}
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
{{- end }}
{{- if not .Values.hub.embeddings.autoscaling.enabled }}
replicas: {{ .Values.hub.embeddings.replicas | default 1 }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
{{- with .Values.hub.embeddings.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.embeddings.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.hub.embeddings.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.embeddings.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.embeddings.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.embeddings.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.embeddings.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.deployment.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
{{- end }}
containers:
- name: hub-embeddings
image: "{{ .Values.hub.embeddings.image.repository }}:{{ .Values.hub.embeddings.image.tag }}"
imagePullPolicy: {{ .Values.hub.embeddings.image.pullPolicy }}
{{- with .Values.hub.embeddings.command }}
command:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.hub.embeddings.args }}
args:
{{- toYaml .Values.hub.embeddings.args | nindent 12 }}
{{- else }}
args:
- --model-id
- {{ .Values.hub.embeddings.model | quote }}
- --port
- {{ .Values.hub.embeddings.port | quote }}
- --huggingface-hub-cache
- {{ .Values.hub.embeddings.persistence.mountPath | quote }}
- --served-model-name
- {{ include "formbricks.hubEmbeddingsServedModelName" . | quote }}
{{- with .Values.hub.embeddings.revision }}
- --revision
- {{ . | quote }}
{{- end }}
{{- with .Values.hub.embeddings.extraArgs }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.hub.embeddings.port }}
protocol: TCP
- name: metrics
containerPort: {{ .Values.hub.embeddings.prometheusPort }}
protocol: TCP
{{- if or .Values.hub.embeddings.auth.enabled .Values.hub.embeddings.huggingFace.existingSecret .Values.hub.embeddings.huggingFace.token (gt (len .Values.hub.embeddings.env) 0) }}
env:
{{- if .Values.hub.embeddings.auth.enabled }}
- name: API_KEY
valueFrom:
secretKeyRef:
name: {{ include "formbricks.hubEmbeddingsSecretName" . }}
key: {{ .Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" | quote }}
{{- end }}
{{- if or .Values.hub.embeddings.huggingFace.existingSecret .Values.hub.embeddings.huggingFace.token }}
- name: HF_TOKEN
valueFrom:
secretKeyRef:
name: {{ include "formbricks.hubEmbeddingsHuggingFaceSecretName" . }}
key: {{ .Values.hub.embeddings.huggingFace.tokenKey | default "HF_TOKEN" | quote }}
{{- end }}
{{- range $key, $value := .Values.hub.embeddings.env }}
{{- if not (or (and $.Values.hub.embeddings.auth.enabled (eq $key "API_KEY")) (and (or $.Values.hub.embeddings.huggingFace.existingSecret $.Values.hub.embeddings.huggingFace.token) (eq $key "HF_TOKEN"))) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- with .Values.hub.embeddings.probes.startupProbe }}
startupProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.hub.embeddings.probes.readinessProbe }}
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.hub.embeddings.probes.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.hub.embeddings.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.hub.embeddings.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.hub.embeddings.persistence.enabled }}
volumeMounts:
- name: model-cache
mountPath: {{ .Values.hub.embeddings.persistence.mountPath }}
{{- end }}
{{- if .Values.hub.embeddings.persistence.enabled }}
volumes:
- name: model-cache
persistentVolumeClaim:
claimName: {{ default (include "formbricks.hubEmbeddingsName" .) .Values.hub.embeddings.persistence.existingClaim }}
{{- end }}
{{- end }}
@@ -0,0 +1,23 @@
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled .Values.hub.embeddings.persistence.enabled (not .Values.hub.embeddings.persistence.existingClaim) }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "formbricks.hubEmbeddingsName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
accessModes:
{{- toYaml .Values.hub.embeddings.persistence.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.hub.embeddings.persistence.size }}
{{- with .Values.hub.embeddings.persistence.storageClass }}
storageClassName: {{ . | quote }}
{{- end }}
{{- end }}
@@ -0,0 +1,20 @@
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled (not .Values.hub.embeddings.auth.existingSecret) }}
---
apiVersion: v1
kind: Secret
metadata:
name: {{ include "formbricks.hubEmbeddingsSecretName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
type: Opaque
data:
{{ .Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" | quote }}: {{ include "formbricks.hubEmbeddingsApiKey" . | b64enc }}
{{- if and (not .Values.hub.embeddings.huggingFace.existingSecret) .Values.hub.embeddings.huggingFace.token }}
{{ .Values.hub.embeddings.huggingFace.tokenKey | default "HF_TOKEN" | quote }}: {{ .Values.hub.embeddings.huggingFace.token | b64enc }}
{{- end }}
{{- end }}
@@ -0,0 +1,35 @@
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "formbricks.hubEmbeddingsName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.embeddings.service.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.embeddings.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.hub.embeddings.service.type }}
selector:
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
ports:
- name: http
port: {{ .Values.hub.embeddings.service.port }}
targetPort: http
protocol: TCP
- name: metrics
port: {{ .Values.hub.embeddings.prometheusPort }}
targetPort: metrics
protocol: TCP
{{- end }}
+102
View File
@@ -0,0 +1,102 @@
{{- if and .Values.hub.enabled .Values.hub.autoscaling.enabled }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "formbricks.hubname" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.autoscaling.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.autoscaling.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "formbricks.hubname" . }}
minReplicas: {{ .Values.hub.autoscaling.minReplicas }}
maxReplicas: {{ .Values.hub.autoscaling.maxReplicas }}
metrics:
{{- toYaml .Values.hub.autoscaling.metrics | nindent 4 }}
{{- with .Values.hub.autoscaling.behavior }}
behavior:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
{{- if and .Values.hub.enabled .Values.hub.worker.enabled .Values.hub.worker.autoscaling.enabled }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "formbricks.hubWorkerName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-worker
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.worker.autoscaling.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.worker.autoscaling.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "formbricks.hubWorkerName" . }}
minReplicas: {{ .Values.hub.worker.autoscaling.minReplicas }}
maxReplicas: {{ .Values.hub.worker.autoscaling.maxReplicas }}
metrics:
{{- toYaml .Values.hub.worker.autoscaling.metrics | nindent 4 }}
{{- with .Values.hub.worker.autoscaling.behavior }}
behavior:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled .Values.hub.embeddings.autoscaling.enabled }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "formbricks.hubEmbeddingsName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.embeddings.autoscaling.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.embeddings.autoscaling.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "formbricks.hubEmbeddingsName" . }}
minReplicas: {{ .Values.hub.embeddings.autoscaling.minReplicas }}
maxReplicas: {{ .Values.hub.embeddings.autoscaling.maxReplicas }}
metrics:
{{- toYaml .Values.hub.embeddings.autoscaling.metrics | nindent 4 }}
{{- with .Values.hub.embeddings.autoscaling.behavior }}
behavior:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
+129
View File
@@ -0,0 +1,129 @@
{{- if and .Values.hub.enabled .Values.hub.pdb.enabled }}
{{- $hasMinAvailable := not (kindIs "invalid" .Values.hub.pdb.minAvailable) -}}
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.hub.pdb.maxUnavailable) -}}
{{- if and $hasMinAvailable $hasMaxUnavailable }}
{{- fail "hub.pdb.minAvailable and hub.pdb.maxUnavailable are mutually exclusive; set only one" }}
{{- end }}
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
{{- fail "hub.pdb.enabled is true but neither hub.pdb.minAvailable nor hub.pdb.maxUnavailable is set; set exactly one" }}
{{- end }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "formbricks.hubname" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.pdb.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.pdb.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if $hasMinAvailable }}
minAvailable: {{ .Values.hub.pdb.minAvailable }}
{{- end }}
{{- if $hasMaxUnavailable }}
maxUnavailable: {{ .Values.hub.pdb.maxUnavailable }}
{{- end }}
{{- with .Values.hub.pdb.unhealthyPodEvictionPolicy }}
unhealthyPodEvictionPolicy: {{ . }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- if and .Values.hub.enabled .Values.hub.worker.enabled .Values.hub.worker.pdb.enabled }}
{{- $hasMinAvailable := not (kindIs "invalid" .Values.hub.worker.pdb.minAvailable) -}}
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.hub.worker.pdb.maxUnavailable) -}}
{{- if and $hasMinAvailable $hasMaxUnavailable }}
{{- fail "hub.worker.pdb.minAvailable and hub.worker.pdb.maxUnavailable are mutually exclusive; set only one" }}
{{- end }}
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
{{- fail "hub.worker.pdb.enabled is true but neither hub.worker.pdb.minAvailable nor hub.worker.pdb.maxUnavailable is set; set exactly one" }}
{{- end }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "formbricks.hubWorkerName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-worker
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.worker.pdb.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.worker.pdb.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if $hasMinAvailable }}
minAvailable: {{ .Values.hub.worker.pdb.minAvailable }}
{{- end }}
{{- if $hasMaxUnavailable }}
maxUnavailable: {{ .Values.hub.worker.pdb.maxUnavailable }}
{{- end }}
{{- with .Values.hub.worker.pdb.unhealthyPodEvictionPolicy }}
unhealthyPodEvictionPolicy: {{ . }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled .Values.hub.embeddings.pdb.enabled }}
{{- $hasMinAvailable := not (kindIs "invalid" .Values.hub.embeddings.pdb.minAvailable) -}}
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.hub.embeddings.pdb.maxUnavailable) -}}
{{- if and $hasMinAvailable $hasMaxUnavailable }}
{{- fail "hub.embeddings.pdb.minAvailable and hub.embeddings.pdb.maxUnavailable are mutually exclusive; set only one" }}
{{- end }}
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
{{- fail "hub.embeddings.pdb.enabled is true but neither hub.embeddings.pdb.minAvailable nor hub.embeddings.pdb.maxUnavailable is set; set exactly one" }}
{{- end }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "formbricks.hubEmbeddingsName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.embeddings.pdb.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.embeddings.pdb.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if $hasMinAvailable }}
minAvailable: {{ .Values.hub.embeddings.pdb.minAvailable }}
{{- end }}
{{- if $hasMaxUnavailable }}
maxUnavailable: {{ .Values.hub.embeddings.pdb.maxUnavailable }}
{{- end }}
{{- with .Values.hub.embeddings.pdb.unhealthyPodEvictionPolicy }}
unhealthyPodEvictionPolicy: {{ . }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
@@ -0,0 +1,108 @@
{{- if and .Values.hub.enabled .Values.hub.worker.enabled }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "formbricks.hubWorkerName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-worker
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
{{- if not .Values.hub.worker.autoscaling.enabled }}
replicas: {{ .Values.hub.worker.replicas | default 1 }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-worker
spec:
{{- with .Values.hub.worker.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.worker.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.worker.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.worker.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.deployment.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
{{- end }}
{{- if .Values.hub.worker.waitForApi.enabled }}
initContainers:
- name: wait-for-hub-api
image: {{ include "formbricks.hubImage" . }}
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
command:
- sh
- -c
- |
attempts=0
max_attempts={{ .Values.hub.worker.waitForApi.maxAttempts | default 120 }}
until wget --no-verbose --tries=1 --spider http://{{ include "formbricks.hubname" . }}:8080/health; do
attempts=$((attempts+1))
if [ "$attempts" -ge "$max_attempts" ]; then
echo "Hub API health check timed out after $((max_attempts * 5)) seconds"
exit 1
fi
echo "Waiting for Hub API migrations and health check..."
sleep 5
done
{{- end }}
containers:
- name: hub-worker
image: {{ include "formbricks.hubImage" . }}
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
command:
- /app/hub-worker
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
envFrom:
- secretRef:
name: {{ include "formbricks.hubSecretName" . }}
{{- if or .Values.hub.embeddings.enabled (gt (len .Values.hub.env) 0) (gt (len .Values.hub.worker.env) 0) }}
env:
{{- $workerEnv := merge (dict) .Values.hub.env .Values.hub.worker.env }}
{{- include "formbricks.hubEmbeddingEnv" (dict "root" $ "env" $workerEnv) | nindent 12 }}
{{- range $key, $value := .Values.hub.env }}
{{- if and (not (hasKey $.Values.hub.worker.env $key)) (not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key)))) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
{{- range $key, $value := .Values.hub.worker.env }}
{{- if not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key))) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- if .Values.hub.worker.resources }}
resources:
{{- toYaml .Values.hub.worker.resources | nindent 12 }}
{{- end }}
{{- end }}
+315 -5
View File
@@ -557,6 +557,75 @@ serviceMonitor:
path: /metrics
port: metrics
##########################################################
# Cube.js Analytics Configuration
##########################################################
cube:
# Optional internal Cube.js service for XM Suite v5 analytics.
enabled: false
replicas: 1
image:
repository: "cubejs/cube"
tag: "v1.6.6"
pullPolicy: IfNotPresent
imagePullSecrets: []
port: 4000
service:
type: ClusterIP
port: 4000
# Secret values such as CUBEJS_API_SECRET and CUBEJS_DB_* should be supplied
# through envFrom or another secret-management flow.
envFrom: []
env:
CUBEJS_DB_TYPE: "postgres"
CUBEJS_DEFAULT_API_SCOPES: "meta,data"
# Keep the in-memory cache/queue driver at one Cube replica only. The chart
# fails rendering when this remains "memory" and cube.replicas is greater than 1.
CUBEJS_CACHE_AND_QUEUE_DRIVER: "memory"
CUBEJS_JWT_ISSUER: "formbricks-web"
CUBEJS_JWT_AUDIENCE: "formbricks-cube"
containerSecurityContext:
readOnlyRootFilesystem: false
runAsNonRoot: true
runAsUser: 1000
livenessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
readinessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
resources:
limits:
memory: 1Gi
requests:
memory: 512Mi
cpu: "250m"
nodeSelector: {}
tolerations: []
affinity: {}
topologySpreadConstraints: []
##########################################################
# Hub API Configuration
# Formbricks Hub image: ghcr.io/formbricks/hub
@@ -571,10 +640,10 @@ hub:
# Pinned by digest for immutable, reproducible deployments. When digest is set it takes
# precedence over tag, and deployment, init container, and migration job all resolve to the
# same immutable image. Update on each Hub release.
# Current digest corresponds to ghcr.io/formbricks/hub:0.2.0.
digest: "sha256:14db7b3d285b6e9165b55693f9b83d08beff840a255fd77dd12882ee0a62f5cb"
# Current digest corresponds to ghcr.io/formbricks/hub:0.3.0.
digest: "sha256:6c39b1143527137e881df785a5b668625a1fe3edb05485bb5ded19f813c8de88"
# Tag is a fallback for dev/non-prod when digest is cleared; keep aligned with the digest above.
tag: "0.2.0"
tag: "0.3.0"
pullPolicy: IfNotPresent
# Optional override for the secret Hub reads from.
@@ -588,8 +657,249 @@ hub:
# Optional env vars (non-secret). Use existingSecret for secret values such as DATABASE_URL and HUB_API_KEY.
env: {}
# Helm does not deploy Cube. XM Suite v5 analytics requires operators to provide an external Cube instance,
# set deployment.env.CUBEJS_API_URL, and supply CUBEJS_API_SECRET via an existing secret.
# Optional autoscaling for the Hub API deployment.
autoscaling:
enabled: false
additionalLabels: {}
annotations: {}
minReplicas: 1
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior: {}
# Optional PDB for the Hub API deployment. Disabled by default because a single
# Hub replica with minAvailable: 1 blocks voluntary node drains.
pdb:
enabled: false
additionalLabels: {}
annotations: {}
minAvailable: 1
# maxUnavailable: 1
# unhealthyPodEvictionPolicy: AlwaysAllow
worker:
# Hub async jobs (webhook dispatch, embeddings) run in hub-worker. Keep this
# enabled unless another worker deployment processes the same River queues.
enabled: true
replicas: 1
# Optional env vars (non-secret) added only to hub-worker.
env: {}
waitForApi:
# Avoid starting workers before the API service is healthy during installs/upgrades.
enabled: true
# 120 attempts * 5 seconds = 10 minutes.
maxAttempts: 120
resources:
limits:
memory: 512Mi
requests:
memory: 256Mi
cpu: "100m"
autoscaling:
enabled: false
additionalLabels: {}
annotations: {}
minReplicas: 1
maxReplicas: 5
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Pods
value: 1
periodSeconds: 120
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Pods
value: 2
periodSeconds: 60
# Disabled by default because the default worker replica count is 1.
pdb:
enabled: false
additionalLabels: {}
annotations: {}
minAvailable: 1
# maxUnavailable: 1
# unhealthyPodEvictionPolicy: AlwaysAllow
nodeSelector: {}
tolerations: []
affinity: {}
topologySpreadConstraints: []
embeddings:
# Optional self-hosted OpenAI-compatible embeddings runtime for Hub.
enabled: false
runtime: tei
model: Alibaba-NLP/gte-multilingual-base
revision: ""
# Defaults to `model` when empty. Used by TEI OpenAI-compatible responses
# and as Hub's EMBEDDING_MODEL.
servedModelName: ""
# Defaults to http://<release>-hub-embeddings:<service.port>/v1 when empty.
baseUrl: ""
maxConcurrent: "5"
normalize: "false"
replicas: 1
image:
repository: "ghcr.io/huggingface/text-embeddings-inference"
tag: "cpu-1.9"
pullPolicy: IfNotPresent
command: []
# When empty, the chart renders TEI args from model, servedModelName, port,
# revision, and persistence.mountPath. Set this to fully override args.
args: []
extraArgs: []
env: {}
port: 8080
prometheusPort: 9000
service:
type: ClusterIP
port: 8080
annotations: {}
additionalLabels: {}
auth:
# TEI can enforce bearer-token auth with API_KEY. Hub always receives the
# same key as EMBEDDING_PROVIDER_API_KEY because the OpenAI-compatible Hub
# provider requires an API key.
enabled: true
existingSecret: ""
secretKey: EMBEDDING_PROVIDER_API_KEY
apiKey: ""
huggingFace:
# Required only for private/gated models unless the model is pre-cached.
existingSecret: ""
tokenKey: HF_TOKEN
token: ""
persistence:
enabled: true
existingClaim: ""
storageClass: ""
accessModes:
- ReadWriteOnce
size: 10Gi
mountPath: /data
resources:
requests:
cpu: "4"
memory: 8Gi
limits:
memory: 8Gi
autoscaling:
enabled: false
additionalLabels: {}
annotations: {}
minReplicas: 1
maxReplicas: 2
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 600
policies:
- type: Pods
value: 1
periodSeconds: 300
scaleUp:
stabilizationWindowSeconds: 120
policies:
- type: Pods
value: 1
periodSeconds: 120
# Disabled by default because the default embeddings replica count is 1.
pdb:
enabled: false
additionalLabels: {}
annotations: {}
minAvailable: 1
# maxUnavailable: 1
# unhealthyPodEvictionPolicy: AlwaysAllow
probes:
startupProbe:
failureThreshold: 60
periodSeconds: 10
tcpSocket:
port: http
readinessProbe:
failureThreshold: 6
periodSeconds: 10
timeoutSeconds: 5
tcpSocket:
port: http
livenessProbe:
failureThreshold: 6
periodSeconds: 20
timeoutSeconds: 5
tcpSocket:
port: http
podAnnotations: {}
podLabels: {}
podSecurityContext: {}
# Keep empty by default because upstream model-serving images may define
# their own user and need write access to the model cache path.
securityContext: {}
nodeSelector: {}
tolerations: []
affinity: {}
topologySpreadConstraints: []
# XM Suite v5 analytics also requires Cube. Use cube.enabled=true to deploy
# the internal chart-managed Cube service, or set deployment.env.CUBEJS_API_URL
# to an operator-managed Cube endpoint.
# Upgrade migration job runs goose + river before Helm upgrades Hub resources.
# Fresh installs run the same migrations through the Hub deployment init container.
+34 -8
View File
@@ -1,6 +1,6 @@
/* eslint-env es2022 */
const TENANT_MEMBER = "FeedbackRecords.tenantId";
const TENANT_MEMBERS = ["FeedbackRecords.tenantId", "TopicsUnnested.tenantId"];
const REQUIRED_SCOPE = "xm:cube:query";
function assertRequiredEnvironmentVariable(name) {
@@ -114,7 +114,7 @@ function assertValidSecurityContext(securityContext) {
function assertNoCallerTenantMember(query) {
for (const member of collectQueryMembers(query)) {
if (member === TENANT_MEMBER) {
if (TENANT_MEMBERS.includes(member)) {
throw new Error("Cube query rejected: tenant filters are enforced by Cube");
}
}
@@ -122,6 +122,7 @@ function assertNoCallerTenantMember(query) {
function logCubeQueryAuditEvent(context, query, { error, status = "success" } = {}) {
const errorName = error instanceof Error ? error.name : undefined;
const errorMessage = error instanceof Error ? error.message : error ? String(error) : undefined;
console.log(
JSON.stringify({
@@ -138,13 +139,35 @@ function logCubeQueryAuditEvent(context, query, { error, status = "success" } =
source: context.source,
members: collectQueryMembers(query),
...(errorName ? { errorName } : {}),
...(errorMessage ? { errorMessage } : {}),
})
);
}
function logCubeQuerySecurityContextFailure(query, error) {
console.log(
JSON.stringify({
type: "audit",
event: "cube.query",
status: "failure",
timestamp: new Date().toISOString(),
members: collectQueryMembers(query),
errorName: error instanceof Error ? error.name : undefined,
errorMessage: error instanceof Error ? error.message : String(error),
})
);
}
function queryRewrite(query, rewriteContext) {
const cubeQuery = query ?? {};
const context = assertValidSecurityContext(rewriteContext?.securityContext);
let context;
try {
context = assertValidSecurityContext(rewriteContext?.securityContext);
} catch (error) {
logCubeQuerySecurityContextFailure(cubeQuery, error);
throw error;
}
try {
assertNoCallerTenantMember(cubeQuery);
@@ -153,15 +176,18 @@ function queryRewrite(query, rewriteContext) {
throw error;
}
const queriedCubePrefixes = new Set(collectQueryMembers(cubeQuery).map((member) => member.split(".")[0]));
const rewrittenQuery = {
...cubeQuery,
filters: [
...(Array.isArray(cubeQuery.filters) ? cubeQuery.filters : []),
{
member: TENANT_MEMBER,
operator: "equals",
values: [context.tenantId],
},
...TENANT_MEMBERS.filter((member) => queriedCubePrefixes.has(member.split(".")[0])).map(
(member) => ({
member,
operator: "equals",
values: [context.tenantId],
})
),
],
};
+12 -5
View File
@@ -36,7 +36,7 @@ cube(`FeedbackRecords`, {
ELSE ROUND(
(
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
COUNT(CASE WHEN ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
COUNT(CASE WHEN ${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
/ COUNT(*)::numeric
) * 100,
2
@@ -61,7 +61,7 @@ cube(`FeedbackRecords`, {
},
sentiment: {
sql: `sentiment`,
sql: `${CUBE}.metadata->>'sentiment'`,
type: `string`,
description: `Sentiment extracted from metadata JSONB field`,
},
@@ -97,9 +97,9 @@ cube(`FeedbackRecords`, {
},
responseId: {
sql: `response_id`,
sql: `submission_id`,
type: `string`,
description: `Unique identifier linking related feedback records`,
description: `Unique identifier linking related feedback records (submission_id in Hub)`,
},
userId: {
@@ -109,7 +109,7 @@ cube(`FeedbackRecords`, {
},
emotion: {
sql: `emotion`,
sql: `${CUBE}.metadata->>'emotion'`,
type: `string`,
description: `Emotion extracted from metadata JSONB field`,
},
@@ -133,6 +133,7 @@ cube(`TopicsUnnested`, {
sql: `
SELECT
fr.id as feedback_record_id,
fr.tenant_id,
topic_elem.topic
FROM feedback_records fr
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
@@ -156,6 +157,12 @@ cube(`TopicsUnnested`, {
type: `string`,
},
tenantId: {
sql: `tenant_id`,
type: `string`,
description: `Tenant ID for row-level security scoping`,
},
topic: {
sql: `topic`,
type: `string`,
+1 -1
View File
@@ -1,7 +1,7 @@
import type { JobSchedulerTemplateOptions, JobsOptions } from "bullmq";
export const JOBS_QUEUE_NAME = "background-jobs";
export const JOBS_PREFIX = "formbricks:jobs";
export const JOBS_PREFIX = "{formbricks:jobs}";
export const JOB_NAMES = {
testLog: "system.test-log",
+4
View File
@@ -132,6 +132,10 @@ describe("@formbricks/jobs queue helpers", () => {
);
});
test("uses a Redis Cluster hash-tagged prefix for BullMQ keys", () => {
expect(JOBS_PREFIX).toBe("{formbricks:jobs}");
});
test("memoizes the producer queue", async () => {
const first = await getJobsQueue();
const second = await getJobsQueue();