Compare commits

...

71 Commits

Author SHA1 Message Date
Cursor Agent 0493f58976 fix: address language auto-select review feedback 2026-05-14 18:31:55 +00:00
Cursor Agent 3f67305500 test: cover browser language callers 2026-05-14 18:10:34 +00:00
Cursor Agent e4f63a23fe fix: satisfy js-core browser language lint 2026-05-14 18:10:34 +00:00
Cursor Agent fab773eeca test: cover survey language resolution 2026-05-14 18:10:34 +00:00
Cursor Agent 7ef265dec2 chore: add browser language translations 2026-05-14 18:09:37 +00:00
Cursor Agent 74d2eb445f refactor: centralize survey language resolution 2026-05-14 18:08:52 +00:00
Cursor Agent 11a2dcf223 feat: add browser language auto-selection 2026-05-14 18:07:44 +00:00
Bhagya Amarasinghe b7a72ce037 feat: add Traefik gateway auth adapter (#8005) 2026-05-14 22:56:07 +05:30
Bhagya Amarasinghe 86241c47a9 fix: address CodeRabbit gateway auth feedback 2026-05-14 20:33:38 +05:30
Dhruwang Jariwala a9e6bd440d fix(a11y): add feedback source dialog cannot scroll on short screens (#8004) 2026-05-14 16:10:05 +05:30
Javi Aguilar 7c53e7deca fix: prevent duplicated charts in dashboards (#8002)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-14 14:37:49 +04:00
Dhruwang eaf6c889f8 fix(hub): replace missed FormbricksHub.APIError in deleteFeedbackRecord
PR #7992 converted four of the five `err instanceof FormbricksHub.APIError`
checks to `getErrorStatus(err)` and removed the `FormbricksHub` default
import, but left the inline check in `deleteFeedbackRecord`. Since the
import is gone, the orphan reference throws ReferenceError at runtime
(visible in SonarCloud's coverage run on `vitest run --coverage`).

Completes the conversion so all catch handlers use the duck-typed helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:45:06 +05:30
Dhruwang 365c8e88b7 Merge remote-tracking branch 'origin/epic/v5' into fix/eng-924-scrollable-dialog 2026-05-14 15:44:04 +05:30
Dhruwang 3486ab67d7 Removed unnecessary classes 2026-05-14 14:41:08 +05:30
Dhruwang Jariwala defd333d97 fix: refine saving icon and delete source dialog text (#8001) 2026-05-14 14:36:36 +05:30
Dhruwang Jariwala 0e7ea4637d feat(analytics): augment Cube measures with CSAT/CES + correctness fixes (ENG-804) (#7985) 2026-05-14 13:42:58 +05:30
Anshuman Pandey 0475232bad fix: fixes same survey responses from being reported as failures (#7992) 2026-05-14 10:55:51 +04:00
Bhagya Amarasinghe 7b61e3b9bd feat: add Traefik gateway auth adapter 2026-05-14 00:30:47 +05:30
Javi Aguilar b656e94f07 fix(a11y): add feedback source dialog cannot scroll on short screens 2026-05-13 16:32:23 +02:00
Javi Aguilar d73e342028 fix missing dialog description 2026-05-13 15:37:03 +02:00
Javi Aguilar 0a09b68e08 fix: show only loading icon when in saving state 2026-05-13 15:33:49 +02:00
Dhruwang Jariwala 5f5860cb23 feat(unify): add delete option for feedback records (ENG-938) (#7991) 2026-05-13 15:56:55 +04:00
Anshuman Pandey b2a95d4cee fix: correct matrix/ranking feedback records (#7982) 2026-05-13 11:16:52 +04:00
Bhagya Amarasinghe 64b4e18c5a fix(helm): restore TEI float16 dtype (#7988) 2026-05-13 12:28:44 +05:30
Dhruwang Jariwala ae9c1e499a fix: add missing title in feedback directory (#7983) 2026-05-13 12:04:15 +05:30
Dhruwang 0a4e32b848 fix: test 2026-05-13 11:06:19 +05:30
Bhagya Amarasinghe daae319c7a fix(helm): restore TEI float16 dtype 2026-05-13 00:04:33 +05:30
Dhruwang 7d77ed04de refactor(analytics): drop unused dims, broaden CSAT and group-by surface
Remove sentiment, emotion, and the TopicsUnnested join from the Cube
schema, web registry, AI prompt, audit-allowlist, and i18n keys — these
required metadata enrichment that is no longer planned. Tests swap the
removed string dimensions for sourceType to keep coverage intact.

Add csatDissatisfiedCount and csatNeutralCount so dashboards can render
the standard satisfied/neutral/dissatisfied distribution alongside the
existing top-2-box metric.

Expose field_label, field_group_label, language, value_boolean,
value_date, created_at, and updated_at as Cube dimensions. fieldLabel
and fieldGroupLabel unlock "group by question" and the matrix/ranking
aggregations enabled by the recent composite-question PR; the others
round out coverage of the underlying feedback_records columns. Extend
FieldDefinition with a boolean type and matching filter operators.
2026-05-12 18:45:10 +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
Dhruwang 602ffd5bba feat(analytics): augment Cube measures with CSAT/CES + bug fixes (ENG-804)
Adds CSAT and CES measures, plus a couple of universal cross-type measures,
to the FeedbackRecords Cube. Also fixes correctness bugs in the existing NPS
measures and constrains the AI chart-query output to known measure / dimension
ids.

Measures added
- csatScore: % of answered CSAT responses rated 4 or 5 (top-2-box on 1-5).
- csatAverage: AVG of answered CSAT ratings.
- csatSatisfiedCount: count where field_type='csat' AND value_number >= 4.
- csatCount: count of answered CSAT responses.
- cesAverage: AVG of answered CES ratings (1-5 or 1-7 depending on the question).
- cesCount: count of answered CES responses.
- uniqueRespondents: countDistinct(user_id).
- uniqueResponses: countDistinct(submission_id).
- npsAverage: AVG of answered NPS ratings (replaces the old `averageScore`
  measure, which silently averaged every numeric value across all field types).

Bug fixes (existing NPS measures)
- promoterCount / passiveCount / detractorCount / npsScore now filter
  field_type = 'nps'. Before, any numeric value in the band was counted
  (so a CSAT 5/5 was lumped in as a detractor).
- npsScore now divides by *answered* NPS responses only (value_number IS NOT
  NULL); dismissed/abandoned NPS records no longer drag the score toward 0.
- npsScore returns NULL when no answered NPS responses exist instead of the
  literal 0, so empty-data days render as gaps rather than a misleading
  flat-zero line.
- Same NULL-safe denominator fix applied to csatScore.
- csatCount / cesCount now exclude dismissed (value_number IS NULL) responses
  so they match the score denominators.

Dimensions
- Renamed `npsValue` -> `valueNumber` (the underlying value_number column
  stores values for NPS, CSAT, CES, rating, and number field types).
- New `valueText` dimension for grouping by categorical / open-text answers.

AI chart generation
- ZGenerateAIQueryResponse now enum-constrains `measures`, `dimensions`,
  `timeDimensions.dimension`, and `filters.member` against the known ids
  derived from FEEDBACK_FIELDS. Vercel AI SDK + Gemini structured outputs
  enforce these at decoding time, so the model can no longer return invalid
  or hallucinated measure names.
- generateText now pins temperature: 0. Same prompt produces the same query.

Breaking changes
- Charts that referenced `FeedbackRecords.averageScore` need to switch to
  `FeedbackRecords.npsAverage`.
- Charts that referenced `FeedbackRecords.npsValue` need to switch to
  `FeedbackRecords.valueNumber`.

Pre-GA on epic/v5; no production charts are expected to be impacted yet.
2026-05-12 16:05:38 +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
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
137 changed files with 4822 additions and 781 deletions
@@ -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"),
@@ -0,0 +1,14 @@
import { NextRequest } from "next/server";
import { authorizeTraefikRequest } from "@/modules/traefik-auth/service";
const handler = async (request: NextRequest): Promise<Response> => {
return await authorizeTraefikRequest(request);
};
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const DELETE = handler;
export const HEAD = handler;
export const OPTIONS = handler;
@@ -78,6 +78,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
variables: true,
type: true,
showLanguageSwitch: true,
autoSelectLanguage: true,
languages: {
select: {
default: true,
+1
View File
@@ -4925,6 +4925,7 @@ export const previewSurvey = (workspaceName: string, t: TFunction): TSurvey => {
languages: [],
triggers: [],
showLanguageSwitch: false,
autoSelectLanguage: false,
followUps: [],
isBackButtonHidden: false,
isAutoProgressingEnabled: true,
+37 -5
View File
@@ -1604,6 +1604,7 @@ checksums:
workspace/analysis/charts/ai_query_placeholder: 24c3d18f514cb3a9953f04c3b04503a2
workspace/analysis/charts/ai_query_section_description: 66d06342f29bf6658793403856521fd7
workspace/analysis/charts/ai_query_section_title: c0e450a47af7c2a516b77f73cf54db1b
workspace/analysis/charts/already_on_dashboard: c2cee946860c71a71cf03392b2d1fc3a
workspace/analysis/charts/and_filter_logic: 53e8eb67a396fcb5e419bb4cbf0008df
workspace/analysis/charts/apply_changes: ed3da8072dbd27dc0c959777cdcbebf3
workspace/analysis/charts/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
@@ -1665,22 +1666,37 @@ checksums:
workspace/analysis/charts/failed_to_load_dashboards: 876c54d9cc69ceda6f808231e2557eb2
workspace/analysis/charts/failed_to_save_chart: e237cf1a56a8f9ee30067fdb0757f7c5
workspace/analysis/charts/field: cfd632297d7809a3539e90c9cd4728d9
workspace/analysis/charts/field_label_average_score: 5b5aa7322549521d1e813b1c8312d443
workspace/analysis/charts/field_label_ces_average: 3bf598396ea490f3a2bdccf0c94b6aa0
workspace/analysis/charts/field_label_ces_count: 4dc90d50a8e05dd9ba4a9e356926e0cb
workspace/analysis/charts/field_label_collected_at: b41902ddb4586ba4a4611d726b5014aa
workspace/analysis/charts/field_label_count: 9c5848662eb8024ddf360f7e4001a968
workspace/analysis/charts/field_label_created_at: 9ce495d7fc74e1a2ae86c07206a3e531
workspace/analysis/charts/field_label_csat_average: f7c43dac56267f832fbebd6d18efdef1
workspace/analysis/charts/field_label_csat_count: 30c1ab12748b503ffee399ed326e0562
workspace/analysis/charts/field_label_csat_dissatisfied_count: 7f68b2c8302bde5cd93ba86d2163f86d
workspace/analysis/charts/field_label_csat_neutral_count: 19edae275784e8d53dd45003a2e8971a
workspace/analysis/charts/field_label_csat_satisfied_count: 78fcabc88da4b22171be149b1be509bd
workspace/analysis/charts/field_label_csat_score: 89a87f1069641bba10607d5b407cb0aa
workspace/analysis/charts/field_label_detractor_count: eedb15bc383eb0f14d43043e6666c62a
workspace/analysis/charts/field_label_emotion: eb3a31ead51b5c8a8d365d5f904e9206
workspace/analysis/charts/field_label_field_type: 2581066dc304c853a4a817c20996fa08
workspace/analysis/charts/field_label_language: 277fd1a41cc237a437cd1d5e4a80463b
workspace/analysis/charts/field_label_nps_average: 4a61877a06cb8e64a0a8375dd058a548
workspace/analysis/charts/field_label_nps_score: 9c8d0b0b460f9689bd66e81d45e0a2df
workspace/analysis/charts/field_label_nps_value: cb7404025044400e3d7d5600f3133e4f
workspace/analysis/charts/field_label_passive_count: ceb71da8d1382eb2097089dc3ecf76da
workspace/analysis/charts/field_label_promoter_count: c393131a4bd3a25bf6b297beed20e34f
workspace/analysis/charts/field_label_question: 0576462ce60d4263d7c482463fcc9547
workspace/analysis/charts/field_label_question_group: b007e2cfd1262272de3260f8d14d5833
workspace/analysis/charts/field_label_response_id: 73375099cc976dc7203b8e27f5f709e0
workspace/analysis/charts/field_label_sentiment: 9ba5719c80c0136c2d0644217619aff6
workspace/analysis/charts/field_label_source_name: 157675beca12efcd8ec512c5256b1a61
workspace/analysis/charts/field_label_source_type: d1ff69af76c687eb189db72030717570
workspace/analysis/charts/field_label_topic: 7f542b783cd528f00f4f485e35b48dc1
workspace/analysis/charts/field_label_unique_respondents: e340f09af176927f1ed16719ee304274
workspace/analysis/charts/field_label_unique_responses: d9ffcc58f72b9fdb143027703371f22b
workspace/analysis/charts/field_label_updated_at: a3730393cce5adfd9e50123d96640fd6
workspace/analysis/charts/field_label_user_identifier: b0174469c95038766744fb7e64005aec
workspace/analysis/charts/field_label_value_boolean: bbdcd3f46954b6304b9069e94e1371ab
workspace/analysis/charts/field_label_value_date: c8d705d1975affc01c002324725fec3f
workspace/analysis/charts/field_label_value_number: 1f14da79d14bd7b1c2324141f4470675
workspace/analysis/charts/field_label_value_text: e097a597cc507c716401ad18255de578
workspace/analysis/charts/filter_data: 05cc68ed2896feef60bbe3829cd9063d
workspace/analysis/charts/filters: acf5accc113ff3c1992688058576732c
workspace/analysis/charts/filters_toggle_description: ea18bdb212a6a85620125cab89a4b1c1
@@ -1743,6 +1759,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
@@ -3520,6 +3537,10 @@ checksums:
workspace/unify/custom_source_type_placeholder: f139e3e5d70dbf426d7c6b5ab2b198cc
workspace/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
workspace/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
workspace/unify/delete_feedback_record: 86d7262c000cfb1f91ea373036cd3616
workspace/unify/delete_feedback_record_confirmation: dd2b12e75cb52a73f92c997c347a8f36
workspace/unify/delete_feedback_records_confirmation: cd8a4ba828963fb6dab5c96606565079
workspace/unify/delete_source_confirmation: 43b048de6338be1757ade6bf5029b010
workspace/unify/discard_feedback_record_changes_description: 48ccde99858dcbeb4d679749d0f51941
workspace/unify/discard_feedback_record_changes_title: 52df2800f7b0e8a1d04c47113e019a3e
workspace/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
@@ -3529,10 +3550,18 @@ 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_delete_feedback_records: 6096404d164fda196734675885e278c3
workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8
workspace/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
workspace/unify/feedback_directory: 156fa9957e1ee5eadf1f44226a2365e4
workspace/unify/feedback_record_created_successfully: 0ff30472085f1313a5ad53837c83e7c1
workspace/unify/feedback_record_deleted_successfully: ea58f53841cc48dc974f1589f4718da6
workspace/unify/feedback_record_details: 823f3353db049a9d263ef31405054cda
workspace/unify/feedback_record_details_description: 0b6f908154161241ce6bdeb4a2acaecd
workspace/unify/feedback_record_fields: 88c0f13afeb88fe751f85e79b0f73064
@@ -3540,6 +3569,8 @@ checksums:
workspace/unify/feedback_record_updated_successfully: cb40ef4b924e21fa627ebe6809d1d826
workspace/unify/feedback_record_value_required: b54d4d86f82071a93dc979e8eb359cf0
workspace/unify/feedback_records: e24cf48bb6985910f4ffe5e00512d388
workspace/unify/feedback_records_deleted_successfully: 176c27a362d593a62355904c50bb0e9e
workspace/unify/feedback_records_partially_deleted: dff8cd8482e8053ce4186e6b42d0aee8
workspace/unify/feedback_records_refreshed: 4b27a8e2a8dbe8afa945d9f874aa7ef1
workspace/unify/feedback_sources: e58ec9be19db8789e7096a756d24f2b2
workspace/unify/feedback_sources_directory_access_multiple: 11d613bc1e9825aa6faa3db17ae678eb
@@ -3576,6 +3607,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);
}
+291 -32
View File
@@ -40,6 +40,8 @@ const mockSurvey = {
],
} as unknown as TSurvey;
const mockTenantId = "cmp2f6428000504la7iyh87h1";
const mockResponse = {
id: "resp-1",
createdAt: NOW,
@@ -84,13 +86,18 @@ describe("transformResponseToFeedbackRecords", () => {
test("returns empty array when response has no data", () => {
const emptyResponse = { ...mockResponse, data: null } as unknown as TResponse;
const result = transformResponseToFeedbackRecords(emptyResponse, mockSurvey, allMappings);
const result = transformResponseToFeedbackRecords(emptyResponse, mockSurvey, allMappings, mockTenantId);
expect(result).toEqual([]);
});
test("returns empty array when no mappings match the survey", () => {
const otherSurveyMappings = allMappings.map((m) => ({ ...m, surveyId: "other-survey" }));
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, otherSurveyMappings);
const result = transformResponseToFeedbackRecords(
mockResponse,
mockSurvey,
otherSurveyMappings,
mockTenantId
);
expect(result).toEqual([]);
});
@@ -100,7 +107,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-text": "" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result).toEqual([]);
});
@@ -113,14 +120,14 @@ describe("transformResponseToFeedbackRecords", () => {
createMapping({ elementId: "el-text", hubFieldType: "text" }),
createMapping({ elementId: "el-nps", hubFieldType: "nps" }),
];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].field_id).toBe("el-nps");
});
test("transforms text field correctly", () => {
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
source_type: "formbricks_survey",
@@ -137,7 +144,7 @@ describe("transformResponseToFeedbackRecords", () => {
test("transforms nps field correctly", () => {
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].value_number).toBe(9);
expect(result[0].field_type).toBe("nps");
@@ -145,28 +152,28 @@ describe("transformResponseToFeedbackRecords", () => {
test("transforms rating field correctly", () => {
const mappings = [createMapping({ elementId: "el-rating", hubFieldType: "rating" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].value_number).toBe(4);
});
test("transforms date field to ISO string", () => {
const mappings = [createMapping({ elementId: "el-date", hubFieldType: "date" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].value_date).toBe(new Date("2026-01-15").toISOString());
});
test("transforms boolean field correctly", () => {
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].value_boolean).toBe(true);
});
test("transforms categorical (multi-select) field to comma-separated text", () => {
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].value_text).toBe("feat-a, feat-b");
});
@@ -175,13 +182,13 @@ describe("transformResponseToFeedbackRecords", () => {
const mappings = [
createMapping({ elementId: "el-text", hubFieldType: "text", customFieldLabel: "Custom Label" }),
];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
expect(result[0].field_label).toBe("Custom Label");
});
test("sets collected_at from response createdAt", () => {
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
expect(result[0].collected_at).toBe(NOW.toISOString());
});
@@ -189,7 +196,7 @@ describe("transformResponseToFeedbackRecords", () => {
const updatedAt = new Date("2026-02-25T10:00:00.000Z");
const response = { ...mockResponse, createdAt: undefined, updatedAt } as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].collected_at).toBe(updatedAt.toISOString());
});
@@ -199,7 +206,7 @@ describe("transformResponseToFeedbackRecords", () => {
createdAt: "2026-02-26T10:00:00.000Z",
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].collected_at).toBe("2026-02-26T10:00:00.000Z");
});
@@ -209,28 +216,22 @@ describe("transformResponseToFeedbackRecords", () => {
expect(result[0].tenant_id).toBe("tenant-abc");
});
test("omits tenant_id when not provided", () => {
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result[0].tenant_id).toBeUndefined();
});
test("omits language when response language is 'default'", () => {
const response = { ...mockResponse, language: "default" } as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].language).toBeUndefined();
});
test("omits user_id when contact has no userId", () => {
const response = { ...mockResponse, contact: null } as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].user_id).toBeUndefined();
});
test("transforms all mappings in a single call", () => {
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, allMappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, allMappings, mockTenantId);
expect(result).toHaveLength(6);
const fieldIds = result.map((r) => r.field_id);
expect(fieldIds).toEqual(["el-text", "el-nps", "el-rating", "el-date", "el-bool", "el-multi"]);
@@ -246,7 +247,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-bare": "some text" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-bare", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, survey, mappings);
const result = transformResponseToFeedbackRecords(response, survey, mappings, mockTenantId);
expect(result[0].field_label).toBe("Untitled");
});
@@ -257,7 +258,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-nps": "7" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_number).toBe(7);
});
@@ -267,7 +268,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-nps": "not-a-number" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_number).toBeUndefined();
});
@@ -277,7 +278,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-text": { nested: "value" } },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_text).toBe(JSON.stringify({ nested: "value" }));
});
@@ -287,7 +288,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-date": "not-a-date" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-date", hubFieldType: "date" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_date).toBeUndefined();
});
@@ -297,7 +298,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-bool": "1" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_boolean).toBe(true);
});
@@ -307,7 +308,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-bool": "false" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_boolean).toBe(false);
});
@@ -317,7 +318,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-text": ["a", "b", "c"] },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_text).toBe("a, b, c");
});
@@ -327,8 +328,266 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-multi": "single-choice" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_text).toBe("single-choice");
});
test("JSON-stringifies object value for categorical field (matrix/ranking responses)", () => {
const response = {
...mockResponse,
data: { "el-multi": { row1: "col1", row2: "col2" } },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_text).toBe(JSON.stringify({ row1: "col1", row2: "col2" }));
expect(result[0].value_text).not.toBe("[object Object]");
});
test("creates a record for a ranking response (string array)", () => {
const response = {
...mockResponse,
data: { "el-multi": ["LabelA", "LabelB", "LabelC"] },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].field_id).toBe("el-multi");
expect(result[0].value_text).toBe("LabelA, LabelB, LabelC");
});
test("creates a record for an empty ranking response (empty array)", () => {
const response = {
...mockResponse,
data: { "el-multi": [] },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].value_text).toBe("");
});
test("JSON-stringifies object value for unknown hubFieldType (default branch)", () => {
const response = {
...mockResponse,
data: { "el-multi": { row1: "col1" } },
} as unknown as TResponse;
const mappings = [
createMapping({
elementId: "el-multi",
hubFieldType: "unknown-type" as TConnectorFormbricksMapping["hubFieldType"],
}),
];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_text).toBe(JSON.stringify({ row1: "col1" }));
expect(result[0].value_text).not.toBe("[object Object]");
});
});
describe("matrix expansion", () => {
const matrixSurvey = {
id: "survey-1",
name: "Matrix Survey",
blocks: [
{
elements: [
{
id: "el-matrix",
type: "matrix",
headline: { default: "Rate each feature" },
rows: [
{ id: "row-1", label: { default: "Speed" } },
{ id: "row-2", label: { default: "Quality" } },
],
columns: [
{ id: "col-1", label: { default: "Good" } },
{ id: "col-2", label: { default: "Bad" } },
],
},
],
},
],
} as unknown as TSurvey;
test("emits one record per answered row with shared field_group_id", () => {
const response = {
id: "resp-matrix",
createdAt: NOW,
data: { "el-matrix": { Speed: "Good", Quality: "Bad" } },
language: "default",
contact: { userId: "user-42" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
expect(result).toHaveLength(2);
expect(result.every((r) => r.field_group_id === "el-matrix")).toBe(true);
expect(result.every((r) => r.field_group_label === "Rate each feature")).toBe(true);
expect(result.every((r) => r.submission_id === "resp-matrix")).toBe(true);
expect(result.every((r) => r.metadata?.question_type === "matrix")).toBe(true);
expect(result[0]).toMatchObject({
field_id: "el-matrix__row-1",
field_label: "Speed",
field_type: "categorical",
value_text: "Good",
});
expect(result[1]).toMatchObject({
field_id: "el-matrix__row-2",
field_label: "Quality",
value_text: "Bad",
});
});
test("skips matrix rows with empty cell value", () => {
const response = {
id: "resp-matrix-partial",
createdAt: NOW,
data: { "el-matrix": { Speed: "Good", Quality: "" } },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].field_id).toBe("el-matrix__row-1");
});
test("skips matrix rows whose label does not match any row choice", () => {
const response = {
id: "resp-matrix-stale",
createdAt: NOW,
data: { "el-matrix": { "Old Row Label": "Good", Quality: "Bad" } },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].field_id).toBe("el-matrix__row-2");
});
test("JSON-stringifies non-string matrix cell value (regression for ENG-891)", () => {
const cellObject = { a: 1 };
const response = {
id: "resp-matrix-obj",
createdAt: NOW,
data: { "el-matrix": { Speed: cellObject } },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
field_id: "el-matrix__row-1",
field_label: "Speed",
field_group_id: "el-matrix",
field_group_label: "Rate each feature",
metadata: { question_type: "matrix" },
value_text: JSON.stringify(cellObject),
});
expect(result[0].value_text).not.toBe("[object Object]");
});
test("emits no records for empty matrix response", () => {
const response = {
id: "resp-empty",
createdAt: NOW,
data: { "el-matrix": {} },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
expect(result).toEqual([]);
});
});
describe("ranking expansion", () => {
const rankingSurvey = {
id: "survey-1",
name: "Ranking Survey",
blocks: [
{
elements: [
{
id: "el-ranking",
type: "ranking",
headline: { default: "Rank these features" },
choices: [
{ id: "ch-1", label: { default: "Reports" } },
{ id: "ch-2", label: { default: "Dashboards" } },
{ id: "ch-3", label: { default: "Alerts" } },
],
},
],
},
],
} as unknown as TSurvey;
test("emits one record per ranked item with rank as value_number", () => {
const response = {
id: "resp-ranking",
createdAt: NOW,
data: { "el-ranking": ["Dashboards", "Reports", "Alerts"] },
language: "default",
contact: { userId: "user-42" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-ranking", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, rankingSurvey, mappings, mockTenantId);
expect(result).toHaveLength(3);
expect(result.every((r) => r.field_group_id === "el-ranking")).toBe(true);
expect(result.every((r) => r.field_group_label === "Rank these features")).toBe(true);
expect(result.every((r) => r.field_type === "number")).toBe(true);
expect(result.every((r) => r.metadata?.question_type === "ranking")).toBe(true);
expect(result.every((r) => r.metadata?.total_items === 3)).toBe(true);
expect(result[0]).toMatchObject({
field_id: "el-ranking__ch-2",
field_label: "Dashboards",
value_number: 1,
});
expect(result[1]).toMatchObject({
field_id: "el-ranking__ch-1",
field_label: "Reports",
value_number: 2,
});
expect(result[2]).toMatchObject({
field_id: "el-ranking__ch-3",
field_label: "Alerts",
value_number: 3,
});
});
test("emits no records for empty ranking response", () => {
const response = {
id: "resp-empty",
createdAt: NOW,
data: { "el-ranking": [] },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-ranking", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, rankingSurvey, mappings, mockTenantId);
expect(result).toEqual([]);
});
test("skips ranking items whose label does not match any choice", () => {
const response = {
id: "resp-ranking-stale",
createdAt: NOW,
data: { "el-ranking": ["Reports", "Removed Option"] },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-ranking", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, rankingSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].field_id).toBe("el-ranking__ch-1");
expect(result[0].value_number).toBe(1);
});
});
});
+137 -16
View File
@@ -1,8 +1,15 @@
import "server-only";
import { TConnectorFormbricksMapping, THubFieldType } from "@formbricks/types/connector";
import { TResponse, TResponseData, TResponseDataValue } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import type {
TSurveyElement,
TSurveyElementChoice,
TSurveyMatrixElement,
TSurveyMatrixElementChoice,
TSurveyRankingElement,
} from "@formbricks/types/surveys/elements";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getElementsFromBlocks } from "@/lib/survey/utils";
@@ -14,6 +21,18 @@ const getHeadlineFromElement = (element?: TSurveyElement): string => {
return getTextContent(raw) || "Untitled";
};
const getChoiceLabel = (choice: { label: TSurveyElementChoice["label"] }, language: string): string => {
return getTextContent(getLocalizedValue(choice.label, language));
};
const findChoiceByLabel = <T extends { id: string; label: TSurveyElementChoice["label"] }>(
choices: T[],
label: string,
language: string
): T | undefined => {
return choices.find((choice) => getChoiceLabel(choice, language) === label);
};
const toIsoTimestamp = (value: unknown): string | null => {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
@@ -83,16 +102,114 @@ const convertValueToHubFields = (
case "categorical":
if (typeof value === "string") return { value_text: value };
if (Array.isArray(value)) return { value_text: value.join(", ") };
if (typeof value === "object") return { value_text: JSON.stringify(value) };
return { value_text: String(value) };
default:
return { value_text: typeof value === "string" ? value : String(value) };
if (typeof value === "string") return { value_text: value };
if (Array.isArray(value)) return { value_text: value.join(", ") };
if (typeof value === "object") return { value_text: JSON.stringify(value) };
return { value_text: String(value) };
}
};
type BaseRecordFields = Pick<
FeedbackRecordCreateParams,
"collected_at" | "source_type" | "submission_id" | "tenant_id" | "source_id" | "source_name"
> & {
language?: string;
user_id?: string;
};
const buildBaseFields = (
response: TResponse,
survey: Pick<TSurvey, "id" | "name">,
tenantId: string
): BaseRecordFields => ({
collected_at: getCollectedAt(response),
source_type: "formbricks_survey",
submission_id: response.id,
tenant_id: tenantId,
source_id: survey.id,
source_name: survey.name,
...(response.language && response.language !== "default" ? { language: response.language } : {}),
...(response.contact?.userId ? { user_id: response.contact.userId } : {}),
});
const expandMatrixToRecords = (
element: TSurveyMatrixElement,
mapping: TConnectorFormbricksMapping,
value: TResponseDataValue,
baseFields: BaseRecordFields
): FeedbackRecordCreateParams[] => {
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
const language = baseFields.language ?? "default";
const groupLabel = mapping.customFieldLabel || getHeadlineFromElement(element);
const records: FeedbackRecordCreateParams[] = [];
for (const [rowLabel, columnLabel] of Object.entries(value)) {
if (columnLabel === undefined || columnLabel === null || columnLabel === "") continue;
const row = findChoiceByLabel<TSurveyMatrixElementChoice>(element.rows, rowLabel, language);
if (!row) continue;
const valueFields = convertValueToHubFields(columnLabel as TResponseDataValue, mapping.hubFieldType);
records.push({
...baseFields,
field_id: `${element.id}__${row.id}`,
field_type: mapping.hubFieldType,
field_label: getChoiceLabel(row, "default"),
field_group_id: element.id,
field_group_label: groupLabel,
metadata: { question_type: "matrix" },
...valueFields,
});
}
return records;
};
const expandRankingToRecords = (
element: TSurveyRankingElement,
mapping: TConnectorFormbricksMapping,
value: TResponseDataValue,
baseFields: BaseRecordFields
): FeedbackRecordCreateParams[] => {
if (!Array.isArray(value) || value.length === 0) return [];
const language = baseFields.language ?? "default";
const groupLabel = mapping.customFieldLabel || getHeadlineFromElement(element);
const records: FeedbackRecordCreateParams[] = [];
value.forEach((itemLabel, index) => {
if (typeof itemLabel !== "string" || itemLabel === "") return;
const choice = findChoiceByLabel<TSurveyElementChoice>(element.choices, itemLabel, language);
if (!choice) return;
records.push({
...baseFields,
field_id: `${element.id}__${choice.id}`,
field_type: "number",
field_label: getChoiceLabel(choice, "default"),
field_group_id: element.id,
field_group_label: groupLabel,
metadata: { question_type: "ranking", total_items: value.length },
value_number: index + 1,
});
});
return records;
};
/**
* Transform a Formbricks survey response into FeedbackRecord payloads.
* Called from the pipeline handler when a response is created/finished.
*
* Matrix and ranking questions expand into one record per row/item, sharing a
* field_group_id so Hub analytics can aggregate across them.
*/
export function transformResponseToFeedbackRecords(
response: TResponse,
@@ -106,31 +223,35 @@ export function transformResponseToFeedbackRecords(
const surveyMappings = mappings.filter((m) => m.surveyId === survey.id);
const elements = getElementsFromBlocks(survey.blocks);
const elementMap = new Map(elements.map((el) => [el.id, el]));
const baseFields = buildBaseFields(response, survey, tenantId);
const feedbackRecords: FeedbackRecordCreateParams[] = [];
for (const mapping of surveyMappings) {
const value = extractResponseValue(responseData, mapping.elementId);
if (value === undefined || value === null || value === "") continue;
const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(elementMap.get(mapping.elementId));
const element = elementMap.get(mapping.elementId);
if (element?.type === TSurveyElementTypeEnum.Matrix) {
feedbackRecords.push(...expandMatrixToRecords(element, mapping, value, baseFields));
continue;
}
if (element?.type === TSurveyElementTypeEnum.Ranking) {
feedbackRecords.push(...expandRankingToRecords(element, mapping, value, baseFields));
continue;
}
const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(element);
const valueFields = convertValueToHubFields(value, mapping.hubFieldType);
const feedbackRecord = {
collected_at: getCollectedAt(response),
source_type: "formbricks_survey",
submission_id: response.id,
tenant_id: tenantId,
feedbackRecords.push({
...baseFields,
field_id: mapping.elementId,
field_type: mapping.hubFieldType,
source_id: survey.id,
source_name: survey.name,
field_label: fieldLabel,
...(response.language && response.language !== "default" ? { language: response.language } : {}),
...(response.contact?.userId ? { user_id: response.contact.userId } : {}),
...valueFields,
};
feedbackRecords.push(feedbackRecord as FeedbackRecordCreateParams);
});
}
return feedbackRecords;
@@ -216,6 +216,7 @@ const baseSurveyProperties = {
},
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
autoSelectLanguage: null,
attributeFilters: [],
...commonMockProperties,
};
+67
View File
@@ -24,6 +24,7 @@ import {
mockActionClass,
mockId,
mockOrganizationOutput,
mockSurveyLanguages,
mockSurveyOutput,
mockSurveyWithLogic,
mockTransformedSurveyOutput,
@@ -628,6 +629,33 @@ describe("Tests for createSurvey", () => {
languages: [],
} as TSurveyCreateInput;
const getMultiLanguageCreateSurveyInput = (): TSurveyCreateInput =>
({
...mockCreateSurveyInput,
welcomeCard: {
...mockCreateSurveyInput.welcomeCard,
headline: { default: "Welcome", de: "Willkommen" },
},
questions: mockCreateSurveyInput.questions.map((question) => {
if ("choices" in question && Array.isArray(question.choices)) {
return {
...question,
headline: { ...question.headline, de: question.headline.default },
choices: question.choices.map((choice) => ({
...choice,
label: { ...choice.label, de: choice.label.default },
})),
};
}
return {
...question,
headline: { ...question.headline, de: question.headline.default },
};
}),
languages: mockSurveyLanguages,
}) as TSurveyCreateInput;
const mockActionClasses = [
{
id: "action-123",
@@ -660,6 +688,45 @@ describe("Tests for createSurvey", () => {
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled();
});
test("enables browser language auto-selection by default for new multi-language surveys", async () => {
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput);
prisma.survey.create.mockResolvedValueOnce({
...mockSurveyOutput,
});
await createSurvey(mockWorkspaceId, {
...getMultiLanguageCreateSurveyInput(),
});
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
autoSelectLanguage: true,
}),
})
);
});
test("preserves explicit browser language auto-selection setting on create", async () => {
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput);
prisma.survey.create.mockResolvedValueOnce({
...mockSurveyOutput,
});
await createSurvey(mockWorkspaceId, {
...getMultiLanguageCreateSurveyInput(),
autoSelectLanguage: false,
});
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
autoSelectLanguage: false,
}),
})
);
});
test("creates a private segment for app surveys", async () => {
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput);
prisma.survey.create.mockResolvedValueOnce({
+4
View File
@@ -64,6 +64,7 @@ export const selectSurvey = {
singleUse: true,
pin: true,
showLanguageSwitch: true,
autoSelectLanguage: true,
recaptcha: true,
metadata: true,
customHeadScripts: true,
@@ -630,6 +631,7 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
const { createdBy, languages, ...restSurveyBody } = parsedSurveyBody;
const normalizedCloseOn = restSurveyBody.closeOn instanceof Date ? restSurveyBody.closeOn : null;
const normalizedPublishOn = restSurveyBody.publishOn instanceof Date ? restSurveyBody.publishOn : null;
const hasMultipleEnabledLanguages = (languages ?? []).filter((language) => language.enabled).length > 1;
const actionClasses = await getActionClasses(parsedWorkspaceId);
@@ -640,6 +642,8 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
publishOn: normalizedPublishOn,
status: restSurveyBody.status ?? "draft",
}),
autoSelectLanguage:
restSurveyBody.autoSelectLanguage ?? (hasMultipleEnabledLanguages ? true : undefined),
// @ts-expect-error - languages would be undefined in case of empty array
languages: languages?.length ? languages : undefined,
triggers: restSurveyBody.triggers
+21 -1
View File
@@ -2,7 +2,7 @@ import * as nextHeaders from "next/headers";
import { describe, expect, test, vi } from "vitest";
import { AVAILABLE_LOCALES, DEFAULT_LOCALE } from "@/lib/constants";
import { appLanguages } from "@/lib/i18n/utils";
import { findMatchingLocale } from "./locale";
import { findMatchingBrowserLanguageCodes, findMatchingLocale } from "./locale";
// Mock the Next.js headers function
vi.mock("next/headers", () => ({
@@ -36,6 +36,26 @@ describe("locale", () => {
expect(nextHeaders.headers).toHaveBeenCalled();
});
test("ignores Accept-Language quality values when matching locales", async () => {
vi.mocked(nextHeaders.headers).mockReturnValue({
get: vi.fn().mockReturnValue("de-DE;q=0.9,en-US;q=0.8"),
} as any);
const result = await findMatchingLocale();
expect(result).toBe("de-DE");
});
test("returns browser language codes without quality values", async () => {
vi.mocked(nextHeaders.headers).mockReturnValue({
get: vi.fn().mockReturnValue("es-MX,es;q=0.9,en-US;q=0.8"),
} as any);
const result = await findMatchingBrowserLanguageCodes();
expect(result).toEqual(["es-MX", "es", "en-US"]);
});
test("returns normalized match when available", async () => {
// Assuming we have 'en-US' in AVAILABLE_LOCALES but not 'en-GB'
const availableLocale = AVAILABLE_LOCALES.find((locale) => locale.startsWith("en-"));
+16 -2
View File
@@ -2,11 +2,25 @@ import { headers } from "next/headers";
import { TUserLocale } from "@formbricks/types/user";
import { AVAILABLE_LOCALES, DEFAULT_LOCALE } from "@/lib/constants";
const getAcceptedLanguageCodesFromHeader = (acceptLanguage: string | null): string[] => {
return (
acceptLanguage
?.split(",")
.map((language) => language.trim().split(";")[0].trim())
.filter(Boolean) ?? []
);
};
export const findMatchingBrowserLanguageCodes = async (): Promise<string[]> => {
const headersList = await headers();
return getAcceptedLanguageCodesFromHeader(headersList.get("accept-language"));
};
export const findMatchingLocale = async (): Promise<TUserLocale> => {
const headersList = await headers();
const acceptLanguage = headersList.get("accept-language");
const userLocales = acceptLanguage?.split(",");
if (!userLocales) {
const userLocales = getAcceptedLanguageCodesFromHeader(acceptLanguage);
if (!userLocales.length) {
return DEFAULT_LOCALE;
}
// First, try to find an exact match without normalization
+39 -5
View File
@@ -1669,6 +1669,7 @@
"ai_query_placeholder": "z.B. Wie viele Nutzer haben sich letzte Woche angemeldet?",
"ai_query_section_description": "Beschreibe, was du sehen möchtest, und lass die KI das Diagramm erstellen.",
"ai_query_section_title": "Frag deine Daten",
"already_on_dashboard": "Bereits im Dashboard",
"and_filter_logic": "UND",
"apply_changes": "Änderungen übernehmen",
"chart": "Diagramm",
@@ -1730,22 +1731,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Diagramm konnte nicht gespeichert werden",
"field": "Feld",
"field_label_average_score": "Durchschnittliche Bewertung",
"field_label_ces_average": "CES-Durchschnitt",
"field_label_ces_count": "CES-Anzahl",
"field_label_collected_at": "Erfasst am",
"field_label_count": "Anzahl",
"field_label_created_at": "Erstellt am",
"field_label_csat_average": "CSAT-Durchschnitt",
"field_label_csat_count": "CSAT-Anzahl",
"field_label_csat_dissatisfied_count": "CSAT Unzufriedene Anzahl",
"field_label_csat_neutral_count": "CSAT Neutrale Anzahl",
"field_label_csat_satisfied_count": "CSAT-Zufriedene Anzahl",
"field_label_csat_score": "CSAT-Score",
"field_label_detractor_count": "Anzahl Kritiker",
"field_label_emotion": "Emotion",
"field_label_field_type": "Feldtyp",
"field_label_language": "Sprache",
"field_label_nps_average": "NPS-Durchschnitt",
"field_label_nps_score": "NPS-Score",
"field_label_nps_value": "NPS-Wert",
"field_label_passive_count": "Anzahl Passive",
"field_label_promoter_count": "Anzahl Promoter",
"field_label_question": "Frage",
"field_label_question_group": "Fragengruppe",
"field_label_response_id": "Antwort-ID",
"field_label_sentiment": "Stimmung",
"field_label_source_name": "Quellenname",
"field_label_source_type": "Quellentyp",
"field_label_topic": "Thema",
"field_label_unique_respondents": "Eindeutige Teilnehmer",
"field_label_unique_responses": "Eindeutige Antworten",
"field_label_updated_at": "Aktualisiert am",
"field_label_user_identifier": "Benutzerkennung",
"field_label_value_boolean": "Wert (Boolean)",
"field_label_value_date": "Wert (Datum)",
"field_label_value_number": "Wert (Zahl)",
"field_label_value_text": "Wert (Text)",
"filter_data": "Daten filtern",
"filters": "Filter",
"filters_toggle_description": "Nur Daten einbeziehen, die die folgenden Bedingungen erfüllen.",
@@ -1810,6 +1826,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",
@@ -2816,6 +2833,8 @@
"auto_save_disabled": "Automatisches Speichern deaktiviert",
"auto_save_disabled_tooltip": "Deine Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht versehentlich aktualisiert werden.",
"auto_save_on": "Automatisches Speichern an",
"auto_select_browser_language": "Browsersprache standardmäßig verwenden",
"auto_select_browser_language_description": "Öffnet die Umfrage automatisch in der Browsersprache der befragten Person, wenn diese Sprache aktiv ist. Fällt auf die Standardsprache zurück.",
"automatically_close_survey_after": "Umfrage automatisch schließen nach",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Schließe die Umfrage automatisch nach einer bestimmten Anzahl an Antworten.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Schließe die Umfrage automatisch, wenn der Nutzer nach einer bestimmten Anzahl an Sekunden nicht antwortet.",
@@ -3680,6 +3699,10 @@
"custom_source_type_placeholder": "Geben Sie den benutzerdefinierten Quelltyp ein",
"default_connector_name_csv": "CSV-Import",
"default_connector_name_formbricks": "Formbricks-Umfrage-Verbindung",
"delete_feedback_record": "Feedback-Eintrag löschen",
"delete_feedback_record_confirmation": "Dadurch wird der Feedback-Eintrag dauerhaft gelöscht und aus dem verbundenen Verzeichnis entfernt.",
"delete_feedback_records_confirmation": "Dadurch werden {count} Feedback-Einträge dauerhaft gelöscht und aus dem verbundenen Verzeichnis entfernt.",
"delete_source_confirmation": "Wenn du diese Quelle löschst, werden zukünftige Importe gestoppt und das gespeicherte Mapping entfernt. Vorhandene Feedback-Datensätze bleiben weiterhin verfügbar.",
"discard_feedback_record_changes_description": "Ihre Änderungen gehen verloren, wenn Sie diese Schublade schließen.",
"discard_feedback_record_changes_title": "Nicht gespeicherte Änderungen verwerfen?",
"drop_a_field_here": "Ziehe ein Feld hierher",
@@ -3689,10 +3712,18 @@
"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_delete_feedback_records": "Feedback-Einträge konnten nicht gelöscht werden",
"failed_to_load_feedback_records": "Feedback-Einträge konnten nicht geladen werden",
"feedback_date": "Aktuelles Datum",
"feedback_directory": "Feedback-Verzeichnis",
"feedback_record_created_successfully": "Feedback-Datensatz erfolgreich erstellt",
"feedback_record_deleted_successfully": "Feedback-Eintrag erfolgreich gelöscht",
"feedback_record_details": "Details zum Feedback-Datensatz",
"feedback_record_details_description": "Überprüfen und aktualisieren Sie die Felder des Feedback-Datensatzes.",
"feedback_record_fields": "Feedback-Eintragsfelder",
@@ -3700,6 +3731,8 @@
"feedback_record_updated_successfully": "Feedback-Datensatz erfolgreich aktualisiert",
"feedback_record_value_required": "Für den ausgewählten Feldtyp ist ein Wert erforderlich",
"feedback_records": "Feedback-Einträge",
"feedback_records_deleted_successfully": "{count} Feedback-Einträge gelöscht",
"feedback_records_partially_deleted": "{succeeded} von {total} Feedback-Einträgen gelöscht",
"feedback_records_refreshed": "Feedback-Einträge aktualisiert",
"feedback_sources": "Feedback-Quellen",
"feedback_sources_directory_access_multiple": "Neue Datensätze aus diesen Quellen werden gespeichert in: {directoryNames}",
@@ -3736,6 +3769,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",
+39 -5
View File
@@ -1669,6 +1669,7 @@
"ai_query_placeholder": "e.g. How many users signed up last week?",
"ai_query_section_description": "Describe what you want to see and let AI build the chart.",
"ai_query_section_title": "Ask your data",
"already_on_dashboard": "Already on dashboard",
"and_filter_logic": "AND",
"apply_changes": "Apply Changes",
"chart": "Chart",
@@ -1730,22 +1731,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Failed to save chart",
"field": "Field",
"field_label_average_score": "Average Score",
"field_label_ces_average": "CES Average",
"field_label_ces_count": "CES Count",
"field_label_collected_at": "Collected At",
"field_label_count": "Count",
"field_label_created_at": "Created At",
"field_label_csat_average": "CSAT Average",
"field_label_csat_count": "CSAT Count",
"field_label_csat_dissatisfied_count": "CSAT Dissatisfied Count",
"field_label_csat_neutral_count": "CSAT Neutral Count",
"field_label_csat_satisfied_count": "CSAT Satisfied Count",
"field_label_csat_score": "CSAT Score",
"field_label_detractor_count": "Detractor Count",
"field_label_emotion": "Emotion",
"field_label_field_type": "Field Type",
"field_label_language": "Language",
"field_label_nps_average": "NPS Average",
"field_label_nps_score": "NPS Score",
"field_label_nps_value": "NPS Value",
"field_label_passive_count": "Passive Count",
"field_label_promoter_count": "Promoter Count",
"field_label_question": "Question",
"field_label_question_group": "Question Group",
"field_label_response_id": "Response ID",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Source Name",
"field_label_source_type": "Source Type",
"field_label_topic": "Topic",
"field_label_unique_respondents": "Unique Respondents",
"field_label_unique_responses": "Unique Responses",
"field_label_updated_at": "Updated At",
"field_label_user_identifier": "User Identifier",
"field_label_value_boolean": "Value (Boolean)",
"field_label_value_date": "Value (Date)",
"field_label_value_number": "Value (Number)",
"field_label_value_text": "Value (Text)",
"filter_data": "Filter data",
"filters": "Filters",
"filters_toggle_description": "Only include data that meets the following conditions.",
@@ -1810,6 +1826,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",
@@ -2816,6 +2833,8 @@
"auto_save_disabled": "Auto-save disabled",
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
"auto_save_on": "Auto-save on",
"auto_select_browser_language": "Use browser language by default",
"auto_select_browser_language_description": "Automatically open the survey in the respondent's browser language when that language is active. Falls back to the default language.",
"automatically_close_survey_after": "Automatically close survey after",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Automatically close the survey after a certain number of responses.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Automatically close the survey if the user does not respond after certain number of seconds.",
@@ -3680,6 +3699,10 @@
"custom_source_type_placeholder": "Enter custom source type",
"default_connector_name_csv": "CSV Import",
"default_connector_name_formbricks": "Formbricks Survey Connection",
"delete_feedback_record": "Delete feedback record",
"delete_feedback_record_confirmation": "This will permanently delete the feedback record and remove it from the connected directory.",
"delete_feedback_records_confirmation": "This will permanently delete {count} feedback records and remove them from the connected directory.",
"delete_source_confirmation": "Deleting this source will stop future imports and remove its saved mapping. Existing feedback records will remain available.",
"discard_feedback_record_changes_description": "Your changes will be lost if you close this drawer.",
"discard_feedback_record_changes_title": "Discard unsaved changes?",
"drop_a_field_here": "Drop a field here",
@@ -3689,10 +3712,18 @@
"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_delete_feedback_records": "Failed to delete feedback records",
"failed_to_load_feedback_records": "Failed to load feedback records",
"feedback_date": "Current date",
"feedback_directory": "Feedback Directory",
"feedback_record_created_successfully": "Feedback record created successfully",
"feedback_record_deleted_successfully": "Feedback record deleted successfully",
"feedback_record_details": "Feedback record details",
"feedback_record_details_description": "Review and update feedback record fields.",
"feedback_record_fields": "Feedback Record Fields",
@@ -3700,6 +3731,8 @@
"feedback_record_updated_successfully": "Feedback record updated successfully",
"feedback_record_value_required": "A value is required for the selected field type",
"feedback_records": "Feedback Records",
"feedback_records_deleted_successfully": "{count} feedback records deleted",
"feedback_records_partially_deleted": "{succeeded} of {total} feedback records deleted",
"feedback_records_refreshed": "Feedback records refreshed",
"feedback_sources": "Feedback Sources",
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
@@ -3736,6 +3769,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",
+39 -5
View File
@@ -1669,6 +1669,7 @@
"ai_query_placeholder": "p. ej. ¿Cuántos usuarios se registraron la semana pasada?",
"ai_query_section_description": "Describe lo que quieres ver y deja que la IA construya el gráfico.",
"ai_query_section_title": "Pregunta a tus datos",
"already_on_dashboard": "Ya está en el panel",
"and_filter_logic": "Y",
"apply_changes": "Aplicar cambios",
"chart": "Gráfico",
@@ -1730,22 +1731,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Error al guardar el gráfico",
"field": "Campo",
"field_label_average_score": "Puntuación media",
"field_label_ces_average": "Promedio CES",
"field_label_ces_count": "Recuento CES",
"field_label_collected_at": "Recopilado el",
"field_label_count": "Recuento",
"field_label_created_at": "Fecha de creación",
"field_label_csat_average": "Promedio CSAT",
"field_label_csat_count": "Recuento CSAT",
"field_label_csat_dissatisfied_count": "Recuento de insatisfechos CSAT",
"field_label_csat_neutral_count": "Recuento de neutros CSAT",
"field_label_csat_satisfied_count": "Recuento de Satisfechos CSAT",
"field_label_csat_score": "Puntuación CSAT",
"field_label_detractor_count": "Recuento de detractores",
"field_label_emotion": "Emoción",
"field_label_field_type": "Tipo de campo",
"field_label_language": "Idioma",
"field_label_nps_average": "Promedio NPS",
"field_label_nps_score": "Puntuación NPS",
"field_label_nps_value": "Valor NPS",
"field_label_passive_count": "Recuento de pasivos",
"field_label_promoter_count": "Recuento de promotores",
"field_label_question": "Pregunta",
"field_label_question_group": "Grupo de preguntas",
"field_label_response_id": "ID de respuesta",
"field_label_sentiment": "Sentimiento",
"field_label_source_name": "Nombre de origen",
"field_label_source_type": "Tipo de origen",
"field_label_topic": "Tema",
"field_label_unique_respondents": "Encuestados Únicos",
"field_label_unique_responses": "Respuestas Únicas",
"field_label_updated_at": "Fecha de actualización",
"field_label_user_identifier": "Identificador de usuario",
"field_label_value_boolean": "Valor (Booleano)",
"field_label_value_date": "Valor (Fecha)",
"field_label_value_number": "Valor (Número)",
"field_label_value_text": "Valor (Texto)",
"filter_data": "Filtrar datos",
"filters": "Filtros",
"filters_toggle_description": "Incluye solo los datos que cumplan las siguientes condiciones.",
@@ -1810,6 +1826,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",
@@ -2816,6 +2833,8 @@
"auto_save_disabled": "Guardado automático desactivado",
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
"auto_save_on": "Guardado automático activado",
"auto_select_browser_language": "Usar el idioma del navegador por defecto",
"auto_select_browser_language_description": "Abre automáticamente la encuesta en el idioma del navegador de la persona encuestada cuando ese idioma está activo. Si no coincide, usa el idioma predeterminado.",
"automatically_close_survey_after": "Cerrar automáticamente la encuesta después de",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Cerrar automáticamente la encuesta después de un cierto número de respuestas.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Cerrar automáticamente la encuesta si el usuario no responde después de cierto número de segundos.",
@@ -3680,6 +3699,10 @@
"custom_source_type_placeholder": "Ingrese el tipo de fuente personalizado",
"default_connector_name_csv": "Importación CSV",
"default_connector_name_formbricks": "Conexión de encuesta de Formbricks",
"delete_feedback_record": "Eliminar registro de comentarios",
"delete_feedback_record_confirmation": "Esto eliminará permanentemente el registro de comentarios y lo quitará del directorio conectado.",
"delete_feedback_records_confirmation": "Esto eliminará permanentemente {count} registros de comentarios y los quitará del directorio conectado.",
"delete_source_confirmation": "Eliminar esta fuente detendrá futuras importaciones y eliminará su mapeo guardado. Los registros de comentarios existentes seguirán estando disponibles.",
"discard_feedback_record_changes_description": "Sus cambios se perderán si cierra este cajón.",
"discard_feedback_record_changes_title": "¿Descartar los cambios no guardados?",
"drop_a_field_here": "Suelta un campo aquí",
@@ -3689,10 +3712,18 @@
"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_delete_feedback_records": "No se pudieron eliminar los registros de comentarios",
"failed_to_load_feedback_records": "Error al cargar los registros de comentarios",
"feedback_date": "Fecha actual",
"feedback_directory": "Directorio de feedback",
"feedback_record_created_successfully": "Registro de comentarios creado correctamente",
"feedback_record_deleted_successfully": "Registro de comentarios eliminado correctamente",
"feedback_record_details": "Detalles del registro de comentarios",
"feedback_record_details_description": "Revise y actualice los campos del registro de comentarios.",
"feedback_record_fields": "Campos de registro de comentarios",
@@ -3700,6 +3731,8 @@
"feedback_record_updated_successfully": "Registro de comentarios actualizado correctamente",
"feedback_record_value_required": "Se requiere un valor para el tipo de campo seleccionado",
"feedback_records": "Registros de comentarios",
"feedback_records_deleted_successfully": "{count} registros de comentarios eliminados",
"feedback_records_partially_deleted": "{succeeded} de {total} registros de comentarios eliminados",
"feedback_records_refreshed": "Registros de comentarios actualizados",
"feedback_sources": "Fuentes de feedback",
"feedback_sources_directory_access_multiple": "Los nuevos registros de estas fuentes se almacenarán en: {directoryNames}",
@@ -3736,6 +3769,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",
+39 -5
View File
@@ -1669,6 +1669,7 @@
"ai_query_placeholder": "ex. Combien d'utilisateurs se sont inscrits la semaine dernière?",
"ai_query_section_description": "Décrivez ce que vous souhaitez voir et laissez l'IA créer le graphique.",
"ai_query_section_title": "Interrogez vos données",
"already_on_dashboard": "Déjà sur le tableau de bord",
"and_filter_logic": "ET",
"apply_changes": "Appliquer les modifications",
"chart": "Graphique",
@@ -1730,22 +1731,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Échec de l'enregistrement du graphique",
"field": "Champ",
"field_label_average_score": "Score moyen",
"field_label_ces_average": "Moyenne CES",
"field_label_ces_count": "Nombre CES",
"field_label_collected_at": "Collecté le",
"field_label_count": "Nombre",
"field_label_created_at": "Créé le",
"field_label_csat_average": "Moyenne CSAT",
"field_label_csat_count": "Nombre CSAT",
"field_label_csat_dissatisfied_count": "Nombre d'insatisfaits CSAT",
"field_label_csat_neutral_count": "Nombre de neutres CSAT",
"field_label_csat_satisfied_count": "Nombre de clients satisfaits CSAT",
"field_label_csat_score": "Score CSAT",
"field_label_detractor_count": "Nombre de détracteurs",
"field_label_emotion": "Émotion",
"field_label_field_type": "Type de champ",
"field_label_language": "Langue",
"field_label_nps_average": "Moyenne NPS",
"field_label_nps_score": "Score NPS",
"field_label_nps_value": "Valeur NPS",
"field_label_passive_count": "Nombre de passifs",
"field_label_promoter_count": "Nombre de promoteurs",
"field_label_question": "Question",
"field_label_question_group": "Groupe de questions",
"field_label_response_id": "ID de réponse",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Nom de la source",
"field_label_source_type": "Type de source",
"field_label_topic": "Sujet",
"field_label_unique_respondents": "Répondants uniques",
"field_label_unique_responses": "Réponses uniques",
"field_label_updated_at": "Mis à jour le",
"field_label_user_identifier": "Identifiant utilisateur",
"field_label_value_boolean": "Valeur (booléenne)",
"field_label_value_date": "Valeur (date)",
"field_label_value_number": "Valeur (Nombre)",
"field_label_value_text": "Valeur (Texte)",
"filter_data": "Filtrer les données",
"filters": "Filtres",
"filters_toggle_description": "Inclure uniquement les données qui répondent aux conditions suivantes.",
@@ -1810,6 +1826,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",
@@ -2816,6 +2833,8 @@
"auto_save_disabled": "Sauvegarde automatique désactivée",
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
"auto_save_on": "Sauvegarde automatique activée",
"auto_select_browser_language": "Utiliser la langue du navigateur par défaut",
"auto_select_browser_language_description": "Ouvre automatiquement l'enquête dans la langue du navigateur de la personne interrogée lorsque cette langue est active. Sinon, la langue par défaut est utilisée.",
"automatically_close_survey_after": "Fermer automatiquement l'enquête après",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fermer automatiquement l'enquête après un certain nombre de réponses.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fermer automatiquement l'enquête si l'utilisateur ne répond pas après un certain nombre de secondes.",
@@ -3680,6 +3699,10 @@
"custom_source_type_placeholder": "Entrez le type de source personnalisé",
"default_connector_name_csv": "Importation CSV",
"default_connector_name_formbricks": "Connexion de sondage Formbricks",
"delete_feedback_record": "Supprimer l'enregistrement de commentaire",
"delete_feedback_record_confirmation": "Cela supprimera définitivement l'enregistrement de commentaire et le retirera du répertoire connecté.",
"delete_feedback_records_confirmation": "Cela supprimera définitivement {count} enregistrements de commentaires et les retirera du répertoire connecté.",
"delete_source_confirmation": "La suppression de cette source arrêtera les futures importations et supprimera sa configuration de mapping enregistrée. Les enregistrements de feedback existants resteront disponibles.",
"discard_feedback_record_changes_description": "Vos modifications seront perdues si vous fermez ce tiroir.",
"discard_feedback_record_changes_title": "Supprimer les modifications non enregistrées ?",
"drop_a_field_here": "Déposez un champ ici",
@@ -3689,10 +3712,18 @@
"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_delete_feedback_records": "Échec de la suppression des enregistrements de commentaires",
"failed_to_load_feedback_records": "Échec du chargement des enregistrements de feedback",
"feedback_date": "Date actuelle",
"feedback_directory": "Répertoire de retours",
"feedback_record_created_successfully": "Enregistrement de commentaires créé avec succès",
"feedback_record_deleted_successfully": "Enregistrement de commentaire supprimé avec succès",
"feedback_record_details": "Détails de l'enregistrement des commentaires",
"feedback_record_details_description": "Examiner et mettre à jour les champs denregistrement des commentaires.",
"feedback_record_fields": "Champs d'enregistrement de feedback",
@@ -3700,6 +3731,8 @@
"feedback_record_updated_successfully": "L'enregistrement des commentaires a été mis à jour avec succès",
"feedback_record_value_required": "Une valeur est requise pour le type de champ sélectionné",
"feedback_records": "Enregistrements de feedback",
"feedback_records_deleted_successfully": "{count} enregistrements de commentaires supprimés",
"feedback_records_partially_deleted": "{succeeded} enregistrements de commentaires supprimés sur {total}",
"feedback_records_refreshed": "Enregistrements de feedback actualisés",
"feedback_sources": "Sources de feedback",
"feedback_sources_directory_access_multiple": "Les nouveaux enregistrements de ces sources seront stockés dans : {directoryNames}",
@@ -3736,6 +3769,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",
+39 -5
View File
@@ -1669,6 +1669,7 @@
"ai_query_placeholder": "pl. Hány felhasználó regisztrált a múlt héten?",
"ai_query_section_description": "Írd le, mit szeretnél látni, és hagyd, hogy az AI elkészítse a diagramot.",
"ai_query_section_title": "Kérdezd meg az adataidat",
"already_on_dashboard": "Már a vezérlőpulton van",
"and_filter_logic": "ÉS",
"apply_changes": "Módosítások alkalmazása",
"chart": "Diagram",
@@ -1730,22 +1731,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "A diagram mentése sikertelen",
"field": "Mező",
"field_label_average_score": "Átlagos pontszám",
"field_label_ces_average": "CES átlag",
"field_label_ces_count": "CES darabszám",
"field_label_collected_at": "Gyűjtve",
"field_label_count": "Darabszám",
"field_label_created_at": "Létrehozás dátuma",
"field_label_csat_average": "CSAT átlag",
"field_label_csat_count": "CSAT darabszám",
"field_label_csat_dissatisfied_count": "CSAT elégedetlen válaszok száma",
"field_label_csat_neutral_count": "CSAT semleges válaszok száma",
"field_label_csat_satisfied_count": "CSAT elégedett válaszadók száma",
"field_label_csat_score": "CSAT pontszám",
"field_label_detractor_count": "Kritikusok száma",
"field_label_emotion": "Érzelem",
"field_label_field_type": "Mező típusa",
"field_label_language": "Nyelv",
"field_label_nps_average": "NPS átlag",
"field_label_nps_score": "NPS pontszám",
"field_label_nps_value": "NPS érték",
"field_label_passive_count": "Passzívak száma",
"field_label_promoter_count": "Támogatók száma",
"field_label_question": "Kérdés",
"field_label_question_group": "Kérdéscsoport",
"field_label_response_id": "Válaszazonosító",
"field_label_sentiment": "Hangulat",
"field_label_source_name": "Forrás neve",
"field_label_source_type": "Forrás típusa",
"field_label_topic": "Téma",
"field_label_unique_respondents": "Egyedi válaszadók",
"field_label_unique_responses": "Egyedi válaszok",
"field_label_updated_at": "Frissítés dátuma",
"field_label_user_identifier": "Felhasználóazonosító",
"field_label_value_boolean": "Érték (logikai)",
"field_label_value_date": "Érték (dátum)",
"field_label_value_number": "Érték (szám)",
"field_label_value_text": "Érték (szöveg)",
"filter_data": "Adatok szűrése",
"filters": "Szűrők",
"filters_toggle_description": "Csak azokat az adatokat tartalmazza, amelyek megfelelnek a következő feltételeknek.",
@@ -1810,6 +1826,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",
@@ -2816,6 +2833,8 @@
"auto_save_disabled": "Az automatikus mentés letiltva",
"auto_save_disabled_tooltip": "A kérdőív csak akkor kerül automatikusan mentésre, ha piszkozatban van. Ez biztosítja, hogy a nyilvános kérdőívek ne legyenek véletlenül frissítve.",
"auto_save_on": "Automatikus mentés bekapcsolva",
"auto_select_browser_language": "A böngésző nyelvének használata alapértelmezésként",
"auto_select_browser_language_description": "Automatikusan a válaszadó böngészőnyelvén nyitja meg a kérdőívet, ha ez a nyelv aktív. Ellenkező esetben az alapértelmezett nyelvre vált.",
"automatically_close_survey_after": "Kérdőív automatikus lezárása ezután:",
"automatically_close_the_survey_after_a_certain_number_of_responses": "A kérdőív automatikus lezárása egy bizonyos számú válasz után.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "A kérdőív automatikus lezárása, ha a felhasználó nem válaszol egy bizonyos másodpercnyi idő után.",
@@ -3680,6 +3699,10 @@
"custom_source_type_placeholder": "Adja meg az egyéni forrástípust",
"default_connector_name_csv": "CSV importálás",
"default_connector_name_formbricks": "Formbricks kérdőív kapcsolat",
"delete_feedback_record": "Visszajelzési bejegyzés törlése",
"delete_feedback_record_confirmation": "Ez véglegesen törli a visszajelzési bejegyzést, és eltávolítja a csatlakoztatott könyvtárból.",
"delete_feedback_records_confirmation": "Ez véglegesen töröl {count} visszajelzési bejegyzést, és eltávolítja őket a csatlakoztatott könyvtárból.",
"delete_source_confirmation": "Ezen forrás törlésével leállítja a jövőbeli importálásokat, és eltávolítja annak mentett leképezését. A meglévő visszajelzési rekordok továbbra is elérhetők maradnak.",
"discard_feedback_record_changes_description": "A módosítások elvesznek, ha bezárja ezt a fiókot.",
"discard_feedback_record_changes_title": "Elveti a nem mentett módosításokat?",
"drop_a_field_here": "Húzz ide egy mezőt",
@@ -3689,10 +3712,18 @@
"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_delete_feedback_records": "A visszajelzési rekordok törlése sikertelen",
"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",
"feedback_record_created_successfully": "A visszajelzési rekord sikeresen létrehozva",
"feedback_record_deleted_successfully": "A visszajelzési bejegyzés sikeresen törölve",
"feedback_record_details": "A visszajelzési rekord részletei",
"feedback_record_details_description": "Tekintse át és frissítse a visszajelzési rekordmezőket.",
"feedback_record_fields": "Visszajelzési rekord mezők",
@@ -3700,6 +3731,8 @@
"feedback_record_updated_successfully": "A visszajelzési rekord sikeresen frissítve",
"feedback_record_value_required": "A kiválasztott mezőtípushoz értéket kell megadni",
"feedback_records": "Visszajelzési rekordok",
"feedback_records_deleted_successfully": "{count} visszajelzési bejegyzés törölve",
"feedback_records_partially_deleted": "{succeeded} / {total} visszajelzési rekord törölve",
"feedback_records_refreshed": "Visszajelzési rekordok frissítve",
"feedback_sources": "Visszajelzési források",
"feedback_sources_directory_access_multiple": "Az ezekből a forrásokból származó új rekordok a következő helyen lesznek tárolva: {directoryNames}",
@@ -3736,6 +3769,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ó",
+39 -5
View File
@@ -1669,6 +1669,7 @@
"ai_query_placeholder": "例: 先週何人のユーザーが登録しましたか?",
"ai_query_section_description": "表示したい内容を説明すると、AIがチャートを作成します。",
"ai_query_section_title": "データに質問する",
"already_on_dashboard": "すでにダッシュボードに追加済み",
"and_filter_logic": "AND",
"apply_changes": "変更を適用",
"chart": "チャート",
@@ -1730,22 +1731,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "チャートの保存に失敗しました",
"field": "フィールド",
"field_label_average_score": "平均スコア",
"field_label_ces_average": "CES平均",
"field_label_ces_count": "CES件数",
"field_label_collected_at": "収集日時",
"field_label_count": "カウント",
"field_label_created_at": "作成日時",
"field_label_csat_average": "CSAT平均",
"field_label_csat_count": "CSAT件数",
"field_label_csat_dissatisfied_count": "CSAT 不満足数",
"field_label_csat_neutral_count": "CSAT 中立数",
"field_label_csat_satisfied_count": "CSAT満足件数",
"field_label_csat_score": "CSATスコア",
"field_label_detractor_count": "批判者数",
"field_label_emotion": "感情",
"field_label_field_type": "フィールドタイプ",
"field_label_language": "言語",
"field_label_nps_average": "NPS平均",
"field_label_nps_score": "NPSスコア",
"field_label_nps_value": "NPS値",
"field_label_passive_count": "中立者数",
"field_label_promoter_count": "推奨者数",
"field_label_question": "質問",
"field_label_question_group": "質問グループ",
"field_label_response_id": "回答ID",
"field_label_sentiment": "感情分析",
"field_label_source_name": "ソース名",
"field_label_source_type": "ソースタイプ",
"field_label_topic": "トピック",
"field_label_unique_respondents": "ユニーク回答者数",
"field_label_unique_responses": "ユニーク回答数",
"field_label_updated_at": "更新日時",
"field_label_user_identifier": "ユーザー識別子",
"field_label_value_boolean": "値(ブール値)",
"field_label_value_date": "値(日付)",
"field_label_value_number": "値(数値)",
"field_label_value_text": "値(テキスト)",
"filter_data": "データをフィルター",
"filters": "フィルター",
"filters_toggle_description": "以下の条件を満たすデータのみを含めます。",
@@ -1810,6 +1826,7 @@
},
"dashboards": {
"add_count_charts": "{count}個のグラフを追加",
"chart_removed": "チャートがダッシュボードから削除されました",
"charts_add_failed": "ダッシュボードへのグラフの追加に失敗しました",
"charts_add_partial_failure": "{count}個のグラフの追加に失敗しました",
"charts_added_to_dashboard": "グラフをダッシュボードに追加しました",
@@ -2816,6 +2833,8 @@
"auto_save_disabled": "自動保存が無効",
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
"auto_save_on": "自動保存オン",
"auto_select_browser_language": "ブラウザーの言語をデフォルトで使用",
"auto_select_browser_language_description": "その言語が有効な場合、回答者のブラウザー言語でアンケートを自動的に開きます。一致しない場合はデフォルト言語に戻ります。",
"automatically_close_survey_after": "フォームを自動的に閉じる",
"automatically_close_the_survey_after_a_certain_number_of_responses": "一定の回答数に達した後にフォームを自動的に閉じます。",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
@@ -3680,6 +3699,10 @@
"custom_source_type_placeholder": "カスタムソースタイプを入力してください",
"default_connector_name_csv": "CSVインポート",
"default_connector_name_formbricks": "Formbricks フォーム接続",
"delete_feedback_record": "フィードバック記録を削除",
"delete_feedback_record_confirmation": "この操作により、フィードバック記録が完全に削除され、接続されているディレクトリから削除されます。",
"delete_feedback_records_confirmation": "この操作により、{count}件のフィードバック記録が完全に削除され、接続されているディレクトリから削除されます。",
"delete_source_confirmation": "このソースを削除すると、今後のインポートが停止され、保存されたマッピングが削除されます。既存のフィードバック記録は引き続き利用可能です。",
"discard_feedback_record_changes_description": "このドロワーを閉じると、変更内容は失われます。",
"discard_feedback_record_changes_title": "保存されていない変更を破棄しますか?",
"drop_a_field_here": "ここにフィールドをドロップ",
@@ -3689,10 +3712,18 @@
"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_delete_feedback_records": "フィードバックレコードの削除に失敗しました",
"failed_to_load_feedback_records": "フィードバックレコードの読み込みに失敗しました",
"feedback_date": "現在の日付",
"feedback_directory": "フィードバックディレクトリ",
"feedback_record_created_successfully": "フィードバックレコードが正常に作成されました",
"feedback_record_deleted_successfully": "フィードバック記録を削除しました",
"feedback_record_details": "フィードバック記録の詳細",
"feedback_record_details_description": "フィードバック レコード フィールドを確認して更新します。",
"feedback_record_fields": "フィードバックレコードフィールド",
@@ -3700,6 +3731,8 @@
"feedback_record_updated_successfully": "フィードバックレコードが正常に更新されました",
"feedback_record_value_required": "選択したフィールド タイプには値が必要です",
"feedback_records": "フィードバックレコード",
"feedback_records_deleted_successfully": "{count}件のフィードバック記録を削除しました",
"feedback_records_partially_deleted": "{total}件中{succeeded}件のフィードバックレコードを削除しました",
"feedback_records_refreshed": "フィードバックレコードを更新しました",
"feedback_sources": "フィードバックソース",
"feedback_sources_directory_access_multiple": "これらのソースからの新しいレコードは次の場所に保存されます:{directoryNames}",
@@ -3736,6 +3769,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": "任意",
+39 -5
View File
@@ -1669,6 +1669,7 @@
"ai_query_placeholder": "bijv. Hoeveel gebruikers hebben zich vorige week aangemeld?",
"ai_query_section_description": "Beschrijf wat je wilt zien en laat AI de grafiek bouwen.",
"ai_query_section_title": "Vraag het aan je data",
"already_on_dashboard": "Al op dashboard",
"and_filter_logic": "EN",
"apply_changes": "Wijzigingen toepassen",
"chart": "Grafiek",
@@ -1730,22 +1731,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Opslaan van diagram mislukt",
"field": "Veld",
"field_label_average_score": "Gemiddelde score",
"field_label_ces_average": "CES Gemiddelde",
"field_label_ces_count": "CES Aantal",
"field_label_collected_at": "Verzameld op",
"field_label_count": "Aantal",
"field_label_created_at": "Aangemaakt op",
"field_label_csat_average": "CSAT Gemiddelde",
"field_label_csat_count": "CSAT Aantal",
"field_label_csat_dissatisfied_count": "CSAT Aantal ontevreden",
"field_label_csat_neutral_count": "CSAT Aantal neutraal",
"field_label_csat_satisfied_count": "CSAT Tevreden Aantal",
"field_label_csat_score": "CSAT Score",
"field_label_detractor_count": "Aantal detractors",
"field_label_emotion": "Emotie",
"field_label_field_type": "Veldtype",
"field_label_language": "Taal",
"field_label_nps_average": "NPS Gemiddelde",
"field_label_nps_score": "NPS-score",
"field_label_nps_value": "NPS-waarde",
"field_label_passive_count": "Aantal passieven",
"field_label_promoter_count": "Aantal promoters",
"field_label_question": "Vraag",
"field_label_question_group": "Vraaggroep",
"field_label_response_id": "Antwoord-ID",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Bronnaam",
"field_label_source_type": "Brontype",
"field_label_topic": "Onderwerp",
"field_label_unique_respondents": "Unieke Respondenten",
"field_label_unique_responses": "Unieke Antwoorden",
"field_label_updated_at": "Bijgewerkt op",
"field_label_user_identifier": "Gebruikersidentificatie",
"field_label_value_boolean": "Waarde (Boolean)",
"field_label_value_date": "Waarde (Datum)",
"field_label_value_number": "Waarde (Getal)",
"field_label_value_text": "Waarde (Tekst)",
"filter_data": "Data filteren",
"filters": "Filters",
"filters_toggle_description": "Neem alleen gegevens op die aan de volgende voorwaarden voldoen.",
@@ -1810,6 +1826,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",
@@ -2816,6 +2833,8 @@
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
"auto_save_on": "Automatisch opslaan aan",
"auto_select_browser_language": "Browsertaal standaard gebruiken",
"auto_select_browser_language_description": "Opent de enquête automatisch in de browsertaal van de respondent wanneer die taal actief is. Valt terug op de standaardtaal.",
"automatically_close_survey_after": "Sluit de enquête daarna automatisch af",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Sluit de enquête automatisch af na een bepaald aantal reacties.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Sluit de enquête automatisch af als de gebruiker na een bepaald aantal seconden niet reageert.",
@@ -3680,6 +3699,10 @@
"custom_source_type_placeholder": "Voer een aangepast brontype in",
"default_connector_name_csv": "CSV import",
"default_connector_name_formbricks": "Formbricks Survey verbinding",
"delete_feedback_record": "Feedbackrecord verwijderen",
"delete_feedback_record_confirmation": "Hiermee wordt het feedbackrecord permanent verwijderd en uit de gekoppelde map gehaald.",
"delete_feedback_records_confirmation": "Hiermee worden {count} feedbackrecords permanent verwijderd en uit de gekoppelde map gehaald.",
"delete_source_confirmation": "Het verwijderen van deze bron stopt toekomstige imports en verwijdert de opgeslagen mapping. Bestaande feedbackgegevens blijven beschikbaar.",
"discard_feedback_record_changes_description": "Als u deze lade sluit, gaan uw wijzigingen verloren.",
"discard_feedback_record_changes_title": "Niet-opgeslagen wijzigingen verwijderen?",
"drop_a_field_here": "Zet hier een veld neer",
@@ -3689,10 +3712,18 @@
"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_delete_feedback_records": "Feedbackgegevens verwijderen mislukt",
"failed_to_load_feedback_records": "Kan feedbackrecords niet laden",
"feedback_date": "Huidige datum",
"feedback_directory": "Feedbackmap",
"feedback_record_created_successfully": "Feedbackrecord is succesvol aangemaakt",
"feedback_record_deleted_successfully": "Feedbackrecord succesvol verwijderd",
"feedback_record_details": "Details van feedbackrecord",
"feedback_record_details_description": "Controleer en update de feedbackrecordvelden.",
"feedback_record_fields": "Feedbackrecordvelden",
@@ -3700,6 +3731,8 @@
"feedback_record_updated_successfully": "Feedbackrecord is succesvol bijgewerkt",
"feedback_record_value_required": "Er is een waarde vereist voor het geselecteerde veldtype",
"feedback_records": "Feedbackrecords",
"feedback_records_deleted_successfully": "{count} feedbackrecords verwijderd",
"feedback_records_partially_deleted": "{succeeded} van {total} feedbackgegevens verwijderd",
"feedback_records_refreshed": "Feedbackrecords vernieuwd",
"feedback_sources": "Feedbackbronnen",
"feedback_sources_directory_access_multiple": "Nieuwe records van deze bronnen worden opgeslagen in: {directoryNames}",
@@ -3736,6 +3769,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",
+39 -5
View File
@@ -1669,6 +1669,7 @@
"ai_query_placeholder": "ex: Quantos usuários se cadastraram na semana passada?",
"ai_query_section_description": "Descreva o que você quer ver e deixe a IA construir o gráfico.",
"ai_query_section_title": "Pergunte aos seus dados",
"already_on_dashboard": "Já está no painel",
"and_filter_logic": "E",
"apply_changes": "Aplicar alterações",
"chart": "Gráfico",
@@ -1730,22 +1731,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Falha ao salvar gráfico",
"field": "Campo",
"field_label_average_score": "Pontuação média",
"field_label_ces_average": "Média CES",
"field_label_ces_count": "Contagem CES",
"field_label_collected_at": "Coletado em",
"field_label_count": "Contagem",
"field_label_created_at": "Criado em",
"field_label_csat_average": "Média CSAT",
"field_label_csat_count": "Contagem CSAT",
"field_label_csat_dissatisfied_count": "Contagem de Insatisfeitos CSAT",
"field_label_csat_neutral_count": "Contagem de Neutros CSAT",
"field_label_csat_satisfied_count": "Contagem de Satisfeitos CSAT",
"field_label_csat_score": "Pontuação CSAT",
"field_label_detractor_count": "Contagem de detratores",
"field_label_emotion": "Emoção",
"field_label_field_type": "Tipo de campo",
"field_label_language": "Idioma",
"field_label_nps_average": "Média NPS",
"field_label_nps_score": "Pontuação de NPS",
"field_label_nps_value": "Valor de NPS",
"field_label_passive_count": "Contagem de passivos",
"field_label_promoter_count": "Contagem de promotores",
"field_label_question": "Pergunta",
"field_label_question_group": "Grupo de Perguntas",
"field_label_response_id": "ID da resposta",
"field_label_sentiment": "Sentimento",
"field_label_source_name": "Nome da fonte",
"field_label_source_type": "Tipo de fonte",
"field_label_topic": "Tópico",
"field_label_unique_respondents": "Respondentes Únicos",
"field_label_unique_responses": "Respostas Únicas",
"field_label_updated_at": "Atualizado em",
"field_label_user_identifier": "Identificador do usuário",
"field_label_value_boolean": "Valor (Booleano)",
"field_label_value_date": "Valor (Data)",
"field_label_value_number": "Valor (Número)",
"field_label_value_text": "Valor (Texto)",
"filter_data": "Filtrar dados",
"filters": "Filtros",
"filters_toggle_description": "Incluir apenas dados que atendam às seguintes condições.",
@@ -1810,6 +1826,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",
@@ -2816,6 +2833,8 @@
"auto_save_disabled": "Salvamento automático desativado",
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
"auto_save_on": "Salvamento automático ativado",
"auto_select_browser_language": "Usar o idioma do navegador por padrão",
"auto_select_browser_language_description": "Abre automaticamente a pesquisa no idioma do navegador do respondente quando esse idioma está ativo. Caso contrário, usa o idioma padrão.",
"automatically_close_survey_after": "Fechar pesquisa automaticamente após",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente a pesquisa depois de um certo número de respostas.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Feche automaticamente a pesquisa se o usuário não responder depois de alguns segundos.",
@@ -3680,6 +3699,10 @@
"custom_source_type_placeholder": "Insira o tipo de fonte personalizado",
"default_connector_name_csv": "Importação CSV",
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
"delete_feedback_record": "Excluir registro de feedback",
"delete_feedback_record_confirmation": "Isso excluirá permanentemente o registro de feedback e o removerá do diretório conectado.",
"delete_feedback_records_confirmation": "Isso excluirá permanentemente {count} registros de feedback e os removerá do diretório conectado.",
"delete_source_confirmation": "Excluir esta fonte interromperá futuras importações e removerá seu mapeamento salvo. Os registros de feedback existentes permanecerão disponíveis.",
"discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.",
"discard_feedback_record_changes_title": "Descartar alterações não salvas?",
"drop_a_field_here": "Solte um campo aqui",
@@ -3689,10 +3712,18 @@
"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_delete_feedback_records": "Falha ao excluir registros de feedback",
"failed_to_load_feedback_records": "Falha ao carregar registros de feedback",
"feedback_date": "Data atual",
"feedback_directory": "Diretório de Feedback",
"feedback_record_created_successfully": "Registro de feedback criado com sucesso",
"feedback_record_deleted_successfully": "Registro de feedback excluído com sucesso",
"feedback_record_details": "Detalhes do registro de feedback",
"feedback_record_details_description": "Revise e atualize os campos de registro de feedback.",
"feedback_record_fields": "Campos do registro de feedback",
@@ -3700,6 +3731,8 @@
"feedback_record_updated_successfully": "Registro de feedback atualizado com sucesso",
"feedback_record_value_required": "Um valor é obrigatório para o tipo de campo selecionado",
"feedback_records": "Registros de feedback",
"feedback_records_deleted_successfully": "{count} registros de feedback excluídos",
"feedback_records_partially_deleted": "{succeeded} de {total} registros de feedback excluídos",
"feedback_records_refreshed": "Registros de feedback atualizados",
"feedback_sources": "Fontes de Feedback",
"feedback_sources_directory_access_multiple": "Novos registros dessas fontes serão armazenados em: {directoryNames}",
@@ -3736,6 +3769,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",
+39 -5
View File
@@ -1669,6 +1669,7 @@
"ai_query_placeholder": "ex: Quantos utilizadores se registaram na semana passada?",
"ai_query_section_description": "Descreve o que queres ver e deixa a IA construir o gráfico.",
"ai_query_section_title": "Pergunta aos teus dados",
"already_on_dashboard": "Já está no painel",
"and_filter_logic": "E",
"apply_changes": "Aplicar alterações",
"chart": "Gráfico",
@@ -1730,22 +1731,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Falha ao guardar gráfico",
"field": "Campo",
"field_label_average_score": "Pontuação média",
"field_label_ces_average": "Média CES",
"field_label_ces_count": "Contagem CES",
"field_label_collected_at": "Recolhido em",
"field_label_count": "Contagem",
"field_label_created_at": "Criado em",
"field_label_csat_average": "Média CSAT",
"field_label_csat_count": "Contagem CSAT",
"field_label_csat_dissatisfied_count": "Contagem de CSAT Insatisfeito",
"field_label_csat_neutral_count": "Contagem de CSAT Neutro",
"field_label_csat_satisfied_count": "Contagem de Satisfeitos CSAT",
"field_label_csat_score": "Pontuação CSAT",
"field_label_detractor_count": "Contagem de detratores",
"field_label_emotion": "Emoção",
"field_label_field_type": "Tipo de campo",
"field_label_language": "Idioma",
"field_label_nps_average": "Média NPS",
"field_label_nps_score": "Pontuação NPS",
"field_label_nps_value": "Valor NPS",
"field_label_passive_count": "Contagem de passivos",
"field_label_promoter_count": "Contagem de promotores",
"field_label_question": "Pergunta",
"field_label_question_group": "Grupo de Perguntas",
"field_label_response_id": "ID de resposta",
"field_label_sentiment": "Sentimento",
"field_label_source_name": "Nome da origem",
"field_label_source_type": "Tipo de origem",
"field_label_topic": "Tópico",
"field_label_unique_respondents": "Inquiridos Únicos",
"field_label_unique_responses": "Respostas Únicas",
"field_label_updated_at": "Atualizado em",
"field_label_user_identifier": "Identificador de utilizador",
"field_label_value_boolean": "Valor (Booleano)",
"field_label_value_date": "Valor (Data)",
"field_label_value_number": "Valor (Número)",
"field_label_value_text": "Valor (Texto)",
"filter_data": "Filtrar dados",
"filters": "Filtros",
"filters_toggle_description": "Incluir apenas dados que cumpram as seguintes condições.",
@@ -1810,6 +1826,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",
@@ -2816,6 +2833,8 @@
"auto_save_disabled": "Guardar automático desativado",
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
"auto_save_on": "Guardar automático ativado",
"auto_select_browser_language": "Usar o idioma do navegador por predefinição",
"auto_select_browser_language_description": "Abre automaticamente o inquérito no idioma do navegador do respondente quando esse idioma está ativo. Caso contrário, usa o idioma predefinido.",
"automatically_close_survey_after": "Fechar automaticamente o inquérito após",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente o inquérito após um certo número de respostas",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fechar automaticamente o inquérito se o utilizador não responder após um certo número de segundos.",
@@ -3680,6 +3699,10 @@
"custom_source_type_placeholder": "Insira o tipo de fonte personalizado",
"default_connector_name_csv": "Importação CSV",
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
"delete_feedback_record": "Eliminar registo de feedback",
"delete_feedback_record_confirmation": "Esta ação irá eliminar permanentemente o registo de feedback e removê-lo do diretório associado.",
"delete_feedback_records_confirmation": "Esta ação irá eliminar permanentemente {count} registos de feedback e removê-los do diretório associado.",
"delete_source_confirmation": "Eliminar esta origem irá parar importações futuras e remover o seu mapeamento guardado. Os registos de feedback existentes permanecerão disponíveis.",
"discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.",
"discard_feedback_record_changes_title": "Descartar alterações não salvas?",
"drop_a_field_here": "Solte um campo aqui",
@@ -3689,10 +3712,18 @@
"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_delete_feedback_records": "Falha ao eliminar registos de feedback",
"failed_to_load_feedback_records": "Falha ao carregar registos de feedback",
"feedback_date": "Data atual",
"feedback_directory": "Diretório de Feedback",
"feedback_record_created_successfully": "Registro de feedback criado com sucesso",
"feedback_record_deleted_successfully": "Registo de feedback eliminado com sucesso",
"feedback_record_details": "Detalhes do registro de feedback",
"feedback_record_details_description": "Revise e atualize os campos de registro de feedback.",
"feedback_record_fields": "Campos de registo de feedback",
@@ -3700,6 +3731,8 @@
"feedback_record_updated_successfully": "Registro de feedback atualizado com sucesso",
"feedback_record_value_required": "Um valor é obrigatório para o tipo de campo selecionado",
"feedback_records": "Registos de feedback",
"feedback_records_deleted_successfully": "{count} registos de feedback eliminados",
"feedback_records_partially_deleted": "{succeeded} de {total} registos de feedback eliminados",
"feedback_records_refreshed": "Registos de feedback atualizados",
"feedback_sources": "Fontes de Feedback",
"feedback_sources_directory_access_multiple": "Novos registos destas fontes serão armazenados em: {directoryNames}",
@@ -3736,6 +3769,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",
+39 -5
View File
@@ -1669,6 +1669,7 @@
"ai_query_placeholder": "ex: Câți utilizatori s-au înscris săptămâna trecută?",
"ai_query_section_description": "Descrie ce vrei să vezi și lasă AI-ul să construiască graficul.",
"ai_query_section_title": "Întreabă-ți datele",
"already_on_dashboard": "Deja pe tabloul de bord",
"and_filter_logic": "ȘI",
"apply_changes": "Aplică modificările",
"chart": "Grafic",
@@ -1730,22 +1731,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Nu s-a putut salva graficul",
"field": "Câmp",
"field_label_average_score": "Scor mediu",
"field_label_ces_average": "Media CES",
"field_label_ces_count": "Număr CES",
"field_label_collected_at": "Colectat la",
"field_label_count": "Număr",
"field_label_created_at": "Creat la",
"field_label_csat_average": "Media CSAT",
"field_label_csat_count": "Număr CSAT",
"field_label_csat_dissatisfied_count": "Număr CSAT Nemulțumiți",
"field_label_csat_neutral_count": "Număr CSAT Neutri",
"field_label_csat_satisfied_count": "Număr clienți mulțumiți CSAT",
"field_label_csat_score": "Scor CSAT",
"field_label_detractor_count": "Număr de detractori",
"field_label_emotion": "Emoție",
"field_label_field_type": "Tip câmp",
"field_label_language": "Limbă",
"field_label_nps_average": "Media NPS",
"field_label_nps_score": "Scor NPS",
"field_label_nps_value": "Valoare NPS",
"field_label_passive_count": "Număr de pasivi",
"field_label_promoter_count": "Număr de promotori",
"field_label_question": "Întrebare",
"field_label_question_group": "Grup de întrebări",
"field_label_response_id": "ID răspuns",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Nume sursă",
"field_label_source_type": "Tip sursă",
"field_label_topic": "Subiect",
"field_label_unique_respondents": "Respondenți unici",
"field_label_unique_responses": "Răspunsuri unice",
"field_label_updated_at": "Actualizat la",
"field_label_user_identifier": "Identificator utilizator",
"field_label_value_boolean": "Valoare (Boolean)",
"field_label_value_date": "Valoare (Dată)",
"field_label_value_number": "Valoare (Număr)",
"field_label_value_text": "Valoare (Text)",
"filter_data": "Filtrează datele",
"filters": "Filtre",
"filters_toggle_description": "Include doar datele care îndeplinesc următoarele condiții.",
@@ -1810,6 +1826,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",
@@ -2816,6 +2833,8 @@
"auto_save_disabled": "Salvare automată dezactivată",
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
"auto_save_on": "Salvare automată activată",
"auto_select_browser_language": "Folosește implicit limba browserului",
"auto_select_browser_language_description": "Deschide automat sondajul în limba browserului respondentului atunci când această limbă este activă. Revine la limba implicită.",
"automatically_close_survey_after": "Închideți automat sondajul după",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Închideți automat sondajul după un număr anumit de răspunsuri.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Închideți automat sondajul dacă utilizatorul nu răspunde după un anumit număr de secunde.",
@@ -3680,6 +3699,10 @@
"custom_source_type_placeholder": "Introduceți tipul de sursă personalizat",
"default_connector_name_csv": "Import CSV",
"default_connector_name_formbricks": "Conexiune chestionar Formbricks",
"delete_feedback_record": "Șterge înregistrarea de feedback",
"delete_feedback_record_confirmation": "Aceasta va șterge definitiv înregistrarea de feedback și o va elimina din directorul conectat.",
"delete_feedback_records_confirmation": "Aceasta va șterge definitiv {count} înregistrări de feedback și le va elimina din directorul conectat.",
"delete_source_confirmation": "Ștergerea acestei surse va opri importurile viitoare și va elimina maparea salvată. Înregistrările de feedback existente vor rămâne disponibile.",
"discard_feedback_record_changes_description": "Modificările dvs. se vor pierde dacă închideți acest sertar.",
"discard_feedback_record_changes_title": "Renunțați la modificările nesalvate?",
"drop_a_field_here": "Trage un câmp aici",
@@ -3689,10 +3712,18 @@
"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_delete_feedback_records": "Eșec la ștergerea înregistrărilor de feedback",
"failed_to_load_feedback_records": "Nu s-au putut încărca înregistrările de feedback",
"feedback_date": "Data curentă",
"feedback_directory": "Director de Feedback",
"feedback_record_created_successfully": "Înregistrare de feedback creată cu succes",
"feedback_record_deleted_successfully": "Înregistrarea de feedback a fost ștearsă cu succes",
"feedback_record_details": "Detaliile înregistrării feedback-ului",
"feedback_record_details_description": "Examinați și actualizați câmpurile pentru înregistrarea de feedback.",
"feedback_record_fields": "Câmpuri înregistrare feedback",
@@ -3700,6 +3731,8 @@
"feedback_record_updated_successfully": "Înregistrarea feedback-ului a fost actualizată cu succes",
"feedback_record_value_required": "Este necesară o valoare pentru tipul de câmp selectat",
"feedback_records": "Înregistrări de feedback",
"feedback_records_deleted_successfully": "{count} înregistrări de feedback șterse",
"feedback_records_partially_deleted": "{succeeded} din {total} înregistrări de feedback șterse",
"feedback_records_refreshed": "Înregistrările de feedback au fost actualizate",
"feedback_sources": "Surse de feedback",
"feedback_sources_directory_access_multiple": "Înregistrările noi din aceste surse vor fi stocate în: {directoryNames}",
@@ -3736,6 +3769,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",
+39 -5
View File
@@ -1669,6 +1669,7 @@
"ai_query_placeholder": "например: Сколько пользователей зарегистрировались на прошлой неделе?",
"ai_query_section_description": "Опиши, что хочешь увидеть, и AI построит график.",
"ai_query_section_title": "Спроси свои данные",
"already_on_dashboard": "Уже на дашборде",
"and_filter_logic": "И",
"apply_changes": "Применить изменения",
"chart": "График",
@@ -1730,22 +1731,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Не удалось сохранить график",
"field": "Поле",
"field_label_average_score": "Средний балл",
"field_label_ces_average": "Средний CES",
"field_label_ces_count": "Количество CES",
"field_label_collected_at": "Дата сбора",
"field_label_count": "Количество",
"field_label_created_at": "Дата создания",
"field_label_csat_average": "Средний CSAT",
"field_label_csat_count": "Количество CSAT",
"field_label_csat_dissatisfied_count": "Количество недовольных (CSAT)",
"field_label_csat_neutral_count": "Количество нейтральных (CSAT)",
"field_label_csat_satisfied_count": "Количество удовлетворённых (CSAT)",
"field_label_csat_score": "Оценка CSAT",
"field_label_detractor_count": "Количество критиков",
"field_label_emotion": "Эмоция",
"field_label_field_type": "Тип поля",
"field_label_language": "Язык",
"field_label_nps_average": "Средний NPS",
"field_label_nps_score": "Оценка NPS",
"field_label_nps_value": "Значение NPS",
"field_label_passive_count": "Количество пассивных",
"field_label_promoter_count": "Количество промоутеров",
"field_label_question": "Вопрос",
"field_label_question_group": "Группа вопросов",
"field_label_response_id": "ID ответа",
"field_label_sentiment": "Тональность",
"field_label_source_name": "Название источника",
"field_label_source_type": "Тип источника",
"field_label_topic": "Тема",
"field_label_unique_respondents": "Уникальные респонденты",
"field_label_unique_responses": "Уникальные ответы",
"field_label_updated_at": "Дата обновления",
"field_label_user_identifier": "Идентификатор пользователя",
"field_label_value_boolean": "Значение (логическое)",
"field_label_value_date": "Значение (дата)",
"field_label_value_number": "Значение (число)",
"field_label_value_text": "Значение (текст)",
"filter_data": "Фильтровать данные",
"filters": "Фильтры",
"filters_toggle_description": "Включай только те данные, которые соответствуют следующим условиям.",
@@ -1810,6 +1826,7 @@
},
"dashboards": {
"add_count_charts": "Добавить {count} график(ов)",
"chart_removed": "График удалён с панели",
"charts_add_failed": "Не удалось добавить графики на дашборд",
"charts_add_partial_failure": "Не удалось добавить {count} график(ов)",
"charts_added_to_dashboard": "Графики добавлены на дашборд",
@@ -2816,6 +2833,8 @@
"auto_save_disabled": "Автосохранение отключено",
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
"auto_save_on": "Автосохранение включено",
"auto_select_browser_language": "Использовать язык браузера по умолчанию",
"auto_select_browser_language_description": "Автоматически открывает опрос на языке браузера респондента, если этот язык активен. В противном случае используется язык по умолчанию.",
"automatically_close_survey_after": "Автоматически закрыть опрос через",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Автоматически закрывать опрос после определённого количества ответов.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Автоматически закрывать опрос, если пользователь не ответил за определённое количество секунд.",
@@ -3680,6 +3699,10 @@
"custom_source_type_placeholder": "Введите собственный тип источника",
"default_connector_name_csv": "Импорт CSV",
"default_connector_name_formbricks": "Подключение опроса Formbricks",
"delete_feedback_record": "Удалить запись обратной связи",
"delete_feedback_record_confirmation": "Это навсегда удалит запись обратной связи и уберёт её из подключённого каталога.",
"delete_feedback_records_confirmation": "Это навсегда удалит {count} {count, plural, one {запись обратной связи} few {записи обратной связи} many {записей обратной связи} other {записей обратной связи}} и уберёт их из подключённого каталога.",
"delete_source_confirmation": "Удаление этого источника остановит будущие импорты и удалит сохранённое сопоставление. Существующие записи обратной связи останутся доступны.",
"discard_feedback_record_changes_description": "Ваши изменения будут потеряны, если вы закроете этот ящик.",
"discard_feedback_record_changes_title": "Отменить несохраненные изменения?",
"drop_a_field_here": "Перетащи сюда поле",
@@ -3689,10 +3712,18 @@
"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_delete_feedback_records": "Не удалось удалить записи обратной связи",
"failed_to_load_feedback_records": "Не удалось загрузить отзывы",
"feedback_date": "Текущая дата",
"feedback_directory": "Директория обратной связи",
"feedback_record_created_successfully": "Запись отзыва успешно создана",
"feedback_record_deleted_successfully": "Запись обратной связи успешно удалена",
"feedback_record_details": "Детали записи обратной связи",
"feedback_record_details_description": "Просмотрите и обновите поля записи отзыва.",
"feedback_record_fields": "Поля записи отзыва",
@@ -3700,6 +3731,8 @@
"feedback_record_updated_successfully": "Запись отзыва успешно обновлена.",
"feedback_record_value_required": "Требуется значение для выбранного типа поля.",
"feedback_records": "Записи отзывов",
"feedback_records_deleted_successfully": "Удалено {count} {count, plural, one {запись обратной связи} few {записи обратной связи} many {записей обратной связи} other {записей обратной связи}}",
"feedback_records_partially_deleted": "Удалено {succeeded} из {total} записей обратной связи",
"feedback_records_refreshed": "Записи отзывов обновлены",
"feedback_sources": "Источники обратной связи",
"feedback_sources_directory_access_multiple": "Новые записи из этих источников будут сохранены в: {directoryNames}",
@@ -3736,6 +3769,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": "Необязательно",
+39 -5
View File
@@ -1669,6 +1669,7 @@
"ai_query_placeholder": "t.ex. Hur många användare registrerade sig förra veckan?",
"ai_query_section_description": "Beskriv vad du vill se så bygger AI diagrammet åt dig.",
"ai_query_section_title": "Fråga din data",
"already_on_dashboard": "Redan på instrumentpanelen",
"and_filter_logic": "OCH",
"apply_changes": "Verkställ ändringar",
"chart": "Diagram",
@@ -1730,22 +1731,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Det gick inte att spara diagrammet",
"field": "Fält",
"field_label_average_score": "Genomsnittligt betyg",
"field_label_ces_average": "CES-medelvärde",
"field_label_ces_count": "CES-antal",
"field_label_collected_at": "Insamlad",
"field_label_count": "Antal",
"field_label_created_at": "Skapad",
"field_label_csat_average": "CSAT-medelvärde",
"field_label_csat_count": "CSAT-antal",
"field_label_csat_dissatisfied_count": "CSAT-antal missnöjda",
"field_label_csat_neutral_count": "CSAT-antal neutrala",
"field_label_csat_satisfied_count": "CSAT antal nöjda",
"field_label_csat_score": "CSAT-poäng",
"field_label_detractor_count": "Antal kritiker",
"field_label_emotion": "Känsla",
"field_label_field_type": "Fälttyp",
"field_label_language": "Språk",
"field_label_nps_average": "NPS-medelvärde",
"field_label_nps_score": "NPS-poäng",
"field_label_nps_value": "NPS-värde",
"field_label_passive_count": "Antal passiva",
"field_label_promoter_count": "Antal förespråkare",
"field_label_question": "Fråga",
"field_label_question_group": "Frågegrupp",
"field_label_response_id": "Svar-ID",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Källnamn",
"field_label_source_type": "Källtyp",
"field_label_topic": "Ämne",
"field_label_unique_respondents": "Unika respondenter",
"field_label_unique_responses": "Unika svar",
"field_label_updated_at": "Uppdaterad",
"field_label_user_identifier": "Användar-ID",
"field_label_value_boolean": "Värde (Boolean)",
"field_label_value_date": "Värde (Datum)",
"field_label_value_number": "Värde (Nummer)",
"field_label_value_text": "Värde (Text)",
"filter_data": "Filtrera data",
"filters": "Filter",
"filters_toggle_description": "Inkludera bara data som uppfyller följande villkor.",
@@ -1810,6 +1826,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",
@@ -2816,6 +2833,8 @@
"auto_save_disabled": "Automatisk sparning inaktiverad",
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
"auto_save_on": "Automatisk sparning på",
"auto_select_browser_language": "Använd webbläsarens språk som standard",
"auto_select_browser_language_description": "Öppnar automatiskt enkäten på respondentens webbläsarspråk när det språket är aktivt. Faller tillbaka till standardspråket.",
"automatically_close_survey_after": "Stäng enkäten automatiskt efter",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Stäng enkäten automatiskt efter ett visst antal svar.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Stäng enkäten automatiskt om användaren inte svarar efter ett visst antal sekunder.",
@@ -3680,6 +3699,10 @@
"custom_source_type_placeholder": "Ange anpassad källtyp",
"default_connector_name_csv": "CSV-import",
"default_connector_name_formbricks": "Formbricks Survey-anslutning",
"delete_feedback_record": "Ta bort feedbackpost",
"delete_feedback_record_confirmation": "Detta kommer permanent ta bort feedbackposten och radera den från den anslutna katalogen.",
"delete_feedback_records_confirmation": "Detta kommer permanent ta bort {count} feedbackposter och radera dem från den anslutna katalogen.",
"delete_source_confirmation": "Att ta bort den här källan kommer att stoppa framtida importer och ta bort dess sparade mappning. Befintliga feedbackposter kommer att förbli tillgängliga.",
"discard_feedback_record_changes_description": "Dina ändringar kommer att gå förlorade om du stänger den här lådan.",
"discard_feedback_record_changes_title": "Vill du ignorera osparade ändringar?",
"drop_a_field_here": "Släpp ett fält här",
@@ -3689,10 +3712,18 @@
"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_delete_feedback_records": "Misslyckades att ta bort feedbackposter",
"failed_to_load_feedback_records": "Det gick inte att ladda feedbackposter",
"feedback_date": "Aktuellt datum",
"feedback_directory": "Feedback-katalog",
"feedback_record_created_successfully": "Feedbackposten har skapats",
"feedback_record_deleted_successfully": "Feedbackposten har tagits bort",
"feedback_record_details": "Feedbackpostdetaljer",
"feedback_record_details_description": "Granska och uppdatera fält för feedbackposter.",
"feedback_record_fields": "Fält för feedbackpost",
@@ -3700,6 +3731,8 @@
"feedback_record_updated_successfully": "Feedbackposten har uppdaterats",
"feedback_record_value_required": "Ett värde krävs för den valda fälttypen",
"feedback_records": "Feedbackposter",
"feedback_records_deleted_successfully": "{count} feedbackposter har tagits bort",
"feedback_records_partially_deleted": "{succeeded} av {total} feedbackposter raderade",
"feedback_records_refreshed": "Feedbackposter har uppdaterats",
"feedback_sources": "Feedback Sources",
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
@@ -3736,6 +3769,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",
+39 -5
View File
@@ -1669,6 +1669,7 @@
"ai_query_placeholder": "örn. Geçen hafta kaç kullanıcı kaydoldu?",
"ai_query_section_description": "Ne görmek istediğini anlat, AI grafiği oluştursun.",
"ai_query_section_title": "Verilerine sor",
"already_on_dashboard": "Zaten panoda",
"and_filter_logic": "VE",
"apply_changes": "Değişiklikleri Uygula",
"chart": "Grafik",
@@ -1730,22 +1731,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Grafik kaydedilemedi",
"field": "Alan",
"field_label_average_score": "Ortalama Puan",
"field_label_ces_average": "CES Ortalaması",
"field_label_ces_count": "CES Sayısı",
"field_label_collected_at": "Toplandığı Tarih",
"field_label_count": "Sayı",
"field_label_created_at": "Oluşturulma Tarihi",
"field_label_csat_average": "CSAT Ortalaması",
"field_label_csat_count": "CSAT Sayısı",
"field_label_csat_dissatisfied_count": "CSAT Memnuniyetsiz Sayısı",
"field_label_csat_neutral_count": "CSAT Nötr Sayısı",
"field_label_csat_satisfied_count": "CSAT Memnun Sayısı",
"field_label_csat_score": "CSAT Puanı",
"field_label_detractor_count": "Eleştirmen Sayısı",
"field_label_emotion": "Duygu",
"field_label_field_type": "Alan Türü",
"field_label_language": "Dil",
"field_label_nps_average": "NPS Ortalaması",
"field_label_nps_score": "NPS Puanı",
"field_label_nps_value": "NPS Değeri",
"field_label_passive_count": "Pasif Sayısı",
"field_label_promoter_count": "Tavsiye Eden Sayısı",
"field_label_question": "Soru",
"field_label_question_group": "Soru Grubu",
"field_label_response_id": "Yanıt Kimliği",
"field_label_sentiment": "Duygu Durumu",
"field_label_source_name": "Kaynak Adı",
"field_label_source_type": "Kaynak Türü",
"field_label_topic": "Konu",
"field_label_unique_respondents": "Benzersiz Katılımcılar",
"field_label_unique_responses": "Benzersiz Yanıtlar",
"field_label_updated_at": "Güncellenme Tarihi",
"field_label_user_identifier": "Kullanıcı Tanımlayıcısı",
"field_label_value_boolean": "Değer (Boolean)",
"field_label_value_date": "Değer (Tarih)",
"field_label_value_number": "Değer (Sayı)",
"field_label_value_text": "Değer (Metin)",
"filter_data": "Verileri filtrele",
"filters": "Filtreler",
"filters_toggle_description": "Yalnızca aşağıdaki koşulları karşılayan verileri dahil et.",
@@ -1810,6 +1826,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",
@@ -2816,6 +2833,8 @@
"auto_save_disabled": "Otomatik kaydetme devre dışı",
"auto_save_disabled_tooltip": "Anketin otomatik olarak kaydedilmesi yalnızca taslak durumdayken çalışır. Bu, yayınlanmış anketlerin istemeden güncellenmemesini sağlar.",
"auto_save_on": "Otomatik kaydetme açık",
"auto_select_browser_language": "Varsayılan olarak tarayıcı dilini kullan",
"auto_select_browser_language_description": "Bu dil aktif olduğunda anketi otomatik olarak yanıtlayıcının tarayıcı dilinde açar. Eşleşme yoksa varsayılan dile döner.",
"automatically_close_survey_after": "Anketi otomatik olarak kapat",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Belirli sayıda yanıt alındıktan sonra anketi otomatik olarak kapat.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Kullanıcı belirli saniye içinde yanıt vermezse anketi otomatik olarak kapat.",
@@ -3680,6 +3699,10 @@
"custom_source_type_placeholder": "Özel kaynak türünü girin",
"default_connector_name_csv": "CSV İçe Aktarma",
"default_connector_name_formbricks": "Formbricks Anket Bağlantısı",
"delete_feedback_record": "Geri bildirim kaydını sil",
"delete_feedback_record_confirmation": "Bu işlem geri bildirim kaydını kalıcı olarak silecek ve bağlı dizinden kaldıracaktır.",
"delete_feedback_records_confirmation": "Bu işlem {count} geri bildirim kaydını kalıcı olarak silecek ve bağlı dizinden kaldıracaktır.",
"delete_source_confirmation": "Bu kaynağı silmek, gelecekteki içe aktarmaları durduracak ve kayıtlı eşleştirmesini kaldıracak. Mevcut geri bildirim kayıtları erişilebilir durumda kalacak.",
"discard_feedback_record_changes_description": "Bu çekmeceyi kapatırsanız değişiklikleriniz kaybolacak.",
"discard_feedback_record_changes_title": "Kaydedilmemiş değişiklikler silinsin mi?",
"drop_a_field_here": "Buraya bir alan bırakın",
@@ -3689,10 +3712,18 @@
"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_delete_feedback_records": "Geri bildirim kayıtları silinemedi",
"failed_to_load_feedback_records": "Geri bildirim kayıtları yüklenemedi",
"feedback_date": "Geçerli tarih",
"feedback_directory": "Geri Bildirim Dizini",
"feedback_record_created_successfully": "Geri bildirim kaydı başarıyla oluşturuldu",
"feedback_record_deleted_successfully": "Geri bildirim kaydı başarıyla silindi",
"feedback_record_details": "Geri bildirim kaydı ayrıntıları",
"feedback_record_details_description": "Geri bildirim kayıt alanlarını inceleyin ve güncelleyin.",
"feedback_record_fields": "Geri Bildirim Kayıt Alanları",
@@ -3700,6 +3731,8 @@
"feedback_record_updated_successfully": "Geri bildirim kaydı başarıyla güncellendi",
"feedback_record_value_required": "Seçilen alan türü için bir değer gerekli",
"feedback_records": "Geri Bildirim Kayıtları",
"feedback_records_deleted_successfully": "{count} geri bildirim kaydı silindi",
"feedback_records_partially_deleted": "{total} geri bildirim kaydından {succeeded} tanesi silindi",
"feedback_records_refreshed": "Geri bildirim kayıtları yenilendi",
"feedback_sources": "Feedback Sources",
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
@@ -3736,6 +3769,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ı",
+39 -5
View File
@@ -1669,6 +1669,7 @@
"ai_query_placeholder": "例如:上周有多少用户注册?",
"ai_query_section_description": "描述你想要看到的内容,让 AI 帮你生成图表。",
"ai_query_section_title": "向你的数据提问",
"already_on_dashboard": "已在仪表板上",
"and_filter_logic": "且",
"apply_changes": "应用更改",
"chart": "图表",
@@ -1730,22 +1731,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "图表保存失败",
"field": "字段",
"field_label_average_score": "平均",
"field_label_ces_average": "CES 平均",
"field_label_ces_count": "CES 数量",
"field_label_collected_at": "收集时间",
"field_label_count": "数量",
"field_label_created_at": "创建时间",
"field_label_csat_average": "CSAT 平均值",
"field_label_csat_count": "CSAT 数量",
"field_label_csat_dissatisfied_count": "CSAT 不满意数量",
"field_label_csat_neutral_count": "CSAT 中立数量",
"field_label_csat_satisfied_count": "CSAT 满意数量",
"field_label_csat_score": "CSAT 得分",
"field_label_detractor_count": "贬损者数量",
"field_label_emotion": "情感",
"field_label_field_type": "字段类型",
"field_label_language": "语言",
"field_label_nps_average": "NPS 平均值",
"field_label_nps_score": "NPS 得分",
"field_label_nps_value": "NPS 值",
"field_label_passive_count": "中立者数量",
"field_label_promoter_count": "推荐者数量",
"field_label_question": "问题",
"field_label_question_group": "问题组",
"field_label_response_id": "响应 ID",
"field_label_sentiment": "情绪",
"field_label_source_name": "来源名称",
"field_label_source_type": "来源类型",
"field_label_topic": "主题",
"field_label_unique_respondents": "独立受访者",
"field_label_unique_responses": "独立回复",
"field_label_updated_at": "更新时间",
"field_label_user_identifier": "用户标识",
"field_label_value_boolean": "值(布尔型)",
"field_label_value_date": "值(日期)",
"field_label_value_number": "数值",
"field_label_value_text": "文本值",
"filter_data": "筛选数据",
"filters": "筛选条件",
"filters_toggle_description": "仅包含符合以下条件的数据。",
@@ -1810,6 +1826,7 @@
},
"dashboards": {
"add_count_charts": "添加 {count} 个图表",
"chart_removed": "图表已从仪表板中移除",
"charts_add_failed": "添加图表到仪表板失败",
"charts_add_partial_failure": "添加 {count} 个图表失败",
"charts_added_to_dashboard": "图表已添加到仪表板",
@@ -2816,6 +2833,8 @@
"auto_save_disabled": "自动保存已禁用",
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
"auto_save_on": "自动保存已启用",
"auto_select_browser_language": "默认使用浏览器语言",
"auto_select_browser_language_description": "当受访者的浏览器语言处于启用状态时,自动以该语言打开调查。否则回退到默认语言。",
"automatically_close_survey_after": "自动 关闭 调查 后",
"automatically_close_the_survey_after_a_certain_number_of_responses": "自动 关闭 调查 在 达到 一定数量 的 回应 后",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "用户未在一定秒数内应答时 自动关闭 问卷",
@@ -3680,6 +3699,10 @@
"custom_source_type_placeholder": "输入自定义来源类型",
"default_connector_name_csv": "CSV 导入",
"default_connector_name_formbricks": "Formbricks 调查连接",
"delete_feedback_record": "删除反馈记录",
"delete_feedback_record_confirmation": "这将永久删除该反馈记录并从关联目录中移除。",
"delete_feedback_records_confirmation": "这将永久删除 {count} 条反馈记录并从关联目录中移除。",
"delete_source_confirmation": "删除此数据源将停止未来的导入并移除其保存的映射。现有的反馈记录将保持可用。",
"discard_feedback_record_changes_description": "如果关闭此抽屉,您的更改将会丢失。",
"discard_feedback_record_changes_title": "放弃未保存的更改?",
"drop_a_field_here": "将字段拖到这里",
@@ -3689,10 +3712,18 @@
"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_delete_feedback_records": "删除反馈记录失败",
"failed_to_load_feedback_records": "加载反馈记录失败",
"feedback_date": "当前日期",
"feedback_directory": "反馈目录",
"feedback_record_created_successfully": "反馈记录创建成功",
"feedback_record_deleted_successfully": "反馈记录已成功删除",
"feedback_record_details": "反馈记录详情",
"feedback_record_details_description": "查看并更新反馈记录字段。",
"feedback_record_fields": "反馈记录字段",
@@ -3700,6 +3731,8 @@
"feedback_record_updated_successfully": "反馈记录更新成功",
"feedback_record_value_required": "所选字段类型需要一个值",
"feedback_records": "反馈记录",
"feedback_records_deleted_successfully": "已删除 {count} 条反馈记录",
"feedback_records_partially_deleted": "已删除 {succeeded} 条(共 {total} 条)反馈记录",
"feedback_records_refreshed": "反馈记录已刷新",
"feedback_sources": "Feedback Sources",
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
@@ -3736,6 +3769,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": "可选",
+39 -5
View File
@@ -1669,6 +1669,7 @@
"ai_query_placeholder": "例如:上週有多少用戶註冊?",
"ai_query_section_description": "描述你想看到的內容,讓 AI 幫你建立圖表。",
"ai_query_section_title": "詢問你的數據",
"already_on_dashboard": "已在儀表板上",
"and_filter_logic": "且",
"apply_changes": "套用變更",
"chart": "圖表",
@@ -1730,22 +1731,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "儲存圖表失敗",
"field": "欄位",
"field_label_average_score": "平均分數",
"field_label_ces_average": "CES 平均值",
"field_label_ces_count": "CES 數量",
"field_label_collected_at": "收集時間",
"field_label_count": "數量",
"field_label_created_at": "建立時間",
"field_label_csat_average": "CSAT 平均值",
"field_label_csat_count": "CSAT 數量",
"field_label_csat_dissatisfied_count": "CSAT 不滿意數量",
"field_label_csat_neutral_count": "CSAT 中立數量",
"field_label_csat_satisfied_count": "CSAT 滿意數量",
"field_label_csat_score": "CSAT 分數",
"field_label_detractor_count": "批評者數量",
"field_label_emotion": "情緒",
"field_label_field_type": "欄位類型",
"field_label_language": "語言",
"field_label_nps_average": "NPS 平均值",
"field_label_nps_score": "NPS 分數",
"field_label_nps_value": "NPS 值",
"field_label_passive_count": "中立者數量",
"field_label_promoter_count": "推廣者數量",
"field_label_question": "問題",
"field_label_question_group": "問題群組",
"field_label_response_id": "回應 ID",
"field_label_sentiment": "情感",
"field_label_source_name": "來源名稱",
"field_label_source_type": "來源類型",
"field_label_topic": "主題",
"field_label_unique_respondents": "不重複受訪者",
"field_label_unique_responses": "不重複回應",
"field_label_updated_at": "更新時間",
"field_label_user_identifier": "使用者識別碼",
"field_label_value_boolean": "值(布林)",
"field_label_value_date": "值(日期)",
"field_label_value_number": "數值",
"field_label_value_text": "文字值",
"filter_data": "篩選資料",
"filters": "篩選條件",
"filters_toggle_description": "只包含符合下列條件的資料。",
@@ -1810,6 +1826,7 @@
},
"dashboards": {
"add_count_charts": "新增 {count} 個圖表",
"chart_removed": "圖表已從儀表板移除",
"charts_add_failed": "無法將圖表新增至儀表板",
"charts_add_partial_failure": "無法新增 {count} 個圖表",
"charts_added_to_dashboard": "圖表已新增至儀表板",
@@ -2816,6 +2833,8 @@
"auto_save_disabled": "自動儲存已停用",
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
"auto_save_on": "自動儲存已啟用",
"auto_select_browser_language": "預設使用瀏覽器語言",
"auto_select_browser_language_description": "當受訪者的瀏覽器語言已啟用時,自動以該語言開啟問卷。否則會回到預設語言。",
"automatically_close_survey_after": "在指定時間自動關閉問卷",
"automatically_close_the_survey_after_a_certain_number_of_responses": "在收到一定數量的回覆後自動關閉問卷。",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
@@ -3680,6 +3699,10 @@
"custom_source_type_placeholder": "輸入自訂來源類型",
"default_connector_name_csv": "CSV 匯入",
"default_connector_name_formbricks": "Formbricks 問卷連線",
"delete_feedback_record": "刪除意見回饋記錄",
"delete_feedback_record_confirmation": "這將永久刪除此意見回饋記錄,並從已連結的目錄中移除。",
"delete_feedback_records_confirmation": "這將永久刪除 {count} 筆意見回饋記錄,並從已連結的目錄中移除。",
"delete_source_confirmation": "刪除此來源將停止未來的匯入並移除其已儲存的對應設定。現有的意見回饋記錄將保持可用。",
"discard_feedback_record_changes_description": "如果關閉此抽屜,您的變更將會遺失。",
"discard_feedback_record_changes_title": "放棄未儲存的變更?",
"drop_a_field_here": "請將欄位拖曳到這裡",
@@ -3689,10 +3712,18 @@
"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_delete_feedback_records": "刪除意見回饋記錄失敗",
"failed_to_load_feedback_records": "載入回饋紀錄失敗",
"feedback_date": "目前日期",
"feedback_directory": "意見回饋目錄",
"feedback_record_created_successfully": "回饋記錄創建成功",
"feedback_record_deleted_successfully": "意見回饋記錄已成功刪除",
"feedback_record_details": "反饋記錄詳情",
"feedback_record_details_description": "查看並更新回饋記錄欄位。",
"feedback_record_fields": "回饋紀錄欄位",
@@ -3700,6 +3731,8 @@
"feedback_record_updated_successfully": "回饋記錄更新成功",
"feedback_record_value_required": "所選欄位類型需要一個值",
"feedback_records": "回饋紀錄",
"feedback_records_deleted_successfully": "已刪除 {count} 筆意見回饋記錄",
"feedback_records_partially_deleted": "已刪除 {succeeded} 筆意見回饋記錄,共 {total} 筆",
"feedback_records_refreshed": "回饋紀錄已更新",
"feedback_sources": "Feedback Sources",
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
@@ -3736,6 +3769,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": "選填",
@@ -51,6 +51,7 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
styling: true,
workspaceOverwrites: true,
showLanguageSwitch: true,
autoSelectLanguage: true,
})
.partial({
redirectUrl: true,
@@ -66,6 +67,7 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
styling: true,
workspaceOverwrites: true,
showLanguageSwitch: true,
autoSelectLanguage: true,
inlineTriggers: true,
displayPercentage: true,
})
@@ -187,7 +187,7 @@ describe("executeTenantScopedQuery", () => {
...scopedInput,
query: {
measures: ["FeedbackRecords.count"],
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["secret-value"] }],
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] }],
},
});
@@ -197,7 +197,7 @@ describe("executeTenantScopedQuery", () => {
targetType: "cubeQuery",
newObject: expect.objectContaining({
query: expect.objectContaining({
filterMembers: ["FeedbackRecords.sentiment"],
filterMembers: ["FeedbackRecords.sourceType"],
filterCount: 1,
}),
}),
@@ -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", () => {
@@ -147,7 +157,7 @@ describe("cube queryRewrite", () => {
filters: [
{
or: [
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.tenantId", operator: "equals", values: ["workspace-2"] },
],
},
@@ -185,13 +195,13 @@ describe("cube queryRewrite", () => {
test("appends the mandatory tenant filter from security context", () => {
const query = {
measures: ["FeedbackRecords.count"],
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] }],
};
const rewrittenQuery = queryRewrite(query, { securityContext });
expect(rewrittenQuery.filters).toEqual([
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.tenantId", operator: "equals", values: ["frd-1"] },
]);
expect(query.filters).toHaveLength(1);
@@ -201,7 +211,7 @@ describe("cube queryRewrite", () => {
queryRewrite(
{
measures: ["FeedbackRecords.count"],
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["secret-value"] }],
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] }],
},
{ securityContext }
);
@@ -10,7 +10,7 @@ describe("cube-query", () => {
expect(() =>
validateCubeQueryMembers({
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
dimensions: ["FeedbackRecords.sourceType"],
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt" }],
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["survey"] }],
order: { "FeedbackRecords.collectedAt": "desc" },
@@ -18,10 +18,6 @@ describe("cube-query", () => {
).not.toThrow();
});
test("allows TopicsUnnested dimensions from joined cube", () => {
expect(() => validateCubeQueryMembers({ dimensions: ["TopicsUnnested.topic"] })).not.toThrow();
});
test("throws for invalid members across query sections", () => {
expect(() =>
validateCubeQueryMembers({
@@ -57,7 +53,7 @@ describe("cube-query", () => {
filters: [
{
or: [
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] },
{
and: [{ member: "FeedbackRecords.tenantId", operator: "equals", values: ["workspace-2"] }],
},
@@ -90,7 +86,7 @@ describe("cube-query", () => {
expect(() =>
validateCubeQueryMembers({
measures: ["FeedbackRecords.count", null],
dimensions: [{ member: "FeedbackRecords.sentiment" }],
dimensions: [{ member: "FeedbackRecords.sourceType" }],
segments: [0],
timeDimensions: [null, { dimension: null }],
filters: [null, { member: { name: "FeedbackRecords.sourceType" } }, { and: [0] }, { or: "bad" }],
@@ -103,7 +99,7 @@ describe("cube-query", () => {
test("summarizes query members without raw filter values", () => {
const summary = getCubeQueryAuditSummary({
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
dimensions: ["FeedbackRecords.sourceType"],
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] }],
order: [["FeedbackRecords.collectedAt", "desc"]],
limit: 50,
@@ -111,7 +107,7 @@ describe("cube-query", () => {
expect(summary).toEqual({
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
dimensions: ["FeedbackRecords.sourceType"],
segments: [],
timeDimensions: [],
filterMembers: ["FeedbackRecords.sourceType"],
@@ -125,12 +121,12 @@ describe("cube-query", () => {
test("summarizes only valid member names from malformed query shapes", () => {
const summary = getCubeQueryAuditSummary({
measures: ["FeedbackRecords.count", null],
dimensions: [{ member: "FeedbackRecords.sentiment" }],
dimensions: [{ member: "FeedbackRecords.sourceType" }],
timeDimensions: [null, { dimension: "FeedbackRecords.collectedAt" }],
filters: [
null,
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] },
{ and: [0, { member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }] },
{ and: [0, { member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] }] },
],
order: [
[null, "asc"],
@@ -143,7 +139,7 @@ describe("cube-query", () => {
dimensions: [],
segments: [],
timeDimensions: ["FeedbackRecords.collectedAt"],
filterMembers: ["FeedbackRecords.sentiment", "FeedbackRecords.sourceType"],
filterMembers: ["FeedbackRecords.sourceType"],
filterCount: 2,
orderMembers: ["FeedbackRecords.collectedAt"],
});
@@ -3,7 +3,7 @@ import type { TChartQuery } from "@formbricks/types/analysis";
export const TENANT_MEMBER = "FeedbackRecords.tenantId";
const ALLOWED_CUBE_PREFIXES = ["FeedbackRecords.", "TopicsUnnested."];
const ALLOWED_CUBE_PREFIXES = ["FeedbackRecords."];
const INVALID_MEMBER_REFERENCE = "invalid member reference";
type TQueryAuditSummary = {
@@ -310,9 +310,9 @@ export const validateCubeQueryMembers = (query: TChartQuery): void => {
if (result.invalidMembers.length > 0) {
throw new Error(
`Invalid query members (must start with FeedbackRecords. or TopicsUnnested.): ${uniqueSorted(
result.invalidMembers
).join(", ")}`
`Invalid query members (must start with FeedbackRecords.): ${uniqueSorted(result.invalidMembers).join(
", "
)}`
);
}
};
@@ -175,7 +175,7 @@ describe("chart Cube actions", () => {
mocks.generateText.mockResolvedValue({
output: {
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
dimensions: ["FeedbackRecords.sourceType"],
timeDimensions: null,
chartType: "bar",
filters: null,
@@ -198,7 +198,7 @@ describe("chart Cube actions", () => {
expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith({
query: {
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
dimensions: ["FeedbackRecords.sourceType"],
},
feedbackDirectoryId: "frd-1",
workspaceId: "workspace-1",
+22 -4
View File
@@ -21,6 +21,11 @@ import {
} from "@/modules/ee/analysis/charts/lib/charts";
import { checkFeedbackDirectoryAccess, checkWorkspaceAccess } from "@/modules/ee/analysis/lib/access";
import { generateSchemaContext } from "@/modules/ee/analysis/lib/ai-schema-context";
import {
FEEDBACK_DIMENSION_IDS,
FEEDBACK_MEASURE_IDS,
FEEDBACK_TIME_DIMENSION_IDS,
} from "@/modules/ee/analysis/lib/schema-definition";
import { ZChartCreateInput, ZChartType, ZChartUpdateInput } from "@/modules/ee/analysis/types/analysis";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsDashboardsEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -287,13 +292,25 @@ export const executeQueryAction = authenticatedActionClient
const CUBE_NAME = "FeedbackRecords";
const toEnumTuple = (values: readonly string[]): [string, ...string[]] => {
if (values.length === 0) {
throw new Error("AI query schema requires at least one allowed id");
}
return [values[0], ...values.slice(1)];
};
const ZMeasureId = z.enum(toEnumTuple(FEEDBACK_MEASURE_IDS));
const ZDimensionId = z.enum(toEnumTuple(FEEDBACK_DIMENSION_IDS));
const ZTimeDimensionId = z.enum(toEnumTuple(FEEDBACK_TIME_DIMENSION_IDS));
const ZFilterMemberId = z.enum(toEnumTuple([...FEEDBACK_MEASURE_IDS, ...FEEDBACK_DIMENSION_IDS]));
const ZGenerateAIQueryResponse = z.object({
measures: z.array(z.string()),
dimensions: z.array(z.string()).nullable(),
measures: z.array(ZMeasureId),
dimensions: z.array(ZDimensionId).nullable(),
timeDimensions: z
.array(
z.object({
dimension: z.string(),
dimension: ZTimeDimensionId,
granularity: z.enum(["hour", "day", "week", "month", "quarter", "year"]).nullable(),
dateRange: z.string().nullable(),
})
@@ -303,7 +320,7 @@ const ZGenerateAIQueryResponse = z.object({
filters: z
.array(
z.object({
member: z.string(),
member: ZFilterMemberId,
operator: z.enum([
"equals",
"notEquals",
@@ -364,6 +381,7 @@ export const generateAIChartAction = authenticatedActionClient
output: Output.object({ schema: ZGenerateAIQueryResponse }),
system: schemaContext,
prompt: `User request: "${parsedInput.prompt}"`,
temperature: 0,
});
const measures = output.measures.length > 0 ? output.measures : [`${CUBE_NAME}.count`];
@@ -26,7 +26,7 @@ interface AddToDashboardDialogProps {
onOpenChange: (open: boolean) => void;
chartName: string;
onChartNameChange: (name: string) => void;
dashboards: Array<{ id: string; name: string }>;
dashboards: Array<{ id: string; name: string; containsChart?: boolean }>;
selectedDashboardId: string | undefined;
onDashboardSelect: (id: string) => void;
onConfirm: () => void;
@@ -89,8 +89,13 @@ export function AddToDashboardDialog({
</SelectTrigger>
<SelectContent position="popper" className="max-h-[200px]">
{dashboards.map((dashboard) => (
<SelectItem key={dashboard.id} value={dashboard.id}>
<SelectItem key={dashboard.id} value={dashboard.id} disabled={dashboard.containsChart}>
{dashboard.name}
{dashboard.containsChart && (
<span className="ml-2 text-xs text-gray-500">
({t("workspace.analysis.charts.already_on_dashboard")})
</span>
)}
</SelectItem>
))}
</SelectContent>
@@ -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>
@@ -35,7 +35,9 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
const [isAddingToDashboard, setIsAddingToDashboard] = useState(false);
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string; containsChart?: boolean }>>(
[]
);
const [selectedDashboardId, setSelectedDashboardId] = useState<string>();
useEffect(() => {
@@ -47,14 +49,20 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
};
}
void getDashboardsAction({ workspaceId })
void getDashboardsAction({ workspaceId, chartId: chart.id })
.then((result) => {
if (cancelled) {
return;
}
if (result?.data) {
setDashboards(result.data.map((dashboard) => ({ id: dashboard.id, name: dashboard.name })));
setDashboards(
result.data.map((dashboard) => ({
id: dashboard.id,
name: dashboard.name,
containsChart: dashboard.containsChart,
}))
);
} else {
toast.error(getFormattedErrorMessage(result));
}
@@ -71,7 +79,7 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
return () => {
cancelled = true;
};
}, [isAddToDashboardDialogOpen, workspaceId]);
}, [isAddToDashboardDialogOpen, workspaceId, chart.id]);
const handleDeleteChart = async () => {
setIsDeleting(true);
@@ -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
@@ -234,28 +233,21 @@ export const duplicateDashboardAction = authenticatedActionClient
const ZGetDashboardsAction = z.object({
workspaceId: ZId,
chartId: ZId.optional(),
});
export const getDashboardsAction = authenticatedActionClient
.inputSchema(ZGetDashboardsAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetDashboardsAction>;
}) => {
const { organizationId, workspaceId } = await checkWorkspaceAccess(
ctx.user.id,
parsedInput.workspaceId,
"read"
);
await checkDashboardsEnabled(organizationId);
.action(async ({ ctx, parsedInput }) => {
const { organizationId, workspaceId } = await checkWorkspaceAccess(
ctx.user.id,
parsedInput.workspaceId,
"read"
);
await checkDashboardsEnabled(organizationId);
return getDashboards(workspaceId);
}
);
return getDashboards(workspaceId, parsedInput.chartId);
});
const ZGetDashboardAction = z.object({
workspaceId: ZId,
@@ -325,3 +317,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 };
})
);
@@ -89,10 +89,19 @@ export function AddExistingChartsDialog({
const handleAdd = async () => {
if (selectedChartIds.length === 0) return;
const chartIdsToAdd = Array.from(new Set(selectedChartIds)).filter(
(chartId) => !existingChartIdsRef.current.includes(chartId)
);
if (chartIdsToAdd.length === 0) {
setSelectedChartIds([]);
return;
}
setIsAdding(true);
try {
const results = await Promise.allSettled(
selectedChartIds.map((chartId) => addChartToDashboardAction({ workspaceId, chartId, dashboardId }))
chartIdsToAdd.map((chartId) => addChartToDashboardAction({ workspaceId, chartId, dashboardId }))
);
const fulfilled = results.filter(
@@ -112,7 +121,7 @@ export function AddExistingChartsDialog({
} else {
toast.success(
t("workspace.analysis.dashboards.charts_added_to_dashboard", {
count: selectedChartIds.length,
count: chartIdsToAdd.length,
})
);
}
@@ -134,7 +143,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" />
@@ -72,7 +72,7 @@ export const DashboardControlBar = ({
const editModeActions = [
{
icon: CheckIcon,
icon: isSaving ? null : CheckIcon,
tooltip: hasChanges ? t("common.save") : t("common.no_changes"),
onClick: onSave,
isVisible: true,
@@ -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 })),
},
};
@@ -93,6 +98,7 @@ const makePrismaError = (code: string) =>
describe("Dashboard Service", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTxWidget.findFirst.mockResolvedValue(null);
});
describe("createDashboard", () => {
@@ -459,6 +465,40 @@ describe("Dashboard Service", () => {
name: "DatabaseError",
});
});
test("returns containsChart per dashboard when chartId is provided", async () => {
const dashboards = [
{
...mockDashboard,
creator: { name: "Alice" },
_count: { widgets: 3 },
widgets: [{ id: "widget-1" }],
},
{
...mockDashboard,
id: "dash-2",
name: "Dashboard 2",
creator: null,
_count: { widgets: 0 },
widgets: [],
},
];
vi.mocked(prisma.dashboard.findMany).mockResolvedValue(dashboards as any);
const { getDashboards } = await import("./dashboards");
const result = await getDashboards(mockWorkspaceId, mockChartId);
expect(result[0]).toMatchObject({ containsChart: true });
expect(result[1]).toMatchObject({ containsChart: false });
expect((result[0] as any).widgets).toBeUndefined();
expect(prisma.dashboard.findMany).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId },
orderBy: { createdAt: "desc" },
select: expect.objectContaining({
widgets: { where: { chartId: mockChartId }, select: { id: true }, take: 1 },
}),
});
});
});
describe("updateWidgetLayouts", () => {
@@ -649,6 +689,26 @@ describe("Dashboard Service", () => {
expect(mockTxWidget.create).not.toHaveBeenCalled();
});
test("throws InvalidInputError when chart is already on the dashboard", async () => {
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxWidget.findFirst.mockResolvedValue({ id: "existing-widget-abc-123" });
const { addChartToDashboard } = await import("./dashboards");
await expect(
addChartToDashboard({
dashboardId: mockDashboardId,
chartId: mockChartId,
workspaceId: mockWorkspaceId,
layout: mockLayout,
})
).rejects.toMatchObject({
name: "InvalidInputError",
});
expect(mockTxWidget.aggregate).not.toHaveBeenCalled();
expect(mockTxWidget.create).not.toHaveBeenCalled();
});
test("throws InvalidInputError on unique constraint violation", async () => {
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
@@ -672,4 +732,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
);
});
});
});
@@ -158,19 +158,34 @@ export const getDashboard = async (dashboardId: string, workspaceId: string) =>
}
};
export const getDashboards = async (workspaceId: string): Promise<TDashboardWithCount[]> => {
validateInputs([workspaceId, ZId]);
export const getDashboards = async (
workspaceId: string,
chartId?: string
): Promise<TDashboardWithCount[]> => {
validateInputs([workspaceId, ZId], [chartId, ZId.optional()]);
try {
return await prisma.dashboard.findMany({
const select = {
...selectDashboard,
creator: { select: { name: true } },
_count: { select: { widgets: true } },
...(chartId ? { widgets: { where: { chartId }, select: { id: true }, take: 1 } } : {}),
};
const dashboards = await prisma.dashboard.findMany({
where: { workspaceId },
orderBy: { createdAt: "desc" },
select: {
...selectDashboard,
creator: { select: { name: true } },
_count: { select: { widgets: true } },
},
select,
});
if (!chartId) {
return dashboards;
}
return dashboards.map(({ widgets, ...rest }) => ({
...rest,
containsChart: (widgets?.length ?? 0) > 0,
}));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -301,6 +316,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]);
@@ -319,6 +359,18 @@ export const addChartToDashboard = async (data: TAddWidgetInput) => {
throw new ResourceNotFoundError("Dashboard", data.dashboardId);
}
const existingWidget = await tx.dashboardWidget.findFirst({
where: {
dashboardId: data.dashboardId,
chartId: data.chartId,
},
select: { id: true },
});
if (existingWidget) {
throw new InvalidInputError("This chart is already on the dashboard");
}
const [maxOrder, existingWidgets] = await Promise.all([
tx.dashboardWidget.aggregate({
where: { dashboardId: data.dashboardId },
@@ -350,7 +402,7 @@ export const addChartToDashboard = async (data: TAddWidgetInput) => {
{ isolationLevel: "Serializable" }
);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
if (error instanceof ResourceNotFoundError || error instanceof InvalidInputError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -52,7 +52,7 @@ ${operatorsText}
## Guidelines
- Always include at least one measure. If unspecified, default to \`${CUBE_NAME}.count\`.
- Use dimension IDs exactly as shown (e.g. \`FeedbackRecords.sentiment\`, \`FeedbackRecords.collectedAt\`).
- Use dimension IDs exactly as shown (e.g. \`FeedbackRecords.sourceType\`, \`FeedbackRecords.collectedAt\`).
- For time-based filtering (date range only, no time grouping): add a timeDimension with dimension \`${CUBE_NAME}.collectedAt\` and dateRange. Do NOT include granularity (default is None / filter only).
- For time-series or trend questions (e.g. "over time", "by day", "weekly", "monthly"): add a timeDimension with dimension, granularity (hour/day/week/month/quarter/year), and dateRange.
- Choose the most appropriate chart type: bar, line, area, pie, or big_number (for single-number queries).
@@ -22,13 +22,13 @@ describe("query-builder", () => {
test("adds dimensions when present", () => {
const config: ChartBuilderState = {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: ["FeedbackRecords.sentiment"],
selectedDimensions: ["FeedbackRecords.userId"],
filters: [],
filterLogic: "and",
timeDimension: null,
};
const query = buildCubeQuery(config);
expect(query.dimensions).toEqual(["FeedbackRecords.sentiment"]);
expect(query.dimensions).toEqual(["FeedbackRecords.userId"]);
});
test("adds time dimension with string dateRange", () => {
@@ -93,7 +93,7 @@ describe("query-builder", () => {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: [],
filters: [
{ id: "f1", field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ id: "f1", field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
{ id: "f2", field: "FeedbackRecords.sourceType", operator: "set", values: null },
],
filterLogic: "and",
@@ -101,7 +101,7 @@ describe("query-builder", () => {
};
const query = buildCubeQuery(config);
expect(query.filters).toEqual([
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.sourceType", operator: "set" },
]);
});
@@ -110,14 +110,14 @@ describe("query-builder", () => {
const config: ChartBuilderState = {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: [],
filters: [{ id: "f1", field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
filters: [{ id: "f1", field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
filterLogic: "or",
timeDimension: null,
};
const query = buildCubeQuery(config);
expect(query.filters).toEqual([
{
or: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
or: [{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
},
]);
});
@@ -136,12 +136,12 @@ describe("query-builder", () => {
test("parses AND member filters", () => {
const query = {
measures: ["FeedbackRecords.count"],
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
filters: [{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
};
const state = parseQueryToState(query);
expect(state.filterLogic).toBe("and");
expect(state.filters?.map(({ field, operator, values }) => ({ field, operator, values }))).toEqual([
{ field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
]);
});
@@ -150,14 +150,14 @@ describe("query-builder", () => {
measures: ["FeedbackRecords.count"],
filters: [
{
or: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
or: [{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
},
],
};
const state = parseQueryToState(query);
expect(state.filterLogic).toBe("or");
expect(state.filters?.map(({ field, operator, values }) => ({ field, operator, values }))).toEqual([
{ field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
]);
});
@@ -202,7 +202,7 @@ describe("query-builder", () => {
test("buildCubeQuery then parseQueryToState restores state", () => {
const config: ChartBuilderState = {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: ["FeedbackRecords.sentiment"],
selectedDimensions: ["FeedbackRecords.userId"],
filters: [{ id: "f1", field: "FeedbackRecords.sourceType", operator: "equals", values: ["survey"] }],
filterLogic: "and",
timeDimension: {
@@ -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;
}
@@ -32,9 +32,9 @@ describe("schema-definition", () => {
describe("getFieldById", () => {
test("returns dimension by id", () => {
const field = getFieldById("FeedbackRecords.sentiment");
const field = getFieldById("FeedbackRecords.sourceType");
expect(field).toBeDefined();
expect(field?.label).toBe("Sentiment");
expect(field?.label).toBe("Source Type");
expect(field?.type).toBe("string");
});
@@ -56,7 +56,7 @@ describe("schema-definition", () => {
});
test("returns field label for known dimension/measure", () => {
expect(formatCubeColumnHeader("FeedbackRecords.sentiment")).toBe("Sentiment");
expect(formatCubeColumnHeader("FeedbackRecords.sourceType")).toBe("Source Type");
expect(formatCubeColumnHeader("FeedbackRecords.count")).toBe("Count");
});
@@ -74,5 +74,25 @@ describe("schema-definition", () => {
expect(FEEDBACK_FIELDS.dimensions.length).toBeGreaterThan(0);
expect(FEEDBACK_FIELDS.measures.length).toBeGreaterThan(0);
});
test("exposes CSAT, CES, NPS and universal measures", () => {
const ids = FEEDBACK_FIELDS.measures.map((m) => m.id);
expect(ids).toEqual(
expect.arrayContaining([
"FeedbackRecords.count",
"FeedbackRecords.uniqueRespondents",
"FeedbackRecords.uniqueResponses",
"FeedbackRecords.npsScore",
"FeedbackRecords.npsAverage",
"FeedbackRecords.csatScore",
"FeedbackRecords.csatAverage",
"FeedbackRecords.csatSatisfiedCount",
"FeedbackRecords.csatCount",
"FeedbackRecords.cesAverage",
"FeedbackRecords.cesCount",
])
);
expect(ids).not.toContain("FeedbackRecords.averageScore");
});
});
});
@@ -7,7 +7,7 @@ import type { TFunction } from "i18next";
export interface FieldDefinition {
id: string;
label: string;
type: "string" | "number" | "time";
type: "string" | "number" | "time" | "boolean";
description?: string;
}
@@ -20,12 +20,6 @@ export interface MeasureDefinition {
export const FEEDBACK_FIELDS = {
dimensions: [
{
id: "FeedbackRecords.sentiment",
label: "Sentiment",
type: "string",
description: "Sentiment extracted from feedback",
},
{
id: "FeedbackRecords.sourceType",
label: "Source Type",
@@ -45,10 +39,22 @@ export const FEEDBACK_FIELDS = {
description: "Type of feedback field (e.g., nps, text, rating)",
},
{
id: "FeedbackRecords.emotion",
label: "Emotion",
id: "FeedbackRecords.fieldLabel",
label: "Question",
type: "string",
description: "Emotion extracted from metadata JSONB field",
description: "Human-readable label of the question/field",
},
{
id: "FeedbackRecords.fieldGroupLabel",
label: "Question Group",
type: "string",
description: "Label of the parent composite question for matrix/ranking rows",
},
{
id: "FeedbackRecords.language",
label: "Language",
type: "string",
description: 'Response language code (e.g., "en", "de")',
},
{
id: "FeedbackRecords.userId",
@@ -63,10 +69,30 @@ export const FEEDBACK_FIELDS = {
description: "Unique identifier linking related feedback records",
},
{
id: "FeedbackRecords.npsValue",
label: "NPS Value",
id: "FeedbackRecords.valueNumber",
label: "Value (Number)",
type: "number",
description: "Raw NPS score value (0-10)",
description:
"Numeric answer value (NPS 0-10, CSAT 1-5, CES 1-5 or 1-7, rating, number). Pair with a fieldType filter to keep scales consistent.",
},
{
id: "FeedbackRecords.valueText",
label: "Value (Text)",
type: "string",
description:
"Text answer value (open text, or the label of a multiple-choice/categorical answer). Pair with a fieldType filter to keep types consistent.",
},
{
id: "FeedbackRecords.valueBoolean",
label: "Value (Boolean)",
type: "boolean",
description: "Boolean answer value (yes/no). Pair with a fieldType filter.",
},
{
id: "FeedbackRecords.valueDate",
label: "Value (Date)",
type: "time",
description: "Date answer value. Pair with a fieldType filter.",
},
{
id: "FeedbackRecords.collectedAt",
@@ -75,10 +101,16 @@ export const FEEDBACK_FIELDS = {
description: "Timestamp when the feedback was collected",
},
{
id: "TopicsUnnested.topic",
label: "Topic",
type: "string",
description: "Individual topic from the topics array",
id: "FeedbackRecords.createdAt",
label: "Created At",
type: "time",
description: "Timestamp when the feedback record was created in Hub",
},
{
id: "FeedbackRecords.updatedAt",
label: "Updated At",
type: "time",
description: "Timestamp when the feedback record was last updated in Hub",
},
] as FieldDefinition[],
measures: [
@@ -89,38 +121,106 @@ export const FEEDBACK_FIELDS = {
description: "Total number of feedback responses",
},
{
id: "FeedbackRecords.promoterCount",
label: "Promoter Count",
type: "count",
description: "Number of promoters (NPS score 9-10)",
id: "FeedbackRecords.uniqueRespondents",
label: "Unique Respondents",
type: "number",
description: "Number of unique users who provided feedback",
},
{
id: "FeedbackRecords.detractorCount",
label: "Detractor Count",
type: "count",
description: "Number of detractors (NPS score 0-6)",
},
{
id: "FeedbackRecords.passiveCount",
label: "Passive Count",
type: "count",
description: "Number of passives (NPS score 7-8)",
id: "FeedbackRecords.uniqueResponses",
label: "Unique Responses",
type: "number",
description: "Number of unique survey submissions",
},
{
id: "FeedbackRecords.npsScore",
label: "NPS Score",
type: "number",
description: "Net Promoter Score: ((Promoters - Detractors) / Total) * 100",
description: "Net Promoter Score: ((Promoters - Detractors) / Total NPS responses) * 100",
},
{
id: "FeedbackRecords.averageScore",
label: "Average Score",
id: "FeedbackRecords.npsAverage",
label: "NPS Average",
type: "number",
description: "Average NPS score",
description: "Average NPS rating (0-10)",
},
{
id: "FeedbackRecords.promoterCount",
label: "Promoter Count",
type: "count",
description: "Number of NPS promoters (score 9-10)",
},
{
id: "FeedbackRecords.passiveCount",
label: "Passive Count",
type: "count",
description: "Number of NPS passives (score 7-8)",
},
{
id: "FeedbackRecords.detractorCount",
label: "Detractor Count",
type: "count",
description: "Number of NPS detractors (score 0-6)",
},
{
id: "FeedbackRecords.csatScore",
label: "CSAT Score",
type: "number",
description: "CSAT Score: % of CSAT responses rated 4 or 5 (top-2-box on the 1-5 scale)",
},
{
id: "FeedbackRecords.csatAverage",
label: "CSAT Average",
type: "number",
description: "Average CSAT rating (1-5)",
},
{
id: "FeedbackRecords.csatSatisfiedCount",
label: "CSAT Satisfied Count",
type: "count",
description: "Number of satisfied CSAT responses (top-2-box on the 1-5 scale)",
},
{
id: "FeedbackRecords.csatDissatisfiedCount",
label: "CSAT Dissatisfied Count",
type: "count",
description: "Number of dissatisfied CSAT responses (bottom-2-box on the 1-5 scale)",
},
{
id: "FeedbackRecords.csatNeutralCount",
label: "CSAT Neutral Count",
type: "count",
description: "Number of neutral CSAT responses (middle box on the 1-5 scale)",
},
{
id: "FeedbackRecords.csatCount",
label: "CSAT Count",
type: "count",
description: "Number of CSAT responses",
},
{
id: "FeedbackRecords.cesAverage",
label: "CES Average",
type: "number",
description: "Average CES rating (scale is 1-5 or 1-7 depending on the question)",
},
{
id: "FeedbackRecords.cesCount",
label: "CES Count",
type: "count",
description: "Number of CES responses",
},
] as MeasureDefinition[],
};
export const FEEDBACK_MEASURE_IDS: string[] = FEEDBACK_FIELDS.measures.map((m) => m.id);
export const FEEDBACK_DIMENSION_IDS: string[] = FEEDBACK_FIELDS.dimensions.map((d) => d.id);
export const FEEDBACK_TIME_DIMENSION_IDS: string[] = FEEDBACK_FIELDS.dimensions
.filter((d) => d.type === "time")
.map((d) => d.id);
export type FilterOperator =
| "equals"
| "notEquals"
@@ -137,6 +237,7 @@ export const FILTER_OPERATORS: Record<string, FilterOperator[]> = {
string: ["equals", "notEquals", "contains", "notContains", "set", "notSet"],
number: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
time: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
boolean: ["equals", "notEquals", "set", "notSet"],
};
export const TIME_GRANULARITIES = ["hour", "day", "week", "month", "quarter", "year"] as const;
@@ -166,7 +267,7 @@ export const DATE_PRESETS = [
/**
* Get filter operators for a given field type.
*/
export function getFilterOperatorsForType(type: "string" | "number" | "time"): FilterOperator[] {
export function getFilterOperatorsForType(type: "string" | "number" | "time" | "boolean"): FilterOperator[] {
return FILTER_OPERATORS[type] || FILTER_OPERATORS.string;
}
@@ -184,22 +285,39 @@ export function getFieldById(id: string): FieldDefinition | MeasureDefinition |
*/
export function getTranslatedFieldLabel(id: string, t: TFunction): string {
const labels: Record<string, string> = {
"FeedbackRecords.sentiment": t("workspace.analysis.charts.field_label_sentiment"),
"FeedbackRecords.sourceType": t("workspace.analysis.charts.field_label_source_type"),
"FeedbackRecords.sourceName": t("workspace.analysis.charts.field_label_source_name"),
"FeedbackRecords.fieldType": t("workspace.analysis.charts.field_label_field_type"),
"FeedbackRecords.emotion": t("workspace.analysis.charts.field_label_emotion"),
"FeedbackRecords.fieldLabel": t("workspace.analysis.charts.field_label_question"),
"FeedbackRecords.fieldGroupLabel": t("workspace.analysis.charts.field_label_question_group"),
"FeedbackRecords.language": t("workspace.analysis.charts.field_label_language"),
"FeedbackRecords.userId": t("workspace.analysis.charts.field_label_user_identifier"),
"FeedbackRecords.responseId": t("workspace.analysis.charts.field_label_response_id"),
"FeedbackRecords.npsValue": t("workspace.analysis.charts.field_label_nps_value"),
"FeedbackRecords.valueNumber": t("workspace.analysis.charts.field_label_value_number"),
"FeedbackRecords.valueText": t("workspace.analysis.charts.field_label_value_text"),
"FeedbackRecords.valueBoolean": t("workspace.analysis.charts.field_label_value_boolean"),
"FeedbackRecords.valueDate": t("workspace.analysis.charts.field_label_value_date"),
"FeedbackRecords.collectedAt": t("workspace.analysis.charts.field_label_collected_at"),
"TopicsUnnested.topic": t("workspace.analysis.charts.field_label_topic"),
"FeedbackRecords.createdAt": t("workspace.analysis.charts.field_label_created_at"),
"FeedbackRecords.updatedAt": t("workspace.analysis.charts.field_label_updated_at"),
"FeedbackRecords.count": t("workspace.analysis.charts.field_label_count"),
"FeedbackRecords.promoterCount": t("workspace.analysis.charts.field_label_promoter_count"),
"FeedbackRecords.detractorCount": t("workspace.analysis.charts.field_label_detractor_count"),
"FeedbackRecords.passiveCount": t("workspace.analysis.charts.field_label_passive_count"),
"FeedbackRecords.uniqueRespondents": t("workspace.analysis.charts.field_label_unique_respondents"),
"FeedbackRecords.uniqueResponses": t("workspace.analysis.charts.field_label_unique_responses"),
"FeedbackRecords.npsScore": t("workspace.analysis.charts.field_label_nps_score"),
"FeedbackRecords.averageScore": t("workspace.analysis.charts.field_label_average_score"),
"FeedbackRecords.npsAverage": t("workspace.analysis.charts.field_label_nps_average"),
"FeedbackRecords.promoterCount": t("workspace.analysis.charts.field_label_promoter_count"),
"FeedbackRecords.passiveCount": t("workspace.analysis.charts.field_label_passive_count"),
"FeedbackRecords.detractorCount": t("workspace.analysis.charts.field_label_detractor_count"),
"FeedbackRecords.csatScore": t("workspace.analysis.charts.field_label_csat_score"),
"FeedbackRecords.csatAverage": t("workspace.analysis.charts.field_label_csat_average"),
"FeedbackRecords.csatSatisfiedCount": t("workspace.analysis.charts.field_label_csat_satisfied_count"),
"FeedbackRecords.csatDissatisfiedCount": t(
"workspace.analysis.charts.field_label_csat_dissatisfied_count"
),
"FeedbackRecords.csatNeutralCount": t("workspace.analysis.charts.field_label_csat_neutral_count"),
"FeedbackRecords.csatCount": t("workspace.analysis.charts.field_label_csat_count"),
"FeedbackRecords.cesAverage": t("workspace.analysis.charts.field_label_ces_average"),
"FeedbackRecords.cesCount": t("workspace.analysis.charts.field_label_ces_count"),
};
return labels[id] ?? getFieldById(id)?.label ?? id;
}
@@ -82,6 +82,7 @@ export type TDashboard = {
export type TDashboardWithCount = TDashboard & {
creator: { name: string } | null;
_count: { widgets: number };
containsChart?: boolean;
};
// ── Widget input schema ─────────────────────────────────────────────────────
@@ -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>
);
+55 -10
View File
@@ -1,19 +1,25 @@
"use server";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory";
import { getIsUnifyFeedbackEnabled } from "@/modules/ee/license-check/lib/utils";
import { createFeedbackRecord, retrieveFeedbackRecord, updateFeedbackRecord } from "@/modules/hub/service";
import {
createFeedbackRecord,
deleteFeedbackRecord,
retrieveFeedbackRecord,
updateFeedbackRecord,
} from "@/modules/hub/service";
import type { FeedbackRecordCreateParams, FeedbackRecordUpdateParams } from "@/modules/hub/types";
import {
TCreateFeedbackRecordAction,
TRetrieveFeedbackRecordAction,
TUpdateFeedbackRecordAction,
ZCreateFeedbackRecordAction,
ZDeleteFeedbackRecordAction,
ZRetrieveFeedbackRecordAction,
ZUpdateFeedbackRecordAction,
} from "./types";
@@ -50,10 +56,14 @@ const getWorkspaceDirectoryIds = async (workspaceId: string): Promise<Set<string
return new Set(directories.map((directory) => directory.id));
};
const assertRecordBelongsToWorkspace = (directoryIds: Set<string>, tenantId: string): void => {
const assertRecordBelongsToWorkspace = (
directoryIds: Set<string>,
tenantId: string,
recordId: string | null
): void => {
if (!directoryIds.has(tenantId)) {
// Throw a generic error indistinguishable from "not found" to prevent IDOR
throw new Error("Feedback record not found");
// Same error shape as a genuine "not found" to prevent IDOR via response differences
throw new ResourceNotFoundError("Feedback record", recordId);
}
};
@@ -74,10 +84,14 @@ export const retrieveFeedbackRecordAction = authenticatedActionClient
const recordResult = await retrieveFeedbackRecord(parsedInput.recordId);
if (!recordResult.data || recordResult.error) {
throw new Error("Feedback record not found");
throw new ResourceNotFoundError("Feedback record", parsedInput.recordId);
}
assertRecordBelongsToWorkspace(workspaceDirectoryIds, recordResult.data.tenant_id);
assertRecordBelongsToWorkspace(
workspaceDirectoryIds,
recordResult.data.tenant_id,
parsedInput.recordId
);
return recordResult.data;
}
@@ -96,7 +110,7 @@ export const createFeedbackRecordAction = authenticatedActionClient
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertRecordBelongsToWorkspace(workspaceDirectoryIds, parsedInput.recordInput.tenant_id);
assertRecordBelongsToWorkspace(workspaceDirectoryIds, parsedInput.recordInput.tenant_id, null);
const { recordInput } = parsedInput;
const createParams: FeedbackRecordCreateParams = {
@@ -146,10 +160,14 @@ export const updateFeedbackRecordAction = authenticatedActionClient
const currentRecordResult = await retrieveFeedbackRecord(parsedInput.recordId);
if (!currentRecordResult.data || currentRecordResult.error) {
throw new Error("Feedback record not found");
throw new ResourceNotFoundError("Feedback record", parsedInput.recordId);
}
assertRecordBelongsToWorkspace(workspaceDirectoryIds, currentRecordResult.data.tenant_id);
assertRecordBelongsToWorkspace(
workspaceDirectoryIds,
currentRecordResult.data.tenant_id,
parsedInput.recordId
);
const { updateInput } = parsedInput;
const updateParams: FeedbackRecordUpdateParams = {
@@ -176,3 +194,30 @@ export const updateFeedbackRecordAction = authenticatedActionClient
return updateResult.data;
}
);
export const deleteFeedbackRecordAction = authenticatedActionClient
.inputSchema(ZDeleteFeedbackRecordAction)
.action(async ({ ctx, parsedInput }) => {
const [, workspaceDirectoryIds] = await Promise.all([
ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite"),
getWorkspaceDirectoryIds(parsedInput.workspaceId),
]);
const currentRecordResult = await retrieveFeedbackRecord(parsedInput.recordId);
if (!currentRecordResult.data || currentRecordResult.error) {
throw new ResourceNotFoundError("Feedback record", parsedInput.recordId);
}
assertRecordBelongsToWorkspace(
workspaceDirectoryIds,
currentRecordResult.data.tenant_id,
parsedInput.recordId
);
const deleteResult = await deleteFeedbackRecord(parsedInput.recordId);
if (!deleteResult.data || deleteResult.error) {
throw new Error(deleteResult.error?.message || "Failed to delete feedback record");
}
return { recordId: parsedInput.recordId };
});
@@ -10,6 +10,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import type { FeedbackRecordData } from "@/modules/hub/types";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
FormControl,
FormError,
@@ -37,6 +38,7 @@ import {
import { Switch } from "@/modules/ui/components/switch";
import {
createFeedbackRecordAction,
deleteFeedbackRecordAction,
retrieveFeedbackRecordAction,
updateFeedbackRecordAction,
} from "../actions";
@@ -87,6 +89,8 @@ export const FeedbackRecordFormDrawer = ({
const [isLoadingRecord, setIsLoadingRecord] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const defaultValues = useMemo(() => getCreateDefaults(directories), [directories]);
@@ -283,6 +287,24 @@ export const FeedbackRecordFormDrawer = ({
return true;
};
const handleDelete = async () => {
if (!recordId) return;
setIsDeleting(true);
try {
const result = await deleteFeedbackRecordAction({ workspaceId, recordId });
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("workspace.unify.feedback_record_deleted_successfully"));
setIsDeleteDialogOpen(false);
await onSuccess();
onOpenChange(false);
} finally {
setIsDeleting(false);
}
};
const handleSubmit = form.handleSubmit(async (values) => {
form.clearErrors();
@@ -785,15 +807,30 @@ export const FeedbackRecordFormDrawer = ({
</FormProvider>
)}
<SheetFooter className="mt-2">
<Button variant="outline" onClick={requestClose} disabled={isSubmitting}>
{t("common.cancel")}
</Button>
{canWrite && (
<Button onClick={handleSubmit} loading={isSubmitting} disabled={isLoadingRecord}>
{mode === "create" ? t("workspace.unify.add_feedback_record") : t("common.save")}
<SheetFooter className="mt-2 sm:justify-between">
{isEditMode && canWrite && recordId ? (
<Button
variant="destructive"
onClick={() => setIsDeleteDialogOpen(true)}
disabled={isSubmitting || isLoadingRecord || isDeleting}>
{t("common.delete")}
</Button>
) : (
<span />
)}
<div className="flex gap-2">
<Button variant="outline" onClick={requestClose} disabled={isSubmitting || isDeleting}>
{t("common.cancel")}
</Button>
{canWrite && (
<Button
onClick={handleSubmit}
loading={isSubmitting}
disabled={isLoadingRecord || isDeleting}>
{mode === "create" ? t("workspace.unify.add_feedback_record") : t("common.save")}
</Button>
)}
</div>
</SheetFooter>
</SheetContent>
</Sheet>
@@ -809,6 +846,15 @@ export const FeedbackRecordFormDrawer = ({
onDecline={() => setIsDiscardDialogOpen(false)}
onConfirm={handleDiscardChanges}
/>
<DeleteDialog
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
deleteWhat={t("workspace.unify.delete_feedback_record")}
text={t("workspace.unify.delete_feedback_record_confirmation")}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</>
);
};
@@ -0,0 +1,52 @@
"use client";
import { Trash2Icon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
interface FeedbackRecordsTableToolbarLeftProps {
selectedCount: number;
recordsCount: number;
isEmpty: boolean;
onClearSelection: () => void;
onBulkDelete: () => void;
}
export const FeedbackRecordsTableToolbarLeft = ({
selectedCount,
recordsCount,
isEmpty,
onClearSelection,
onBulkDelete,
}: Readonly<FeedbackRecordsTableToolbarLeftProps>) => {
const { t } = useTranslation();
if (selectedCount > 0) {
return (
<div className="flex items-center gap-x-2 rounded-md bg-primary p-1 px-2 text-xs text-white">
<span className="lowercase">
{`${selectedCount} ${t("workspace.unify.feedback_records").toLowerCase()} ${t("common.selected")}`}
</span>
<span>|</span>
<Button variant="outline" size="sm" className="h-6 border-none px-2" onClick={onClearSelection}>
{t("common.clear_selection")}
</Button>
<span>|</span>
<Button variant="secondary" size="sm" className="h-6 gap-1 px-2" onClick={onBulkDelete}>
{t("common.delete")}
<Trash2Icon />
</Button>
</div>
);
}
if (isEmpty) {
return <span />;
}
return (
<p className="text-sm text-slate-500">
{t("workspace.unify.showing_count_loaded", { count: recordsCount })}
</p>
);
};
@@ -21,6 +21,8 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import type { FeedbackRecordData } from "@/modules/hub/types";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -29,9 +31,11 @@ import {
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { deleteFeedbackRecordAction } from "../actions";
import { formatSourceType } from "../lib/utils";
import { CsvImportModal } from "../sources/components/csv-import-modal";
import { FeedbackRecordFormDrawer } from "./feedback-record-form-drawer";
import { FeedbackRecordsTableToolbarLeft } from "./feedback-records-table-toolbar-left";
const RECORDS_PER_PAGE = 50;
@@ -87,8 +91,40 @@ export const FeedbackRecordsTable = ({
const [drawerRecordId, setDrawerRecordId] = useState<string | undefined>();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const hasMore = Object.keys(cursors).length > 0;
const selectedCount = selectedIds.size;
const allOnPageSelected = records.length > 0 && records.every((record) => selectedIds.has(record.id));
const someOnPageSelected = records.some((record) => selectedIds.has(record.id));
const toggleAllOnPage = (checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (checked) {
records.forEach((record) => next.add(record.id));
} else {
records.forEach((record) => next.delete(record.id));
}
return next;
});
};
const toggleOne = (recordId: string, checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (checked) {
next.add(recordId);
} else {
next.delete(recordId);
}
return next;
});
};
const clearSelection = () => setSelectedIds(new Set());
const directories = useMemo(
() =>
@@ -160,6 +196,7 @@ export const FeedbackRecordsTable = ({
const mergedRecords = result.records.toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1));
setRecords(mergedRecords);
setCursors(result.newCursors);
setSelectedIds(new Set());
setIsRefreshing(false);
toast.success(t("workspace.unify.feedback_records_refreshed"), { id: toastId });
};
@@ -199,6 +236,56 @@ export const FeedbackRecordsTable = ({
const isEmpty = records.length === 0 && !isRefreshing;
const handleBulkDelete = async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
setIsDeleting(true);
const CHUNK_SIZE = 5;
const failedIds: string[] = [];
try {
for (let i = 0; i < ids.length; i += CHUNK_SIZE) {
const chunk = ids.slice(i, i + CHUNK_SIZE);
const results = await Promise.all(
chunk.map(async (recordId) => ({
recordId,
result: await deleteFeedbackRecordAction({ workspaceId, recordId }),
}))
);
results.forEach(({ recordId, result }) => {
if (!result?.data) failedIds.push(recordId);
});
}
const succeeded = ids.filter((id) => !failedIds.includes(id));
if (succeeded.length > 0) {
setRecords((prev) => prev.filter((record) => !succeeded.includes(record.id)));
setSelectedIds((prev) => {
const next = new Set(prev);
succeeded.forEach((id) => next.delete(id));
return next;
});
}
if (failedIds.length === 0) {
toast.success(
t("workspace.unify.feedback_records_deleted_successfully", { count: succeeded.length })
);
} else if (succeeded.length === 0) {
toast.error(t("workspace.unify.failed_to_delete_feedback_records"));
} else {
toast.error(
t("workspace.unify.feedback_records_partially_deleted", {
succeeded: succeeded.length,
total: ids.length,
})
);
}
} finally {
setIsDeleting(false);
setIsBulkDeleteDialogOpen(false);
}
};
const openEditDrawer = (recordId: string) => {
setDrawerMode("edit");
setDrawerRecordId(recordId);
@@ -213,19 +300,24 @@ export const FeedbackRecordsTable = ({
const hasCsvSources = csvSources.length > 0;
let headerCheckboxChecked: boolean | "indeterminate" = false;
if (allOnPageSelected) {
headerCheckboxChecked = true;
} else if (someOnPageSelected) {
headerCheckboxChecked = "indeterminate";
}
return (
<>
<div className="space-y-3">
<div className="flex items-center justify-between">
{isEmpty ? (
<span />
) : (
<p className="text-sm text-slate-500">
{t("workspace.unify.showing_count_loaded", {
count: records.length,
})}
</p>
)}
<FeedbackRecordsTableToolbarLeft
selectedCount={selectedCount}
recordsCount={records.length}
isEmpty={isEmpty}
onClearSelection={clearSelection}
onBulkDelete={() => setIsBulkDeleteDialogOpen(true)}
/>
<div className="flex items-center gap-2">
{canWrite &&
(hasCsvSources ? (
@@ -280,6 +372,13 @@ export const FeedbackRecordsTable = ({
<table className="w-full min-w-[900px]">
<thead>
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
<th className="w-10 px-4 py-3">
<Checkbox
aria-label={t("common.select_all")}
checked={headerCheckboxChecked}
onCheckedChange={(checked) => toggleAllOnPage(checked === true)}
/>
</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
@@ -292,7 +391,7 @@ export const FeedbackRecordsTable = ({
{isEmpty ? (
<tbody>
<tr>
<td colSpan={7}>
<td colSpan={8}>
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
</div>
@@ -308,6 +407,8 @@ export const FeedbackRecordsTable = ({
workspaceId={workspaceId}
locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"}
t={t}
isSelected={selectedIds.has(record.id)}
onSelectChange={(checked) => toggleOne(record.id, checked)}
onClick={() => openEditDrawer(record.id)}
/>
))}
@@ -342,6 +443,15 @@ export const FeedbackRecordsTable = ({
onSuccess={handleRefresh}
/>
<DeleteDialog
open={isBulkDeleteDialogOpen}
setOpen={setIsBulkDeleteDialogOpen}
deleteWhat={t("workspace.unify.feedback_records")}
text={t("workspace.unify.delete_feedback_records_confirmation", { count: selectedCount })}
onDelete={handleBulkDelete}
isDeleting={isDeleting}
/>
{csvImportSource && (
<CsvImportModal
open={csvImportSource !== null}
@@ -363,12 +473,16 @@ const FeedbackRecordRow = ({
workspaceId,
locale,
t,
isSelected,
onSelectChange,
onClick,
}: {
record: FeedbackRecordData;
workspaceId: string;
locale: string;
t: TFunction;
isSelected: boolean;
onSelectChange: (checked: boolean) => void;
onClick: () => void;
}) => {
const value = formatValue(record, t, locale);
@@ -379,10 +493,10 @@ const FeedbackRecordRow = ({
return (
<tr
className="cursor-pointer text-sm text-slate-700 transition-colors focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
className={`cursor-pointer text-sm text-slate-700 transition-colors focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 ${isSelected ? "bg-slate-50" : ""}`}
tabIndex={0}
role="button"
aria-label={record.field_label ?? record.field_id}
aria-selected={isSelected}
onClick={onClick}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
@@ -390,6 +504,16 @@ const FeedbackRecordRow = ({
onClick();
}
}}>
<td
className="w-10 px-4 py-3"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}>
<Checkbox
aria-label={record.field_label ?? record.field_id}
checked={isSelected}
onCheckedChange={(checked) => onSelectChange(checked === true)}
/>
</td>
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
{formatDateTimeForDisplay(new Date(record.collected_at), locale)}
</td>
@@ -178,6 +178,7 @@ export function ConnectorRowDropdown({
setOpen={setIsDeleteDialogOpen}
onDelete={handleDelete}
isDeleting={isDeleting}
text={t("workspace.unify.delete_source_confirmation")}
/>
</div>
);
@@ -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>
@@ -18,6 +18,7 @@ import { Alert } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
@@ -49,6 +50,7 @@ import {
TSourceField,
TUnifySurvey,
ZFormbricksConnectorForm,
getTranslatedConnectorError,
} from "../types";
import {
TConnectorOptionId,
@@ -339,7 +341,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 +375,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);
}
@@ -414,18 +426,25 @@ export const CreateConnectorModal = ({
<DialogDescription>{getDialogDescription(currentStep, selectedType, t)}</DialogDescription>
</DialogHeader>
<div className="py-4">
<DialogBody>
{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 +454,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 +466,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 +483,9 @@ export const CreateConnectorModal = ({
</SelectContent>
</Select>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -470,7 +493,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 +505,9 @@ export const CreateConnectorModal = ({
/>
</div>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -569,7 +594,7 @@ export const CreateConnectorModal = ({
)}
</div>
)}
</div>
</DialogBody>
<DialogFooter>
{currentStep === "mapping" && (
@@ -578,7 +603,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;
}
};
@@ -78,3 +78,10 @@ export const ZUpdateFeedbackRecordAction = z.object({
});
export type TUpdateFeedbackRecordAction = z.infer<typeof ZUpdateFeedbackRecordAction>;
export const ZDeleteFeedbackRecordAction = z.object({
workspaceId: ZId,
recordId: ZFeedbackRecordId,
});
export type TDeleteFeedbackRecordAction = z.infer<typeof ZDeleteFeedbackRecordAction>;
+7 -21
View File
@@ -1,14 +1,8 @@
import "server-only";
import { NextRequest } from "next/server";
import { feedbackRecordsEnvoyAuthorizer } from "@/modules/hub/feedback-records-gateway";
import {
TEnvoyRequestAuthorizer,
authenticateEnvoyRequest,
buildStatusResponse,
parseEnvoyRequestMetadata,
} from "./shared";
const envoyAuthorizers: TEnvoyRequestAuthorizer[] = [feedbackRecordsEnvoyAuthorizer];
import { gatewayRequestAuthorizers } from "@/modules/gateway-auth/lib/authorizers";
import { authorizeGatewayRequest } from "@/modules/gateway-auth/lib/request";
import { buildEnvoyAllowResponse, parseEnvoyRequestMetadata } from "./shared";
export const authorizeEnvoyRequest = async (request: NextRequest): Promise<Response> => {
const requestMetadata = parseEnvoyRequestMetadata(request);
@@ -16,20 +10,12 @@ export const authorizeEnvoyRequest = async (request: NextRequest): Promise<Respo
return requestMetadata.errorResponse;
}
const authorizer = envoyAuthorizers.find((candidate) => candidate.matches(requestMetadata.originalRequest));
if (!authorizer) {
return buildStatusResponse(400, "Unsupported Envoy auth route");
}
const authenticationResult = await authenticateEnvoyRequest(request, authorizer.gatewayToken);
if (authenticationResult.status === "missing" || authenticationResult.status === "invalid") {
return buildStatusResponse(401, "Unauthorized");
}
return await authorizer.authorize({
return await authorizeGatewayRequest({
request,
originalRequest: requestMetadata.originalRequest,
principal: authenticationResult.principal,
authorizers: gatewayRequestAuthorizers,
requestId: request.headers.get("x-request-id") ?? "unknown",
buildAllowResponse: buildEnvoyAllowResponse,
unsupportedRouteMessage: "Unsupported Envoy auth route",
});
};
+6 -116
View File
@@ -1,51 +1,11 @@
import "server-only";
import { NextRequest } from "next/server";
import { prisma } from "@formbricks/database";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateApiKeyFromHeaders, getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
import { getProxySession } from "@/modules/auth/lib/proxy-session";
import { TGatewayOriginalRequest, buildGatewayStatusResponse } from "@/modules/gateway-auth/lib/request";
const ENVOY_AUTH_PREFIX = "/api/envoy-auth";
const HEADERS_TO_REMOVE_ON_ALLOW = "x-api-key,authorization,cookie";
export type TEnvoyOriginalRequest = {
method: string;
url: URL;
};
export type TEnvoyAuthenticatedPrincipal =
| {
type: "apiKey";
authentication: TAuthenticationApiKey;
}
| {
type: "user";
userId: string;
source: "session" | "jwt";
};
export type TEnvoyGatewayTokenHandler = {
getTokenFromHeaders: (headers: Headers) => string | null;
verifyToken: (token: string) => { userId: string };
};
export type TEnvoyAuthenticationResult =
| { status: "authenticated"; principal: TEnvoyAuthenticatedPrincipal }
| { status: "invalid" }
| { status: "missing" };
export type TEnvoyRequestAuthorizer = {
matches: (originalRequest: TEnvoyOriginalRequest) => boolean;
gatewayToken?: TEnvoyGatewayTokenHandler;
authorize: (params: {
request: NextRequest;
originalRequest: TEnvoyOriginalRequest;
principal: TEnvoyAuthenticatedPrincipal;
requestId: string;
}) => Promise<Response>;
};
export const buildAllowResponse = (): Response =>
export const buildEnvoyAllowResponse = (): Response =>
new Response(null, {
status: 200,
headers: {
@@ -53,20 +13,12 @@ export const buildAllowResponse = (): Response =>
},
});
export const buildStatusResponse = (status: number, message: string): Response =>
new Response(message, {
status,
headers: {
"content-type": "text/plain; charset=utf-8",
},
});
export const parseEnvoyRequestMetadata = (
request: NextRequest
): { originalRequest: TEnvoyOriginalRequest } | { errorResponse: Response } => {
): { originalRequest: TGatewayOriginalRequest } | { errorResponse: Response } => {
if (!request.nextUrl.pathname.startsWith(`${ENVOY_AUTH_PREFIX}/`)) {
return {
errorResponse: buildStatusResponse(400, "Invalid Envoy auth request path"),
errorResponse: buildGatewayStatusResponse(400, "Invalid Envoy auth request path"),
};
}
@@ -77,7 +29,7 @@ export const parseEnvoyRequestMetadata = (
if (originalPathSegments.length === 0) {
return {
errorResponse: buildStatusResponse(400, "Missing original request path"),
errorResponse: buildGatewayStatusResponse(400, "Missing original request path"),
};
}
@@ -93,69 +45,7 @@ export const parseEnvoyRequestMetadata = (
};
} catch {
return {
errorResponse: buildStatusResponse(400, "Invalid original request path"),
errorResponse: buildGatewayStatusResponse(400, "Invalid original request path"),
};
}
};
export const authenticateEnvoyRequest = async (
request: NextRequest,
gatewayToken?: TEnvoyGatewayTokenHandler
): Promise<TEnvoyAuthenticationResult> => {
if (getApiKeyFromHeaders(request.headers)) {
const apiKeyAuthentication = await authenticateApiKeyFromHeaders(request.headers);
if (!apiKeyAuthentication) {
return { status: "invalid" };
}
return {
status: "authenticated",
principal: {
type: "apiKey",
authentication: apiKeyAuthentication,
},
};
}
if (gatewayToken) {
const token = gatewayToken.getTokenFromHeaders(request.headers);
if (token) {
try {
const { userId } = gatewayToken.verifyToken(token);
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, isActive: true },
});
if (!user || user.isActive === false) {
return { status: "invalid" };
}
return {
status: "authenticated",
principal: {
type: "user",
userId: user.id,
source: "jwt",
},
};
} catch {
return { status: "invalid" };
}
}
}
const proxySession = await getProxySession(request);
if (!proxySession) {
return { status: "missing" };
}
return {
status: "authenticated",
principal: {
type: "user",
userId: proxySession.userId,
source: "session",
},
};
};
@@ -0,0 +1,5 @@
import "server-only";
import { feedbackRecordsGatewayAuthorizer } from "@/modules/hub/feedback-records-gateway";
import { TGatewayRequestAuthorizer } from "./request";
export const gatewayRequestAuthorizers: TGatewayRequestAuthorizer[] = [feedbackRecordsGatewayAuthorizer];
@@ -0,0 +1,114 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { authenticateGatewayRequest } from "./request";
const {
mockAuthenticateApiKeyFromHeaders,
mockGetApiKeyFromHeaders,
mockGetProxySession,
mockUserFindUnique,
mockLoggerWarn,
} = vi.hoisted(() => ({
mockAuthenticateApiKeyFromHeaders: vi.fn(),
mockGetApiKeyFromHeaders: vi.fn(),
mockGetProxySession: vi.fn(),
mockUserFindUnique: vi.fn(),
mockLoggerWarn: vi.fn(),
}));
vi.mock("@/modules/api/lib/api-key-auth", () => ({
authenticateApiKeyFromHeaders: mockAuthenticateApiKeyFromHeaders,
getApiKeyFromHeaders: mockGetApiKeyFromHeaders,
}));
vi.mock("@/modules/auth/lib/proxy-session", () => ({
getProxySession: mockGetProxySession,
}));
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: mockUserFindUnique,
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
info: vi.fn(),
warn: mockLoggerWarn,
error: vi.fn(),
},
}));
describe("authenticateGatewayRequest", () => {
beforeEach(() => {
vi.resetAllMocks();
mockGetApiKeyFromHeaders.mockReturnValue(null);
mockAuthenticateApiKeyFromHeaders.mockResolvedValue(null);
mockGetProxySession.mockResolvedValue(null);
mockUserFindUnique.mockResolvedValue({ id: "user_1", isActive: true });
});
test("logs and returns invalid when an explicit API key cannot be authenticated", async () => {
mockGetApiKeyFromHeaders.mockReturnValue("fbk_invalid");
const result = await authenticateGatewayRequest(new NextRequest("http://localhost/test"));
expect(result).toEqual({ status: "invalid" });
expect(mockLoggerWarn).toHaveBeenCalledWith(
{ hasApiKey: true, reason: "invalid_api_key" },
"Gateway authentication failed"
);
});
test("logs and returns invalid when gateway token verification fails", async () => {
const verifyError = new Error("invalid token");
const result = await authenticateGatewayRequest(new NextRequest("http://localhost/test"), {
getTokenFromHeaders: () => "header.payload.signature",
verifyToken: () => {
throw verifyError;
},
});
expect(result).toEqual({ status: "invalid" });
expect(mockLoggerWarn).toHaveBeenCalledWith(
{ error: verifyError, hasToken: true, reason: "token_verification_failed" },
"Gateway authentication failed"
);
});
test("logs and returns invalid when the gateway token user is inactive", async () => {
mockUserFindUnique.mockResolvedValue({ id: "user_1", isActive: false });
const result = await authenticateGatewayRequest(new NextRequest("http://localhost/test"), {
getTokenFromHeaders: () => "header.payload.signature",
verifyToken: () => ({ userId: "user_1" }),
});
expect(result).toEqual({ status: "invalid" });
expect(mockLoggerWarn).toHaveBeenCalledWith(
{
hasToken: true,
reason: "user_missing_or_inactive",
userId: "user_1",
userFound: true,
isActive: false,
},
"Gateway authentication failed"
);
});
test("propagates user lookup errors instead of converting them into invalid auth", async () => {
const lookupError = new Error("database unavailable");
mockUserFindUnique.mockRejectedValue(lookupError);
await expect(
authenticateGatewayRequest(new NextRequest("http://localhost/test"), {
getTokenFromHeaders: () => "header.payload.signature",
verifyToken: () => ({ userId: "user_1" }),
})
).rejects.toThrow("database unavailable");
});
});
@@ -0,0 +1,171 @@
import "server-only";
import { NextRequest } from "next/server";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateApiKeyFromHeaders, getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
import { getProxySession } from "@/modules/auth/lib/proxy-session";
export type TGatewayOriginalRequest = {
method: string;
url: URL;
};
export type TGatewayAuthenticatedPrincipal =
| {
type: "apiKey";
authentication: TAuthenticationApiKey;
}
| {
type: "user";
userId: string;
source: "session" | "jwt";
};
export type TGatewayTokenHandler = {
getTokenFromHeaders: (headers: Headers) => string | null;
verifyToken: (token: string) => { userId: string };
};
export type TGatewayAuthenticationResult =
| { status: "authenticated"; principal: TGatewayAuthenticatedPrincipal }
| { status: "invalid" }
| { status: "missing" };
export type TGatewayAuthorizationDecision = { status: "allow" } | { status: "deny"; response: Response };
export type TGatewayRequestAuthorizer = {
matches: (originalRequest: TGatewayOriginalRequest) => boolean;
gatewayToken?: TGatewayTokenHandler;
authorize: (params: {
request: NextRequest;
originalRequest: TGatewayOriginalRequest;
principal: TGatewayAuthenticatedPrincipal;
requestId: string;
}) => Promise<TGatewayAuthorizationDecision>;
};
export const buildGatewayStatusResponse = (status: number, message: string): Response =>
new Response(message, {
status,
headers: {
"content-type": "text/plain; charset=utf-8",
},
});
export const allowGatewayRequest = (): TGatewayAuthorizationDecision => ({ status: "allow" });
export const authenticateGatewayRequest = async (
request: NextRequest,
gatewayToken?: TGatewayTokenHandler
): Promise<TGatewayAuthenticationResult> => {
if (getApiKeyFromHeaders(request.headers)) {
const apiKeyAuthentication = await authenticateApiKeyFromHeaders(request.headers);
if (!apiKeyAuthentication) {
logger.warn({ hasApiKey: true, reason: "invalid_api_key" }, "Gateway authentication failed");
return { status: "invalid" };
}
return {
status: "authenticated",
principal: {
type: "apiKey",
authentication: apiKeyAuthentication,
},
};
}
if (gatewayToken) {
const token = gatewayToken.getTokenFromHeaders(request.headers);
if (token) {
let userId: string;
try {
({ userId } = gatewayToken.verifyToken(token));
} catch (error) {
logger.warn(
{ error, hasToken: true, reason: "token_verification_failed" },
"Gateway authentication failed"
);
return { status: "invalid" };
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, isActive: true },
});
if (!user || user.isActive === false) {
logger.warn(
{
hasToken: true,
reason: "user_missing_or_inactive",
userId,
userFound: Boolean(user),
isActive: user?.isActive ?? null,
},
"Gateway authentication failed"
);
return { status: "invalid" };
}
return {
status: "authenticated",
principal: {
type: "user",
userId: user.id,
source: "jwt",
},
};
}
}
const proxySession = await getProxySession(request);
if (!proxySession) {
return { status: "missing" };
}
return {
status: "authenticated",
principal: {
type: "user",
userId: proxySession.userId,
source: "session",
},
};
};
export const authorizeGatewayRequest = async ({
request,
originalRequest,
authorizers,
requestId,
buildAllowResponse,
unsupportedRouteMessage,
}: {
request: NextRequest;
originalRequest: TGatewayOriginalRequest;
authorizers: TGatewayRequestAuthorizer[];
requestId: string;
buildAllowResponse: () => Response;
unsupportedRouteMessage: string;
}): Promise<Response> => {
const authorizer = authorizers.find((candidate) => candidate.matches(originalRequest));
if (!authorizer) {
return buildGatewayStatusResponse(400, unsupportedRouteMessage);
}
const authenticationResult = await authenticateGatewayRequest(request, authorizer.gatewayToken);
if (authenticationResult.status === "missing" || authenticationResult.status === "invalid") {
return buildGatewayStatusResponse(401, "Unauthorized");
}
const authorizationDecision = await authorizer.authorize({
request,
originalRequest,
principal: authenticationResult.principal,
requestId,
});
return authorizationDecision.status === "allow" ? buildAllowResponse() : authorizationDecision.response;
};
@@ -12,11 +12,11 @@ import { getBearerTokenFromHeaders } from "@/modules/api/lib/api-key-auth";
import { getFeedbackDirectoryAuthContext } from "@/modules/ee/feedback-directory/lib/feedback-directory";
import { getIsUnifyFeedbackEnabled } from "@/modules/ee/license-check/lib/utils";
import {
TEnvoyAuthenticatedPrincipal,
TEnvoyRequestAuthorizer,
buildAllowResponse,
buildStatusResponse,
} from "@/modules/envoy-auth/shared";
TGatewayAuthenticatedPrincipal,
TGatewayRequestAuthorizer,
allowGatewayRequest,
buildGatewayStatusResponse,
} from "@/modules/gateway-auth/lib/request";
import { getFeedbackRecordTenant } from "@/modules/hub/service";
const FEEDBACK_RECORDS_V3_PREFIX = "/api/v3/feedbackRecords";
@@ -137,7 +137,7 @@ const parseFeedbackRecordsGatewayRoute = (method: string, pathname: string): TPa
return null;
};
type TAuthenticatedGatewayPrincipal = TEnvoyAuthenticatedPrincipal;
type TAuthenticatedGatewayPrincipal = TGatewayAuthenticatedPrincipal;
const parseTenantId = (tenantId: string | null): string | null => {
if (!tenantId) {
@@ -200,7 +200,7 @@ const resolveTenantId = async (
const tenantId = parseTenantId(originalUrl.searchParams.get("tenant_id"));
if (!tenantId) {
return {
errorResponse: buildStatusResponse(400, "Invalid or missing tenant_id"),
errorResponse: buildGatewayStatusResponse(400, "Invalid or missing tenant_id"),
};
}
@@ -212,7 +212,7 @@ const resolveTenantId = async (
const tenantId = parseTenantId(typeof body?.tenant_id === "string" ? body.tenant_id : null);
if (!tenantId) {
return {
errorResponse: buildStatusResponse(400, "Invalid or missing tenant_id"),
errorResponse: buildGatewayStatusResponse(400, "Invalid or missing tenant_id"),
};
}
@@ -227,7 +227,7 @@ const resolveTenantId = async (
"Feedback record tenant lookup returned not found"
);
return {
errorResponse: buildStatusResponse(403, "Forbidden"),
errorResponse: buildGatewayStatusResponse(403, "Forbidden"),
};
}
@@ -236,7 +236,7 @@ const resolveTenantId = async (
"Feedback record tenant lookup failed"
);
return {
errorResponse: buildStatusResponse(503, "Feedback record lookup failed"),
errorResponse: buildGatewayStatusResponse(503, "Feedback record lookup failed"),
};
}
@@ -247,14 +247,14 @@ const resolveTenantId = async (
"Feedback record tenant lookup returned invalid tenant"
);
return {
errorResponse: buildStatusResponse(503, "Feedback record lookup failed"),
errorResponse: buildGatewayStatusResponse(503, "Feedback record lookup failed"),
};
}
return { tenantId };
};
const authorizeGatewayRequest = async (
const authorizeFeedbackRecordsGatewayRequest = async (
principal: TAuthenticatedGatewayPrincipal,
feedbackDirectoryId: string,
requiredPermission: TFeedbackRecordsGatewayPermission
@@ -308,7 +308,7 @@ const authorizeGatewayRequest = async (
}
};
export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
export const feedbackRecordsGatewayAuthorizer: TGatewayRequestAuthorizer = {
matches: (originalRequest) => normalizeFeedbackRecordsPath(originalRequest.url.pathname) !== null,
gatewayToken: {
getTokenFromHeaders: getFeedbackRecordsGatewayJwtFromHeaders,
@@ -317,15 +317,21 @@ export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
authorize: async ({ request, originalRequest, principal, requestId }) => {
const route = parseFeedbackRecordsGatewayRoute(originalRequest.method, originalRequest.url.pathname);
if (!route) {
return buildStatusResponse(400, "Unsupported FeedbackRecords route");
return {
status: "deny",
response: buildGatewayStatusResponse(400, "Unsupported FeedbackRecords route"),
};
}
const tenantResolution = await resolveTenantId(request, route, originalRequest.url, requestId);
if ("errorResponse" in tenantResolution) {
return tenantResolution.errorResponse;
return {
status: "deny",
response: tenantResolution.errorResponse,
};
}
const authorizationResult = await authorizeGatewayRequest(
const authorizationResult = await authorizeFeedbackRecordsGatewayRequest(
principal,
tenantResolution.tenantId,
route.requiredPermission
@@ -341,7 +347,10 @@ export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
},
"Feedback records gateway authorization denied"
);
return buildStatusResponse(403, "Forbidden");
return {
status: "deny",
response: buildGatewayStatusResponse(403, "Forbidden"),
};
}
logger.info(
@@ -355,6 +364,6 @@ export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
"Feedback records gateway authorization allowed"
);
return buildAllowResponse();
return allowGatewayRequest();
},
};
+66
View File
@@ -4,6 +4,7 @@ import FormbricksHub from "@formbricks/hub";
import {
createFeedbackRecord,
createFeedbackRecordsBatch,
deleteFeedbackRecord,
getFeedbackRecordTenant,
listFeedbackRecords,
retrieveFeedbackRecord,
@@ -95,6 +96,24 @@ describe("hub service", () => {
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ message: "Network error" });
});
test("reads status from a foreign error class (simulates dual module scope)", async () => {
// Simulates the SDK being loaded into a different module scope under Next dev/Turbopack:
// the thrown error is NOT instanceof the FormbricksHub.APIError reference captured in service.ts.
class ForeignConflictError extends Error {
readonly status = 409;
}
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: {
create: vi.fn().mockRejectedValue(new ForeignConflictError("duplicate submission_id")),
},
} as any);
const result = await createFeedbackRecord(sampleInput);
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ status: 409, message: "duplicate submission_id" });
});
});
describe("listFeedbackRecords", () => {
@@ -278,6 +297,53 @@ describe("hub service", () => {
});
});
describe("deleteFeedbackRecord", () => {
test("returns config error when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
const result = await deleteFeedbackRecord("rec-1");
expect(result.data).toBeNull();
expect(result.error?.message).toContain("HUB_API_KEY");
});
test("returns data when client.delete resolves", async () => {
const deleteSpy = vi.fn().mockResolvedValue(undefined);
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { delete: deleteSpy },
} as any);
const result = await deleteFeedbackRecord("rec-1");
expect(deleteSpy).toHaveBeenCalledWith("rec-1");
expect(result.data).toEqual({ deleted: true });
expect(result.error).toBeNull();
});
test("returns error when client.delete throws APIError", async () => {
const apiError = new (FormbricksHub as any).APIError("Forbidden", 403);
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { delete: vi.fn().mockRejectedValue(apiError) },
} as any);
const result = await deleteFeedbackRecord("rec-1");
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ status: 403, message: "Forbidden" });
});
test("returns error when client.delete throws non-API error", async () => {
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { delete: vi.fn().mockRejectedValue(new Error("network")) },
} as any);
const result = await deleteFeedbackRecord("rec-1");
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ status: 0, message: "network" });
});
});
describe("createFeedbackRecordsBatch", () => {
test("returns all errors when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
+36 -5
View File
@@ -1,6 +1,5 @@
import "server-only";
import { createCacheKey } from "@formbricks/cache";
import FormbricksHub from "@formbricks/hub";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
import { getHubClient } from "./hub-client";
@@ -33,8 +32,15 @@ const getErrorMessage = (err: unknown): string => {
return "Unknown error";
};
// Duck-typed: `instanceof` against the SDK error class breaks under Next dev/Turbopack
// when @formbricks/hub is loaded into more than one module scope.
const getErrorStatus = (err: unknown): number =>
err && typeof err === "object" && typeof (err as { status?: unknown }).status === "number"
? (err as { status: number }).status
: 0;
const createResultFromError = (err: unknown): HubFeedbackRecordResult => {
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
const status = getErrorStatus(err);
const message = getErrorMessage(err);
return { data: null, error: { status, message, detail: message } };
};
@@ -98,6 +104,31 @@ export const updateFeedbackRecord = async (
}
};
export type HubFeedbackRecordDeleteResult = {
data: { deleted: true } | null;
error: HubError | null;
};
/**
* Delete a single feedback record in the Hub by id.
*/
export const deleteFeedbackRecord = async (id: string): Promise<HubFeedbackRecordDeleteResult> => {
const client = getHubClient();
if (!client) {
return { data: null, error: { ...NO_CONFIG_ERROR } };
}
try {
await client.feedbackRecords.delete(id);
return { data: { deleted: true }, error: null };
} catch (err) {
logger.warn({ err, id }, "Hub: deleteFeedbackRecord failed");
const status = getErrorStatus(err);
const message = getErrorMessage(err);
return { data: null, error: { status, message, detail: message } };
}
};
export type ListFeedbackRecordsResult = {
data: FeedbackRecordListResponse | null;
error: HubError | null;
@@ -128,7 +159,7 @@ export const listFeedbackRecords = async (
return { data, error: null };
} catch (err) {
logger.warn({ err }, "Hub: listFeedbackRecords failed");
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
const status = getErrorStatus(err);
const message = getErrorMessage(err);
return { data: null, error: { status, message, detail: message } };
}
@@ -146,7 +177,7 @@ export const semanticSearchFeedbackRecords = async (
return { data, error: null };
} catch (err) {
logger.warn({ err, tenantId: input.tenant_id }, "Hub: semanticSearchFeedbackRecords failed");
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
const status = getErrorStatus(err);
const message = getErrorMessage(err);
return { data: null, error: { status, message, detail: message } };
}
@@ -171,7 +202,7 @@ export const getFeedbackRecordTenant = async (recordId: string): Promise<Feedbac
return { data, error: null };
} catch (err) {
logger.warn({ err, recordId }, "Hub: getFeedbackRecordTenant failed");
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
const status = getErrorStatus(err);
const message = err instanceof Error ? err.message : String(err);
return { data: null, error: { status, message, detail: message } };
}
+1
View File
@@ -41,6 +41,7 @@ export const selectSurvey = {
singleUse: true,
pin: true,
showLanguageSwitch: true,
autoSelectLanguage: true,
recaptcha: true,
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
@@ -18,6 +18,7 @@ import { SurveyCompletedMessage } from "@/modules/survey/link/components/survey-
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { VerifyEmail } from "@/modules/survey/link/components/verify-email";
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
import { getSurveyLanguageCode } from "@/modules/survey/link/lib/utils";
import { TWorkspaceContextForLinkSurvey } from "@/modules/survey/link/lib/workspace";
interface SurveyRendererProps {
@@ -36,6 +37,7 @@ interface SurveyRendererProps {
// New props - pre-fetched in parent
workspaceContext: TWorkspaceContextForLinkSurvey;
locale: TUserLocale;
browserLanguageCodes?: string[];
responseCount?: number;
}
@@ -59,6 +61,7 @@ export const renderSurvey = async ({
isPreview,
workspaceContext,
locale,
browserLanguageCodes = [],
responseCount,
}: SurveyRendererProps) => {
const langParam = searchParams.lang;
@@ -110,7 +113,7 @@ export const renderSurvey = async ({
<VerifyEmail
survey={survey}
isErrorComponent={true}
languageCode={getLanguageCode(langParam, survey)}
languageCode={getSurveyLanguageCode(langParam, survey, browserLanguageCodes)}
styling={workspace.styling}
locale={locale}
/>
@@ -120,7 +123,7 @@ export const renderSurvey = async ({
<VerifyEmail
singleUseId={searchParams.suId ?? ""}
survey={survey}
languageCode={getLanguageCode(langParam, survey)}
languageCode={getSurveyLanguageCode(langParam, survey, browserLanguageCodes)}
styling={workspace.styling}
locale={locale}
/>
@@ -129,7 +132,7 @@ export const renderSurvey = async ({
// Compute final styling based on workspace and survey settings
const styling = computeStyling(workspace.styling, survey.styling);
const languageCode = getLanguageCode(langParam, survey);
const languageCode = getSurveyLanguageCode(langParam, survey, browserLanguageCodes);
const publicDomain = getPublicDomain();
// Handle PIN-protected surveys
@@ -196,24 +199,3 @@ function computeStyling(
}
return surveyStyling?.overwriteThemeStyling ? surveyStyling : workspaceStyling;
}
/**
* Determines the language code to use for the survey.
* Checks URL parameter against available survey languages and returns
* "default" if language is not found or disabled.
*/
function getLanguageCode(langParam: string | undefined, survey: TSurvey): string {
if (!langParam) return "default";
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code.toLowerCase() === langParam.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
);
});
if (!selectedLanguage || selectedLanguage?.default || !selectedLanguage?.enabled) {
return "default";
}
return selectedLanguage.language.code;
}
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { findMatchingLocale } from "@/lib/utils/locale";
import { findMatchingBrowserLanguageCodes, findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { verifyContactSurveyToken } from "@/modules/ee/contacts/lib/contact-survey-link";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
@@ -136,10 +136,11 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
singleUseId = validatedSingleUseId;
}
// Parallel fetch of environment context and locale
const [workspaceContext, locale, singleUseResponse] = await Promise.all([
// Parallel fetch of workspace context, locale, browser language, and contact response
const [workspaceContext, locale, browserLanguageCodes, singleUseResponse] = await Promise.all([
getWorkspaceContextForLinkSurvey(survey.workspaceId),
findMatchingLocale(),
findMatchingBrowserLanguageCodes(),
// Fetch existing response for this contact
getExistingContactResponse(survey.id, contactId)(),
]);
@@ -158,6 +159,7 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
singleUseResponse,
workspaceContext,
locale,
browserLanguageCodes,
responseCount,
});
};
+1
View File
@@ -58,6 +58,7 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
styling: true,
surveyClosedMessage: true,
showLanguageSwitch: true,
autoSelectLanguage: true,
recaptcha: true,
metadata: true,
+82 -1
View File
@@ -3,7 +3,13 @@ import { TJsWorkspaceStateSurvey } from "@formbricks/types/js";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getElementsFromSurveyBlocks, getWebAppLocale, isRTL, isRTLLanguage } from "./utils";
import {
getElementsFromSurveyBlocks,
getSurveyLanguageCode,
getWebAppLocale,
isRTL,
isRTLLanguage,
} from "./utils";
const createMockSurvey = (languages: TSurvey["languages"] = []): TSurvey =>
({
@@ -45,6 +51,7 @@ const createMockSurvey = (languages: TSurvey["languages"] = []): TSurvey =>
delay: 0,
autoComplete: null,
showLanguageSwitch: null,
autoSelectLanguage: null,
recaptcha: null,
isBackButtonHidden: false,
isCaptureIpEnabled: false,
@@ -98,6 +105,80 @@ describe("getWebAppLocale", () => {
});
});
describe("getSurveyLanguageCode", () => {
const language = (code: string, overrides: Partial<TSurvey["languages"][number]> = {}) => ({
language: {
id: `lang-${code}`,
code,
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
},
default: false,
enabled: true,
...overrides,
});
test("uses the URL language parameter before browser language auto-selection", () => {
const survey = {
...createMockSurvey([language("en", { default: true }), language("de")]),
autoSelectLanguage: true,
};
expect(getSurveyLanguageCode("de", survey, ["en-US"])).toBe("de");
});
test("matches browser language exactly when auto-selection is enabled", () => {
const survey = {
...createMockSurvey([language("en", { default: true }), language("de-DE")]),
autoSelectLanguage: true,
};
expect(getSurveyLanguageCode(undefined, survey, ["de-DE", "en-US"])).toBe("de-DE");
});
test("matches browser language by base language when exact variant is unavailable", () => {
const survey = {
...createMockSurvey([language("en", { default: true }), language("es-ES")]),
autoSelectLanguage: true,
};
expect(getSurveyLanguageCode(undefined, survey, ["es-MX", "en-US"])).toBe("es-ES");
});
test("uses aliases and ignores disabled languages", () => {
const survey = {
...createMockSurvey([
language("en", { default: true }),
language("de", { enabled: false }),
language("fr-FR", {
language: {
id: "lang-fr-FR",
code: "fr-FR",
alias: "fr",
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
},
}),
]),
autoSelectLanguage: true,
};
expect(getSurveyLanguageCode(undefined, survey, ["de-DE", "fr-CA"])).toBe("fr-FR");
});
test("falls back to default language when auto-selection is disabled or unmatched", () => {
const survey = createMockSurvey([language("en", { default: true }), language("de")]);
expect(getSurveyLanguageCode(undefined, survey, ["de-DE"])).toBe("default");
expect(getSurveyLanguageCode(undefined, { ...survey, autoSelectLanguage: true }, ["fr-FR"])).toBe(
"default"
);
});
});
describe("isRTL", () => {
test("detects RTL characters", () => {
expect(isRTL("مرحبا")).toBe(true);
+17
View File
@@ -1,6 +1,7 @@
import { TJsWorkspaceStateSurvey } from "@formbricks/types/js";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { resolveSurveyLanguage } from "@formbricks/types/surveys/language";
import { TSurvey } from "@formbricks/types/surveys/types";
export function isRTL(text: string): boolean {
@@ -55,6 +56,22 @@ export function isRTLLanguage(survey: TJsWorkspaceStateSurvey, languageCode: str
export const getElementsFromSurveyBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] =>
blocks.flatMap((block) => block.elements);
export const getSurveyLanguageCode = (
langParam: string | undefined,
survey: TSurvey,
browserLanguageCodes: string[] = []
): string => {
return (
resolveSurveyLanguage({
languages: survey.languages,
explicitLanguageCode: langParam,
browserLanguageCodes,
autoSelectLanguage: survey.autoSelectLanguage,
unmatchedExplicitLanguageBehavior: "fallback",
}) ?? "default"
);
};
/**
* Maps survey language codes to web app locale codes.
* Falls back to "en-US" if the language is not available in web app locales.
+7 -5
View File
@@ -3,7 +3,7 @@ import { notFound } from "next/navigation";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { TSurvey } from "@formbricks/types/surveys/types";
import { findMatchingLocale } from "@/lib/utils/locale";
import { findMatchingBrowserLanguageCodes, findMatchingLocale } from "@/lib/utils/locale";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
@@ -93,17 +93,18 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
if (isSingleUseSurvey) {
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
if (!validatedSingleUseId) {
// Need to fetch workspace for error page - fetch environmentContext for it
const environmentContext = await getWorkspaceContextForLinkSurvey(survey.workspaceId);
return <SurveyInactive status="link invalid" workspace={environmentContext.workspace} />;
// Need to fetch workspace for error page.
const workspaceContext = await getWorkspaceContextForLinkSurvey(survey.workspaceId);
return <SurveyInactive status="link invalid" workspace={workspaceContext.workspace} />;
}
singleUseId = validatedSingleUseId;
}
// Stage 2: Parallel fetch of all remaining data
const [workspaceContext, locale, singleUseResponse] = await Promise.all([
const [workspaceContext, locale, browserLanguageCodes, singleUseResponse] = await Promise.all([
getWorkspaceContextForLinkSurvey(survey.workspaceId),
findMatchingLocale(),
findMatchingBrowserLanguageCodes(),
// Only fetch single-use response if we have a validated ID
isSingleUseSurvey && singleUseId
? getResponseBySingleUseId(survey.id, singleUseId)()
@@ -124,6 +125,7 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
isPreview,
workspaceContext,
locale,
browserLanguageCodes,
responseCount,
});
};
@@ -149,7 +149,7 @@ export const LanguageView = ({
buttonVariant: "destructive",
onConfirm: () => {
// Strip all non-default language keys from the survey data
let cleanedSurvey = localSurvey;
let cleanedSurvey = { ...localSurvey, autoSelectLanguage: false };
for (const lang of localSurvey.languages) {
if (!lang.default) {
cleanedSurvey = removeLanguageKeysFromSurvey(cleanedSurvey, lang.language.code);
@@ -172,6 +172,7 @@ export const LanguageView = ({
const language = workspaceLanguages.find((lang) => lang.code === languageCode);
if (!language) return;
const isNewMultiLanguageSurvey = localSurvey.languages.length === 0;
let languageExists = false;
const newLanguages =
localSurvey.languages.map((lang) => {
@@ -187,7 +188,11 @@ export const LanguageView = ({
}
setConfirmationModalInfo((prev) => ({ ...prev, open: false }));
setLocalSurvey({ ...localSurvey, languages: newLanguages });
setLocalSurvey({
...localSurvey,
languages: newLanguages,
autoSelectLanguage: isNewMultiLanguageSurvey ? true : localSurvey.autoSelectLanguage,
});
};
const handleToggleLanguage = (code: string) => {
@@ -254,7 +259,7 @@ export const LanguageView = ({
buttonText: t("workspace.surveys.edit.remove_translations"),
buttonVariant: "destructive",
onConfirm: () => {
updateSurveyTranslations(localSurvey, []);
updateSurveyTranslations({ ...localSurvey, autoSelectLanguage: false }, []);
setIsMultiLanguageActivated(false);
setConfirmationModalInfo((prev) => ({ ...prev, open: false }));
},
@@ -265,6 +270,10 @@ export const LanguageView = ({
setLocalSurvey({ ...localSurvey, showLanguageSwitch: !localSurvey.showLanguageSwitch });
};
const handleAutoSelectLanguageToggle = () => {
setLocalSurvey({ ...localSurvey, autoSelectLanguage: !localSurvey.autoSelectLanguage });
};
const openTranslationModal = (code: string) => {
setActiveLanguageCode(code);
setTranslationModalOpen(true);
@@ -489,6 +498,16 @@ export const LanguageView = ({
)}
childBorder={true}
/>
<AdvancedOptionToggle
customContainerClass="px-0 pt-0"
htmlId="autoSelectLanguage"
disabled={enabledLanguages.length <= 1}
isChecked={!!localSurvey.autoSelectLanguage}
onToggle={handleAutoSelectLanguageToggle}
title={t("workspace.surveys.edit.auto_select_browser_language")}
description={t("workspace.surveys.edit.auto_select_browser_language_description")}
childBorder={true}
/>
</div>
)}
@@ -38,6 +38,7 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
segment: null,
languages: [],
showLanguageSwitch: false,
autoSelectLanguage: false,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
variables: [],
@@ -0,0 +1,242 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { authorizeTraefikRequest } from "./service";
const {
mockAuthenticateApiKeyFromHeaders,
mockGetApiKeyFromHeaders,
mockGetBearerTokenFromHeaders,
mockGetProxySession,
mockVerifyFeedbackRecordsGatewayToken,
mockGetFeedbackDirectoryAuthContext,
mockGetFeedbackRecordTenant,
mockCheckAuthorizationUpdated,
mockUserFindUnique,
mockGetIsUnifyFeedbackEnabled,
} = vi.hoisted(() => ({
mockAuthenticateApiKeyFromHeaders: vi.fn(),
mockGetApiKeyFromHeaders: vi.fn(),
mockGetBearerTokenFromHeaders: vi.fn(),
mockGetProxySession: vi.fn(),
mockVerifyFeedbackRecordsGatewayToken: vi.fn(),
mockGetFeedbackDirectoryAuthContext: vi.fn(),
mockGetFeedbackRecordTenant: vi.fn(),
mockCheckAuthorizationUpdated: vi.fn(),
mockUserFindUnique: vi.fn(),
mockGetIsUnifyFeedbackEnabled: vi.fn(),
}));
vi.mock("@/modules/api/lib/api-key-auth", () => ({
authenticateApiKeyFromHeaders: mockAuthenticateApiKeyFromHeaders,
getApiKeyFromHeaders: mockGetApiKeyFromHeaders,
getBearerTokenFromHeaders: mockGetBearerTokenFromHeaders,
}));
vi.mock("@/modules/auth/lib/proxy-session", () => ({
getProxySession: mockGetProxySession,
}));
vi.mock("@/lib/jwt", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/jwt")>();
return {
...actual,
verifyFeedbackRecordsGatewayToken: mockVerifyFeedbackRecordsGatewayToken,
};
});
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: mockUserFindUnique,
},
},
}));
vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({
getFeedbackDirectoryAuthContext: mockGetFeedbackDirectoryAuthContext,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsUnifyFeedbackEnabled: mockGetIsUnifyFeedbackEnabled,
}));
vi.mock("@/modules/hub/service", () => ({
getFeedbackRecordTenant: mockGetFeedbackRecordTenant,
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: mockCheckAuthorizationUpdated,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
const feedbackDirectoryId = "clxx1234567890123456789012";
const feedbackRecordId = "0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8";
const createRequest = ({
method = "GET",
forwardedMethod = "GET",
forwardedUri,
headers = {},
body,
adapterUrl = "http://localhost/api/traefik-auth/feedback-records",
}: {
method?: string;
forwardedMethod?: string;
forwardedUri?: string;
headers?: Record<string, string>;
body?: BodyInit;
adapterUrl?: string;
} = {}) =>
new NextRequest(adapterUrl, {
method,
headers: {
"x-forwarded-method": forwardedMethod,
...(forwardedUri ? { "x-forwarded-uri": forwardedUri } : {}),
"x-forwarded-host": "app.example.com",
"x-forwarded-proto": "https",
...headers,
},
body,
});
describe("authorizeTraefikRequest", () => {
beforeEach(() => {
vi.resetAllMocks();
mockGetApiKeyFromHeaders.mockReturnValue(null);
mockGetBearerTokenFromHeaders.mockReturnValue(null);
mockAuthenticateApiKeyFromHeaders.mockResolvedValue(null);
mockGetProxySession.mockResolvedValue(null);
mockVerifyFeedbackRecordsGatewayToken.mockImplementation(() => {
throw new Error("invalid token");
});
mockGetFeedbackDirectoryAuthContext.mockResolvedValue({
organizationId: "org_1",
workspaceIds: ["workspace_1"],
isArchived: false,
});
mockGetFeedbackRecordTenant.mockResolvedValue({
data: { tenantId: feedbackDirectoryId },
error: null,
});
mockCheckAuthorizationUpdated.mockResolvedValue(true);
mockUserFindUnique.mockResolvedValue({ id: "user_1", isActive: true });
mockGetIsUnifyFeedbackEnabled.mockResolvedValue(true);
});
test("allows requests using Traefik forwarded method and URI metadata", async () => {
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
});
const response = await authorizeTraefikRequest(
createRequest({
method: "POST",
forwardedMethod: "POST",
forwardedUri: "/api/v3/feedbackRecords",
headers: {
authorization: "Bearer fbk_test",
"content-type": "application/json",
},
body: JSON.stringify({ tenant_id: feedbackDirectoryId }),
})
);
expect(response.status).toBe(200);
expect(response.headers.get("x-envoy-auth-headers-to-remove")).toBeNull();
});
test("uses the forwarded URI instead of the Traefik auth endpoint URL", async () => {
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
});
const response = await authorizeTraefikRequest(
createRequest({
adapterUrl: "http://localhost/api/traefik-auth/not-the-original-route",
forwardedUri: `/v1/feedback-records?tenant_id=${feedbackDirectoryId}`,
})
);
expect(response.status).toBe(200);
});
test("returns 400 when Traefik forwarded metadata is missing", async () => {
const response = await authorizeTraefikRequest(
new NextRequest("http://localhost/api/traefik-auth/v1/feedback-records", {
method: "GET",
})
);
expect(response.status).toBe(400);
});
test("authorizes record lookups through the shared FeedbackRecords authorizer", async () => {
mockGetBearerTokenFromHeaders.mockReturnValue("header.payload.signature");
mockVerifyFeedbackRecordsGatewayToken.mockReturnValue({ userId: "user_1" });
const response = await authorizeTraefikRequest(
createRequest({
forwardedMethod: "PATCH",
forwardedUri: `/v1/feedback-records/${feedbackRecordId}`,
headers: {
authorization: "Bearer header.payload.signature",
},
})
);
expect(response.status).toBe(200);
expect(mockGetFeedbackRecordTenant).toHaveBeenCalledWith(feedbackRecordId);
expect(mockCheckAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "workspaceTeam",
workspaceId: "workspace_1",
minPermission: "readWrite",
},
],
});
});
test("returns 401 for invalid explicit JWT instead of falling back to session cookies", async () => {
mockGetBearerTokenFromHeaders.mockReturnValue("header.payload.signature");
mockGetProxySession.mockResolvedValue({
userId: "user_1",
});
const response = await authorizeTraefikRequest(
createRequest({
forwardedUri: `/v1/feedback-records?tenant_id=${feedbackDirectoryId}`,
headers: {
authorization: "Bearer header.payload.signature",
cookie: "next-auth.session-token=still-present",
},
})
);
expect(response.status).toBe(401);
expect(mockGetProxySession).not.toHaveBeenCalled();
});
});
+21
View File
@@ -0,0 +1,21 @@
import "server-only";
import { NextRequest } from "next/server";
import { gatewayRequestAuthorizers } from "@/modules/gateway-auth/lib/authorizers";
import { authorizeGatewayRequest } from "@/modules/gateway-auth/lib/request";
import { buildTraefikAllowResponse, parseTraefikRequestMetadata } from "./shared";
export const authorizeTraefikRequest = async (request: NextRequest): Promise<Response> => {
const requestMetadata = parseTraefikRequestMetadata(request);
if ("errorResponse" in requestMetadata) {
return requestMetadata.errorResponse;
}
return await authorizeGatewayRequest({
request,
originalRequest: requestMetadata.originalRequest,
authorizers: gatewayRequestAuthorizers,
requestId: request.headers.get("x-request-id") ?? request.headers.get("x-forwarded-for") ?? "unknown",
buildAllowResponse: buildTraefikAllowResponse,
unsupportedRouteMessage: "Unsupported Traefik auth route",
});
};
+54
View File
@@ -0,0 +1,54 @@
import "server-only";
import { NextRequest } from "next/server";
import { TGatewayOriginalRequest, buildGatewayStatusResponse } from "@/modules/gateway-auth/lib/request";
const TRAEFIK_AUTH_PREFIX = "/api/traefik-auth";
const isTraefikAuthPath = (pathname: string): boolean =>
pathname === TRAEFIK_AUTH_PREFIX || pathname.startsWith(`${TRAEFIK_AUTH_PREFIX}/`);
const buildForwardedRequestUrl = (request: NextRequest, forwardedUri: string): URL => {
if (forwardedUri.startsWith("http://") || forwardedUri.startsWith("https://")) {
return new URL(forwardedUri);
}
const proto = request.headers.get("x-forwarded-proto") || "https";
const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || "traefik-auth.local";
const normalizedUri = forwardedUri.startsWith("/") ? forwardedUri : `/${forwardedUri}`;
return new URL(normalizedUri, `${proto}://${host}`);
};
export const buildTraefikAllowResponse = (): Response => new Response(null, { status: 200 });
export const parseTraefikRequestMetadata = (
request: NextRequest
): { originalRequest: TGatewayOriginalRequest } | { errorResponse: Response } => {
if (!isTraefikAuthPath(request.nextUrl.pathname)) {
return {
errorResponse: buildGatewayStatusResponse(400, "Invalid Traefik auth request path"),
};
}
const forwardedMethod = request.headers.get("x-forwarded-method")?.trim();
const forwardedUri = request.headers.get("x-forwarded-uri")?.trim();
if (!forwardedMethod || !forwardedUri) {
return {
errorResponse: buildGatewayStatusResponse(400, "Missing original request metadata"),
};
}
try {
return {
originalRequest: {
method: forwardedMethod.toUpperCase(),
url: buildForwardedRequestUrl(request, forwardedUri),
},
};
} catch {
return {
errorResponse: buildGatewayStatusResponse(400, "Invalid original request URI"),
};
}
};
@@ -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;
@@ -3,7 +3,7 @@ import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { Button } from "../button";
interface IconAction {
icon: LucideIcon;
icon: LucideIcon | null;
tooltip: string;
onClick?: () => void;
isVisible?: boolean;
@@ -37,7 +37,7 @@ export const IconBar = ({ actions }: IconBarProps) => {
disabled={action.disabled}
loading={action.isLoading}
aria-label={action.tooltip}>
<action.icon />
{action.icon ? <action.icon /> : null}
</Button>
</TooltipRenderer>
</span>
@@ -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,
+8 -2
View File
@@ -48,10 +48,15 @@ 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
@@ -181,6 +186,7 @@ Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If yo
| 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.extraArgs | list | `["--dtype","float16"]` | Additional args appended to the generated TEI args. |
| hub.embeddings.huggingFace.existingSecret | string | `""` | |
| hub.embeddings.huggingFace.token | string | `""` | |
| hub.embeddings.huggingFace.tokenKey | string | `"HF_TOKEN"` | |
+200
View File
@@ -0,0 +1,200 @@
/* eslint-env es2022 */
const TENANT_MEMBERS = ["FeedbackRecords.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,110 @@
// This schema maps to the `feedback_records` table owned by the Formbricks Hub Postgres.
// If the Hub changes column names or types, 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,
},
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`,
},
tenantId: {
sql: `tenant_id`,
type: `string`,
description: `Tenant ID linking to FeedbackDirectory`,
},
},
});
+8
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.

Some files were not shown because too many files have changed in this diff Show More