Compare commits

..

320 Commits

Author SHA1 Message Date
Tiago Farto 19fb6126cc chore: update openapi example 2026-05-18 17:08:53 +00:00
Tiago Farto 49473f17e3 chore: always use language maps, allow filtering multiple languages 2026-05-18 17:05:44 +00:00
Tiago Farto 0df059adcd chore: remove version query param 2026-05-18 16:53:47 +00:00
Tiago Farto fb463f6fc4 chore: fix build; mcp related changes 2026-05-18 16:43:07 +00:00
Tiago Farto 311e49311b chore: assorted improvements 2026-05-18 15:31:51 +00:00
Tiago Farto ff7ac26ba5 Merge branch 'main' into chore/v3_get_survey 2026-05-18 15:09:18 +00:00
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
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
Dhruwang Jariwala f4ca7c46ef fix: add Hub and Cube env vars to Docker build secrets (#7950) 2026-05-07 17:22:05 +05:30
Dhruwang c252d8c4c9 fix: update tests for required Cube and Hub env vars
Tests now expect validation failures when CUBEJS_API_URL, CUBEJS_API_SECRET,
or HUB_API_KEY are missing, and all test env helpers provide HUB_API_KEY.

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 16:18:35 +05:30
Anshuman Pandey ecf3aacca3 fix: removes auto feedback directory linking with workspaces (#7947) 2026-05-07 13:46:25 +04:00
Dhruwang Jariwala a0f3d2a651 chore: upgrade Hub to 0.3.0 and SDK to 0.5.0 (#7948) 2026-05-07 14:59:11 +05:30
Dhruwang 16bbd7a447 chore: upgrade Hub to 0.3.0 and SDK to 0.5.0
Hub 0.3.0 renames the `user_identifier` API field to `user_id` (breaking
change). This commit bumps the Hub Docker image, upgrades the
@formbricks/hub TypeScript SDK from 0.4.3 to 0.5.0, and renames every
`user_identifier` reference in Zod schemas, server actions, transform
pipeline, form components, CubeJS schema, connector types, and seed data
to match the new API contract.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 14:31:09 +05:30
Bhagya Amarasinghe a276aa6d34 fix(helm): default embeddings model to gte multilingual 2026-05-07 13:46:29 +05:30
Javi Aguilar d192fbf839 add CR changes 2026-05-07 10:12:06 +02:00
Javi Aguilar c5d52df9b7 use i18n interpolation properly 2026-05-07 10:12:06 +02:00
Javi Aguilar 550e859a2d feat(unify): add CTA to create a survey before using it as feedback source if there are none 2026-05-07 10:12:06 +02:00
Dhruwang Jariwala 6fb9cf28b1 fix: add cursor-based pagination and fix refresh for feedback records (#7935)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-07 10:13:59 +04:00
Dhruwang Jariwala 8c47cdba73 chore: drop explicit feedback directory grants, use implicit auth (#7941) 2026-05-07 10:24:30 +05:30
Bhagya Amarasinghe e6b6f5e6d3 feat(helm): add Hub worker and embeddings runtime 2026-05-07 01:45:45 +05:30
pandeymangg 6218153351 fixes tests 2026-05-06 16:33:03 +05:30
pandeymangg 9ef4be270b fix: removes feedback directory auth from api keys 2026-05-06 16:29:06 +05:30
Dhruwang Jariwala ed42df34c4 feat(ai): support Vertex AI ADC credentials (#7938) 2026-05-06 12:37:24 +05:30
Dhruwang Jariwala 8c8ff8e396 feat: gate AI chart generation behind all AI checks (#7937) 2026-05-06 10:09:49 +05:30
Dhruwang 72cf2d6a50 test: add coverage for getAIDataAnalysisUnavailableReason 2026-05-05 18:06:02 +05:30
Bhagya Amarasinghe c5d629ef25 feat(ai): support Vertex AI ADC credentials 2026-05-05 18:04:30 +05:30
Dhruwang 71cb8bdff5 refactor: extract getAIDataAnalysisUnavailableReason to shared utility
Move duplicated function to @/lib/ai/service and export TAIUnavailableReason
type. Remove local copies from charts-list-page and dashboard-detail-page.
2026-05-05 17:40:42 +05:30
Dhruwang 850fb8acc3 feat: gate AI chart generation behind all 3 AI checks
- Server-side: Replace hardcoded OpenAI with provider-agnostic `getAiModel(env)` and enforce
  `assertOrganizationAIConfigured(organizationId, "dataAnalysis")` which validates license
  entitlement, org-level toggle, and instance configuration
- Client-side: Instead of hiding AI section when unavailable, show it disabled with a tooltip
  explaining the reason (not in plan / not enabled / instance not configured), following the
  same pattern as AI translate
- Thread `isAIAvailable` and `aiUnavailableReason` through the component chain from server
  pages down to `AIQuerySection`
- Update test mocks to match new provider-agnostic AI imports
2026-05-05 17:21:22 +05:30
Dhruwang Jariwala 94c9e8fcf1 feat: gate Unify Feedback, FRDs, Dashboards behind license (#7924) 2026-05-05 17:15:14 +05:30
pandeymangg 49a8c8c686 adds nav links 2026-05-05 16:33:35 +05:30
pandeymangg 2832831db1 chore: merge with epic/v5 2026-05-05 16:21:34 +05:30
pandeymangg b5e6567194 fixes 2026-05-05 16:13:12 +05:30
Dhruwang Jariwala 86d3f2fae1 chore: hardening cube tenant isolation (#7920) 2026-05-05 16:03:11 +05:30
pandeymangg 62d09f6a8f chore: merge with epic/v5 2026-05-05 15:14:53 +05:30
Johannes 74dd778630 feat: similar feedback preview (#7917)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-05 13:28:08 +04:00
Tiago Farto 7ac99c0840 chore: update 2026-05-05 08:50:03 +00:00
Tiago Farto dde0f8d32c Merge branch 'epic/v5' into chore/harden-cube-tenant-isolation 2026-05-05 08:49:36 +00:00
Tiago Farto bcd3c91075 chore: address PR concerns 2026-05-05 08:39:56 +00:00
Bhagya Amarasinghe f376c620ab docs: align self-hosting docs for Formbricks v5 (#7906) 2026-05-05 12:52:18 +05:30
Bhagya Amarasinghe 4865a78338 docs: align v5 cube deployment guidance 2026-05-05 12:50:41 +05:30
Bhagya Amarasinghe a7c8e1acf9 docs: add MinIO to RustFS migration pointer 2026-05-05 12:47:02 +05:30
Bhagya Amarasinghe e5a097e56e docs: address CodeRabbit review feedback 2026-05-05 12:47:02 +05:30
Bhagya Amarasinghe 1ddde9cac7 docs: align self-hosting docs for Formbricks v5 2026-05-05 12:47:02 +05:30
Anshuman Pandey 59f5cdfb4b fix: hub pinned at specific tag/digest (#7923) 2026-05-05 11:15:41 +04:00
pandeymangg 8431eaf9f6 chore: merge with epic/v5 2026-05-05 11:38:39 +05:30
Dhruwang Jariwala f228e8e06a chore: Rename FeedbackRecordDirectory to FeedbackDirectory (#7925) 2026-05-05 09:15:20 +04:00
Dhruwang Jariwala 5e6ab81cb1 fix: migrate feedback-sources page to unified settings navigation (#7928) 2026-05-05 10:09:30 +05:30
Tiago Farto 1417a5a654 chore: restore document 2026-05-04 13:05:53 +00:00
Tiago Farto f8ae92b3be chore: remove doc 2026-05-04 13:04:37 +00:00
Dhruwang 1bc3f79f30 fix: translations 2026-05-04 18:25:11 +05:30
Dhruwang 7151dd5234 fix: migrate feedback-sources page to unified settings navigation
The feedback-sources page was still using the old WorkspaceConfigNavigation
(secondary tabs) instead of the new unified settings sidebar introduced in
#7904. This caused an inconsistent navigation experience.

Changes:
- Create new route at /settings/workspace/feedback-sources
- Add feedback-sources entry to SettingsSidebarContent
- Remove old WorkspaceConfigNavigation from ConnectorsSection
- Redirect old /feedback-sources route to new settings path
- Update all stale /feedback-sources links across the codebase
2026-05-04 18:20:54 +05:30
Dhruwang Jariwala 086315ce33 feat: unify settings UI with shared sidebar navigation (#7904) 2026-05-04 17:37:53 +05:30
Tiago Farto e01b4311ca chore: cleaned documentation duplication 2026-05-04 11:52:41 +00:00
Tiago Farto dd757394af fix: make bundled cube optional 2026-05-04 11:03:37 +00:00
Dhruwang 507f80f9b0 fix: update stale settings routes to match new /settings/{organization,workspace}/ structure
All internal links (billing, enterprise, general, api-keys, feedback-record-directories,
integrations) now point to their correct nested paths under /settings/organization/ or
/settings/workspace/. Also adds feedback-record-directories to the new sidebar nav with
the member visibility rule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 16:31:25 +05:30
pandeymangg 8562232280 adds tests 2026-05-04 13:27:22 +05:30
pandeymangg 1234e6685a fixes feedback 2026-05-04 13:15:37 +05:30
pandeymangg 40a5e8ea6a fixes tests and i18n validation 2026-05-04 13:12:38 +05:30
pandeymangg 319a76a70d moves connectors, dashboards and frd to ee 2026-05-04 12:55:34 +05:30
Tiago Farto 2abf8e1d8c fix: log rejected cube tenant queries 2026-04-30 16:52:41 +00:00
Tiago Farto a985dc698b refactor: simplify cube query filter traversal 2026-04-30 16:50:55 +00:00
Tiago Farto 7b59a6300e fix: address cube tenant isolation review 2026-04-30 16:34:09 +00:00
Tiago Farto bf8b4079fd test: isolate cube env config test 2026-04-30 16:20:29 +00:00
Tiago Farto 5704bfbc03 chore: hardening cube tenant isolation 2026-04-30 16:00:08 +00:00
Dhruwang 0920ccf2c3 fix: remove unused isBilling prop and stale translation keys
- Remove isBilling from WorkspaceBreadcrumb/WorkspaceAndOrgSwitch prop chain
- Remove unused common.organization_settings and common.unify translation keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 18:24:32 +05:30
Dhruwang db0c9e7c55 fix: update E2E action tests to wait for user-actions URL
The tests were waiting for a redirect to app-connection that no longer
exists — user-actions is now a standalone page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 18:09:45 +05:30
Dhruwang ef87d899b9 fix: simplify dropdown menus and fix Connect Your App icon
- Replace individual settings items in workspace/org dropdowns with single Settings link
- Change Connect Your App icon from ListChecksIcon to UnplugIcon
- Remove unused code (isActiveOrganizationSetting, isActiveWorkspaceSetting, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 18:06:19 +05:30
Bhagya Amarasinghe ea92ef9fce feat: add FeedbackRecords Envoy gateway (#7818) 2026-04-30 17:17:05 +05:30
pandeymangg 778fc2acf1 fix 2026-04-30 16:36:09 +05:30
Dhruwang 2ffef36c89 fix: update E2E tests for user-actions route and Teams heading ambiguity
- action.spec.ts: navigate to user-actions page instead of app-connection
- organization.spec.ts: use level:1 to disambiguate "Teams" heading

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 16:30:22 +05:30
pandeymangg 1d6bda74df removes route test 2026-04-30 16:26:47 +05:30
pandeymangg 12ff0b7c0e sonar issue fix 2026-04-30 16:19:11 +05:30
Dhruwang fa1079bac1 fix: update E2E tests for renamed settings labels
- "Look & Feel" comments → "Appearance"
- "Members & Teams" heading assertion → "Teams"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 16:07:18 +05:30
Dhruwang 1403f0bb01 fix: pass missing isBilling prop to WorkspaceBreadcrumb
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 16:06:34 +05:30
Dhruwang c79553633f fix: use export...from to re-export default in user-actions routes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 16:04:04 +05:30
Bhagya Amarasinghe f16fb3b62f fix: restore required feedback record list params 2026-04-30 15:59:45 +05:30
Dhruwang 7dfc7f4825 docs: update references to renamed settings labels
- "Configuration" → "Settings"
- "Look & Feel" → "Appearance"
- "Website & App Connection" → "Connect Your App" / "User Actions"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 15:46:47 +05:30
Dhruwang 1ecc9f1722 fix: restore settings sidebar, rename labels, fix SonarQube issues, and extract user-actions page
- Restore settings sidebar in MainNavigation (lost during epic/v5 merge)
- Rename "Configuration" to "Settings", "Look & Feel" to "Appearance", etc.
- Fix SonarQube issues: duplicate class, regex injection, nested ternary, inline arrow functions
- Extract User Actions from Connect Your App into its own settings page
- Update all i18n translation keys across locales

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 15:41:50 +05:30
Dhruwang 7d1c02b54b Merge remote-tracking branch 'origin/epic/v5' into feat/settings-cleanup-v5 2026-04-30 14:56:35 +05:30
Bhagya Amarasinghe f2c452d7f9 feat: make cubejs mandatory for xm suite v5 (#7913) 2026-04-30 14:34:50 +05:30
Bhagya Amarasinghe afcfbb7a3a fix: address cube review follow-ups 2026-04-30 14:17:54 +05:30
Bhagya Amarasinghe 7f8c9dcbb8 chore: merge epic/v5 into feedback records gateway 2026-04-30 01:22:24 +05:30
Bhagya Amarasinghe 3998e4da31 fix: resolve sonar quality gate warning 2026-04-30 00:59:25 +05:30
Bhagya Amarasinghe 48086faffc fix: address cube review feedback 2026-04-30 00:39:49 +05:30
Bhagya Amarasinghe 38a0d7c810 Merge remote-tracking branch 'origin/epic/v5' into bhagya/eng-765-make-cubejs-mandatory-for-xm-suite-v5 2026-04-30 00:32:05 +05:30
Bhagya Amarasinghe b17bb88daa fix: require cube env vars in app config 2026-04-30 00:30:11 +05:30
Anshuman Pandey f59e9f13ec feat: refresh analysis charts and dashboard feedback gating (#7915)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-29 16:29:14 +04:00
Anshuman Pandey 5169dec510 feat: wire workspace settings to feedback record directories (#7910)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 14:49:50 +04:00
Bhagya Amarasinghe 0df16f6f0c feat: make cubejs mandatory for xm suite v5 2026-04-29 16:08:24 +05:30
Anshuman Pandey 8442dedf9c fix: removes project references (#7907) 2026-04-29 14:17:42 +04:00
Dhruwang 22c27c5ebb fix: remove unused params prop from notifications page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 15:27:50 +05:30
Dhruwang 6638dceb04 feat: disable settings for billing role, hide back button, add org switcher to landing sidebar
- Disable all workspace and select org settings items for billing-role users
- Hide the top bar (back button) for billing users in settings mode
- Add organization switcher with lazy-loaded org list to landing sidebar
- Pass isMultiOrgEnabled to landing sidebar for create-org option

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 15:24:42 +05:30
Dhruwang 8558121e46 feat: enhance SettingsSidebarContent with tooltip and popover for disabled items
- Added Tooltip and Popover components to provide user feedback for disabled navigation items.
- Implemented conditional rendering of tooltips and popovers based on item state.
- Introduced a disabledMessage prop to display appropriate messages for unauthorized actions.
2026-04-29 14:46:48 +05:30
Dhruwang f1279d51e5 fix: transaltions 2026-04-29 14:42:48 +05:30
Dhruwang 926706be9d fix: merge epic/v5, fix stale integration URLs and settings workspace switcher
- Resolve merge conflict in create-connector-modal (keep NoFeedbackRecordDirectoryAlert)
- Fix GoBackButton URLs in slack, google-sheets, airtable integration pages to use /settings/workspace/integrations path
- Fix connectHref values in integrations page (webhooks, google-sheets, airtable, slack, notion, JS SDK)
- Fix handleWorkspaceChange to stay in settings mode when switching workspace from settings sidebar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 14:36:06 +05:30
Dhruwang 85b456e619 fix: navigate to surveys via URL in multi-language e2e test
The settings sidebar replaces the main nav, so the "Surveys" link is
not visible when on a settings page. Use direct URL navigation instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 13:46:34 +05:30
Bhagya Amarasinghe 3bac488a29 fix: address gateway review follow-ups 2026-04-29 12:27:41 +05:30
Johannes fbe2a31133 refactor: align connector enum with formbricks_survey (#7825)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-29 10:57:20 +04:00
Bhagya Amarasinghe 79d618f77c refactor: generalize gateway token minting 2026-04-29 12:16:08 +05:30
Anshuman Pandey 89eb04f813 fix: adds submission id to csv connector (#7898) 2026-04-29 10:36:16 +04:00
Dhruwang 8a2b349329 feat: unify settings under /workspaces/[id]/settings with shared sidebar navigation
Consolidate all settings (Account, Organization, Workspace) under a unified
URL structure with a shared sidebar that replaces the main navigation when
in settings mode. Remove horizontal nav bars, old dropdown-based navigation
patterns, and route group layouts in favor of real URL segments.

- Move workspace settings from /(workspace)/ to /settings/workspace/
- Move org settings from /settings/(organization)/ to /settings/organization/
- Move account settings from /settings/(account)/ to /settings/account/
- Add SettingsSidebarContent with inline workspace/org switchers
- Replace main sidebar with settings nav when pathname includes /settings
- Update all page headings to match sidebar nav labels
- Update e2e tests for new URL structure and navigation patterns
- Remove unused translation keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 18:26:58 +05:30
Dhruwang Jariwala a862b739f7 fix: consistent enabled/disabled wording for connector status (#7897) 2026-04-28 15:11:44 +05:30
Dhruwang 4e5df85538 fix: make pipeline dispatch fire-and-forget in management responses route
Pipeline errors (snapshot loading or dispatch) should not prevent the
201 response from being returned. Dispatch pipeline events without
awaiting so the response is returned immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 14:31:24 +05:30
Dhruwang 727b349086 fix: resolve pre-existing build errors on epic/v5
- Add optional chaining for organization.billing in response pipeline
- Add missing feedbackRecordDirectoryId to Chart seed data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 13:47:20 +05:30
Dhruwang f75db6b1d0 fix: translations 2026-04-28 12:39:12 +05:30
Dhruwang 7ffca53577 fix: use consistent enabled/disabled wording for connector status badges
The dropdown actions say "Enable"/"Disable" but the status badges showed
"In Progress"/"Paused". Now both use "Enabled"/"Disabled" for consistency.

Resolves ENG-769

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 12:24:49 +05:30
Dhruwang Jariwala 25614b23fc chore: remove legacy styling fields (questionColor, inputColor) (#7783) 2026-04-28 11:22:03 +05:30
Johannes 016e14d0f1 fix: (Depr Env QA) Surface legacy env var on Connection page (#7773) 2026-04-27 14:20:25 +00:00
Bhagya Amarasinghe be80db8418 fix: address envoy auth review findings 2026-04-27 19:31:43 +05:30
Bhagya Amarasinghe bcc3789ce8 refactor: generalize envoy auth dispatch 2026-04-27 18:31:58 +05:30
Dhruwang 5e76ebdfc1 fix: treat JSON null as absent in legacy styling migration
The `?` operator only checks key existence — if the form layer saved
`{"elementHeadlineColor": null}` (JSON null = "use default"), the
migration skipped the copy and then removed the legacy key, losing
the color value. Switch to COALESCE(styling->'field', 'null'::jsonb)
= 'null'::jsonb which catches both missing keys (SQL NULL) and JSON
null values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 17:07:05 +05:30
Bhagya Amarasinghe 150f256721 fix: decouple pipeline from response ingestion (#7651) 2026-04-27 12:59:27 +05:30
pandeymangg da7971328c little cleanup 2026-04-27 12:58:42 +05:30
Bhagya Amarasinghe a6cd56b196 fix: migrate response pipeline to BullMQ (#7651) 2026-04-27 12:58:42 +05:30
pandeymangg 7c81cf119e adds test for schedulePipelineDrain when env vars are not set 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe 8d29b24352 fix: address latest pipeline review comments (#7651) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe a1ae849496 fix: address CodeRabbit pipeline findings (#7651) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe 4d0a686e89 fix: address pipeline PR checks (#7651) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe 364915e4c8 fix: decouple pipeline from response ingestion (#1487) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe ada2518d0c fix: address feedback records gateway build failures 2026-04-24 17:30:41 +05:30
Bhagya Amarasinghe 57d1c0ed99 fix: resolve feedback records PR check failures 2026-04-24 16:53:57 +05:30
Tiago 817b299436 chore: rename gcp ai provider to google (#7815) 2026-04-24 10:10:58 +00:00
Tiago Farto c140dae872 Merge branch 'epic/v5' into chore/rename_google 2026-04-24 09:51:39 +00:00
Bhagya Amarasinghe 6036a8c767 fix: harden FeedbackRecords Envoy auth routing 2026-04-24 13:51:54 +05:30
Dhruwang Jariwala bf592937f4 feat: AI-powered survey translation (#7793) 2026-04-24 12:55:36 +05:30
Bhagya Amarasinghe 1cfadd968a feat: add FeedbackRecords Envoy gateway 2026-04-24 02:17:54 +05:30
Tiago Farto 21ed383a46 chore: address PR concerns 2026-04-23 13:45:09 +00:00
Bhagya Amarasinghe 7ed7101ac1 feat: adds feedback record directory auth to api keys (#7804) 2026-04-23 18:04:17 +05:30
Tiago Farto 7aa12a4f0c chore: rename google ai things 2026-04-23 12:27:39 +00:00
pandeymangg 2e926936fb addressed feedback 2026-04-23 17:46:09 +05:30
Dhruwang 8edef8aede refactor: replace repeated union type with TDimension alias in TBaseStyling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 16:22:13 +05:30
Dhruwang 54fb202285 fix: add jsonb_typeof guard to legacy styling migration
Ensures the UPDATE only processes JSONB objects, preventing errors
on unexpected scalar or array values in the styling column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 16:16:50 +05:30
Dhruwang c720a462a7 fix: rename inputColor to inputTextColor in survey-ui storybook files
Aligns storybook story helpers and element stories with the legacy
field removal — inputColor → inputTextColor, mapping to
--fb-input-text-color CSS variable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 16:12:53 +05:30
Dhruwang a386451e6e refactor: consolidate imports in actions.ts
Merged the import statements for ZAITranslationField and translateFields from the translate-fields module to streamline the code structure.
2026-04-23 15:17:31 +05:30
Dhruwang f0bf111e7b fix: test 2026-04-23 15:08:18 +05:30
Dhruwang 8a57a5b74b addressed feedback 2026-04-23 15:05:16 +05:30
Dhruwang 434cb1d0d0 refactor: remove BullMQ background jobs from AI translation
Replace the async job queue + Redis polling pattern with a direct
server action call. The translation now runs synchronously inside
translateSurveyFieldsAction, removing the need for BullMQ job
definitions, processors, cache keys, and client-side polling logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:11:28 +05:30
pandeymangg 8bde75a9ff chore: merge with epic/v5 2026-04-23 11:06:54 +05:30
pandeymangg 6b880f29cb chore: merge with epic/v5 2026-04-23 11:00:25 +05:30
Dhruwang Jariwala 969c9834e5 fix: (Depr Env QA) update lang keys (#7786) 2026-04-23 10:57:47 +05:30
pandeymangg 5e33b7c9a4 fixes and e2e fixes 2026-04-23 10:55:20 +05:30
Dhruwang 230ea744fa fix: simplify workspace permissions assignment in ViewPermissionModal 2026-04-23 10:38:18 +05:30
Anshuman Pandey fae1fb8f96 fix: cleans up environmentId references (#7792) 2026-04-23 09:05:00 +04:00
Dhruwang eac35daed9 fix: transllations 2026-04-23 10:34:29 +05:30
Johannes 45accc1edb fix: align workspace naming in setup and email preview
Replace remaining environment wording with workspace terminology across setup flows, API key permissions, and email preview text, and switch the email Tailwind config to ESM so formatting hooks run under the current Node runtime.

Made-with: Cursor
2026-04-23 10:30:30 +05:30
Dhruwang Jariwala 02ebe8e9f8 fix: (Depr Env QA) update docs (#7784) 2026-04-23 10:23:23 +05:30
pandeymangg cae859e326 sonarcloud fixes 2026-04-22 17:15:19 +05:30
pandeymangg 5352d563b6 fixes and consistency 2026-04-22 17:03:18 +05:30
Anshuman Pandey 711f2bfe67 chore: restores feedback record directory changes to epic/v5 (#7806) 2026-04-22 15:22:15 +04:00
Dhruwang 6fcb5d39a2 fix: address code review feedback for AI translation
- Reuse ZAITranslationField from @formbricks/jobs instead of duplicating
  the schema locally in actions.ts; tighten sourceLanguage/targetLanguage
  validators with .min(1) to match the downstream job schema

- Guard against undefined translations in getAITranslationResultAction
  instead of using the unsafe `translations!` assertion — return
  "pending" status for malformed cache entries

- Use createCacheKey.custom("ai-translation", jobId) instead of raw
  template strings to follow cache key conventions

- Improve JSON parsing robustness: strip markdown code fences before
  attempting JSON.parse, log raw response on parse failures

- Add stale-request guard and error handling to the AI availability
  useEffect in language-view.tsx

- Replace shared pollingCancelledRef boolean with per-invocation Symbol
  token to prevent stale polling loops from clobbering state when the
  modal is reopened

- Track timeout explicitly with a timedOut flag so the "timed out" toast
  doesn't fire when polling was actually cancelled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 16:01:57 +05:30
pandeymangg 1ed9859ee7 fixes tests 2026-04-22 15:58:02 +05:30
pandeymangg cd72a0a78d adds translations 2026-04-22 15:44:45 +05:30
pandeymangg 2b09795787 feat: adds frd auth to api keys 2026-04-22 15:43:33 +05:30
Dhruwang 2451acb9bd fix: AI translation security, error handling, and test coverage
- Add userId verification in getAITranslationResultAction (security)
- Use OperationNotAllowedError for auth failures
- Store failure marker in cache on last BullMQ attempt
- Make JSON parsing more robust (extract first {...} block)
- Add "keep modal open" hint to translating toast
- Add test coverage for process-ai-translation-job

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 15:18:54 +05:30
Dhruwang 14dcded91b Merge branch 'epic/v5' of https://github.com/formbricks/formbricks into feat/ai-survey-translation 2026-04-22 15:01:12 +05:30
Dhruwang 46062f91cd refactors 2026-04-22 14:50:46 +05:30
Dhruwang Jariwala ffd4478184 chore: merge epic/dashboards into epic/v5 (#7798)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Theodór Tómas <theodortomas@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
Co-authored-by: Chowdhury Tafsir Ahmed Siddiki <ctafsiras@gmail.com>
Co-authored-by: neila <40727091+neila@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Tiago <1585571+xernobyl@users.noreply.github.com>
Co-authored-by: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com>
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Gulshan <gulshanbahadur002@gmail.com>
Co-authored-by: Tiago Farto <tiago@formbricks.com>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 12:24:05 +04:00
Dhruwang 69da1862fa Merge latest epic/v5 (survey scheduling) into ai-translation branch
Resolve merge conflicts to combine AI translation and survey scheduling
features in jobs package, instrumentation, and tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 13:39:10 +05:30
Dhruwang c11d3241ab fix: test and translations 2026-04-22 12:59:27 +05:30
Tiago 3fb09a1a26 feat: survey scheduling (#7766) 2026-04-21 15:00:51 +00:00
Dhruwang 6efa449c10 feat: add AI-powered survey translation via BullMQ background jobs
Add a "Translate with AI" button to the Manage Translations modal that
auto-populates empty translation fields using the configured AI provider.
Translation runs as a BullMQ background job with results cached in Redis
and polled by the client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 17:38:47 +05:30
Dhruwang 34b94689ca fix: address CodeRabbit review feedback on docs
- Fix broken API URLs in docs-feedback.mdx (remove /workspaces segment, fix https://, remove stray };)
- Add missing workspaceId path params to v2 spec (responses, displays, user)
- Remove environmentId from required arrays in v2 request schemas
- Fix stale terminology: environment→workspace in database-model, tenant-separation, tags, actions
- Fix broken link to removed test-environment page in webhooks.mdx
- Fix redundant "codebase" in naming-conventions description
- Use neutral hostname in audit-logging example
- Hyphenate "open-source" in license.mdx
- Consistent workspaceId formatting in wordpress.mdx
- Update link text to match anchor in actions.mdx
- Remove dual environmentId/workspaceId in headless-surveys example
- Fix stale <project_id> placeholder in headless-surveys
- Fix awkward Next.js card copy in framework-guides.mdx
- Clarify BC wording in v2 introduction.mdx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:35:26 +05:30
Tiago Farto 901fac7e08 chore: fix env vars 2026-04-21 09:56:32 +00:00
Dhruwang 739c662863 chore: merge epic/v5 and resolve openapi.json conflicts
Accept epic/v5 removals (attribute-classes, people endpoints) and
re-apply workspaceId rename + deprecation notes + endpoint cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:22:17 +05:30
Dhruwang 535974ff8a fix: align API specs with actual routes and add environment deprecation notes
- Restore v1 openapi.json from main with environmentId→workspaceId rename
- Remove 5 non-existent v1 endpoints and their orphaned MDX pages
- Update v1 descriptions from "environment" to "workspace" terminology
- Add environment deprecation notes to all v1 client API endpoints
- Remove 2 non-existent v2 client endpoints (contacts attributes, identify)
- Rename v2 project-teams → workspace-teams (path, operationIds, schema)
- Preserve environment deprecation notes in v2 spec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:15:52 +05:30
Tiago Farto a8b97abe9a Merge remote-tracking branch 'origin/epic/v5' into feat/survey-scheduling
# Conflicts:
#	pnpm-lock.yaml
2026-04-21 09:37:41 +00:00
Johannes 28103604b4 fix: (Depr Env QA) api v1/me regression (#7761)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-21 13:26:01 +04:00
Tiago Farto b5a7e15386 chore: fix build 2026-04-21 09:07:45 +00:00
Tiago Farto fec4746d5d chore: address PR comments 2026-04-21 08:51:32 +00:00
pandeymangg 175323e7d9 chore: merge with main 2026-04-21 14:07:14 +05:30
Tiago Farto 6130737d51 chore: fix DST bug 2026-04-20 16:57:48 +00:00
Tiago Farto bf10a8d0b2 Merge branch 'epic/v5' into feat/survey-scheduling 2026-04-20 16:09:16 +00:00
Tiago Farto 612f8dceb8 chore: fix test 2026-04-20 15:50:38 +00:00
Tiago 0303f16db4 feat: BullMQ background jobs + response pipeline (#7779) 2026-04-20 15:30:20 +00:00
Tiago Farto 07635b160e chore: fix test; migration 2026-04-20 15:13:27 +00:00
Tiago Farto 9cfcffdb5e chore: bug fix; tests 2026-04-20 14:22:06 +00:00
Tiago Farto 02264ffc5f chore: build fix 2026-04-20 13:25:01 +00:00
Tiago Farto 7dde3edd8d chore: fix tests 2026-04-20 12:30:03 +00:00
Dhruwang 730ab6a609 fix: use valid hex colors in styles unit tests
Replace invalid fake hex values (e.g. "#btn-bg", "#headline-color") with
valid hex colors so isLight() and mixColor() don't throw. Add missing
inputTextColor to the survey styling test so --fb-placeholder-color is set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 17:41:19 +05:30
Johannes 4304a7efd6 Rename Projects to Workspaces in docs 2026-04-20 13:56:27 +02:00
Tiago Farto a89d598f8d Merge branch 'epic/bullmq' into feat/survey-scheduling 2026-04-20 11:46:13 +00:00
Tiago Farto 6ff5af712f chore: clean tests 2026-04-20 11:35:47 +00:00
Anshuman Pandey 398ba79e7e feat: ces and csat questions (#7688)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-20 15:33:26 +04:00
Dhruwang 4e75a57692 chore: remove legacy styling fields (questionColor, inputColor) for v5
Add a SQL migration that copies legacy coarse-grained styling fields
to granular equivalents (e.g. questionColor → elementHeadlineColor,
inputColor → inputBgColor) and strips the legacy keys from the JSONB.

Remove the runtime deriveNewFieldsFromLegacy() shim, all fallback
chains in CSS variable generation, and update types, schemas, tests,
and OpenAPI spec to reflect the new canonical field names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 17:00:20 +05:30
Tiago Farto 5127de9de0 chore: revert CI action 2026-04-20 11:11:09 +00:00
Tiago Farto 2bf7788a1b Merge branch 'epic/bullmq' into feat/survey-scheduling 2026-04-20 11:07:33 +00:00
Tiago Farto ee8122778b chore: address PR comments 2026-04-20 10:43:32 +00:00
Tiago Farto 8aaa7ed9c0 chore: build fix 2026-04-20 10:00:06 +00:00
Johannes bc7c8c5715 remove environment ID andenv references 2026-04-20 11:40:33 +02:00
Dhruwang Jariwala ab1ea7a5ce fix: remove legacy API rewrites from next.config.mjs (#7764)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-20 13:38:30 +04:00
Tiago Farto 4f749355e0 chore: fix coverage test 2026-04-20 09:14:06 +00:00
Tiago Farto 18b60ddd35 chore: fix build 2026-04-20 08:52:59 +00:00
Tiago Farto 87f1b01c7a chore: fix broken tests 2026-04-20 08:40:44 +00:00
Tiago Farto 851ea0deb2 chore: fix broken lock 2026-04-20 08:32:45 +00:00
pandeymangg 9abbbfdd35 chore: merge with main 2026-04-20 13:07:36 +05:30
Johannes 990c0eee31 refined UX 2026-04-19 16:05:29 +02:00
Tiago Farto 07f16b8a43 chore: fix build 2026-04-17 23:23:51 +00:00
Tiago Farto bf556b0608 chore: fix linting 2026-04-17 22:26:57 +00:00
Tiago Farto 8b0766a46e chore: bix fuild 2026-04-17 22:16:17 +00:00
Tiago Farto 1f995d6e25 chore: build fix 2026-04-17 20:05:11 +00:00
Tiago Farto 975a4d57f8 chore: fix build 2026-04-17 19:50:23 +00:00
Tiago Farto 69bd576fc5 chore: fix build 2026-04-17 16:37:22 +00:00
Tiago Farto a2e4a3bbd7 chore: fix build 2026-04-17 16:27:18 +00:00
Tiago Farto 281f854332 chore: address PR comments 2026-04-17 15:36:12 +00:00
Tiago Farto 24496774a5 chore: fix build 2026-04-17 14:57:55 +00:00
Tiago Farto aeaf3215b4 chore: fix 2026-04-17 14:51:51 +00:00
Tiago Farto f4c5162590 Merge epic/bullmq into feat/survey-scheduling 2026-04-17 14:47:05 +00:00
Tiago Farto dedb7389f0 Merge origin/epic/v5 into epic/bullmq 2026-04-17 14:33:21 +00:00
Tiago Farto 7aed1b84de chore: translations, fixes 2026-04-17 11:59:17 +00:00
Bhagya Amarasinghe 9d2e988c59 feat: remove app rate limits for Envoy-covered routes (#7714)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-17 12:43:22 +04:00
Tiago 31d2ea7444 chore: Move Response Pipeline to BullMQ (#7695) 2026-04-15 10:12:41 +03:00
pandeymangg 3da7129413 fixes tests 2026-04-14 17:09:13 +05:30
pandeymangg 75fbb23190 chore: merge with main 2026-04-14 17:01:17 +05:30
Tiago Farto d361c334d3 chore: fixed management snapshot gap 2026-04-13 14:28:31 +03:00
Tiago Farto a4d808b479 chore: build fix 2026-04-13 13:10:33 +03:00
Tiago Farto 18ae1748d3 chore: address PR comments 2026-04-13 12:50:21 +03:00
Dhruwang Jariwala 60f6ca9463 chore: deprecate environments (#7693)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-10 09:13:47 +04:00
Tiago Farto 3404e0c494 chore: fix string date convertion error 2026-04-09 17:14:14 +03:00
Tiago Farto 83499ae552 chore: fix build 2026-04-09 15:14:44 +03:00
Tiago Farto 2ac0c1eb07 chore: refactor 2026-04-09 15:04:31 +03:00
Tiago Farto 54ede3015e chore: fix build 2026-04-09 14:09:46 +03:00
Tiago Farto 1b4f05a062 chore: fix linting issue 2026-04-09 13:59:34 +03:00
Tiago Farto 197dbf5aa6 chore: address pr comments 2026-04-09 13:45:32 +03:00
pandeymangg aa27d242bb chore: merge with main 2026-04-09 15:26:30 +05:30
Tiago 7ca52a7a93 feat: Introduce BullMQ setup to Formbricks (#7684) 2026-04-09 11:47:58 +03:00
Tiago Farto 4a48839d17 Merge branch 'feat/background_workers_v1' into chore/response-to-bullmq 2026-04-09 11:43:30 +03:00
Tiago Farto 92bd9bdac7 chore: address PR comments 2026-04-09 11:26:12 +03:00
Tiago Farto ad4b6f8b8c chore: addressing additional PR comments 2026-04-09 10:39:01 +03:00
Tiago Farto 8de5079db3 chore: lint fix 2026-04-09 10:07:29 +03:00
Tiago Farto a60206dd44 chore: fix sonarqube warnings 2026-04-09 09:59:09 +03:00
Tiago Farto d66abdcdaf chore: refactoring 2026-04-09 09:26:38 +03:00
Tiago Farto 03fa41a911 fix: tighten v2 response validation details typing 2026-04-08 23:23:37 +03:00
Tiago Farto cab438e474 chore: refactor 2026-04-08 21:47:15 +03:00
Tiago Farto a6dfe78c81 fix: restore response pipeline safety guards 2026-04-08 20:47:47 +03:00
Tiago Farto e4d96f4379 fix: resolve jobs runtime type import for web build 2026-04-08 17:16:17 +03:00
Tiago Farto 581a66b4a9 chore: fix problems 2026-04-08 17:00:36 +03:00
Tiago Farto 5cf0c15812 chore: response to bullmq 2026-04-08 14:43:50 +03:00
Tiago Farto ebaa2d363c chore: fix flaky test 2026-04-08 10:25:48 +03:00
Tiago Farto 597ea40b75 chore: fix linting issues 2026-04-08 10:16:24 +03:00
Tiago Farto 3c39dcc2de chore: increased test coverage 2026-04-08 09:51:58 +03:00
Tiago Farto e8df1dbb35 chore: fix sonarqube warning 2026-04-07 22:15:10 +03:00
Tiago Farto 84987ce557 chore: linter fixes 2026-04-07 21:42:23 +03:00
Tiago Farto 784ed855d7 chore: additional tests; address PR comments 2026-04-07 21:14:52 +03:00
Tiago Farto 5a17d4144d fix: normalize storage result typing for web build 2026-04-07 19:07:15 +03:00
Tiago Farto 65c9db86c6 fix: separate storage type exports and imports 2026-04-07 18:04:27 +03:00
Tiago Farto bc94d34d1e fix: narrow storage route results by property 2026-04-07 17:41:13 +03:00
Tiago Farto 22be60a0ba fix: align storage type exports for web build 2026-04-07 17:18:53 +03:00
Tiago Farto a384963863 fix: type storage delete wrappers 2026-04-07 16:34:51 +03:00
Tiago Farto c067ae73bb fix: narrow storage delete result in route 2026-04-07 16:25:36 +03:00
Tiago Farto dc78a30cbe fix: repair pnpm lockfile for BullMQ branch 2026-04-07 16:13:17 +03:00
Tiago Farto 9c9ae8a3a2 test: fix env test on v5 branch 2026-04-07 16:01:21 +03:00
Tiago Farto 29a08151aa chore: addressed PR concerns 2026-04-07 15:59:20 +03:00
Tiago Farto f42a8822a9 chore: background workers trough bullMQ 2026-04-07 15:56:12 +03:00
Dhruwang Jariwala a771ae189a refactor: rename Project to Workspace across entire codebase (#7620) 2026-03-31 17:01:17 +05:30
Anshuman Pandey 029e069af6 feat: feedback record directories (#7592) 2026-03-27 04:18:20 -07:00
Matti Nannt 81272b96e1 feat: port hub xm-suite config to epic/v5 (#7578) 2026-03-25 11:04:42 +00:00
50 changed files with 2165 additions and 1524 deletions
+11
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import {
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
@@ -118,3 +119,13 @@ describe("successResponse", () => {
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
});
});
describe("noContentResponse", () => {
test("returns 204 without a body", async () => {
const res = noContentResponse({ requestId: "req-empty" });
expect(res.status).toBe(204);
expect(res.headers.get("X-Request-Id")).toBe("req-empty");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.text()).toBe("");
});
});
+15
View File
@@ -171,3 +171,18 @@ export function successResponse<T>(
}
);
}
export function noContentResponse(options?: { requestId?: string; cache?: string }): Response {
const headers: Record<string, string> = {
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return new Response(null, {
status: 204,
headers,
});
}
@@ -1,318 +0,0 @@
import { ApiKeyPermission } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { DELETE } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const queueAuditEvent = vi.mocked((await import("@/modules/ee/audit-logs/lib/handler")).queueAuditEvent);
const surveyId = "clxx1234567890123456789012";
const workspaceId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) {
headers["x-request-id"] = requestId;
}
return new NextRequest(url, {
method: "DELETE",
headers,
});
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: true },
},
workspacePermissions: [
{
workspaceId,
workspaceName: "W",
permission: ApiKeyPermission.write,
},
],
};
describe("DELETE /api/v3/surveys/[surveyId]", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(getSurvey).mockResolvedValue({
id: surveyId,
name: "Delete me",
workspaceId: workspaceId,
type: "link",
status: "draft",
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "User" },
singleUse: null,
} as any);
vi.mocked(deleteSurvey).mockResolvedValue({
id: surveyId,
workspaceId,
type: "link",
segment: null,
triggers: [],
} as any);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
organizationId: "org_1",
});
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(401);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 200 with session auth and deletes the survey", async () => {
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-delete"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"readWrite",
"req-delete",
`/api/v3/surveys/${surveyId}`
);
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
expect(await res.json()).toEqual({
data: {
id: surveyId,
},
});
});
test("returns 200 with x-api-key when the key can delete in the survey workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const res = await DELETE(
createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-api-key", {
"x-api-key": "fbk_test",
}),
{
params: Promise.resolve({ surveyId }),
} as never
);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
workspaceId,
"readWrite",
"req-api-key",
`/api/v3/surveys/${surveyId}`
);
});
test("returns 400 when surveyId is invalid", async () => {
const res = await DELETE(createRequest("http://localhost/api/v3/surveys/not-a-cuid"), {
params: Promise.resolve({ surveyId: "not-a-cuid" }),
} as never);
expect(res.status).toBe(400);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 403 when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValueOnce(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
});
test("returns 403 when the user lacks readWrite workspace access", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-forbidden",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-forbidden"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "unknown",
organizationId: "unknown",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: undefined,
})
);
});
test("returns 500 when survey deletion fails", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down"));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-db"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 403 when the survey is deleted after authorization succeeds", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", surveyId));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-race"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
test("queues an audit log with target, actor, organization, and old object", async () => {
await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-audit"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
});
+152 -25
View File
@@ -3,41 +3,173 @@ import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import {
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import {
V3SurveyLanguageError,
V3SurveyUnsupportedShapeError,
serializeV3SurveyResource,
} from "@/app/api/v3/surveys/serializers";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { parseV3SurveyLanguageQuery } from "../language";
const surveyParamsSchema = z.object({
surveyId: z.cuid2(),
});
const surveyQuerySchema = z
.object({
lang: z
.union([z.string(), z.array(z.string())])
.transform((value, ctx) => {
const parsedLanguageQuery = parseV3SurveyLanguageQuery(value);
if (!parsedLanguageQuery.ok) {
ctx.addIssue({
code: "custom",
message: parsedLanguageQuery.message,
});
return z.NEVER;
}
return parsedLanguageQuery.languages;
})
.optional(),
})
.strict();
async function getAuthorizedSurvey(params: {
surveyId: string;
authentication: Parameters<typeof requireV3WorkspaceAccess>[0];
access: "read" | "readWrite";
requestId: string;
instance: string;
}) {
const { surveyId, authentication, access, requestId, instance } = params;
const survey = await getSurvey(surveyId);
if (!survey) {
return {
survey: null,
authResult: null,
response: problemForbidden(requestId, "You are not authorized to access this resource", instance),
};
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.workspaceId,
access,
requestId,
instance
);
if (authResult instanceof Response) {
return { survey: null, authResult: null, response: authResult };
}
return { survey, authResult, response: null };
}
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: surveyParamsSchema,
query: surveyQuerySchema,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const { survey, response } = await getAuthorizedSurvey({
surveyId,
authentication,
access: "read",
requestId,
instance,
});
if (response) {
log.warn({ statusCode: response.status }, "Survey not found or not accessible");
return response;
}
try {
return successResponse(serializeV3SurveyResource(survey, { lang: parsedInput.query.lang }), {
requestId,
cache: "private, no-store",
});
} catch (error) {
if (error instanceof V3SurveyLanguageError) {
log.warn({ statusCode: 400, lang: parsedInput.query.lang }, "Invalid survey language selector");
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: [
{
name: "lang",
reason: error.message,
},
],
});
}
if (error instanceof V3SurveyUnsupportedShapeError) {
log.warn({ statusCode: 400 }, "Unsupported v3 survey shape");
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: [
{
name: "survey",
reason: error.message,
},
],
});
}
throw error;
}
} catch (error) {
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey get unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const DELETE = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
schemas: {
params: z.object({
surveyId: z.cuid2(),
}),
params: surveyParamsSchema,
},
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const survey = await getSurvey(surveyId);
if (!survey) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
const { survey, authResult, response } = await getAuthorizedSurvey({
surveyId,
authentication,
survey.workspaceId,
"readWrite",
access: "readWrite",
requestId,
instance
);
instance,
});
if (authResult instanceof Response) {
return authResult;
if (response) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return response;
}
if (auditLog) {
@@ -46,14 +178,9 @@ export const DELETE = withV3ApiWrapper({
auditLog.oldObject = survey;
}
const deletedSurvey = await deleteSurvey(surveyId);
await deleteSurvey(surveyId);
return successResponse(
{
id: deletedSurvey.id,
},
{ requestId }
);
return noContentResponse({ requestId });
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
@@ -0,0 +1,106 @@
import { describe, expect, test } from "vitest";
import {
normalizeV3SurveyLanguageTag,
parseV3SurveyLanguageQuery,
resolveV3SurveyLanguageCode,
} from "./language";
const languages = [
{ code: "en-US", enabled: true },
{ code: "de-DE", enabled: true },
{ code: "fr-FR", enabled: false },
];
describe("normalizeV3SurveyLanguageTag", () => {
test.each([
["EN_us", "en-US"],
["en-us", "en-US"],
["de", "de"],
["zh_hans_cn", "zh-Hans-CN"],
])("normalizes %s to %s", (input, expected) => {
expect(normalizeV3SurveyLanguageTag(input)).toBe(expected);
});
test("returns null for invalid language tags", () => {
expect(normalizeV3SurveyLanguageTag("not a locale")).toBeNull();
});
});
describe("parseV3SurveyLanguageQuery", () => {
test("parses comma-separated language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE, pt_PT, EN_us")).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US"],
});
});
test("parses repeated language selectors", () => {
expect(parseV3SurveyLanguageQuery(["de-DE", "pt_PT,en_us"])).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US"],
});
});
test("deduplicates language selectors case-insensitively", () => {
expect(parseV3SurveyLanguageQuery("de-DE,DE_de")).toEqual({
ok: true,
languages: ["de-DE"],
});
});
test("rejects empty language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE,")).toEqual({
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
});
});
test("rejects invalid language selectors", () => {
expect(parseV3SurveyLanguageQuery("not a locale")).toEqual({
ok: false,
message: "Language 'not a locale' is not a valid locale code",
});
});
});
describe("resolveV3SurveyLanguageCode", () => {
test("matches configured languages case-insensitively and normalizes underscores", () => {
expect(resolveV3SurveyLanguageCode("DE_de", languages)).toEqual({ ok: true, code: "de-DE" });
});
test("resolves language-only tags when exactly one configured language matches", () => {
expect(resolveV3SurveyLanguageCode("de", languages)).toEqual({ ok: true, code: "de-DE" });
});
test("resolves disabled configured languages for management reads", () => {
expect(resolveV3SurveyLanguageCode("fr", languages)).toEqual({ ok: true, code: "fr-FR" });
});
test("returns ambiguous when language-only tags match multiple configured languages", () => {
expect(
resolveV3SurveyLanguageCode("pt", [
{ code: "pt-BR", enabled: true },
{ code: "pt-PT", enabled: true },
])
).toEqual({
ok: false,
reason: "ambiguous",
message: "Language 'pt' is ambiguous for this survey; use one of pt-BR, pt-PT",
});
});
test("returns unknown for languages not configured on the survey", () => {
expect(resolveV3SurveyLanguageCode("es-ES", languages)).toEqual({
ok: false,
reason: "unknown",
message: "Language 'es-ES' is not configured for this survey",
});
});
test("resolves the implicit default language for surveys without configured languages", () => {
expect(resolveV3SurveyLanguageCode("en", [{ code: "en-US", enabled: true }])).toEqual({
ok: true,
code: "en-US",
});
});
});
+112
View File
@@ -0,0 +1,112 @@
type TV3SurveyLanguageInput = {
code: string;
enabled: boolean;
};
type TV3SurveyLanguageQueryInput = string | string[];
type TResolveV3SurveyLanguageCodeResult =
| { ok: true; code: string }
| { ok: false; reason: "invalid" | "unknown" | "ambiguous"; message: string };
type TParseV3SurveyLanguageQueryResult = { ok: true; languages: string[] } | { ok: false; message: string };
export function normalizeV3SurveyLanguageTag(value: string): string | null {
const normalizedSeparators = value.trim().replaceAll("_", "-");
try {
return Intl.getCanonicalLocales(normalizedSeparators)[0] ?? null;
} catch {
return null;
}
}
export function parseV3SurveyLanguageQuery(
value: TV3SurveyLanguageQueryInput
): TParseV3SurveyLanguageQueryResult {
const requestedLanguages = (Array.isArray(value) ? value : [value])
.flatMap((entry) => entry.split(","))
.map((entry) => entry.trim());
if (requestedLanguages.some((entry) => entry.length === 0)) {
return {
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
};
}
const normalizedLanguages: string[] = [];
for (const language of requestedLanguages) {
const normalizedLanguage = normalizeV3SurveyLanguageTag(language);
if (!normalizedLanguage) {
return {
ok: false,
message: `Language '${language}' is not a valid locale code`,
};
}
if (!normalizedLanguages.some((entry) => entry.toLowerCase() === normalizedLanguage.toLowerCase())) {
normalizedLanguages.push(normalizedLanguage);
}
}
return { ok: true, languages: normalizedLanguages };
}
function getLanguageSubtag(languageTag: string): string {
return languageTag.split("-")[0]?.toLowerCase() ?? languageTag.toLowerCase();
}
export function resolveV3SurveyLanguageCode(
requestedLanguage: string,
languages: TV3SurveyLanguageInput[]
): TResolveV3SurveyLanguageCodeResult {
const normalizedRequestedLanguage = normalizeV3SurveyLanguageTag(requestedLanguage);
if (!normalizedRequestedLanguage) {
return {
ok: false,
reason: "invalid",
message: `Language '${requestedLanguage}' is not a valid locale code`,
};
}
const normalizedLanguages = languages.map((language) => ({
...language,
code: normalizeV3SurveyLanguageTag(language.code) ?? language.code,
}));
const exactMatch = normalizedLanguages.find(
(language) => language.code.toLowerCase() === normalizedRequestedLanguage.toLowerCase()
);
if (exactMatch) {
return { ok: true, code: exactMatch.code };
}
const requestedSubtag = getLanguageSubtag(normalizedRequestedLanguage);
const hasRegionOrScript = normalizedRequestedLanguage.includes("-");
const matchingLanguages = hasRegionOrScript
? []
: normalizedLanguages.filter((language) => getLanguageSubtag(language.code) === requestedSubtag);
if (matchingLanguages.length > 1) {
return {
ok: false,
reason: "ambiguous",
message: `Language '${normalizedRequestedLanguage}' is ambiguous for this survey; use one of ${matchingLanguages.map((language) => language.code).join(", ")}`,
};
}
const languageMatch = matchingLanguages[0];
if (languageMatch) {
return { ok: true, code: languageMatch.code };
}
return {
ok: false,
reason: "unknown",
message: `Language '${normalizedRequestedLanguage}' is not configured for this survey`,
};
}
@@ -0,0 +1,274 @@
import { describe, expect, test } from "vitest";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { V3SurveyUnsupportedShapeError, serializeV3SurveyResource } from "./serializers";
const baseSurvey = {
id: "survey_1",
workspaceId: "workspace_1",
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T11:00:00.000Z"),
name: "Product Feedback",
type: "link",
status: "draft",
metadata: { cx: "enterprise" },
languages: [
{
default: true,
enabled: true,
language: { id: "lang_1", code: "en-US", alias: "en", createdAt: new Date(), updatedAt: new Date() },
},
{
default: false,
enabled: true,
language: { id: "lang_2", code: "de-DE", alias: "de", createdAt: new Date(), updatedAt: new Date() },
},
{
default: false,
enabled: false,
language: { id: "lang_3", code: "fr-FR", alias: "fr", createdAt: new Date(), updatedAt: new Date() },
},
],
questions: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome", "de-DE": "Willkommen", "fr-FR": "Bienvenue" },
},
blocks: [
{
id: "block_1",
name: "Intro",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { default: "What should we improve?", "de-DE": "Was sollen wir verbessern?" },
subheader: { default: "Tell us more" },
required: true,
},
],
},
],
endings: [],
hiddenFields: { enabled: false, fieldIds: [] },
variables: [],
} as unknown as TSurvey;
describe("serializeV3SurveyResource", () => {
test("returns canonical multilingual fields using real locale codes", () => {
const resource = serializeV3SurveyResource(baseSurvey);
expect(resource.defaultLanguage).toBe("en-US");
expect(resource).not.toHaveProperty("language");
expect(resource.languages).toEqual([
{ code: "en-US", default: true, enabled: true },
{ code: "de-DE", default: false, enabled: true },
{ code: "fr-FR", default: false, enabled: false },
]);
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
"fr-FR": "Bienvenue",
},
},
});
expect(resource).toMatchObject({
blocks: [
{
elements: [
{
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
},
],
},
],
});
});
test("does not expose the internal default pseudo-locale for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
blocks: [
{
id: "block_1",
name: "Intro",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { default: "What should we improve?" },
required: true,
},
],
},
],
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect(resource.defaultLanguage).toBe("en-US");
expect(resource.languages).toEqual([{ code: "en-US", default: true, enabled: true }]);
expect(resource).toMatchObject({
welcomeCard: { headline: { "en-US": "Welcome" } },
blocks: [
{
elements: [
{
headline: { "en-US": "What should we improve?" },
},
],
},
],
});
});
test("filters the implicit default language for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey, { lang: ["en"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({ welcomeCard: { headline: { "en-US": "Welcome" } } });
});
test("preserves stored locale variants when their keys use non-canonical casing or separators", () => {
const survey = {
...baseSurvey,
welcomeCard: {
enabled: true,
headline: { default: "Welcome", de_de: "Willkommen" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
},
},
});
});
test("filters fields for case-insensitive underscore language selectors while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["DE_de"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: { headline: { "de-DE": "Willkommen" } },
blocks: [
{
elements: [
{
headline: { "de-DE": "Was sollen wir verbessern?" },
subheader: { "de-DE": "Tell us more" },
},
],
},
],
});
});
test("resolves language-only selectors against configured survey languages", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["de"] });
expect(resource).toMatchObject({ welcomeCard: { headline: { "de-DE": "Willkommen" } } });
});
test("filters disabled configured languages for management reads", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["fr"] });
expect(resource).toMatchObject({ welcomeCard: { headline: { "fr-FR": "Bienvenue" } } });
});
test("filters multiple requested languages while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["en-US", "de"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
},
},
blocks: [
{
elements: [
{
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
},
],
},
],
});
});
test("rejects ambiguous language-only selectors", () => {
const survey = {
...baseSurvey,
languages: [
{
default: true,
enabled: true,
language: {
id: "lang_1",
code: "pt-BR",
alias: "br",
createdAt: new Date(),
updatedAt: new Date(),
},
},
{
default: false,
enabled: true,
language: {
id: "lang_2",
code: "pt-PT",
alias: "pt",
createdAt: new Date(),
updatedAt: new Date(),
},
},
],
} as unknown as TSurvey;
expect(() => serializeV3SurveyResource(survey, { lang: ["pt"] })).toThrow(
"Language 'pt' is ambiguous for this survey; use one of pt-BR, pt-PT"
);
});
test("rejects legacy question-based survey shapes instead of returning an incomplete block resource", () => {
const survey = {
...baseSurvey,
questions: [{ id: "legacy_question", type: "openText", headline: { default: "Legacy question" } }],
blocks: [],
} as unknown as TSurvey;
expect(() => serializeV3SurveyResource(survey)).toThrow(V3SurveyUnsupportedShapeError);
expect(() => serializeV3SurveyResource(survey)).toThrow(
"Legacy question-based surveys are not supported by the v3 survey management API"
);
});
});
+182 -3
View File
@@ -1,13 +1,192 @@
import type { TSurvey } from "@/modules/survey/list/types/surveys";
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
import type { TSurvey as TSurveyListRecord } from "@/modules/survey/list/types/surveys";
import { normalizeV3SurveyLanguageTag, resolveV3SurveyLanguageCode } from "./language";
export type TV3SurveyListItem = Omit<TSurvey, "singleUse">;
export type TV3SurveyListItem = Omit<TSurveyListRecord, "singleUse">;
const DEFAULT_V3_SURVEY_LANGUAGE = "en-US";
type TV3SurveyLanguage = {
code: string;
default: boolean;
enabled: boolean;
};
type TSerializedValue =
| string
| number
| boolean
| null
| TSerializedValue[]
| { [key: string]: TSerializedValue };
export class V3SurveyLanguageError extends Error {
constructor(message: string) {
super(message);
this.name = "V3SurveyLanguageError";
}
}
export class V3SurveyUnsupportedShapeError extends Error {
constructor(message: string) {
super(message);
this.name = "V3SurveyUnsupportedShapeError";
}
}
/**
* Keep the v3 API contract isolated from internal persistence naming.
* Surveys are scoped by workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
export function serializeV3SurveyListItem(survey: TSurveyListRecord): TV3SurveyListItem {
const { singleUse: _omitSingleUse, ...rest } = survey;
return rest;
}
function toIsoString(value: Date | string): string {
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
function getSurveyLanguages(survey: TInternalSurvey): TV3SurveyLanguage[] {
const languages = (survey.languages ?? []).map((surveyLanguage) => ({
code: normalizeV3SurveyLanguageTag(surveyLanguage.language.code) ?? surveyLanguage.language.code,
default: surveyLanguage.default,
enabled: surveyLanguage.enabled,
}));
if (languages.length === 0) {
return [{ code: DEFAULT_V3_SURVEY_LANGUAGE, default: true, enabled: true }];
}
return languages;
}
function getDefaultLanguage(survey: TInternalSurvey): string {
const defaultLanguageCode = survey.languages?.find((surveyLanguage) => surveyLanguage.default)?.language
.code;
return defaultLanguageCode
? (normalizeV3SurveyLanguageTag(defaultLanguageCode) ?? defaultLanguageCode)
: DEFAULT_V3_SURVEY_LANGUAGE;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isI18nString(value: unknown): value is Record<string, string> {
return (
isPlainObject(value) &&
typeof value.default === "string" &&
Object.values(value).every((entry) => typeof entry === "string")
);
}
function getI18nValueForLanguage(value: Record<string, string>, languageCode: string): string | undefined {
if (typeof value[languageCode] === "string") {
return value[languageCode];
}
const matchingKey = Object.keys(value).find(
(key) => normalizeV3SurveyLanguageTag(key)?.toLowerCase() === languageCode.toLowerCase()
);
return matchingKey ? value[matchingKey] : undefined;
}
function serializeCanonicalValue(
value: unknown,
defaultLanguage: string,
languageCodes: Set<string>,
options?: { fallbackMissingTranslations?: boolean }
): TSerializedValue {
if (isI18nString(value)) {
const result: Record<string, string> = {
[defaultLanguage]: value.default,
};
for (const languageCode of languageCodes) {
const translatedValue = getI18nValueForLanguage(value, languageCode);
if (languageCode !== defaultLanguage) {
if (translatedValue !== undefined) {
result[languageCode] = translatedValue;
} else if (options?.fallbackMissingTranslations) {
result[languageCode] = value.default;
}
}
}
if (!languageCodes.has(defaultLanguage)) {
delete result[defaultLanguage];
}
return result;
}
if (Array.isArray(value)) {
return value.map((entry) => serializeCanonicalValue(entry, defaultLanguage, languageCodes, options));
}
if (isPlainObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
serializeCanonicalValue(entry, defaultLanguage, languageCodes, options),
])
);
}
return value as TSerializedValue;
}
function resolveRequestedLanguage(languages: TV3SurveyLanguage[], language: string): string {
const result = resolveV3SurveyLanguageCode(language, languages);
if (!result.ok) {
throw new V3SurveyLanguageError(result.message);
}
return result.code;
}
function resolveRequestedLanguages(languages: TV3SurveyLanguage[], requestedLanguages?: string[]): string[] {
if (!requestedLanguages) {
return [];
}
return requestedLanguages.map((language) => resolveRequestedLanguage(languages, language));
}
export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { lang?: string[] }) {
if (Array.isArray(survey.questions) && survey.questions.length > 0) {
throw new V3SurveyUnsupportedShapeError(
"Legacy question-based surveys are not supported by the v3 survey management API"
);
}
const defaultLanguage = getDefaultLanguage(survey);
const languages = getSurveyLanguages(survey);
const configuredLanguageCodes = new Set(languages.map((language) => language.code));
const requestedLanguages = resolveRequestedLanguages(languages, options?.lang);
const languageCodes = requestedLanguages.length > 0 ? new Set(requestedLanguages) : configuredLanguageCodes;
const serializeValue = (value: unknown) =>
serializeCanonicalValue(value, defaultLanguage, languageCodes, {
fallbackMissingTranslations: requestedLanguages.length > 0,
});
return {
id: survey.id,
workspaceId: survey.workspaceId,
createdAt: toIsoString(survey.createdAt),
updatedAt: toIsoString(survey.updatedAt),
name: survey.name,
type: survey.type,
status: survey.status,
metadata: survey.metadata,
defaultLanguage,
languages,
welcomeCard: serializeValue(survey.welcomeCard),
blocks: serializeValue(survey.blocks),
endings: serializeValue(survey.endings),
hiddenFields: survey.hiddenFields,
variables: survey.variables,
};
}
+5 -11
View File
@@ -1602,15 +1602,13 @@ checksums:
workspace/analysis/charts/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
workspace/analysis/charts/add_to_dashboard: 9941c3d30895bb8e25ce8d4e03d33a08
workspace/analysis/charts/advanced_chart_builder_config_prompt: c2fe2c1a076f27d3ae62a4db75474b0a
workspace/analysis/charts/ai_enable_in_settings: 426cb4525381e193e6c4dcce286e60c8
workspace/analysis/charts/ai_instance_not_configured: 6deeb8aeaff3982d07e1d5a045e06d2d
workspace/analysis/charts/ai_not_available: 173abfcd32dd45edcc258dfdaaed494b
workspace/analysis/charts/ai_not_enabled: 2066fe71ecf8994ba738c79b63a1934b
workspace/analysis/charts/ai_not_in_plan: 4b75e143c97d657bd91f857ff2bbf33f
workspace/analysis/charts/ai_not_enabled: 8651fdac58cd311d17a48001a880318d
workspace/analysis/charts/ai_not_in_plan: 60bb0792a1ed98c07d8694029cdfdb43
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/ai_upgrade_plan: 81c9e7a593c0e9290f7078ecdc1c6693
workspace/analysis/charts/already_on_dashboard: c2cee946860c71a71cf03392b2d1fc3a
workspace/analysis/charts/and_filter_logic: 53e8eb67a396fcb5e419bb4cbf0008df
workspace/analysis/charts/apply_changes: ed3da8072dbd27dc0c959777cdcbebf3
@@ -2486,19 +2484,16 @@ checksums:
workspace/settings/feedback_directories/nav_label: cf9a57b3cbac0f04b98e06fb693e986e
workspace/settings/feedback_directories/no_access: 707627df25fbaa28f18aa0f0d03dcb81
workspace/settings/feedback_directories/no_connectors: ccc725ff9a82a7b8ab68de735490a9b9
workspace/settings/feedback_directories/no_unassigned_workspaces_description: c96a260b582e6c930de72e6e69f9a9a6
workspace/settings/feedback_directories/no_unassigned_workspaces_title: 458d4289d73d799561bec26a0bb1a1a3
workspace/settings/feedback_directories/pause_connectors_confirmation_description: 0e30f827576b931651b9eae44e00279b
workspace/settings/feedback_directories/pause_connectors_confirmation_title: da1950dbb9ce62caa65c87ae8b88b1a1
workspace/settings/feedback_directories/select_workspaces_placeholder: 7d8c8f5910b264525f73bd32107765db
workspace/settings/feedback_directories/show_archived: c4c1c3bbddc1bb1540c079b589a2d3de
workspace/settings/feedback_directories/title: cf9a57b3cbac0f04b98e06fb693e986e
workspace/settings/feedback_directories/unarchive: 671fc7e9d7c8cb4d182a25a46551c168
workspace/settings/feedback_directories/unarchive_workspace_conflict: ed44bc0bd570b40de5251d04abf7bd08
workspace/settings/feedback_directories/unarchive_workspace_conflict: 82f4b8ebaf41589cfb96e6398dafcc76
workspace/settings/feedback_directories/upgrade_prompt_description: eb8a4bf60bcae458899e1ea94094789d
workspace/settings/feedback_directories/upgrade_prompt_title: 0a7b67ccf15a0aa8c64e5da7feb6e532
workspace/settings/feedback_directories/workspace_access: 32407b39cf878fb579559c1ed3660892
workspace/settings/feedback_directories/workspace_assigned_to_directory: 6b907668667a9c74a99c437fa3cc2046
workspace/settings/feedback_directories/workspaces_already_linked: ef6248289707611a44950c3406aec0ec
workspace/settings/feedback_directories/workspaces_being_added: e01628710aff05c5172f2f43aab1f6fb
workspace/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
@@ -2602,8 +2597,8 @@ checksums:
workspace/settings/profile/save_the_following_backup_codes_in_a_safe_place: a5b9d38083770375f2372f93ac9a7b2b
workspace/settings/profile/scan_the_qr_code_below_with_your_authenticator_app: 5a6b60928590ce3b6be1bdf1d34cd45e
workspace/settings/profile/security_description: e833adde4e3e26795e61a93619c6caec
workspace/settings/profile/sso_identity_confirmation_failed: 2d699f31f3e92bca9508a2772b071a1f
workspace/settings/profile/sso_identity_confirmation_may_be_required_for_deletion: 9a5d190ed96e0149ed431c130c40284d
workspace/settings/profile/sso_identity_confirmation_failed: 9d0fcabd5321c07af1caf627b0c68bdf
workspace/settings/profile/sso_identity_confirmation_may_be_required_for_deletion: a220681b82105f16803bb542853809f4
workspace/settings/profile/two_factor_authentication: 97a428a54e41d68810a12dbae075f371
workspace/settings/profile/two_factor_authentication_description: 1429e4eeaea193f15fb508875d4fb601
workspace/settings/profile/two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app: 308ba145b3dc485ff4f17387e977b1f9
@@ -3520,7 +3515,6 @@ checksums:
workspace/unify/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2
workspace/unify/api_ingestion: a14642d27bbb6843f9f4903b6555dfbb
workspace/unify/api_ingestion_settings_description: a2597917ca1c724607d1d32178d670b3
workspace/unify/api_ingestion_setup_description: d18a267d0e50198682950f5341307fa3
workspace/unify/auto_generated: 6e83e8febd63275692c444cb8074531d
workspace/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
workspace/unify/clear_mapping: 9bd7c716667838b9f203f5af0ac2d651
@@ -38,38 +38,6 @@ describe("convertToCsv", () => {
parseSpy.mockRestore();
});
test("should defang formula injection payloads in cell values", async () => {
const payloads = [
'=HYPERLINK("https://evil.tld","Click")',
"+1+1",
"-2+3",
"@SUM(A1:A2)",
"\tleading-tab",
"\rleading-cr",
];
const rows = payloads.map((p) => ({ name: p, age: 0 }));
const csv = await convertToCsv(["name", "age"], rows);
const lines = csv.trim().split("\n").slice(1); // drop header
payloads.forEach((p, i) => {
// each value should be prefixed with a single quote so the spreadsheet
// app treats it as text rather than a formula
expect(lines[i].startsWith(`"'${p.charAt(0)}`)).toBe(true);
});
});
test("should defang formula injection in field/header names", async () => {
const csv = await convertToCsv(["=evil", "age"], [{ "=evil": "x", age: 1 }]);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=evil","age"');
expect(lines[1]).toBe('"x",1');
});
test("should not alter benign strings", async () => {
const csv = await convertToCsv(["name"], [{ name: "Alice = Bob" }]);
const lines = csv.trim().split("\n");
expect(lines[1]).toBe('"Alice = Bob"');
});
});
describe("convertToXlsxBuffer", () => {
@@ -92,15 +60,4 @@ describe("convertToXlsxBuffer", () => {
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
expect(cleaned).toEqual(data);
});
test("should defang formula injection payloads in xlsx cells", () => {
const payload = '=HYPERLINK("https://evil.tld","Click")';
const buffer = convertToXlsxBuffer(["name"], [{ name: payload }]);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
const cell = sheet["A2"];
// value stored as plain text, not as a formula (no `f` property)
expect(cell.f).toBeUndefined();
expect(cell.v).toBe(`'${payload}`);
});
});
+3 -26
View File
@@ -2,36 +2,15 @@ import { AsyncParser } from "@json2csv/node";
import * as xlsx from "xlsx";
import { logger } from "@formbricks/logger";
// Defang spreadsheet formula injection. Cell values starting with
// =, +, -, @, tab, or CR are evaluated as formulas by Excel/Sheets/Numbers.
const FORMULA_TRIGGER = /^[=+\-@\t\r]/;
const sanitizeFormulaInjection = <T>(value: T): T => {
if (typeof value === "string" && FORMULA_TRIGGER.test(value)) {
return `'${value}` as T;
}
return value;
};
const sanitizeRows = (rows: Record<string, string | number>[]): Record<string, string | number>[] =>
rows.map((row) =>
Object.fromEntries(
Object.entries(row).map(([key, value]) => [
sanitizeFormulaInjection(key),
sanitizeFormulaInjection(value),
])
)
);
export const convertToCsv = async (fields: string[], jsonData: Record<string, string | number>[]) => {
let csv: string = "";
const parser = new AsyncParser({
fields: fields.map(sanitizeFormulaInjection),
fields,
});
try {
csv = await parser.parse(sanitizeRows(jsonData)).promise();
csv = await parser.parse(jsonData).promise();
} catch (err) {
logger.error(err, "Failed to convert to CSV");
throw new Error("Failed to convert to CSV");
@@ -45,9 +24,7 @@ export const convertToXlsxBuffer = (
jsonData: Record<string, string | number>[]
): Buffer => {
const wb = xlsx.utils.book_new();
const ws = xlsx.utils.json_to_sheet(sanitizeRows(jsonData), {
header: fields.map(sanitizeFormulaInjection),
});
const ws = xlsx.utils.json_to_sheet(jsonData, { header: fields });
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
return xlsx.write(wb, { type: "buffer", bookType: "xlsx" });
};
+3 -9
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Filter hinzufügen",
"add_to_dashboard": "Zum Dashboard hinzufügen",
"advanced_chart_builder_config_prompt": "Konfiguriere dein Diagramm und klicke auf \"Abfrage ausführen\", um eine Vorschau zu sehen",
"ai_enable_in_settings": "Aktiviere es in den Organisationseinstellungen.",
"ai_instance_not_configured": "KI ist auf dieser Instanz nicht konfiguriert. Kontaktiere deinen Administrator.",
"ai_not_available": "KI-Datenanalyse ist nicht verfügbar.",
"ai_not_enabled": "KI-Datenanalyse ist für diese Organisation deaktiviert.",
"ai_not_in_plan": "KI-Datenanalyse ist in deinem aktuellen Plan nicht verfügbar.",
"ai_not_enabled": "KI-Datenanalyse ist für diese Organisation deaktiviert. Aktiviere sie in den Organisationseinstellungen.",
"ai_not_in_plan": "KI-Datenanalyse ist in deinem aktuellen Tarif nicht verfügbar. Führe ein Upgrade durch, um diese Funktion freizuschalten.",
"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",
"ai_upgrade_plan": "Plan upgraden",
"already_on_dashboard": "Bereits im Dashboard",
"and_filter_logic": "UND",
"apply_changes": "Änderungen übernehmen",
@@ -2593,8 +2591,6 @@
"nav_label": "Feedback-Verzeichnisse",
"no_access": "Du hast keine Berechtigung, Feedback-Verzeichnisse zu verwalten.",
"no_connectors": "Noch keine Feedback-Quellen mit diesem Verzeichnis verknüpft.",
"no_unassigned_workspaces_description": "Jeder Workspace ist bereits mit einem aktiven Feedback-Verzeichnis verknüpft. Entferne einen Workspace aus seinem aktuellen Verzeichnis, bevor du ihn hier zuweist.",
"no_unassigned_workspaces_title": "Keine nicht zugewiesenen Workspaces verfügbar",
"pause_connectors_confirmation_description": "Wenn du diese Feedback-Quellen pausierst, werden keine neuen Einträge mehr hinzugefügt.",
"pause_connectors_confirmation_title": "Verknüpfte Feedback-Quellen pausieren?",
"select_workspaces_placeholder": "Workspaces auswählen...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Organisiere Feedback-Datensätze in Verzeichnissen und leite Daten zum richtigen Workspace weiter. Verfügbar in den Pro- und Scale-Plänen.",
"upgrade_prompt_title": "Upgrade durchführen, um Feedback-Datensatz-Verzeichnisse freizuschalten",
"workspace_access": "Workspace-Zugriff",
"workspace_assigned_to_directory": "{workspaceName} ist mit {directoryName} verknüpft",
"workspaces_already_linked": "Bereits verknüpfte Workspaces",
"workspaces_being_added": "Workspaces, denen Zugriff gewährt wird"
},
@@ -2716,7 +2711,7 @@
"scan_the_qr_code_below_with_your_authenticator_app": "Scanne den QR-Code unten mit deiner Authenticator-App.",
"security_description": "Verwalte dein Passwort und andere Sicherheitseinstellungen wie die Zwei-Faktor-Authentifizierung (2FA).",
"sso_identity_confirmation_failed": "SSO-Identitätsbestätigung fehlgeschlagen. Bitte versuche erneut, dein Konto zu löschen.",
"sso_identity_confirmation_may_be_required_for_deletion": "Bei SSO-Konten kann die Auswahl von Löschen dich zu deinem Identitätsanbieter weiterleiten, um dieses Konto zu bestätigen. Wenn dasselbe Konto bestätigt wird, wird die Löschung automatisch fortgesetzt.",
"sso_identity_confirmation_may_be_required_for_deletion": "Bei SSO-Konten kann dich die Auswahl von Löschen zu deinem Identitätsanbieter weiterleiten, um dieses Konto zu bestätigen. Wenn dasselbe Konto bestätigt wird, wird die Löschung automatisch fortgesetzt.",
"two_factor_authentication": "Zwei-Faktor-Authentifizierung",
"two_factor_authentication_description": "Füge deinem Konto eine zusätzliche Sicherheitsebene hinzu, falls dein Passwort gestohlen wird.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Zwei-Faktor-Authentifizierung aktiviert. Bitte gib den sechsstelligen Code aus deiner Authenticator-App ein.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Zulässige Werte: {values}",
"api_ingestion": "API-Erfassung",
"api_ingestion_settings_description": "Sende Feedback-Datensätze über die Management-API.",
"api_ingestion_setup_description": "Nutze die REST API, um Feedback-Datensätze direkt an Formbricks zu senden. Die API-Ingestion-Docs enthalten den Endpunkt, die Payload-Struktur und Authentifizierungsdetails.",
"auto_generated": "Automatisch generiert",
"change_file": "Datei ändern",
"clear_mapping": "Zuordnung löschen",
+3 -9
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Add filter",
"add_to_dashboard": "Add to Dashboard",
"advanced_chart_builder_config_prompt": "Configure your chart and click \"Run Query\" to preview",
"ai_enable_in_settings": "Enable it in organization settings.",
"ai_instance_not_configured": "AI is not configured on this instance. Contact your administrator.",
"ai_not_available": "AI data analysis is not available.",
"ai_not_enabled": "AI data analysis is disabled for this organization.",
"ai_not_in_plan": "AI data analysis is not available on your current plan.",
"ai_not_enabled": "AI data analysis is disabled for this organization. Enable it in organization settings.",
"ai_not_in_plan": "AI data analysis is not available on your current plan. Upgrade to unlock this feature.",
"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",
"ai_upgrade_plan": "Upgrade plan",
"already_on_dashboard": "Already on dashboard",
"and_filter_logic": "AND",
"apply_changes": "Apply Changes",
@@ -2593,19 +2591,16 @@
"nav_label": "Feedback Directories",
"no_access": "You do not have permission to manage feedback directories.",
"no_connectors": "No feedback sources linked to this directory yet.",
"no_unassigned_workspaces_description": "Every workspace is already linked to an active feedback directory. Remove a workspace from its current directory before assigning it here.",
"no_unassigned_workspaces_title": "No unassigned workspaces available",
"pause_connectors_confirmation_description": "Pausing these feedback sources will stop new records from being added.",
"pause_connectors_confirmation_title": "Pause linked feedback sources?",
"select_workspaces_placeholder": "Select workspaces...",
"show_archived": "Show archived",
"title": "Feedback Directories",
"unarchive": "Unarchive",
"unarchive_workspace_conflict": "Cannot unarchive this directory because one or more assigned workspaces already belong to another active feedback directory.",
"unarchive_workspace_conflict": "Cannot unarchive this directory because one or more assigned workspaces are archived.",
"upgrade_prompt_description": "Organize feedback records into directories and route data to the right workspace. Available on the Pro and Scale plans.",
"upgrade_prompt_title": "Upgrade to unlock Feedback Directories",
"workspace_access": "Workspace access",
"workspace_assigned_to_directory": "{workspaceName} is linked to {directoryName}",
"workspaces_already_linked": "Already linked workspaces",
"workspaces_being_added": "Workspaces being granted access"
},
@@ -3680,7 +3675,6 @@
"allowed_values": "Allowed values: {values}",
"api_ingestion": "API ingestion",
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"api_ingestion_setup_description": "Use the REST API to send feedback records directly into Formbricks. The API ingestion docs include the endpoint, payload shape, and authentication details.",
"auto_generated": "Auto-generated",
"change_file": "Change file",
"clear_mapping": "Clear mapping",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Añadir filtro",
"add_to_dashboard": "Añadir al panel de control",
"advanced_chart_builder_config_prompt": "Configura tu gráfico y haz clic en \"Ejecutar consulta\" para previsualizar",
"ai_enable_in_settings": "Actívalo en la configuración de la organización.",
"ai_instance_not_configured": "La IA no está configurada en esta instancia. Contacta con tu administrador.",
"ai_not_available": "El análisis de datos con IA no está disponible.",
"ai_not_enabled": "El análisis de datos con IA está desactivado para esta organización.",
"ai_not_in_plan": "El análisis de datos con IA no está disponible en tu plan actual.",
"ai_not_enabled": "El análisis de datos con IA está desactivado para esta organización. Actívalo en la configuración de la organización.",
"ai_not_in_plan": "El análisis de datos con IA no está disponible en tu plan actual. Actualiza para desbloquear esta función.",
"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",
"ai_upgrade_plan": "Mejorar plan",
"already_on_dashboard": "Ya está en el panel",
"and_filter_logic": "Y",
"apply_changes": "Aplicar cambios",
@@ -2593,8 +2591,6 @@
"nav_label": "Directorios de Feedback",
"no_access": "No tienes permiso para gestionar directorios de feedback.",
"no_connectors": "Aún no hay fuentes de comentarios vinculadas a este directorio.",
"no_unassigned_workspaces_description": "Cada espacio de trabajo ya está vinculado a un directorio de feedback activo. Elimina un espacio de trabajo de su directorio actual antes de asignarlo aquí.",
"no_unassigned_workspaces_title": "No hay espacios de trabajo sin asignar disponibles",
"pause_connectors_confirmation_description": "Pausar estas fuentes de comentarios detendrá la adición de nuevos registros.",
"pause_connectors_confirmation_title": "¿Pausar las fuentes de comentarios vinculadas?",
"select_workspaces_placeholder": "Selecciona espacios de trabajo...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Organiza los registros de feedback en directorios y dirige los datos al espacio de trabajo adecuado. Disponible en los planes Pro y Scale.",
"upgrade_prompt_title": "Mejora tu plan para desbloquear los Directorios de Registros de Feedback",
"workspace_access": "Acceso al espacio de trabajo",
"workspace_assigned_to_directory": "{workspaceName} está vinculado a {directoryName}",
"workspaces_already_linked": "Espacios de trabajo ya vinculados",
"workspaces_being_added": "Espacios de trabajo a los que se concede acceso"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarda los siguientes códigos de respaldo en un lugar seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Escanea el código QR a continuación con tu aplicación de autenticación.",
"security_description": "Gestiona tu contraseña y otros ajustes de seguridad como la autenticación de dos factores (2FA).",
"sso_identity_confirmation_failed": "La confirmación de identidad SSO ha fallado. Por favor, intenta eliminar tu cuenta de nuevo.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para cuentas con SSO, al seleccionar Eliminar es posible que se te redirija a tu proveedor de identidad para confirmar esta cuenta. Si se confirma la misma cuenta, la eliminación continúa automáticamente.",
"sso_identity_confirmation_failed": "No se pudo confirmar la identidad mediante SSO. Intenta eliminar tu cuenta de nuevo.",
"sso_identity_confirmation_may_be_required_for_deletion": "En las cuentas SSO, al seleccionar Eliminar es posible que se te redirija a tu proveedor de identidad para confirmar esta cuenta. Si se confirma la misma cuenta, la eliminación continuará automáticamente.",
"two_factor_authentication": "Autenticación de dos factores",
"two_factor_authentication_description": "Añade una capa adicional de seguridad a tu cuenta en caso de que tu contraseña sea robada.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticación de dos factores activada. Por favor, introduce el código de seis dígitos de tu aplicación de autenticación.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Valores permitidos: {values}",
"api_ingestion": "Ingesta de API",
"api_ingestion_settings_description": "Envía registros de feedback mediante la API de gestión.",
"api_ingestion_setup_description": "Utiliza la API REST para enviar registros de feedback directamente a Formbricks. La documentación de ingesta de API incluye el endpoint, la estructura del payload y los detalles de autenticación.",
"auto_generated": "Generado automáticamente",
"change_file": "Cambiar archivo",
"clear_mapping": "Borrar asignación",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Ajouter un filtre",
"add_to_dashboard": "Ajouter au tableau de bord",
"advanced_chart_builder_config_prompt": "Configurez votre graphique et cliquez sur « Exécuter la requête » pour prévisualiser",
"ai_enable_in_settings": "Activez-la dans les paramètres de l'organisation.",
"ai_instance_not_configured": "L'IA n'est pas configurée sur cette instance. Contacte ton administrateur.",
"ai_not_available": "L'analyse de données par IA n'est pas disponible.",
"ai_not_enabled": "L'analyse de données par IA est désactivée pour cette organisation.",
"ai_not_in_plan": "L'analyse de données par IA n'est pas disponible avec ton abonnement actuel.",
"ai_not_enabled": "L'analyse de données par IA est désactivée pour cette organisation. Active-la dans les paramètres de l'organisation.",
"ai_not_in_plan": "L'analyse de données par IA n'est pas disponible sur ton forfait actuel. Passe à un forfait supérieur pour débloquer cette fonctionnalité.",
"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",
"ai_upgrade_plan": "Mettre à niveau l'abonnement",
"already_on_dashboard": "Déjà sur le tableau de bord",
"and_filter_logic": "ET",
"apply_changes": "Appliquer les modifications",
@@ -2593,8 +2591,6 @@
"nav_label": "Répertoires de feedback",
"no_access": "Tu n'as pas la permission de gérer les répertoires de retours.",
"no_connectors": "Aucune source de retours liée à ce répertoire pour le moment.",
"no_unassigned_workspaces_description": "Chaque espace de travail est déjà lié à un répertoire de commentaires actif. Retirez un espace de travail de son répertoire actuel avant de l'assigner ici.",
"no_unassigned_workspaces_title": "Aucun espace de travail non assigné disponible",
"pause_connectors_confirmation_description": "Mettre en pause ces sources de retours empêchera l'ajout de nouveaux enregistrements.",
"pause_connectors_confirmation_title": "Mettre en pause les sources de retours liées ?",
"select_workspaces_placeholder": "Sélectionner des espaces de travail...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Organisez les enregistrements de feedback dans des répertoires et dirigez les données vers le bon espace de travail. Disponible avec les forfaits Pro et Scale.",
"upgrade_prompt_title": "Passez à un forfait supérieur pour débloquer les Répertoires d'enregistrements de feedback",
"workspace_access": "Accès à lespace de travail",
"workspace_assigned_to_directory": "{workspaceName} est lié à {directoryName}",
"workspaces_already_linked": "Espaces de travail déjà liés",
"workspaces_being_added": "Espaces de travail en cours d'ajout"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Enregistrez les codes de sauvegarde suivants dans un endroit sûr.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scannez le code QR ci-dessous avec votre application d'authentification.",
"security_description": "Gérez votre mot de passe et d'autres paramètres de sécurité comme l'authentification à deux facteurs (2FA).",
"sso_identity_confirmation_failed": "La confirmation de l'identité SSO a échoué. Veuillez réessayer de supprimer votre compte.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pour les comptes SSO, sélectionner Supprimer peut te rediriger vers ton fournisseur d'identité pour confirmer ce compte. Si le même compte est confirmé, la suppression se poursuit automatiquement.",
"sso_identity_confirmation_failed": "La confirmation d'identité SSO a échoué. Veuillez réessayer de supprimer votre compte.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pour les comptes SSO, sélectionner Supprimer peut vous rediriger vers votre fournisseur d'identité afin de confirmer ce compte. Si le même compte est confirmé, la suppression se poursuit automatiquement.",
"two_factor_authentication": "Authentification à deux facteurs",
"two_factor_authentication_description": "Ajoutez une couche de sécurité supplémentaire à votre compte au cas où votre mot de passe serait volé.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Authentification à deux facteurs activée. Veuillez entrer le code à six chiffres de votre application d'authentification.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Valeurs autorisées: {values}",
"api_ingestion": "Ingestion par API",
"api_ingestion_settings_description": "Envoyer des enregistrements de feedback via l'API de gestion.",
"api_ingestion_setup_description": "Utilise l'API REST pour envoyer directement les retours d'expérience dans Formbricks. La documentation sur l'ingestion API inclut le point de terminaison, la structure de la charge utile et les détails d'authentification.",
"auto_generated": "Généré automatiquement",
"change_file": "Changer de fichier",
"clear_mapping": "Effacer le mappage",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Szűrő hozzáadása",
"add_to_dashboard": "Hozzáadás a vezérlőpulthoz",
"advanced_chart_builder_config_prompt": "Állítsd be a diagramot, és kattints a \"Lekérdezés futtatása\" gombra az előnézethez",
"ai_enable_in_settings": "Engedélyezze a szervezeti beállításokban.",
"ai_instance_not_configured": "Az AI nincs konfigurálva ezen a példányon. Kérjük, lépjen kapcsolatba a rendszergazdával.",
"ai_not_available": "Az AI adatelemzés nem elérhető.",
"ai_not_enabled": "Az AI adatelemzés le van tiltva ezen szervezet számára.",
"ai_not_in_plan": "Az AI adatelemzés nem érhető el az Ön jelenlegi csomagjában.",
"ai_not_enabled": "Az AI adatelemzés le van tiltva ezen szervezet számára. Kérjük, engedélyezze a szervezeti beállításokban.",
"ai_not_in_plan": "Az AI adatelemzés nem elérhető az Ön jelenlegi csomagjában. Kérjük, frissítsen magasabb csomagra ezen funkció feloldásához.",
"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",
"ai_upgrade_plan": "Csomag frissítése",
"already_on_dashboard": "Már a vezérlőpulton van",
"and_filter_logic": "ÉS",
"apply_changes": "Módosítások alkalmazása",
@@ -2593,8 +2591,6 @@
"nav_label": "Visszajelzési könyvtárak",
"no_access": "Önnek nincs jogosultsága a visszajelzési könyvtárak kezeléséhez.",
"no_connectors": "Még nincsenek visszajelzési források kapcsolva ehhez a könyvtárhoz.",
"no_unassigned_workspaces_description": "Minden munkaterület már hozzá van rendelve egy aktív visszajelzési könyvtárhoz. Távolítson el egy munkaterületet a jelenlegi könyvtárából, mielőtt ide rendelné.",
"no_unassigned_workspaces_title": "Nincsenek hozzá nem rendelt munkaterületek",
"pause_connectors_confirmation_description": "Ezen visszajelzési források szüneteltetése megállítja az új rekordok hozzáadását.",
"pause_connectors_confirmation_title": "Szünetelteti a kapcsolódó visszajelzési forrásokat?",
"select_workspaces_placeholder": "Munkaterületek kiválasztása...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Szervezze a visszajelzési rekordokat könyvtárakba, és irányítsa az adatokat a megfelelő munkaterületre. A Pro és Scale csomagokban érhető el.",
"upgrade_prompt_title": "Frissítsen a csomagon, hogy feloldja a Visszajelzési Rekord Könyvtárakat",
"workspace_access": "Munkaterület-hozzáférés",
"workspace_assigned_to_directory": "{workspaceName} hozzá van rendelve ehhez: {directoryName}",
"workspaces_already_linked": "Már kapcsolt munkaterületek",
"workspaces_being_added": "Hozzáférést kapó munkaterületek"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Mentse el a következő visszaszerzési kódokat egy biztonságos helyre.",
"scan_the_qr_code_below_with_your_authenticator_app": "Olvassa be a lenti QR-kódot a hitelesítő alkalmazásával.",
"security_description": "A jelszava és egyéb biztonsági beállítások, például a kétfaktoros hitelesítés (2FA) kezelése.",
"sso_identity_confirmation_failed": "Az SSO-identitás megerősítése sikertelen volt. Kérjük, próbálja meg újra törölni fiókt.",
"sso_identity_confirmation_may_be_required_for_deletion": "SSO-fiókok esetén a Törlés gomb kiválasztása átirányíthatja Önt a személyazonosság-szolgáltatóhoz a fiók megerősítése érdekében. Ha ugyanazt a fiókot erősíti meg, a törlés automatikusan folytatódik.",
"sso_identity_confirmation_failed": "Az SSO-identitás megerősítése nem sikerült. Kérjük, próbáld meg újra törölni a fiókodat.",
"sso_identity_confirmation_may_be_required_for_deletion": "SSO-fiókok esetén a Törlés kiválasztása átirányíthat az identitásszolgáltatóhoz a fiók megerősítéséhez. Ha ugyanazt a fiókot erősítik meg, a törlés automatikusan folytatódik.",
"two_factor_authentication": "Kétfaktoros hitelesítés",
"two_factor_authentication_description": "További biztonsági réteg hozzáadása a fiókjához arra az esetre, ha a jelszavát ellopnák.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "A kétfaktoros hitelesítés engedélyezve van. Adja a meg a 6 számjegyű kódot a hitelesítő alkalmazásából.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Engedélyezett értékek: {values}",
"api_ingestion": "API betöltés",
"api_ingestion_settings_description": "Visszajelzési rekordok küldése a Management API használatával.",
"api_ingestion_setup_description": "Használja a REST API-t, hogy közvetlenül küldjön visszajelzési rekordokat a Formbricks rendszerbe. Az API-betöltési dokumentáció tartalmazza a végpontot, az adatszerkezetet és a hitelesítési részleteket.",
"auto_generated": "Automatikusan generált",
"change_file": "Fájl módosítása",
"clear_mapping": "Leképezés törlése",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "フィルターを追加",
"add_to_dashboard": "ダッシュボードに追加",
"advanced_chart_builder_config_prompt": "チャートを設定して「クエリを実行」をクリックしてプレビューを表示",
"ai_enable_in_settings": "組織設定で有効にしてください。",
"ai_instance_not_configured": "このインスタンスではAIが設定されていません。管理者にお問い合わせください。",
"ai_not_available": "AIデータ分析は利用できません。",
"ai_not_enabled": "この組織ではAIデータ分析が無効になっています。",
"ai_not_in_plan": "現在のプランではAIデータ分析をご利用いただけません。",
"ai_not_enabled": "この組織ではAIデータ分析が無効になっています。組織設定で有効にしてください。",
"ai_not_in_plan": "AIデータ分析は現在のプランではご利用いただけません。この機能を利用するにはアップグレードしてください。",
"ai_query_placeholder": "例: 先週何人のユーザーが登録しましたか?",
"ai_query_section_description": "表示したい内容を説明すると、AIがチャートを作成します。",
"ai_query_section_title": "データに質問する",
"ai_upgrade_plan": "プランをアップグレード",
"already_on_dashboard": "すでにダッシュボードに追加済み",
"and_filter_logic": "AND",
"apply_changes": "変更を適用",
@@ -2593,8 +2591,6 @@
"nav_label": "フィードバックディレクトリ",
"no_access": "フィードバックディレクトリを管理する権限がありません。",
"no_connectors": "このディレクトリにリンクされたフィードバックソースがまだありません。",
"no_unassigned_workspaces_description": "すべてのワークスペースは既にアクティブなフィードバックディレクトリにリンクされています。ここに割り当てる前に、現在のディレクトリからワークスペースを削除してください。",
"no_unassigned_workspaces_title": "未割り当てのワークスペースがありません",
"pause_connectors_confirmation_description": "これらのフィードバックソースを一時停止すると、新しいレコードの追加が停止されます。",
"pause_connectors_confirmation_title": "リンクされたフィードバックソースを一時停止しますか?",
"select_workspaces_placeholder": "ワークスペースを選択...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "フィードバックレコードをディレクトリで整理し、適切なワークスペースにデータを振り分けられます。ProプランおよびScaleプランでご利用いただけます。",
"upgrade_prompt_title": "アップグレードしてフィードバックレコードディレクトリを利用",
"workspace_access": "ワークスペースアクセス",
"workspace_assigned_to_directory": "{workspaceName}は{directoryName}にリンクされています",
"workspaces_already_linked": "既にリンクされているワークスペース",
"workspaces_being_added": "アクセス権が付与されるワークスペース"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "以下のバックアップコードを安全な場所に保存してください。",
"scan_the_qr_code_below_with_your_authenticator_app": "以下のQRコードを認証アプリでスキャンしてください。",
"security_description": "パスワードや二段階認証(2FA)などの他のセキュリティ設定を管理します。",
"sso_identity_confirmation_failed": "SSO本人確認に失敗しました。もう一度アカウントの削除をお試しください。",
"sso_identity_confirmation_may_be_required_for_deletion": "SSOアカウントの場合、削除を選択すると、このアカウントを確認するためにIDプロバイダーリダイレクトされる場合があります。同じアカウントが確認されると、削除自動的に続行されます。",
"sso_identity_confirmation_failed": "SSOでの本人確認に失敗しました。もう一度アカウントの削除をお試しください。",
"sso_identity_confirmation_may_be_required_for_deletion": "SSOアカウントの場合、削除を選択すると、このアカウントを確認するためにIDプロバイダーリダイレクトされることがあります。同じアカウントが確認されると、削除自動的に続行されます。",
"two_factor_authentication": "二段階認証",
"two_factor_authentication_description": "パスワードが盗まれた場合に備えて、アカウントにセキュリティの追加レイヤーを追加します。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "二段階認証が有効になりました。認証アプリから6桁のコードを入力してください。",
@@ -3680,7 +3675,6 @@
"allowed_values": "許可される値: {values}",
"api_ingestion": "API取り込み",
"api_ingestion_settings_description": "管理APIを使用してフィードバックレコードを送信します。",
"api_ingestion_setup_description": "REST APIを使用して、フィードバックレコードをFormbricksに直接送信できます。APIインジェストのドキュメントには、エンドポイント、ペイロード形式、認証の詳細が含まれています。",
"auto_generated": "自動生成",
"change_file": "ファイルを変更",
"clear_mapping": "マッピングをクリア",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Filter toevoegen",
"add_to_dashboard": "Toevoegen aan dashboard",
"advanced_chart_builder_config_prompt": "Configureer je grafiek en klik op \"Query uitvoeren\" om een voorbeeld te zien",
"ai_enable_in_settings": "Schakel het in bij de organisatie-instellingen.",
"ai_instance_not_configured": "AI is niet geconfigureerd op deze instantie. Neem contact op met je beheerder.",
"ai_not_available": "AI-data-analyse is niet beschikbaar.",
"ai_not_enabled": "AI-gegevensanalyse is uitgeschakeld voor deze organisatie.",
"ai_not_in_plan": "AI-gegevensanalyse is niet beschikbaar in je huidige abonnement.",
"ai_not_enabled": "AI-data-analyse is uitgeschakeld voor deze organisatie. Schakel het in bij de organisatie-instellingen.",
"ai_not_in_plan": "AI-data-analyse is niet beschikbaar in je huidige abonnement. Upgrade om deze functie te ontgrendelen.",
"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",
"ai_upgrade_plan": "Abonnement upgraden",
"already_on_dashboard": "Al op dashboard",
"and_filter_logic": "EN",
"apply_changes": "Wijzigingen toepassen",
@@ -2593,8 +2591,6 @@
"nav_label": "Feedbackmappen",
"no_access": "Je hebt geen toestemming om feedbackmappen te beheren.",
"no_connectors": "Nog geen feedbackbronnen gekoppeld aan deze directory.",
"no_unassigned_workspaces_description": "Elke workspace is al gekoppeld aan een actieve feedbackdirectory. Verwijder een workspace uit de huidige directory voordat je deze hier toewijst.",
"no_unassigned_workspaces_title": "Geen niet-toegewezen workspaces beschikbaar",
"pause_connectors_confirmation_description": "Het pauzeren van deze feedbackbronnen stopt het toevoegen van nieuwe gegevens.",
"pause_connectors_confirmation_title": "Gekoppelde feedbackbronnen pauzeren?",
"select_workspaces_placeholder": "Selecteer werkruimtes...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Organiseer feedbackrecords in mappen en routeer gegevens naar de juiste workspace. Beschikbaar op de Pro- en Scale-abonnementen.",
"upgrade_prompt_title": "Upgrade om Feedbackrecord Mappen te ontgrendelen",
"workspace_access": "Workspace-toegang",
"workspace_assigned_to_directory": "{workspaceName} is gekoppeld aan {directoryName}",
"workspaces_already_linked": "Reeds gekoppelde werkruimtes",
"workspaces_being_added": "Werkruimtes die toegang krijgen"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Bewaar de volgende back-upcodes op een veilige plaats.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scan onderstaande QR-code met uw authenticator-app.",
"security_description": "Beheer uw wachtwoord en andere beveiligingsinstellingen zoals tweefactorauthenticatie (2FA).",
"sso_identity_confirmation_failed": "SSO-identiteitsbevestiging mislukt. Probeer je account opnieuw te verwijderen.",
"sso_identity_confirmation_may_be_required_for_deletion": "Voor SSO-accounts kan het zijn dat je bij het selecteren van Verwijderen wordt doorgestuurd naar je identiteitsprovider om dit account te bevestigen. Als hetzelfde account wordt bevestigd, gaat het verwijderen automatisch door.",
"sso_identity_confirmation_failed": "SSO-identiteitsbevestiging is mislukt. Probeer je account opnieuw te verwijderen.",
"sso_identity_confirmation_may_be_required_for_deletion": "Voor SSO-accounts kan het selecteren van Verwijderen je doorsturen naar je identiteitsprovider om dit account te bevestigen. Als hetzelfde account wordt bevestigd, gaat de verwijdering automatisch verder.",
"two_factor_authentication": "Tweefactorauthenticatie",
"two_factor_authentication_description": "Voeg een extra beveiligingslaag toe aan uw account voor het geval uw wachtwoord wordt gestolen.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tweefactorauthenticatie ingeschakeld. Voer de zescijferige code van uw authenticator-app in.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Toegestane waarden: {values}",
"api_ingestion": "API-inname",
"api_ingestion_settings_description": "Verstuur feedbackrecords via de Management API.",
"api_ingestion_setup_description": "Gebruik de REST API om feedbackgegevens rechtstreeks naar Formbricks te sturen. De API-ingestiedocumentatie bevat het endpoint, de payload-structuur en authenticatiegegevens.",
"auto_generated": "Automatisch gegenereerd",
"change_file": "Bestand wijzigen",
"clear_mapping": "Mapping wissen",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Adicionar filtro",
"add_to_dashboard": "Adicionar ao painel",
"advanced_chart_builder_config_prompt": "Configure seu gráfico e clique em \"Executar consulta\" para visualizar",
"ai_enable_in_settings": "Ative nas configurações da organização.",
"ai_instance_not_configured": "A IA não está configurada nesta instância. Entre em contato com seu administrador.",
"ai_not_available": "A análise de dados com IA não está disponível.",
"ai_not_enabled": "A análise de dados por IA está desativada para esta organização.",
"ai_not_in_plan": "A análise de dados por IA não está disponível no seu plano atual.",
"ai_not_enabled": "A análise de dados com IA está desabilitada para esta organização. Habilite nas configurações da organização.",
"ai_not_in_plan": "A análise de dados com IA não está disponível no seu plano atual. Faça upgrade para desbloquear este recurso.",
"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",
"ai_upgrade_plan": "Fazer upgrade do plano",
"already_on_dashboard": "Já está no painel",
"and_filter_logic": "E",
"apply_changes": "Aplicar alterações",
@@ -2593,8 +2591,6 @@
"nav_label": "Diretórios de Feedback",
"no_access": "Você não tem permissão para gerenciar diretórios de feedback.",
"no_connectors": "Nenhuma fonte de feedback vinculada a este diretório ainda.",
"no_unassigned_workspaces_description": "Todos os workspaces já estão vinculados a um diretório de feedback ativo. Remova um workspace do seu diretório atual antes de atribuí-lo aqui.",
"no_unassigned_workspaces_title": "Nenhum workspace não atribuído disponível",
"pause_connectors_confirmation_description": "Pausar essas fontes de feedback impedirá que novos registros sejam adicionados.",
"pause_connectors_confirmation_title": "Pausar fontes de feedback vinculadas?",
"select_workspaces_placeholder": "Selecionar espaços de trabalho...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Organize registros de feedback em diretórios e direcione dados para o workspace certo. Disponível nos planos Pro e Scale.",
"upgrade_prompt_title": "Faça upgrade para desbloquear Diretórios de Registros de Feedback",
"workspace_access": "Acesso ao workspace",
"workspace_assigned_to_directory": "{workspaceName} está vinculado a {directoryName}",
"workspaces_already_linked": "Workspaces já vinculados",
"workspaces_being_added": "Workspaces recebendo acesso"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup em um lugar seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Escaneie o código QR abaixo com seu app autenticador.",
"security_description": "Gerencie sua senha e outras configurações de segurança como a autenticação de dois fatores (2FA).",
"sso_identity_confirmation_failed": "Falha na confirmação de identidade SSO. Tente excluir sua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, ao selecionar Excluir você pode ser redirecionado para seu provedor de identidade para confirmar esta conta. Se a mesma conta for confirmada, a exclusão continua automaticamente.",
"sso_identity_confirmation_failed": "A confirmação de identidade via SSO falhou. Tente excluir sua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, selecionar Excluir pode redirecionar você para o provedor de identidade para confirmar esta conta. Se a mesma conta for confirmada, a exclusão continua automaticamente.",
"two_factor_authentication": "Autenticação de dois fatores",
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso sua senha seja roubada.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Por favor, insira o código de seis dígitos do seu app autenticador.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Valores permitidos: {values}",
"api_ingestion": "Ingestão de API",
"api_ingestion_settings_description": "Envie registros de feedback usando a API de Gerenciamento.",
"api_ingestion_setup_description": "Use a API REST para enviar registros de feedback diretamente para o Formbricks. A documentação de ingestão da API inclui o endpoint, a estrutura do payload e detalhes de autenticação.",
"auto_generated": "Gerado automaticamente",
"change_file": "Alterar arquivo",
"clear_mapping": "Limpar mapeamento",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Adicionar filtro",
"add_to_dashboard": "Adicionar ao painel",
"advanced_chart_builder_config_prompt": "Configura o teu gráfico e clica em \"Executar consulta\" para pré-visualizar",
"ai_enable_in_settings": "Ative nas definições da organização.",
"ai_instance_not_configured": "A IA não está configurada nesta instância. Contacta o teu administrador.",
"ai_not_available": "A análise de dados por IA não está disponível.",
"ai_not_enabled": "A análise de dados com IA está desativada para esta organização.",
"ai_not_in_plan": "A análise de dados com IA não está disponível no teu plano atual.",
"ai_not_enabled": "A análise de dados por IA está desativada para esta organização. Ativa-a nas definições da organização.",
"ai_not_in_plan": "A análise de dados por IA não está disponível no teu plano atual. Faz upgrade para desbloquear esta funcionalidade.",
"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",
"ai_upgrade_plan": "Atualizar plano",
"already_on_dashboard": "Já está no painel",
"and_filter_logic": "E",
"apply_changes": "Aplicar alterações",
@@ -2593,8 +2591,6 @@
"nav_label": "Diretórios de Feedback",
"no_access": "Não tens permissão para gerir diretórios de feedback.",
"no_connectors": "Ainda sem fontes de feedback associadas a este diretório.",
"no_unassigned_workspaces_description": "Todos os espaços de trabalho já estão associados a um diretório de feedback ativo. Remove um espaço de trabalho do seu diretório atual antes de o atribuíres aqui.",
"no_unassigned_workspaces_title": "Nenhum espaço de trabalho disponível sem atribuição",
"pause_connectors_confirmation_description": "Pausar estas fontes de feedback irá impedir a adição de novos registos.",
"pause_connectors_confirmation_title": "Pausar fontes de feedback associadas?",
"select_workspaces_placeholder": "Selecionar espaços de trabalho...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Organiza os registos de feedback em diretórios e encaminha os dados para o workspace certo. Disponível nos planos Pro e Scale.",
"upgrade_prompt_title": "Faz upgrade para desbloquear Diretórios de Registos de Feedback",
"workspace_access": "Acesso ao workspace",
"workspace_assigned_to_directory": "{workspaceName} está associado a {directoryName}",
"workspaces_already_linked": "Workspaces já vinculados",
"workspaces_being_added": "Workspaces a receber acesso"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup num local seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Digitalize o código QR abaixo com a sua aplicação de autenticação.",
"security_description": "Gerir a sua palavra-passe e outras definições de segurança, como a autenticação de dois fatores (2FA).",
"sso_identity_confirmation_failed": "A confirmação de identidade SSO falhou. Por favor, tenta eliminar a tua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, ao selecionares Eliminar podes ser redirecionado para o teu fornecedor de identidade para confirmares esta conta. Se a mesma conta for confirmada, a eliminação continua automaticamente.",
"sso_identity_confirmation_failed": "A confirmação de identidade por SSO falhou. Tenta eliminar a tua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, selecionar Eliminar pode redirecionar-te para o teu fornecedor de identidade para confirmares esta conta. Se a mesma conta for confirmada, a eliminação continua automaticamente.",
"two_factor_authentication": "Autenticação de dois fatores",
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso a sua palavra-passe seja roubada.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Introduza o código de seis dígitos da sua aplicação de autenticação.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Valores permitidos: {values}",
"api_ingestion": "Ingestão de API",
"api_ingestion_settings_description": "Envia registos de feedback através da API de gestão.",
"api_ingestion_setup_description": "Usa a REST API para enviar registos de feedback diretamente para o Formbricks. A documentação da API de ingestão inclui o endpoint, a estrutura do payload e os detalhes de autenticação.",
"auto_generated": "Gerado automaticamente",
"change_file": "Alterar ficheiro",
"clear_mapping": "Limpar mapeamento",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Adaugă filtru",
"add_to_dashboard": "Adaugă la Tablou de Bord",
"advanced_chart_builder_config_prompt": "Configurează graficul și apasă pe \"Rulează interogarea\" pentru previzualizare",
"ai_enable_in_settings": "Activează-l în setările organizației.",
"ai_instance_not_configured": "AI nu este configurat pe această instanță. Contactează administratorul.",
"ai_not_available": "Analiza datelor cu AI nu este disponibilă.",
"ai_not_enabled": "Analiza datelor cu AI este dezactivată pentru această organizație.",
"ai_not_in_plan": "Analiza datelor cu AI nu este disponibilă în planul tău curent.",
"ai_not_enabled": "Analiza datelor cu AI este dezactivată pentru această organizație. Activează-o în setările organizației.",
"ai_not_in_plan": "Analiza datelor cu AI nu este disponibilă în planul tău actual. Treci la un plan superior pentru a debloca această funcție.",
"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",
"ai_upgrade_plan": "Actualizează planul",
"already_on_dashboard": "Deja pe tabloul de bord",
"and_filter_logic": "ȘI",
"apply_changes": "Aplică modificările",
@@ -2593,8 +2591,6 @@
"nav_label": "Directoare de feedback",
"no_access": "Nu ai permisiunea de a gestiona directoarele de feedback.",
"no_connectors": "Nicio sursă de feedback conectată la acest director încă.",
"no_unassigned_workspaces_description": "Fiecare spațiu de lucru este deja conectat la un director de feedback activ. Elimină un spațiu de lucru din directorul său actual înainte de a-l atribui aici.",
"no_unassigned_workspaces_title": "Niciun spațiu de lucru neatribuit disponibil",
"pause_connectors_confirmation_description": "Pauza acestor surse de feedback va opri adăugarea de noi înregistrări.",
"pause_connectors_confirmation_title": "Pui pe pauză sursele de feedback conectate?",
"select_workspaces_placeholder": "Selectează spații de lucru...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Organizează înregistrările de feedback în directoare și direcționează datele către workspace-ul potrivit. Disponibile în planurile Pro și Scale.",
"upgrade_prompt_title": "Actualizează pentru a debloca Directoarele pentru Înregistrări de Feedback",
"workspace_access": "Acces la spațiul de lucru",
"workspace_assigned_to_directory": "{workspaceName} este conectat la {directoryName}",
"workspaces_already_linked": "Spații de lucru deja conectate",
"workspaces_being_added": "Spații de lucru cărora li se acordă acces"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Salvează următoarele coduri de rezervă într-un loc sigur.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scanați codul QR de mai jos cu aplicația dvs. de autentificare.",
"security_description": "Gestionează parola și alte setări de securitate, precum autentificarea în doi pași (2FA).",
"sso_identity_confirmation_failed": "Confirmarea identității SSO a eșuat. Te rugăm să încerci ștergi contul din nou.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pentru conturile SSO, selectarea opțiunii Șterge te poate redirecționa către furnizorul tău de identitate pentru a confirma acest cont. Dacă același cont este confirmat, ștergerea continuă automat.",
"sso_identity_confirmation_failed": "Confirmarea identității SSO a eșuat. Te rugăm să încerci din nou să îți ștergi contul.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pentru conturile SSO, selectarea opțiunii Șterge te poate redirecționa către furnizorul de identitate pentru a confirma acest cont. Dacă același cont este confirmat, ștergerea continuă automat.",
"two_factor_authentication": "Autentificare în doi pași",
"two_factor_authentication_description": "Adăugați un strat suplimentar de securitate la contul dvs. în cazul în care parola este furată.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autentificare în doi pași activată. Introduceți codul de șase cifre din aplicația dvs. de autentificare.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Valori permise: {values}",
"api_ingestion": "Ingestie API",
"api_ingestion_settings_description": "Trimite înregistrări de feedback folosind API-ul de management.",
"api_ingestion_setup_description": "Folosește REST API pentru a trimite înregistrări de feedback direct în Formbricks. Documentația de ingestie API include endpoint-ul, structura payload-ului și detaliile de autentificare.",
"auto_generated": "Generat automat",
"change_file": "Schimbă fișierul",
"clear_mapping": "Șterge maparea",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Добавить фильтр",
"add_to_dashboard": "Добавить на панель",
"advanced_chart_builder_config_prompt": "Настрой график и нажми «Выполнить запрос», чтобы посмотреть предварительный просмотр",
"ai_enable_in_settings": "Включите эту функцию в настройках организации.",
"ai_instance_not_configured": "ИИ не настроен на этом экземпляре. Свяжитесь с администратором.",
"ai_not_available": "Анализ данных с помощью ИИ недоступен.",
"ai_not_enabled": "ИИ-анализ данных отключён для этой организации.",
"ai_not_in_plan": "ИИ-анализ данных недоступен в твоём текущем тарифе.",
"ai_not_enabled": "Анализ данных с помощью ИИ отключён для этой организации. Включите его в настройках организации.",
"ai_not_in_plan": "Анализ данных с помощью ИИ недоступен в вашем текущем тарифе. Обновите тариф, чтобы получить эту функцию.",
"ai_query_placeholder": "например: Сколько пользователей зарегистрировались на прошлой неделе?",
"ai_query_section_description": "Опиши, что хочешь увидеть, и AI построит график.",
"ai_query_section_title": "Спроси свои данные",
"ai_upgrade_plan": "Обновить тариф",
"already_on_dashboard": "Уже на дашборде",
"and_filter_logic": "И",
"apply_changes": "Применить изменения",
@@ -2593,8 +2591,6 @@
"nav_label": "Каталоги отзывов",
"no_access": "У тебя нет прав для управления директориями обратной связи.",
"no_connectors": "К этому каталогу пока не привязаны источники отзывов.",
"no_unassigned_workspaces_description": "Каждое рабочее пространство уже связано с активным каталогом отзывов. Удалите рабочее пространство из текущего каталога, прежде чем назначить его сюда.",
"no_unassigned_workspaces_title": "Нет доступных неназначенных рабочих пространств",
"pause_connectors_confirmation_description": "Приостановка этих источников отзывов остановит добавление новых записей.",
"pause_connectors_confirmation_title": "Приостановить связанные источники отзывов?",
"select_workspaces_placeholder": "Выберите рабочие области...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Организуй записи обратной связи в директории и направляй данные в нужное рабочее пространство. Доступно в тарифах Pro и Scale.",
"upgrade_prompt_title": "Обнови тариф, чтобы получить доступ к директориям записей обратной связи",
"workspace_access": "Доступ к рабочему пространству",
"workspace_assigned_to_directory": "{workspaceName} связано с {directoryName}",
"workspaces_already_linked": "Уже связанные рабочие пространства",
"workspaces_being_added": "Рабочие пространства, которым предоставляется доступ"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Сохраните следующие резервные коды в безопасном месте.",
"scan_the_qr_code_below_with_your_authenticator_app": "Отсканируйте QR-код ниже с помощью вашего приложения-аутентификатора.",
"security_description": "Управляйте паролем и другими настройками безопасности, такими как двухфакторная аутентификация (2FA).",
"sso_identity_confirmation_failed": "Не удалось подтвердить SSO-идентификацию. Попробуйте удалить свой аккаунт ещё раз.",
"sso_identity_confirmation_may_be_required_for_deletion": "Для SSO-аккаунтов при выборе «Удалить» может потребоваться переход к вашему провайдеру идентификации для подтверждения этого аккаунта. Если будет подтверждён тот же аккаунт, удаление продолжится автоматически.",
"sso_identity_confirmation_failed": "Не удалось подтвердить личность через SSO. Попробуйте удалить аккаунт ещё раз.",
"sso_identity_confirmation_may_be_required_for_deletion": "Для аккаунтов SSO при выборе «Удалить» вы можете быть перенаправлены к поставщику удостоверений, чтобы подтвердить этот аккаунт. Если будет подтверждён тот же аккаунт, удаление продолжится автоматически.",
"two_factor_authentication": "Двухфакторная аутентификация",
"two_factor_authentication_description": "Добавьте дополнительный уровень защиты вашему аккаунту на случай, если ваш пароль будет украден.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Двухфакторная аутентификация включена. Пожалуйста, введите шестизначный код из вашего приложения-аутентификатора.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Допустимые значения: {values}",
"api_ingestion": "Импорт через API",
"api_ingestion_settings_description": "Отправляйте записи обратной связи через Management API.",
"api_ingestion_setup_description": "Используйте REST API для прямой отправки записей отзывов в Formbricks. Документация по API включает конечную точку, структуру данных и сведения об аутентификации.",
"auto_generated": "Автоматически генерируется",
"change_file": "Изменить файл",
"clear_mapping": "Очистить сопоставление",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Lägg till filter",
"add_to_dashboard": "Lägg till på instrumentpanelen",
"advanced_chart_builder_config_prompt": "Konfigurera ditt diagram och klicka på \"Kör fråga\" för att förhandsgranska",
"ai_enable_in_settings": "Aktivera det i organisationsinställningarna.",
"ai_instance_not_configured": "AI är inte konfigurerad på denna instans. Kontakta din administratör.",
"ai_not_available": "AI-dataanalys är inte tillgänglig.",
"ai_not_enabled": "AI-dataanalys är inaktiverad för den här organisationen.",
"ai_not_in_plan": "AI-dataanalys är inte tillgänglig i din nuvarande plan.",
"ai_not_enabled": "AI-dataanalys är inaktiverad för denna organisation. Aktivera det i organisationsinställningarna.",
"ai_not_in_plan": "AI-dataanalys är inte tillgänglig i din nuvarande plan. Uppgradera för att låsa upp denna funktion.",
"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",
"ai_upgrade_plan": "Uppgradera plan",
"already_on_dashboard": "Redan på instrumentpanelen",
"and_filter_logic": "OCH",
"apply_changes": "Verkställ ändringar",
@@ -2593,8 +2591,6 @@
"nav_label": "Feedbackkataloger",
"no_access": "Du har inte behörighet att hantera feedback-kataloger.",
"no_connectors": "Inga feedbackkällor länkade till den här katalogen ännu.",
"no_unassigned_workspaces_description": "Varje arbetsyta är redan kopplad till en aktiv feedbackkatalog. Ta bort en arbetsyta från dess nuvarande katalog innan du tilldelar den här.",
"no_unassigned_workspaces_title": "Inga otilldelade arbetsytor tillgängliga",
"pause_connectors_confirmation_description": "Att pausa dessa feedbackkällor kommer att stoppa nya poster från att läggas till.",
"pause_connectors_confirmation_title": "Pausa länkade feedbackkällor?",
"select_workspaces_placeholder": "Välj arbetsytor...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Organisera feedbackposter i kataloger och dirigera data till rätt arbetsyta. Tillgängligt på Pro- och Scale-planerna.",
"upgrade_prompt_title": "Uppgradera för att låsa upp Feedbackpostkataloger",
"workspace_access": "Arbetsyteåtkomst",
"workspace_assigned_to_directory": "{workspaceName} är kopplad till {directoryName}",
"workspaces_already_linked": "Redan länkade arbetsytor",
"workspaces_being_added": "Arbetsytor som beviljas åtkomst"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Spara följande reservkoder på ett säkert ställe.",
"scan_the_qr_code_below_with_your_authenticator_app": "Skanna QR-koden nedan med din autentiseringsapp.",
"security_description": "Hantera ditt lösenord och andra säkerhetsinställningar som tvåfaktorsautentisering (2FA).",
"sso_identity_confirmation_failed": "SSO-identitetsbekräftelse misslyckades. Försök att radera ditt konto igen.",
"sso_identity_confirmation_may_be_required_for_deletion": "För SSO-konton kan ett klick på Radera omdirigera dig till din identitetsleverantör för att bekräfta kontot. Om samma konto bekräftas fortsätter raderingen automatiskt.",
"sso_identity_confirmation_failed": "SSO-identitetsbekräftelsen misslyckades. Försök ta bort ditt konto igen.",
"sso_identity_confirmation_may_be_required_for_deletion": "För SSO-konton kan valet Ta bort omdirigera dig till din identitetsleverantör för att bekräfta kontot. Om samma konto bekräftas fortsätter borttagningen automatiskt.",
"two_factor_authentication": "Tvåfaktorsautentisering",
"two_factor_authentication_description": "Lägg till ett extra säkerhetslager till ditt konto om ditt lösenord blir stulet.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tvåfaktorsautentisering aktiverad. Vänligen ange den sexsiffriga koden från din autentiseringsapp.",
@@ -3680,7 +3675,6 @@
"allowed_values": "Tillåtna värden: {values}",
"api_ingestion": "API ingestion",
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"api_ingestion_setup_description": "Använd REST API för att skicka feedbackposter direkt till Formbricks. API-dokumentationen innehåller endpoint, datastruktur och autentiseringsdetaljer.",
"auto_generated": "Automatiskt genererad",
"change_file": "Byt fil",
"clear_mapping": "Rensa mappning",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "Filtre ekle",
"add_to_dashboard": "Panoya Ekle",
"advanced_chart_builder_config_prompt": "Grafiğini yapılandır ve önizleme için \"Sorguyu Çalıştır\"a tıkla",
"ai_enable_in_settings": "Organizasyon ayarlarından etkinleştirin.",
"ai_instance_not_configured": "Bu örnekte AI yapılandırılmamış. Yöneticinle iletişime geç.",
"ai_not_available": "AI veri analizi mevcut değil.",
"ai_not_enabled": "Bu kuruluş için AI veri analizi devre dışı bırakılmış.",
"ai_not_in_plan": "AI veri analizi mevcut planınızda mevcut değil.",
"ai_not_enabled": "Bu organizasyon için AI veri analizi devre dışı. Organizasyon ayarlarından etkinleştir.",
"ai_not_in_plan": "AI veri analizi mevcut planında bulunmuyor. Bu özelliğin kilidini açmak için yükselt.",
"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",
"ai_upgrade_plan": "Planı yükselt",
"already_on_dashboard": "Zaten panoda",
"and_filter_logic": "VE",
"apply_changes": "Değişiklikleri Uygula",
@@ -2593,8 +2591,6 @@
"nav_label": "Geri Bildirim Dizinleri",
"no_access": "Geri bildirim dizinlerini yönetme yetkin yok.",
"no_connectors": "Bu dizine henüz bağlı geri bildirim kaynağı yok.",
"no_unassigned_workspaces_description": "Her çalışma alanı zaten aktif bir geri bildirim dizinine bağlı. Buraya atamadan önce bir çalışma alanını mevcut dizininden kaldırın.",
"no_unassigned_workspaces_title": "Atanmamış çalışma alanı yok",
"pause_connectors_confirmation_description": "Bu geri bildirim kaynaklarını duraklatmak, yeni kayıtların eklenmesini durdurur.",
"pause_connectors_confirmation_title": "Bağlı geri bildirim kaynakları duraklatılsın mı?",
"select_workspaces_placeholder": "Çalışma alanlarını seç...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "Geri bildirim kayıtlarını dizinler halinde düzenleyin ve verileri doğru çalışma alanına yönlendirin. Pro ve Scale planlarında kullanılabilir.",
"upgrade_prompt_title": "Geri Bildirim Kayıt Dizinlerinin Kilidini Açmak İçin Yükseltin",
"workspace_access": "Çalışma alanı erişimi",
"workspace_assigned_to_directory": "{workspaceName}, {directoryName} dizinine bağlı",
"workspaces_already_linked": "Zaten bağlı çalışma alanları",
"workspaces_being_added": "Erişim verilen çalışma alanları"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Aşağıdaki yedekleme kodlarını güvenli bir yerde sakla.",
"scan_the_qr_code_below_with_your_authenticator_app": "Aşağıdaki QR kodunu kimlik doğrulayıcı uygulamanla tara.",
"security_description": "Şifreni ve iki faktörlü kimlik doğrulama (2FA) gibi diğer güvenlik ayarlarını yönet.",
"sso_identity_confirmation_failed": "SSO kimlik doğrulaması başarısız oldu. Lütfen hesabınızı tekrar silmeyi deneyin.",
"sso_identity_confirmation_may_be_required_for_deletion": "SSO hesapları için Sil seçeneğine tıkladığınızda, bu hesabı onaylamak için kimlik sağlayıcınıza yönlendirilebilirsiniz. Aynı hesap onaylanırsa, silme işlemi otomatik olarak devam eder.",
"sso_identity_confirmation_failed": "SSO kimlik doğrulaması başarısız oldu. Lütfen hesabınızı silmeyi tekrar deneyin.",
"sso_identity_confirmation_may_be_required_for_deletion": "SSO hesaplarında Sil'i seçmek, bu hesabı onaylamanız için sizi kimlik sağlayıcınıza yönlendirebilir. Aynı hesap onaylanırsa silme işlemi otomatik olarak devam eder.",
"two_factor_authentication": "İki faktörlü kimlik doğrulama",
"two_factor_authentication_description": "Şifren çalınması durumunda hesabına ekstra bir güvenlik katmanı ekle.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "İki faktörlü kimlik doğrulama etkinleştirildi. Lütfen kimlik doğrulayıcı uygulamandaki altı haneli kodu gir.",
@@ -3680,7 +3675,6 @@
"allowed_values": "İzin verilen değerler: {values}",
"api_ingestion": "API ingestion",
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"api_ingestion_setup_description": "Geri bildirim kayıtlarını doğrudan Formbricks'e göndermek için REST API'sini kullanın. API entegrasyon dokümanları, endpoint, payload yapısı ve kimlik doğrulama detaylarını içerir.",
"auto_generated": "Otomatik olarak oluşturuldu",
"change_file": "Dosyayı değiştir",
"clear_mapping": "Eşleştirmeyi temizle",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "添加过滤器",
"add_to_dashboard": "添加到 Dashboard",
"advanced_chart_builder_config_prompt": "配置你的图表,然后点击“运行查询”预览",
"ai_enable_in_settings": "在组织设置中启用。",
"ai_instance_not_configured": "此实例未配置 AI。请联系您的管理员。",
"ai_not_available": "AI 数据分析不可用。",
"ai_not_enabled": "此组织已禁用 AI 数据分析。",
"ai_not_in_plan": "您当前的套餐不包含 AI 数据分析功能。",
"ai_not_enabled": "此组织已禁用 AI 数据分析。请在组织设置中启用。",
"ai_not_in_plan": "您当前的套餐不包含 AI 数据分析。升级以解锁此功能。",
"ai_query_placeholder": "例如:上周有多少用户注册?",
"ai_query_section_description": "描述你想要看到的内容,让 AI 帮你生成图表。",
"ai_query_section_title": "向你的数据提问",
"ai_upgrade_plan": "升级套餐",
"already_on_dashboard": "已在仪表板上",
"and_filter_logic": "且",
"apply_changes": "应用更改",
@@ -2593,8 +2591,6 @@
"nav_label": "反馈目录",
"no_access": "你没有管理反馈目录的权限。",
"no_connectors": "暂未关联反馈来源到此目录。",
"no_unassigned_workspaces_description": "每个工作区都已链接到活跃的反馈目录。在此处分配前,请先从当前目录中移除工作区。",
"no_unassigned_workspaces_title": "没有可用的未分配工作区",
"pause_connectors_confirmation_description": "暂停这些反馈来源后,将不会有新记录添加进来。",
"pause_connectors_confirmation_title": "暂停关联反馈来源?",
"select_workspaces_placeholder": "选择工作区...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "将反馈记录整理到目录中,并将数据路由到正确的工作空间。专业版和规模版方案可用。",
"upgrade_prompt_title": "升级以解锁反馈记录目录",
"workspace_access": "工作区访问权限",
"workspace_assigned_to_directory": "{workspaceName} 已链接到 {directoryName}",
"workspaces_already_linked": "已关联的工作区",
"workspaces_being_added": "将被授权访问的工作区"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "请 将 以下 备份 代码 保存在 安全地 方。",
"scan_the_qr_code_below_with_your_authenticator_app": "用 你的 身份验证 应用 扫描 下方 的 二维码。",
"security_description": "管理你的密码和其他安全设置,如双因素认证 (2FA)。",
"sso_identity_confirmation_failed": "SSO 身份确认失败。请尝试再次删除的账户。",
"sso_identity_confirmation_may_be_required_for_deletion": "对于 SSO 账户,选择删除可能会将重定向到身份提供商以确认此账户。如果确认的是同一账户,删除自动继续。",
"sso_identity_confirmation_failed": "SSO 身份确认失败。请再次尝试删除的账户。",
"sso_identity_confirmation_may_be_required_for_deletion": "对于 SSO 账户,选择删除可能会将重定向到身份提供商以确认此账户。如果确认的是同一账户,删除自动继续。",
"two_factor_authentication": "双因素 认证",
"two_factor_authentication_description": "为你的账户增加额外的安全层,以防密码被盗。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "双因素 认证 已启用 。 请输入 从 你的 身份验证 应用 中 的 六 位 数 字 代码 。",
@@ -3680,7 +3675,6 @@
"allowed_values": "允许的值:{values}",
"api_ingestion": "API ingestion",
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"api_ingestion_setup_description": "使用 REST API 直接将反馈记录发送到 Formbricks。API 接入文档包含端点、请求体结构和身份验证详情。",
"auto_generated": "自动生成",
"change_file": "更换文件",
"clear_mapping": "清除映射",
+4 -10
View File
@@ -1667,15 +1667,13 @@
"add_filter": "新增篩選器",
"add_to_dashboard": "新增到儀表板",
"advanced_chart_builder_config_prompt": "設定你的圖表,然後點擊「執行查詢」預覽",
"ai_enable_in_settings": "請在組織設定中啟用。",
"ai_instance_not_configured": "此執行個體未設定 AI。請聯絡您的管理員。",
"ai_not_available": "AI 資料分析無法使用。",
"ai_not_enabled": "此組織已停用 AI 資料分析功能。",
"ai_not_in_plan": "您目前的方案不包含 AI 資料分析功能。",
"ai_not_enabled": "此組織已停用 AI 資料分析。請在組織設定中啟用。",
"ai_not_in_plan": "您目前的方案不包含 AI 資料分析。請升級以解鎖此功能。",
"ai_query_placeholder": "例如:上週有多少用戶註冊?",
"ai_query_section_description": "描述你想看到的內容,讓 AI 幫你建立圖表。",
"ai_query_section_title": "詢問你的數據",
"ai_upgrade_plan": "升級方案",
"already_on_dashboard": "已在儀表板上",
"and_filter_logic": "且",
"apply_changes": "套用變更",
@@ -2593,8 +2591,6 @@
"nav_label": "意見回饋目錄",
"no_access": "你沒有權限管理意見回饋目錄。",
"no_connectors": "此目錄尚未連結任何回饋來源。",
"no_unassigned_workspaces_description": "每個工作區都已連結到作用中的意見回饋目錄。請先從目前目錄中移除工作區,再於此處指派。",
"no_unassigned_workspaces_title": "沒有可用的未指派工作區",
"pause_connectors_confirmation_description": "暫停這些回饋來源將停止新增記錄。",
"pause_connectors_confirmation_title": "暫停已連結的回饋來源?",
"select_workspaces_placeholder": "選擇工作區...",
@@ -2605,7 +2601,6 @@
"upgrade_prompt_description": "將回饋記錄整理至目錄中,並將資料導向正確的工作區。專業版和企業版方案提供此功能。",
"upgrade_prompt_title": "升級以解鎖回饋記錄目錄功能",
"workspace_access": "工作區存取權限",
"workspace_assigned_to_directory": "{workspaceName} 已連結到 {directoryName}",
"workspaces_already_linked": "已連結的工作區",
"workspaces_being_added": "正在授予存取權限的工作區"
},
@@ -2715,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "將下列備份碼儲存在安全的地方。",
"scan_the_qr_code_below_with_your_authenticator_app": "使用您的驗證器應用程式掃描下方的 QR 碼。",
"security_description": "管理您的密碼和其他安全性設定,例如雙重驗證 (2FA)。",
"sso_identity_confirmation_failed": "SSO 身分確認失敗。請再次嘗試刪除的帳。",
"sso_identity_confirmation_may_be_required_for_deletion": "對 SSO 帳,選擇刪除可能會將重新導向至身分提供者以確認此帳號。若確認的是同一帳號,刪除作業將自動繼續。",
"sso_identity_confirmation_failed": "SSO 身分確認失敗。請再次嘗試刪除的帳。",
"sso_identity_confirmation_may_be_required_for_deletion": "對 SSO 帳,選擇刪除可能會將重新導向至身分提供者以確認此帳戶。如果確認的是同一個帳戶,刪除會自動繼續。",
"two_factor_authentication": "雙重驗證",
"two_factor_authentication_description": "在您的密碼被盜時,為您的帳戶新增額外的安全層。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "已啟用雙重驗證。請輸入您驗證器應用程式中的六位數程式碼。",
@@ -3680,7 +3675,6 @@
"allowed_values": "允許的值:{values}",
"api_ingestion": "API ingestion",
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"api_ingestion_setup_description": "使用 REST API 直接將意見回饋記錄傳送至 Formbricks。API 擷取文件包含端點、負載格式及驗證詳情。",
"auto_generated": "自動生成",
"change_file": "更換檔案",
"clear_mapping": "清除對應",
@@ -7,22 +7,18 @@ import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { generateAIChartAction } from "@/modules/ee/analysis/charts/actions";
import {
type TAIUnavailableActionType,
type TAIUnavailableReason,
getAIUnavailableAction,
} from "@/modules/ee/analysis/charts/lib/ai-availability";
import type { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { Alert } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface AIQuerySectionProps {
workspaceId: string;
onChartGenerated: (data: AnalyticsResponse) => void;
feedbackDirectoryId: string;
isAIAvailable?: boolean;
aiUnavailableReason?: TAIUnavailableReason;
aiUnavailableReason?: string;
}
export function AIQuerySection({
@@ -35,31 +31,7 @@ export function AIQuerySection({
const [userQuery, setUserQuery] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const { t } = useTranslation();
const translateAIUnavailableMessage = (reason: TAIUnavailableReason | undefined): string => {
switch (reason) {
case "not_in_plan":
return t("workspace.analysis.charts.ai_not_in_plan");
case "not_enabled":
return t("workspace.analysis.charts.ai_not_enabled");
case "instance_not_configured":
return t("workspace.analysis.charts.ai_instance_not_configured");
default:
return t("workspace.analysis.charts.ai_not_available");
}
};
const translateAIUnavailableAction = (actionType: TAIUnavailableActionType): string => {
switch (actionType) {
case "enable_ai":
return t("workspace.analysis.charts.ai_enable_in_settings");
case "upgrade_plan":
return t("workspace.analysis.charts.ai_upgrade_plan");
}
};
const aiUnavailableMessage = translateAIUnavailableMessage(aiUnavailableReason);
const aiUnavailableAction = getAIUnavailableAction(aiUnavailableReason, workspaceId);
const showAIDataAnalysisDisabledAlert = !isAIAvailable && aiUnavailableReason === "not_enabled";
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -111,31 +83,56 @@ export function AIQuerySection({
maxLength={2000}
disabled={!isAIAvailable || isGenerating}
/>
<Button
type="submit"
variant="default"
className="w-full"
disabled={!isAIAvailable || !userQuery.trim() || isGenerating}
loading={isGenerating}>
<WandSparklesIcon className="h-4 w-4" />
{t("workspace.analysis.charts.create_chart_with_ai")}
</Button>
{!isAIAvailable && (
<Alert variant="info" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
<span>{aiUnavailableMessage}</span>
</AlertDescription>
{aiUnavailableAction && (
<AlertButton asChild>
<Link href={aiUnavailableAction.href}>
{translateAIUnavailableAction(aiUnavailableAction.type)}
</Link>
</AlertButton>
)}
</Alert>
{showAIDataAnalysisDisabledAlert ? (
<Button
type="submit"
variant="default"
className="w-full"
disabled={!isAIAvailable || !userQuery.trim() || isGenerating}
loading={isGenerating}>
<WandSparklesIcon className="h-4 w-4" />
{t("workspace.analysis.charts.create_chart_with_ai")}
</Button>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
type="submit"
variant="default"
className="w-full"
disabled={!isAIAvailable || !userQuery.trim() || isGenerating}
loading={isGenerating}>
<WandSparklesIcon className="h-4 w-4" />
{t("workspace.analysis.charts.create_chart_with_ai")}
</Button>
</div>
</TooltipTrigger>
{!isAIAvailable && (
<TooltipContent>
{{
not_in_plan: t("workspace.analysis.charts.ai_not_in_plan"),
not_enabled: t("workspace.analysis.charts.ai_not_enabled"),
instance_not_configured: t("workspace.analysis.charts.ai_instance_not_configured"),
}[aiUnavailableReason ?? ""] ?? t("workspace.analysis.charts.ai_not_available")}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
</form>
</div>
{showAIDataAnalysisDisabledAlert && (
<Alert variant="info" size="small">
<span className="truncate">{t("workspace.surveys.edit.ai_data_analysis_disabled")}</span>
<Link
href={`/workspaces/${workspaceId}/settings/organization/general`}
className="ml-2 inline-flex shrink-0 underline">
Enable it in organization settings.
</Link>
</Alert>
)}
</div>
);
}
@@ -5,7 +5,6 @@ import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/
import { getTranslate } from "@/lingodotdev/server";
import { ChartsList } from "@/modules/ee/analysis/charts/components/charts-list";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import { getChartsWithCreator } from "@/modules/ee/analysis/charts/lib/charts";
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
@@ -21,8 +20,6 @@ interface ChartsListContentProps {
workspaceId: string;
isReadOnly: boolean;
directories: { id: string; name: string }[];
isAIAvailable: boolean;
aiUnavailableReason?: TAIUnavailableReason;
}
const ChartsListContent = ({
@@ -30,20 +27,11 @@ const ChartsListContent = ({
workspaceId,
isReadOnly,
directories,
isAIAvailable,
aiUnavailableReason,
}: Readonly<ChartsListContentProps>) => {
const charts = use(chartsPromise);
return (
<ChartsList
charts={charts}
workspaceId={workspaceId}
isReadOnly={isReadOnly}
directories={directories}
isAIAvailable={isAIAvailable}
aiUnavailableReason={aiUnavailableReason}
/>
<ChartsList charts={charts} workspaceId={workspaceId} isReadOnly={isReadOnly} directories={directories} />
);
};
@@ -115,8 +103,6 @@ export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPagePro
workspaceId={workspaceId}
isReadOnly={isReadOnly}
directories={directories}
isAIAvailable={isAIAvailable}
aiUnavailableReason={aiUnavailableReason}
/>
) : (
<NoFeedbackRecordsState workspaceId={workspaceId} hasFeedbackSources={connectors.length > 0} />
@@ -1,7 +1,6 @@
import { getTranslate } from "@/lingodotdev/server";
import { ChartRow } from "@/modules/ee/analysis/charts/components/chart-row";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
interface ChartsListProps {
@@ -9,8 +8,6 @@ interface ChartsListProps {
workspaceId: string;
isReadOnly: boolean;
directories: { id: string; name: string }[];
isAIAvailable: boolean;
aiUnavailableReason?: TAIUnavailableReason;
}
export const ChartsList = async ({
@@ -18,8 +15,6 @@ export const ChartsList = async ({
workspaceId,
isReadOnly,
directories,
isAIAvailable,
aiUnavailableReason,
}: Readonly<ChartsListProps>) => {
const t = await getTranslate();
@@ -47,8 +42,6 @@ export const ChartsList = async ({
workspaceId={workspaceId}
directories={directories}
buttonProps={{ variant: "secondary" }}
isAIAvailable={isAIAvailable}
aiUnavailableReason={aiUnavailableReason}
/>
)}
</div>
@@ -4,7 +4,6 @@ import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import { Button, type ButtonProps } from "@/modules/ui/components/button";
interface CreateChartButtonProps {
@@ -16,7 +15,7 @@ interface CreateChartButtonProps {
showIcon?: boolean;
buttonProps?: Omit<ButtonProps, "onClick" | "children">;
isAIAvailable?: boolean;
aiUnavailableReason?: TAIUnavailableReason;
aiUnavailableReason?: string;
}
export function CreateChartButton({
@@ -1,7 +1,6 @@
"use client";
import { CreateChartView } from "@/modules/ee/analysis/charts/components/create-chart-view";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
export interface CreateChartDialogProps {
@@ -14,7 +13,7 @@ export interface CreateChartDialogProps {
onSuccess?: () => void;
directories: { id: string; name: string }[];
isAIAvailable?: boolean;
aiUnavailableReason?: TAIUnavailableReason;
aiUnavailableReason?: string;
}
export function CreateChartDialog({
@@ -11,7 +11,6 @@ import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import { DEFAULT_CHART_TYPE } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { Alert } from "@/modules/ui/components/alert";
@@ -37,7 +36,7 @@ interface CreateChartViewProps {
onSuccess?: () => void;
directories: { id: string; name: string }[];
isAIAvailable?: boolean;
aiUnavailableReason?: TAIUnavailableReason;
aiUnavailableReason?: string;
}
export function CreateChartView({
@@ -1,26 +0,0 @@
import { describe, expect, test } from "vitest";
import { getAIUnavailableAction } from "./ai-availability";
describe("ai availability helpers", () => {
test("returns the organization settings action when AI is not enabled", () => {
expect(getAIUnavailableAction("not_enabled", "workspace-1")).toEqual({
href: "/workspaces/workspace-1/settings/organization/general",
type: "enable_ai",
});
});
test("returns the billing action when AI is not in the plan", () => {
expect(getAIUnavailableAction("not_in_plan", "workspace-1")).toEqual({
href: "/workspaces/workspace-1/settings/organization/billing",
type: "upgrade_plan",
});
});
test("does not return an action when the instance is not configured", () => {
expect(getAIUnavailableAction("instance_not_configured", "workspace-1")).toBeUndefined();
});
test("does not return an action when the reason is unavailable", () => {
expect(getAIUnavailableAction(undefined, "workspace-1")).toBeUndefined();
});
});
@@ -1,28 +0,0 @@
export type TAIUnavailableReason = "not_in_plan" | "not_enabled" | "instance_not_configured";
export type TAIUnavailableActionType = "enable_ai" | "upgrade_plan";
interface AIUnavailableAction {
href: string;
type: TAIUnavailableActionType;
}
export const getAIUnavailableAction = (
reason: TAIUnavailableReason | undefined,
workspaceId: string
): AIUnavailableAction | undefined => {
if (reason === "not_enabled") {
return {
href: `/workspaces/${workspaceId}/settings/organization/general`,
type: "enable_ai",
};
}
if (reason === "not_in_plan") {
return {
href: `/workspaces/${workspaceId}/settings/organization/billing`,
type: "upgrade_plan",
};
}
return undefined;
};
@@ -8,7 +8,6 @@ import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getChartsAction } from "@/modules/ee/analysis/charts/actions";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import { addChartToDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
import { Button } from "@/modules/ui/components/button";
import {
@@ -32,7 +31,7 @@ interface AddExistingChartsDialogProps {
existingChartIds: string[];
onSuccess: () => void;
isAIAvailable?: boolean;
aiUnavailableReason?: TAIUnavailableReason;
aiUnavailableReason?: string;
}
interface ChartOption {
@@ -6,7 +6,6 @@ import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import { deleteDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
import { AddExistingChartsDialog } from "@/modules/ee/analysis/dashboards/components/add-existing-charts-dialog";
import { Button } from "@/modules/ui/components/button";
@@ -23,7 +22,7 @@ interface DashboardControlBarProps {
hasChanges: boolean;
isReadOnly: boolean;
isAIAvailable?: boolean;
aiUnavailableReason?: TAIUnavailableReason;
aiUnavailableReason?: string;
onRefresh: () => void;
onEditToggle: () => void;
onSave: () => void;
@@ -11,7 +11,6 @@ import "react-resizable/css/styles.css";
import type { TChartQuery } from "@formbricks/types/analysis";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import { DashboardControlBar } from "@/modules/ee/analysis/dashboards/components/dashboard-control-bar";
import { DashboardPageHeader } from "@/modules/ee/analysis/dashboards/components/dashboard-page-header";
import { DashboardWidget } from "@/modules/ee/analysis/dashboards/components/dashboard-widget";
@@ -41,7 +40,7 @@ interface DashboardDetailClientProps {
directories: { id: string; name: string }[];
isReadOnly: boolean;
isAIAvailable: boolean;
aiUnavailableReason?: TAIUnavailableReason;
aiUnavailableReason?: string;
}
const widgetsToLayout = (widgets: TDashboardWidget[]): LayoutItem[] => {
@@ -15,7 +15,6 @@ import {
updateFeedbackDirectoryAction,
} from "@/modules/ee/feedback-directory/actions";
import { ArchiveFeedbackDirectory } from "@/modules/ee/feedback-directory/components/feedback-directory-settings/archive-feedback-directory";
import { getWorkspaceAccessConflictState } from "@/modules/ee/feedback-directory/lib/workspace-access-conflicts";
import {
TFeedbackDirectoryDetails,
TFeedbackDirectoryUpdateInput,
@@ -24,7 +23,6 @@ import {
getTranslatedFeedbackDirectoryError,
} from "@/modules/ee/feedback-directory/types/feedback-directory";
import { TOrganizationWorkspace } from "@/modules/ee/teams/team-list/types/workspace";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -98,20 +96,6 @@ export const FeedbackDirectorySettingsModal = ({
[orgWorkspaces, workspaceAccessMap, directory?.id]
);
const workspaceConflictInput = useMemo(
() => ({
orgWorkspaces,
workspaceAccessByWorkspace,
currentDirectoryId: directory?.id,
}),
[orgWorkspaces, workspaceAccessByWorkspace, directory?.id]
);
const workspaceConflictState = useMemo(
() => getWorkspaceAccessConflictState(workspaceConflictInput),
[workspaceConflictInput]
);
const initialWorkspaceIds = useMemo(
() => directory?.workspaces.map((workspace) => workspace.workspaceId) ?? [],
[directory?.workspaces]
@@ -133,7 +117,6 @@ export const FeedbackDirectorySettingsModal = ({
setValue,
reset,
} = form;
const selectedWorkspaceIds = form.watch("workspaceIds") ?? [];
const workspaceNameById = useMemo(() => {
const map = new Map(orgWorkspaces.map((workspace) => [workspace.id, workspace.name]));
@@ -307,7 +290,7 @@ export const FeedbackDirectorySettingsModal = ({
</Muted>
<MultiSelect
options={workspaceOptions}
value={selectedWorkspaceIds}
value={form.watch("workspaceIds") ?? []}
onChange={(selected) => {
setValue("workspaceIds", selected, { shouldDirty: true });
}}
@@ -315,30 +298,6 @@ export const FeedbackDirectorySettingsModal = ({
placeholder={t("workspace.settings.feedback_directories.select_workspaces_placeholder")}
containerClassName="focus-within:ring-0 focus-within:ring-offset-0"
/>
{workspaceConflictState.showBlockedExplanation && (
<Alert variant="info" className="items-start">
<div className="min-w-0 space-y-1">
<AlertTitle className="truncate">
{t("workspace.settings.feedback_directories.no_unassigned_workspaces_title")}
</AlertTitle>
<AlertDescription className="overflow-visible whitespace-normal">
<p>
{t("workspace.settings.feedback_directories.no_unassigned_workspaces_description")}
</p>
<ul className="mt-1 list-disc space-y-0.5 pl-4">
{workspaceConflictState.conflictDetails.map((conflict) => (
<li key={conflict.workspaceId}>
{t("workspace.settings.feedback_directories.workspace_assigned_to_directory", {
workspaceName: conflict.workspaceName,
directoryName: conflict.feedbackDirectoryName,
})}
</li>
))}
</ul>
</AlertDescription>
</div>
</Alert>
)}
</div>
{isEdit && (
@@ -402,7 +402,6 @@ describe("FeedbackDirectory Service", () => {
});
test("unarchives directory", async () => {
vi.mocked(prisma.feedbackDirectory.findUnique).mockResolvedValueOnce(mockDirectoryDetailsDbRow as any);
vi.mocked(prisma.feedbackDirectory.update).mockResolvedValueOnce({} as any);
const result = await updateFeedbackDirectory(mockDirectoryId, mockOrganizationId, {
@@ -410,48 +409,12 @@ describe("FeedbackDirectory Service", () => {
});
expect(result).toBe(true);
expect(prisma.feedbackDirectoryWorkspace.findFirst).toHaveBeenCalledWith({
where: {
workspaceId: { in: [mockWorkspaceId1, mockWorkspaceId2] },
feedbackDirectoryId: { not: mockDirectoryId },
feedbackDirectory: { isArchived: false },
},
select: { workspaceId: true },
});
expect(prisma.feedbackDirectory.update).toHaveBeenCalledWith({
where: { id: mockDirectoryId },
data: { isArchived: false },
});
});
test("throws ResourceNotFoundError when unarchiving and directory cannot be loaded", async () => {
vi.mocked(prisma.feedbackDirectory.findUnique).mockResolvedValueOnce(null);
await expect(
updateFeedbackDirectory(mockDirectoryId, mockOrganizationId, {
isArchived: false,
})
).rejects.toThrow(ResourceNotFoundError);
expect(prisma.feedbackDirectoryWorkspace.findFirst).not.toHaveBeenCalled();
expect(prisma.feedbackDirectory.update).not.toHaveBeenCalled();
});
test("throws InvalidInputError when unarchiving would assign a workspace to two active directories", async () => {
vi.mocked(prisma.feedbackDirectory.findUnique).mockResolvedValueOnce(mockDirectoryDetailsDbRow as any);
vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce({
workspaceId: mockWorkspaceId1,
} as any);
await expect(
updateFeedbackDirectory(mockDirectoryId, mockOrganizationId, {
isArchived: false,
})
).rejects.toThrow(new InvalidInputError("WORKSPACE_ALREADY_ASSIGNED_TO_DIFFERENT_DIRECTORY"));
expect(prisma.feedbackDirectory.update).not.toHaveBeenCalled();
});
test("updates workspace assignments with diff", async () => {
// getFeedbackDirectoryDetails call
vi.mocked(prisma.feedbackDirectory.findUnique).mockResolvedValueOnce(mockDirectoryDetailsDbRow as any);
@@ -1,5 +1,5 @@
import "server-only";
import { Prisma, type PrismaClient } from "@prisma/client";
import { Prisma, PrismaClient } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
@@ -15,11 +15,6 @@ import {
ZFeedbackDirectoryUpdateInput,
} from "@/modules/ee/feedback-directory/types/feedback-directory";
type FeedbackDirectoryPrismaClient = Pick<
PrismaClient,
"connector" | "feedbackDirectory" | "feedbackDirectoryWorkspace" | "workspace"
>;
/**
* Retrieves all feedback directories for a given organization.
*
@@ -191,59 +186,6 @@ export const getWorkspaceFeedbackDirectoryAccess = reactCache(
}
);
const mapFeedbackDirectoryDetails = (directory: {
id: string;
name: string;
isArchived: boolean;
organizationId: string;
workspaces: { workspaceId: string; workspace: { name: string } }[];
connectors: {
id: string;
name: string;
type: string;
workspaceId: string;
workspace: { name: string };
}[];
}): TFeedbackDirectoryDetails => ({
id: directory.id,
name: directory.name,
isArchived: directory.isArchived,
organizationId: directory.organizationId,
workspaces: directory.workspaces.map((dp) => ({
workspaceId: dp.workspaceId,
workspaceName: dp.workspace.name,
})),
connectors: directory.connectors.map((c) => ({
id: c.id,
name: c.name,
type: c.type,
workspaceId: c.workspaceId,
workspaceName: c.workspace.name,
})),
});
const getFeedbackDirectoryWorkspaceIdsWithClient = async (
prismaClient: FeedbackDirectoryPrismaClient,
directoryId: string
): Promise<string[] | null> => {
const directory = await prismaClient.feedbackDirectory.findUnique({
where: { id: directoryId },
select: {
workspaces: {
select: {
workspaceId: true,
},
},
},
});
if (!directory) {
return null;
}
return directory.workspaces.map((workspace) => workspace.workspaceId);
};
export const getFeedbackDirectoryDetails = reactCache(
async (directoryId: string): Promise<TFeedbackDirectoryDetails | null> => {
validateInputs([directoryId, ZId]);
@@ -284,7 +226,23 @@ export const getFeedbackDirectoryDetails = reactCache(
return null;
}
return mapFeedbackDirectoryDetails(directory);
return {
id: directory.id,
name: directory.name,
isArchived: directory.isArchived,
organizationId: directory.organizationId,
workspaces: directory.workspaces.map((dp) => ({
workspaceId: dp.workspaceId,
workspaceName: dp.workspace.name,
})),
connectors: directory.connectors.map((c) => ({
id: c.id,
name: c.name,
type: c.type,
workspaceId: c.workspaceId,
workspaceName: c.workspace.name,
})),
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -321,7 +279,7 @@ export const createFeedbackDirectory = async (
if (count !== workspaceIds.length) {
throw new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG");
}
await assertWorkspacesNotAssignedElsewhere(prisma, undefined, workspaceIds);
await assertWorkspacesNotAssignedElsewhere(undefined, workspaceIds);
}
const directory = await prisma.feedbackDirectory.create({
@@ -363,7 +321,7 @@ export const createFeedbackDirectory = async (
* @throws {InvalidInputError} If any workspace does not belong to the organization.
*/
const buildWorkspaceAssignmentPayload = async (
prismaClient: FeedbackDirectoryPrismaClient,
prismaClient: PrismaClient,
directoryId: string,
workspaceIds: string[],
organizationId: string,
@@ -411,12 +369,11 @@ interface UpdateFeedbackDirectoryOptions {
}
const getArchiveUpdate = async (
prismaClient: FeedbackDirectoryPrismaClient,
directoryId: string,
isArchived: boolean | undefined
): Promise<Pick<Prisma.FeedbackDirectoryUpdateInput, "isArchived">> => {
if (isArchived === true) {
const connectorCount = await prismaClient.connector.count({
const connectorCount = await prisma.connector.count({
where: { feedbackDirectoryId: directoryId },
});
if (connectorCount > 0) {
@@ -426,13 +383,6 @@ const getArchiveUpdate = async (
}
if (isArchived === false) {
const currentWorkspaceIds = await getFeedbackDirectoryWorkspaceIdsWithClient(prismaClient, directoryId);
if (!currentWorkspaceIds) {
throw new ResourceNotFoundError("FeedbackDirectory", directoryId);
}
await assertWorkspacesNotAssignedElsewhere(prismaClient, directoryId, currentWorkspaceIds);
return { isArchived: false };
}
@@ -440,7 +390,6 @@ const getArchiveUpdate = async (
};
const getWorkspaceAssignmentUpdate = async (
prismaClient: FeedbackDirectoryPrismaClient,
directoryId: string,
organizationId: string,
workspaceIds: string[] | undefined
@@ -452,10 +401,10 @@ const getWorkspaceAssignmentUpdate = async (
return { removedWorkspaceIds: [] };
}
const currentWorkspaceIds =
(await getFeedbackDirectoryWorkspaceIdsWithClient(prismaClient, directoryId)) ?? [];
const currentDetails = await getFeedbackDirectoryDetails(directoryId);
const currentWorkspaceIds = currentDetails?.workspaces.map((workspace) => workspace.workspaceId) ?? [];
const assignmentPayload = await buildWorkspaceAssignmentPayload(
prismaClient,
prisma,
directoryId,
workspaceIds,
organizationId,
@@ -497,13 +446,12 @@ const pauseConnectorsInWorkspaces = async (
* conflict check. Omit it on create — every active directory is a conflict.
*/
const assertWorkspacesNotAssignedElsewhere = async (
prismaClient: FeedbackDirectoryPrismaClient,
directoryId: string | undefined,
workspaceIds: string[]
): Promise<void> => {
if (workspaceIds.length === 0) return;
const conflicting = await prismaClient.feedbackDirectoryWorkspace.findFirst({
const conflicting = await prisma.feedbackDirectoryWorkspace.findFirst({
where: {
workspaceId: { in: workspaceIds },
...(directoryId === undefined ? {} : { feedbackDirectoryId: { not: directoryId } }),
@@ -546,42 +494,34 @@ export const updateFeedbackDirectory = async (
try {
const { name, workspaceIds, isArchived } = data;
await prisma.$transaction(
async (tx) => {
if (workspaceIds !== undefined) {
await assertWorkspacesNotAssignedElsewhere(tx, directoryId, workspaceIds);
}
if (workspaceIds !== undefined) {
await assertWorkspacesNotAssignedElsewhere(directoryId, workspaceIds);
}
const archiveUpdate = await getArchiveUpdate(tx, directoryId, isArchived);
const workspaceAssignmentUpdate = await getWorkspaceAssignmentUpdate(
tx,
directoryId,
organizationId,
workspaceIds
);
const payload: Prisma.FeedbackDirectoryUpdateInput = {
...(name !== undefined ? { name } : {}),
...archiveUpdate,
...(workspaceAssignmentUpdate.workspaces
? { workspaces: workspaceAssignmentUpdate.workspaces }
: {}),
};
await tx.feedbackDirectory.update({
where: { id: directoryId },
data: payload,
});
if (options?.pauseConnectorsInRemovedWorkspaces) {
await pauseConnectorsInWorkspaces(tx, directoryId, workspaceAssignmentUpdate.removedWorkspaceIds);
}
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
}
const archiveUpdate = await getArchiveUpdate(directoryId, isArchived);
const workspaceAssignmentUpdate = await getWorkspaceAssignmentUpdate(
directoryId,
organizationId,
workspaceIds
);
const payload: Prisma.FeedbackDirectoryUpdateInput = {
...(name !== undefined ? { name } : {}),
...archiveUpdate,
...(workspaceAssignmentUpdate.workspaces ? { workspaces: workspaceAssignmentUpdate.workspaces } : {}),
};
await prisma.$transaction(async (tx) => {
await tx.feedbackDirectory.update({
where: { id: directoryId },
data: payload,
});
if (options?.pauseConnectorsInRemovedWorkspaces) {
await pauseConnectorsInWorkspaces(tx, directoryId, workspaceAssignmentUpdate.removedWorkspaceIds);
}
});
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -1,102 +0,0 @@
import { describe, expect, test } from "vitest";
import { getWorkspaceAccessConflictState } from "./workspace-access-conflicts";
const orgWorkspaces = [
{ id: "workspace-b", name: "Beta" },
{ id: "workspace-a", name: "Alpha" },
];
describe("workspace access conflict helpers", () => {
test("shows conflicts when every workspace is assigned to a different active directory", () => {
const input = {
orgWorkspaces,
workspaceAccessByWorkspace: [
{
workspaceId: "workspace-b",
feedbackDirectoryId: "directory-2",
feedbackDirectoryName: "Directory B",
},
{
workspaceId: "workspace-a",
feedbackDirectoryId: "directory-1",
feedbackDirectoryName: "Directory A",
},
],
currentDirectoryId: "directory-current",
};
expect(getWorkspaceAccessConflictState(input)).toEqual({
conflictDetails: [
{
workspaceId: "workspace-a",
workspaceName: "Alpha",
feedbackDirectoryName: "Directory A",
},
{
workspaceId: "workspace-b",
workspaceName: "Beta",
feedbackDirectoryName: "Directory B",
},
],
hasSelectableWorkspace: false,
showBlockedExplanation: true,
});
});
test("does not show the blocked explanation when some workspaces are still available", () => {
const input = {
orgWorkspaces,
workspaceAccessByWorkspace: [
{
workspaceId: "workspace-a",
feedbackDirectoryId: "directory-1",
feedbackDirectoryName: "Directory A",
},
],
currentDirectoryId: "directory-current",
};
expect(getWorkspaceAccessConflictState(input)).toEqual({
conflictDetails: [
{
workspaceId: "workspace-a",
workspaceName: "Alpha",
feedbackDirectoryName: "Directory A",
},
],
hasSelectableWorkspace: true,
showBlockedExplanation: false,
});
});
test("treats assignments to the current directory as selectable", () => {
const input = {
orgWorkspaces,
workspaceAccessByWorkspace: [
{
workspaceId: "workspace-a",
feedbackDirectoryId: "directory-current",
feedbackDirectoryName: "Current Directory",
},
{
workspaceId: "workspace-b",
feedbackDirectoryId: "directory-2",
feedbackDirectoryName: "Directory B",
},
],
currentDirectoryId: "directory-current",
};
expect(getWorkspaceAccessConflictState(input)).toEqual({
conflictDetails: [
{
workspaceId: "workspace-b",
workspaceName: "Beta",
feedbackDirectoryName: "Directory B",
},
],
hasSelectableWorkspace: true,
showBlockedExplanation: false,
});
});
});
@@ -1,65 +0,0 @@
interface WorkspaceAccessAssignment {
workspaceId: string;
feedbackDirectoryId: string;
feedbackDirectoryName: string;
}
interface WorkspaceOptionSource {
id: string;
name: string;
}
export interface WorkspaceConflictDetail {
workspaceId: string;
workspaceName: string;
feedbackDirectoryName: string;
}
interface WorkspaceAccessConflictInput {
orgWorkspaces: WorkspaceOptionSource[];
workspaceAccessByWorkspace: WorkspaceAccessAssignment[];
currentDirectoryId?: string;
}
interface WorkspaceAccessConflictState {
conflictDetails: WorkspaceConflictDetail[];
hasSelectableWorkspace: boolean;
showBlockedExplanation: boolean;
}
const sortByWorkspaceName = (a: WorkspaceConflictDetail, b: WorkspaceConflictDetail): number =>
a.workspaceName.localeCompare(b.workspaceName, undefined, { sensitivity: "base" });
export const getWorkspaceAccessConflictState = ({
orgWorkspaces,
workspaceAccessByWorkspace,
currentDirectoryId,
}: WorkspaceAccessConflictInput): WorkspaceAccessConflictState => {
const workspaceAccessMap = new Map(
workspaceAccessByWorkspace.map((assignment) => [assignment.workspaceId, assignment])
);
let hasSelectableWorkspace = false;
const conflictDetails: WorkspaceConflictDetail[] = [];
for (const workspace of orgWorkspaces) {
const assignment = workspaceAccessMap.get(workspace.id);
if (!assignment || assignment.feedbackDirectoryId === currentDirectoryId) {
hasSelectableWorkspace = true;
continue;
}
conflictDetails.push({
workspaceId: workspace.id,
workspaceName: workspace.name,
feedbackDirectoryName: assignment.feedbackDirectoryName,
});
}
conflictDetails.sort(sortByWorkspaceName);
return {
conflictDetails,
hasSelectableWorkspace,
showBlockedExplanation: conflictDetails.length > 0 && !hasSelectableWorkspace,
};
};
@@ -44,8 +44,6 @@ export function ConnectorTypeSelector({
{connectorOptions.map((option) => {
const showNoSurveysAlert =
surveyCount === 0 && option.id === "formbricks_survey" && selectedType === "formbricks_survey";
const showApiIngestionSetupAlert =
option.id === "api_ingestion" && selectedType === "api_ingestion";
return (
<div key={option.id} className="space-y-2">
<button
@@ -76,7 +74,6 @@ export function ConnectorTypeSelector({
</div>
</button>
{showNoSurveysAlert && <NoFormbricksSurveysAlert workspaceId={workspaceId} />}
{showApiIngestionSetupAlert && <ApiIngestionSetupAlert />}
</div>
);
})}
@@ -97,20 +94,6 @@ export function ConnectorTypeSelector({
);
}
const ApiIngestionSetupAlert = () => {
const { t } = useTranslation();
return (
<Alert variant="info" size="small">
<div className="min-w-0 space-y-1">
<AlertDescription className="overflow-visible whitespace-normal">
<p>{t("workspace.unify.api_ingestion_setup_description")}</p>
</AlertDescription>
</div>
</Alert>
);
};
const NoFormbricksSurveysAlert = ({ workspaceId }: Readonly<{ workspaceId: string }>) => {
return (
<Alert variant="info" size="small">
@@ -66,9 +66,6 @@ import { ConnectorTypeSelector } from "./connector-type-selector";
import { CsvConnectorUI } from "./csv-connector-ui";
import { FormbricksQuestionList } from "./formbricks-question-list";
const API_INGESTION_DOCS_URL = "https://formbricks.com/docs/unify-feedback/api/rest-api";
const FEEDBACK_RECORD_MCP_DOCS_URL = "https://formbricks.com/docs/unify-feedback/api/mcp";
interface CreateConnectorModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -263,12 +260,12 @@ export const CreateConnectorModal = ({
if (currentStep !== "selectType" || !selectedType) return;
if (selectedType === "api_ingestion") {
window.open(API_INGESTION_DOCS_URL, "_blank", "noopener,noreferrer");
window.open("https://formbricks.com/docs/unify-feedback/api/rest-api", "_blank", "noopener,noreferrer");
return;
}
if (selectedType === "feedback_record_mcp") {
window.open(FEEDBACK_RECORD_MCP_DOCS_URL, "_blank", "noopener,noreferrer");
window.open("https://formbricks.com/docs/unify-feedback/api/mcp", "_blank", "noopener,noreferrer");
return;
}
@@ -482,6 +479,7 @@ export const CreateConnectorModal = ({
workspaceId={workspaceId}
/>
)}
{currentStep === "mapping" && selectedType === "formbricks_survey" && (
<FormProvider {...formbricksForm}>
<form
@@ -99,12 +99,7 @@ describe("useDeleteSurvey", () => {
0
);
resolveFetch?.(
new Response(JSON.stringify({ data: { id: "survey_1" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
resolveFetch?.(new Response(null, { status: 204 }));
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: surveyKeys.lists() });
@@ -1,5 +1,10 @@
import { describe, expect, test } from "vitest";
import { buildSurveyListSearchParams } from "./v3-surveys-client";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { V3ApiError } from "@/modules/api/lib/v3-client";
import { buildSurveyListSearchParams, deleteSurvey } from "./v3-surveys-client";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("buildSurveyListSearchParams", () => {
test("emits only supported v3 params using normalized filter values", () => {
@@ -39,3 +44,39 @@ describe("buildSurveyListSearchParams", () => {
);
});
});
describe("deleteSurvey", () => {
test("treats 204 No Content as a successful delete", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
await expect(deleteSurvey("survey_1")).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith("/api/v3/surveys/survey_1", {
method: "DELETE",
cache: "no-store",
});
});
test("maps v3 problem responses to V3ApiError", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json(
{
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
},
{ status: 403 }
)
)
);
await expect(deleteSurvey("survey_1")).rejects.toMatchObject<V3ApiError>({
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
});
});
});
@@ -13,12 +13,6 @@ type TV3SurveyListResponse = {
meta: TSurveyListPage["meta"];
};
type TV3DeleteSurveyResponse = {
data: {
id: string;
};
};
export type TSurveyListPage = {
data: TSurveyListItem[];
meta: {
@@ -122,7 +116,7 @@ export async function listSurveys({
};
}
export async function deleteSurvey(surveyId: string): Promise<{ id: string }> {
export async function deleteSurvey(surveyId: string): Promise<void> {
const response = await fetch(`/api/v3/surveys/${surveyId}`, {
method: "DELETE",
cache: "no-store",
@@ -131,7 +125,4 @@ export async function deleteSurvey(surveyId: string): Promise<{ id: string }> {
if (!response.ok) {
throw await parseV3ApiError(response);
}
const body = (await response.json()) as TV3DeleteSurveyResponse;
return body.data;
}
+1 -1
View File
@@ -122,7 +122,7 @@
"react-turnstile": "1.1.5",
"react-use": "17.6.0",
"recharts": "2.15.3",
"sanitize-html": "2.17.4",
"sanitize-html": "2.17.3",
"server-only": "0.0.1",
"sharp": "0.34.5",
"stripe": "20.4.1",
+855 -13
View File
@@ -1,12 +1,11 @@
# V3 API — Surveys (hand-maintained; not generated by generate-api-specs).
# Implementation: apps/web/app/api/v3/surveys/route.ts and apps/web/app/api/v3/surveys/[surveyId]/route.ts
# See apps/web/app/api/v3/README.md and docs/Survey-Server-Actions.md (Part III) for full context.
openapi: 3.1.0
info:
title: Formbricks API v3
description: |
**GET /api/v3/surveys** and **DELETE /api/v3/surveys/{surveyId}** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace).
**GET /api/v3/surveys**, **GET /api/v3/surveys/{surveyId}**, and **DELETE /api/v3/surveys/{surveyId}** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace).
**Spec location:** `docs/api-v3-reference/openapi.yml` (alongside v2 at `docs/api-v2-reference/openapi.yml`).
@@ -34,7 +33,7 @@ info:
The v3-backed survey overview page intentionally removes actions that are not yet exposed by this contract: `Created by` filtering, `Duplicate`, `Copy...`, `Preview`, and `Copy link`.
**Next steps (out of scope for this spec)**
Additional v3 survey endpoints, optional ETag/304, field selection — see Survey-Server-Actions.md Part III.
Additional v3 survey write endpoints, optional ETag/304, field selection, and survey version history.
version: 0.1.0
x-implementation-notes:
route: apps/web/app/api/v3/surveys/route.ts
@@ -198,6 +197,174 @@ paths:
- sessionAuth: []
- apiKeyAuth: []
/api/v3/surveys/{surveyId}:
get:
operationId: getSurveyV3
summary: Retrieve a survey
description: |
Returns the public v3 survey management resource for one survey. By default, translatable
fields are returned as canonical multilingual maps keyed by real locale codes. Use `lang`
to filter those maps to one or more requested locale codes.
tags:
- V3 Surveys
parameters:
- in: path
name: surveyId
required: true
schema:
type: string
format: cuid2
description: Survey identifier.
- in: query
name: lang
required: false
style: form
explode: false
schema:
type: array
items:
type: string
examples:
- [de-DE]
- [de-DE, pt-PT]
description: |
Comma-separated locale code filter for translatable fields, for example `?lang=de-DE,pt-PT`.
The response shape stays stable: translatable fields are always maps keyed by locale code, never
strings. The parser is case-insensitive, accepts `_` or `-` separators, and normalizes to canonical
BCP 47 casing (`de_DE`, `DE-de` → `de-DE`). A language-only selector (`de`) resolves to the matching
configured survey language when exactly one exists; otherwise it returns `400`. Disabled-but-configured
languages are readable in the management API so unfinished translations can be completed. Aliases are
not accepted.
responses:
"200":
description: Survey retrieved successfully
headers:
X-Request-Id:
schema: { type: string }
description: Request correlation ID
Cache-Control:
schema: { type: string }
example: "private, no-store"
content:
application/json:
schema:
type: object
required: [data]
properties:
data:
$ref: "#/components/schemas/SurveyResource"
examples:
canonical:
summary: Canonical multilingual authoring resource
value:
data:
id: clseedsurveycsat000000
workspaceId: clseedworkspace000000000
createdAt: "2026-05-18T09:24:54.014Z"
updatedAt: "2026-05-18T09:24:54.014Z"
name: CSAT Survey
type: link
status: inProgress
metadata: {}
defaultLanguage: en-US
languages:
- code: en-US
default: true
enabled: true
- code: de-DE
default: false
enabled: false
welcomeCard:
enabled: false
blocks:
- id: e0tfwzqk63op37y14z95qq3k
name: Main Block
elements:
- id: nzte4cm8836hgjw63pesziht
type: rating
range: 5
scale: smiley
headline:
en-US: How satisfied are you with our product?
de-DE: Wie zufrieden sind Sie mit unserem Produkt?
required: true
endings: []
hiddenFields:
enabled: false
variables: []
filtered:
summary: Language-filtered projection with ?lang=de-DE
value:
data:
id: clseedsurveycsat000000
workspaceId: clseedworkspace000000000
createdAt: "2026-05-18T09:24:54.014Z"
updatedAt: "2026-05-18T09:24:54.014Z"
name: CSAT Survey
type: link
status: inProgress
metadata: {}
defaultLanguage: en-US
languages:
- code: en-US
default: true
enabled: true
- code: de-DE
default: false
enabled: false
welcomeCard:
enabled: false
blocks:
- id: e0tfwzqk63op37y14z95qq3k
name: Main Block
elements:
- id: nzte4cm8836hgjw63pesziht
type: rating
range: 5
scale: smiley
headline:
de-DE: Wie zufrieden sind Sie mit unserem Produkt?
required: true
endings: []
hiddenFields:
enabled: false
variables: []
"400":
description: Invalid survey id, unsupported query parameter, unknown language, or unsupported legacy survey shape
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"401":
description: Not authenticated (no valid session or API key)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"403":
description: Forbidden — no access, or survey does not exist (404 not used; avoids existence leak)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"429":
description: Rate limit exceeded
headers:
Retry-After:
schema: { type: integer }
description: Seconds until the current rate-limit window resets
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"500":
description: Internal Server Error
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
security:
- sessionAuth: []
- apiKeyAuth: []
delete:
operationId: deleteSurveyV3
summary: Delete a survey
@@ -213,7 +380,7 @@ paths:
format: cuid2
description: Survey identifier.
responses:
"200":
"204":
description: Survey deleted successfully
headers:
X-Request-Id:
@@ -222,10 +389,6 @@ paths:
Cache-Control:
schema: { type: string }
example: "private, no-store"
content:
application/json:
schema:
$ref: "#/components/schemas/SurveyDeleteResponse"
"400":
description: Bad Request
content:
@@ -304,15 +467,694 @@ components:
properties:
enabled: { type: boolean }
isEncrypted: { type: boolean }
SurveyDeleteResponse:
TranslatableText:
allOf:
- $ref: "#/components/schemas/TranslatableTextMap"
description: |
Survey authoring text. `GET /api/v3/surveys/{surveyId}` always returns locale maps keyed by
real locale codes such as `en-US` and `de-DE`. Use `?lang=` to filter which locale keys are included.
The internal storage key `default` is never exposed by v3.
examples:
- en-US: What should we improve?
de-DE: Was sollten wir verbessern?
TranslatableTextMap:
type: object
required: [data]
description: Canonical multilingual text map keyed by real locale codes.
propertyNames:
type: string
description: BCP 47 locale code, for example `en-US`, `de-DE`, or `pt-BR`.
additionalProperties:
type: string
SurveyLanguage:
type: object
description: |
Language configured for this survey. Aliases/display names are intentionally not exposed in v3.
Disabled languages can still be read by the management API so unfinished translations can be completed.
required: [code, default, enabled]
properties:
data:
code:
type: string
description: Canonical locale code.
example: en-US
default:
type: boolean
description: Whether this is the default authoring language.
enabled:
type: boolean
description: Whether this language is enabled for respondent-facing delivery.
SurveyWelcomeCard:
type: object
description: Optional card shown before the first survey block.
required: [enabled]
properties:
enabled:
type: boolean
headline:
$ref: "#/components/schemas/TranslatableText"
subheader:
$ref: "#/components/schemas/TranslatableText"
buttonLabel:
$ref: "#/components/schemas/TranslatableText"
fileUrl:
type: string
videoUrl:
type: string
timeToFinish:
type: boolean
showResponseCount:
type: boolean
additionalProperties: true
SurveyHiddenFields:
type: object
description: |
Hidden fields, sometimes called embedded data in other survey products. Field ids are stable
public identifiers and may be referenced by logic, recall, quotas, integrations, and response data.
Use only letters, numbers, underscores, and hyphens; avoid spaces and reserved ids.
required: [enabled]
properties:
enabled:
type: boolean
fieldIds:
type: array
items:
type: string
pattern: "^[a-zA-Z0-9_-]+$"
uniqueItems: true
additionalProperties: false
SurveyVariable:
oneOf:
- $ref: "#/components/schemas/SurveyNumberVariable"
- $ref: "#/components/schemas/SurveyTextVariable"
description: |
Survey variable. Variable ids are stable references used by logic and calculation actions.
Variable names are human-readable labels and must be unique within the survey.
SurveyNumberVariable:
type: object
description: |
Number variable. Used by `calculate` logic actions with numeric operators such as `add`,
`subtract`, `multiply`, `divide`, or `assign`.
required: [id, name, type, value]
properties:
id:
type: string
format: cuid2
description: Stable variable id referenced from logic.
name:
type: string
pattern: "^[a-z0-9_]+$"
description: Unique variable name. Lowercase letters, numbers, and underscores only.
type:
type: string
enum: [number]
value:
type: number
description: Default numeric value.
additionalProperties: false
SurveyTextVariable:
type: object
description: |
Text variable. Used by `calculate` logic actions with text operators such as `assign` or `concat`.
required: [id, name, type, value]
properties:
id:
type: string
format: cuid2
description: Stable variable id referenced from logic.
name:
type: string
pattern: "^[a-z0-9_]+$"
description: Unique variable name. Lowercase letters, numbers, and underscores only.
type:
type: string
enum: [text]
value:
type: string
description: Default text value.
additionalProperties: false
SurveyEnding:
type: object
description: Ending reached after the last block or a jump action.
required: [id, type]
properties:
id:
type: string
format: cuid2
description: Stable ending id. `jumpToBlock.target` may point to this id.
type:
type: string
enum: [endScreen, redirectToUrl]
headline:
$ref: "#/components/schemas/TranslatableText"
subheader:
$ref: "#/components/schemas/TranslatableText"
buttonLabel:
$ref: "#/components/schemas/TranslatableText"
buttonLink:
type: string
imageUrl:
type: string
videoUrl:
type: string
url:
type: string
description: Redirect URL for `redirectToUrl` endings.
label:
type: string
description: Optional internal label for redirect endings.
additionalProperties: true
SurveyBlock:
type: object
description: |
Block-based survey section. Block ids are stable public identifiers. Logic and fallbacks can
jump to block ids or ending ids, so clients and agents should preserve ids unless intentionally
creating/deleting a block.
required: [id, name, elements]
properties:
id:
type: string
format: cuid2
description: Stable block id.
name:
type: string
minLength: 1
elements:
type: array
minItems: 1
items:
$ref: "#/components/schemas/SurveyElement"
logic:
type: array
items:
$ref: "#/components/schemas/SurveyBlockLogic"
logicFallback:
type: string
format: cuid2
description: Block or ending id used when no logic condition matches.
buttonLabel:
$ref: "#/components/schemas/TranslatableText"
backButtonLabel:
$ref: "#/components/schemas/TranslatableText"
additionalProperties: true
SurveyElement:
type: object
description: |
Survey element/question inside a block. Element ids are stable public identifiers used by
logic, recall strings, response data, quotas, integrations, and analysis. The schema lists
the fields used by all current element types; type-specific fields are present only when relevant.
required: [id, type, headline, required]
properties:
id:
type: string
pattern: "^[a-zA-Z0-9_-]+$"
description: Stable element id. Avoid spaces and reserved ids.
type:
type: string
enum:
- openText
- multipleChoiceSingle
- multipleChoiceMulti
- nps
- rating
- csat
- ces
- consent
- pictureSelection
- cta
- date
- fileUpload
- cal
- matrix
- address
- ranking
- contactInfo
headline:
$ref: "#/components/schemas/TranslatableText"
subheader:
$ref: "#/components/schemas/TranslatableText"
required:
type: boolean
imageUrl:
type: string
videoUrl:
type: string
isDraft:
type: boolean
description: Draft marker used by the editor and future update rules.
placeholder:
$ref: "#/components/schemas/TranslatableText"
longAnswer:
type: boolean
description: "`openText` only."
inputType:
type: string
enum: [text, email, url, number, phone]
description: "`openText` only."
charLimit:
type: object
required: [id]
description: "`openText` character limit configuration."
properties:
id: { type: string }
enabled:
type: boolean
min:
type: number
max:
type: number
additionalProperties: false
choices:
type: array
description: Choice list for multiple choice, ranking, and picture selection elements.
items:
oneOf:
- $ref: "#/components/schemas/SurveyChoice"
- $ref: "#/components/schemas/SurveyPictureChoice"
shuffleOption:
type: string
enum: [none, all, exceptLast, reverseOrderOccasionally, reverseOrderExceptLast]
displayType:
type: string
enum: [list, dropdown]
description: Multiple choice display style.
otherOptionPlaceholder:
$ref: "#/components/schemas/TranslatableText"
lowerLabel:
$ref: "#/components/schemas/TranslatableText"
upperLabel:
$ref: "#/components/schemas/TranslatableText"
isColorCodingEnabled:
type: boolean
scale:
type: string
enum: [number, smiley, star]
description: Rating, CSAT, CES, or NPS scale display.
range:
type: integer
enum: [3, 4, 5, 6, 7, 10]
description: Rating range. CSAT is always 5; CES is 5 or 7.
label:
$ref: "#/components/schemas/TranslatableText"
description: Consent checkbox label.
allowMulti:
type: boolean
description: "`pictureSelection` only."
buttonExternal:
type: boolean
description: "`cta` only."
buttonUrl:
type: string
description: "`cta` only."
ctaButtonLabel:
$ref: "#/components/schemas/TranslatableText"
html:
$ref: "#/components/schemas/TranslatableText"
description: "`date` helper copy."
format:
type: string
enum: [M-d-y, d-M-y, y-M-d]
description: "`date` only."
allowMultipleFiles:
type: boolean
description: "`fileUpload` only."
maxSizeInMB:
type: number
description: "`fileUpload` only."
allowedFileExtensions:
type: array
items:
type: string
description: "`fileUpload` only."
calUserName:
type: string
description: "`cal` only."
calHost:
type: string
description: "`cal` only."
rows:
type: array
description: Matrix rows.
items:
$ref: "#/components/schemas/SurveyChoice"
columns:
type: array
description: Matrix columns.
items:
$ref: "#/components/schemas/SurveyChoice"
addressLine1:
$ref: "#/components/schemas/SurveyToggleInputConfig"
addressLine2:
$ref: "#/components/schemas/SurveyToggleInputConfig"
city:
$ref: "#/components/schemas/SurveyToggleInputConfig"
state:
$ref: "#/components/schemas/SurveyToggleInputConfig"
zip:
$ref: "#/components/schemas/SurveyToggleInputConfig"
country:
$ref: "#/components/schemas/SurveyToggleInputConfig"
firstName:
$ref: "#/components/schemas/SurveyToggleInputConfig"
lastName:
$ref: "#/components/schemas/SurveyToggleInputConfig"
email:
$ref: "#/components/schemas/SurveyToggleInputConfig"
phone:
$ref: "#/components/schemas/SurveyToggleInputConfig"
company:
$ref: "#/components/schemas/SurveyToggleInputConfig"
validation:
$ref: "#/components/schemas/SurveyValidation"
additionalProperties: true
SurveyChoice:
type: object
required: [id, label]
properties:
id:
type: string
description: Stable choice id.
label:
$ref: "#/components/schemas/TranslatableText"
additionalProperties: false
SurveyPictureChoice:
type: object
required: [id, imageUrl]
properties:
id:
type: string
description: Stable picture choice id.
imageUrl:
type: string
additionalProperties: false
SurveyToggleInputConfig:
type: object
description: Field config for address and contact info elements.
required: [show, required, placeholder]
properties:
show:
type: boolean
required:
type: boolean
placeholder:
$ref: "#/components/schemas/TranslatableText"
additionalProperties: false
SurveyValidation:
type: object
description: Optional element-level validation rules.
required: [rules]
properties:
logic:
type: string
enum: [and, or]
default: and
rules:
type: array
items:
$ref: "#/components/schemas/SurveyValidationRule"
additionalProperties: false
SurveyValidationRule:
type: object
required: [id, type, params]
properties:
id:
type: string
type:
type: string
enum:
- minLength
- maxLength
- pattern
- email
- url
- phone
- equals
- doesNotEqual
- contains
- doesNotContain
- minValue
- maxValue
- isGreaterThan
- isLessThan
- minSelections
- maxSelections
- minRanked
- rankAll
- minRowsAnswered
- answerAllRows
- isLaterThan
- isEarlierThan
- isBetween
- isNotBetween
- fileExtensionIs
- fileExtensionIsNot
params:
type: object
additionalProperties: true
field:
type: string
enum:
[
addressLine1,
addressLine2,
city,
state,
zip,
country,
firstName,
lastName,
email,
phone,
company,
]
additionalProperties: false
SurveyBlockLogic:
type: object
description: Conditional logic rule evaluated at block level.
required: [id, conditions, actions]
properties:
id:
type: string
format: cuid2
conditions:
$ref: "#/components/schemas/SurveyConditionGroup"
actions:
type: array
items:
$ref: "#/components/schemas/SurveyLogicAction"
additionalProperties: false
SurveyConditionGroup:
type: object
required: [id, connector, conditions]
properties:
id:
type: string
format: cuid2
connector:
type: string
enum: [and, or]
conditions:
type: array
items:
oneOf:
- $ref: "#/components/schemas/SurveyCondition"
- $ref: "#/components/schemas/SurveyConditionGroup"
additionalProperties: false
SurveyCondition:
type: object
description: |
Single condition. Operators such as `isSubmitted`, `isSkipped`, `isClicked`, `isAccepted`,
`isBooked`, `isSet`, and `isEmpty` do not use `rightOperand`; comparison operators do.
required: [id, leftOperand, operator]
properties:
id:
type: string
format: cuid2
leftOperand:
$ref: "#/components/schemas/SurveyDynamicReference"
operator:
type: string
enum:
- equals
- doesNotEqual
- contains
- doesNotContain
- startsWith
- doesNotStartWith
- endsWith
- doesNotEndWith
- isSubmitted
- isSkipped
- isGreaterThan
- isLessThan
- isGreaterThanOrEqual
- isLessThanOrEqual
- equalsOneOf
- includesAllOf
- includesOneOf
- doesNotIncludeOneOf
- doesNotIncludeAllOf
- isClicked
- isNotClicked
- isAccepted
- isBefore
- isAfter
- isBooked
- isPartiallySubmitted
- isCompletelySubmitted
- isSet
- isNotSet
- isEmpty
- isNotEmpty
- isAnyOf
rightOperand:
$ref: "#/components/schemas/SurveyLogicOperand"
additionalProperties: false
SurveyLogicOperand:
oneOf:
- type: object
required: [type, value]
properties:
type:
type: string
enum: [static]
value:
oneOf:
- type: string
- type: number
- type: array
items:
type: string
additionalProperties: false
- $ref: "#/components/schemas/SurveyDynamicReference"
SurveyDynamicReference:
type: object
description: Dynamic reference to another value in the survey document.
required: [type, value]
properties:
type:
type: string
enum: [element, variable, hiddenField]
value:
type: string
description: Element id, variable id, or hidden field id depending on `type`.
meta:
type: object
additionalProperties:
type: string
additionalProperties: false
SurveyLogicAction:
oneOf:
- $ref: "#/components/schemas/SurveyCalculateAction"
- $ref: "#/components/schemas/SurveyRequireAnswerAction"
- $ref: "#/components/schemas/SurveyJumpToBlockAction"
description: |
Logic action. Keep referenced ids stable: `calculate.variableId` points to a variable id,
`requireAnswer.target` points to an element id, and `jumpToBlock.target` points to a block id
or ending id.
SurveyCalculateAction:
type: object
description: Updates a survey variable when the logic rule matches.
required: [id, objective, variableId, operator, value]
properties:
id:
type: string
format: cuid2
objective:
type: string
enum: [calculate]
variableId:
type: string
format: cuid2
description: Variable id for `calculate`.
operator:
type: string
enum: [assign, concat, add, subtract, multiply, divide]
value:
$ref: "#/components/schemas/SurveyLogicOperand"
additionalProperties: false
SurveyRequireAnswerAction:
type: object
description: Requires an element/question to be answered before continuing.
required: [id, objective, target]
properties:
id:
type: string
format: cuid2
objective:
type: string
enum: [requireAnswer]
target:
type: string
description: Target element id.
additionalProperties: false
SurveyJumpToBlockAction:
type: object
description: Jumps to another block or ending when the logic rule matches.
required: [id, objective, target]
properties:
id:
type: string
format: cuid2
objective:
type: string
enum: [jumpToBlock]
target:
type: string
format: cuid2
description: Target block id or ending id.
additionalProperties: false
SurveyResource:
type: object
required:
- id
- workspaceId
- createdAt
- updatedAt
- name
- type
- status
- metadata
- defaultLanguage
- languages
- welcomeCard
- blocks
- endings
- hiddenFields
- variables
properties:
id: { type: string }
workspaceId: { type: string }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
name: { type: string }
type: { type: string, enum: [link, app, website, web] }
status:
type: string
enum: [draft, inProgress, paused, completed]
metadata:
type: object
nullable: true
additionalProperties: true
defaultLanguage:
type: string
description: Real locale code for the survey default language. The internal `default` translation key is never exposed.
languages:
type: array
items:
$ref: "#/components/schemas/SurveyLanguage"
welcomeCard:
$ref: "#/components/schemas/SurveyWelcomeCard"
blocks:
type: array
items:
$ref: "#/components/schemas/SurveyBlock"
endings:
type: array
items:
$ref: "#/components/schemas/SurveyEnding"
hiddenFields:
$ref: "#/components/schemas/SurveyHiddenFields"
variables:
type: array
items:
$ref: "#/components/schemas/SurveyVariable"
Problem:
type: object
description: RFC 9457 Problem Details for HTTP APIs (`application/problem+json`). Responses typically include a machine-readable `code` field alongside `title`, `status`, `detail`, and `requestId`.
+234 -398
View File
File diff suppressed because it is too large Load Diff