Compare commits

..

29 Commits

Author SHA1 Message Date
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
111 changed files with 3369 additions and 3632 deletions
@@ -284,6 +284,10 @@ runs:
database_url=${{ env.DUMMY_DATABASE_URL }}
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
redis_url=${{ env.DUMMY_REDIS_URL }}
hub_api_url=${{ env.DUMMY_HUB_API_URL }}
hub_api_key=${{ env.DUMMY_HUB_API_KEY }}
cubejs_api_url=${{ env.DUMMY_CUBEJS_API_URL }}
cubejs_api_secret=${{ env.DUMMY_CUBEJS_API_SECRET }}
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
posthog_key=${{ env.POSTHOG_KEY }}
env:
@@ -291,6 +295,10 @@ runs:
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ env.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ env.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ env.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ env.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
+4
View File
@@ -91,5 +91,9 @@ jobs:
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
@@ -73,6 +73,10 @@ jobs:
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
redis_url=redis://localhost:6379
hub_api_url=http://localhost:4000
hub_api_key=build-time-placeholder
cubejs_api_url=http://localhost:4000
cubejs_api_secret=build-time-placeholder
- name: Verify and Initialize PostgreSQL
run: |
@@ -47,4 +47,8 @@ jobs:
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
@@ -105,4 +105,8 @@ jobs:
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+4
View File
@@ -66,6 +66,10 @@ RUN pnpm build --filter=@formbricks/database
RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=encryption_key \
--mount=type=secret,id=redis_url \
--mount=type=secret,id=hub_api_url \
--mount=type=secret,id=hub_api_key \
--mount=type=secret,id=cubejs_api_url \
--mount=type=secret,id=cubejs_api_secret \
--mount=type=secret,id=sentry_auth_token \
--mount=type=secret,id=posthog_key \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
@@ -23,13 +23,9 @@ import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
import { getOrganizationsByUserId } from "./lib/organization";
import { getWorkspacesByUserId } from "./lib/workspace";
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.extend({
feedbackDirectoryId: ZId.optional(),
});
const ZCreateWorkspaceAction = z.object({
organizationId: ZId,
data: ZCreateWorkspaceInput,
data: ZWorkspaceUpdateInput,
});
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
@@ -44,7 +40,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
access: [
{
data: parsedInput.data,
schema: ZCreateWorkspaceInput,
schema: ZWorkspaceUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
@@ -1 +1 @@
export { UnifyFeedbackRecordsPage as default } from "@/modules/ee/unify-feedback/page";
export { default } from "@/modules/ee/unify-feedback/page";
-20
View File
@@ -123,7 +123,6 @@ describe("authenticateRequest", () => {
workspaceName: "Workspace 1",
},
],
feedbackDirectoryPermissions: [],
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: "all",
@@ -162,7 +161,6 @@ describe("authenticateRequest", () => {
lastUsedAt: null,
label: "Test API Key",
apiKeyWorkspaces: [],
apiKeyFeedbackDirectories: [],
};
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
@@ -171,7 +169,6 @@ describe("authenticateRequest", () => {
expect(result).toEqual({
type: "apiKey",
workspacePermissions: [],
feedbackDirectoryPermissions: [],
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: "all",
@@ -192,16 +189,6 @@ describe("authenticateRequest", () => {
lastUsedAt: null,
label: "Test API Key",
apiKeyWorkspaces: [],
apiKeyFeedbackDirectories: [
{
feedbackDirectoryId: "clxx1234567890123456789012",
permission: "write" as const,
feedbackDirectory: {
id: "clxx1234567890123456789012",
name: "Directory 1",
},
},
],
} as any);
const result = await authenticateRequest(request);
@@ -209,13 +196,6 @@ describe("authenticateRequest", () => {
expect(result).toEqual({
type: "apiKey",
workspacePermissions: [],
feedbackDirectoryPermissions: [
{
feedbackDirectoryId: "clxx1234567890123456789012",
feedbackDirectoryName: "Directory 1",
permission: "write",
},
],
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: "all",
@@ -221,7 +221,6 @@ describe("withV3ApiWrapper", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: false } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
const handler = vi.fn(async ({ authentication }) => {
@@ -260,7 +259,6 @@ describe("withV3ApiWrapper", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
const wrapped = withV3ApiWrapper({
@@ -98,7 +98,6 @@ function createMockRequest({ method = "GET", url = "https://api.test/endpoint",
const mockApiAuthentication = {
type: "apiKey" as const,
workspacePermissions: [],
feedbackDirectoryPermissions: [],
apiKeyId: "api-key-1",
organizationId: "org-1",
organizationAccess: "all" as const,
+19 -29
View File
@@ -1784,10 +1784,7 @@ checksums:
workspace/api_keys/api_key_updated: 0e03754eb33742b4ee8d5fdad64c9b3f
workspace/api_keys/delete_api_key_confirmation: b2f0342d4e55f0cb244fe121eeeb10a3
workspace/api_keys/duplicate_access: 7ac7ac5ba755ce94e6fc81afa5a21997
workspace/api_keys/duplicate_directory_access: 4230950c0bf6ebf23410b04627fb7bd3
workspace/api_keys/feedback_directory_access: 6de029dfe5192496d06a7bbf0f52effd
workspace/api_keys/no_api_keys_yet: 58593ed9f7e507dcd7ca7fe069add599
workspace/api_keys/no_directory_permissions_found: 8296e96142af0f8b98ff62535f42fc5c
workspace/api_keys/no_workspace_permissions_found: 1d719624828a9d3e433cdf6b387549f3
workspace/api_keys/organization_access: 96a92fa907b15e0c0e47e33cac15be88
workspace/api_keys/organization_access_description: 773dfeaf6ffbf34dd9a0a3d656a6d83c
@@ -1795,7 +1792,6 @@ checksums:
workspace/api_keys/secret: f041e5eb96121c8b4f2b8af7e0f83a9b
workspace/api_keys/unable_to_copy_api_key: 148506832e31d033fa3569ce292d2120
workspace/api_keys/unable_to_delete_api_key: 1fd76d9a22c5f5f8c241c4891fca8295
workspace/api_keys/unknown_directory: ed07f55f5dba1f451a45f2cf6e01c9a9
workspace/api_keys/unknown_workspace: 4b0df2d07ebc9ab084158b1b9525ae5e
workspace/api_keys/workspace_access: b38cb73197ef5f5fa6653b88c68aa0bd
workspace/app-connection/app_connection: 778d2305e1a9c8efe91c2c7b4af37ae4
@@ -2461,6 +2457,9 @@ checksums:
workspace/settings/feedback_directories/error_directory_name_required: 0f42d7292979006a1069063ab213b8e3
workspace/settings/feedback_directories/error_directory_workspaces_invalid_org: 477b5c1a466c4194668544ffd42ec9bf
workspace/settings/feedback_directories/error_workspace_already_assigned: 6f851ad28a4e91e48fe13da917ea1ae0
workspace/settings/feedback_directories/grant_access_confirm: 0b040e675cf418d8051d7ad92096ccdd
workspace/settings/feedback_directories/grant_workspace_access_title: e959a57192546b1d5ea7062b0ace6aec
workspace/settings/feedback_directories/grant_workspace_access_warning: 09470da541a8b5b221d49aed69bd6739
workspace/settings/feedback_directories/nav_label: cf9a57b3cbac0f04b98e06fb693e986e
workspace/settings/feedback_directories/no_access: 707627df25fbaa28f18aa0f0d03dcb81
workspace/settings/feedback_directories/no_connectors: b1becb4fe4e2ba7c5d277db149f092ff
@@ -2474,6 +2473,8 @@ checksums:
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/workspaces_already_linked: ef6248289707611a44950c3406aec0ec
workspace/settings/feedback_directories/workspaces_being_added: e01628710aff05c5172f2f43aab1f6fb
workspace/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
workspace/settings/general/ai_data_analysis_enabled_description: 46d4f0bdf4ebf89e78f79cc961a2de83
workspace/settings/general/ai_enabled: 3cb1fce89c525e754448d5bd143eb6b5
@@ -3492,6 +3493,7 @@ checksums:
workspace/unify/api_ingestion_settings_description: a2597917ca1c724607d1d32178d670b3
workspace/unify/auto_generated: 6e83e8febd63275692c444cb8074531d
workspace/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
workspace/unify/click_load_sample_csv: 0ee0bf93f10f02863fc658b359706316
workspace/unify/click_to_upload: 74a7e7d79a88b6bbfd9f22084bffdb9b
workspace/unify/collected_at: b41902ddb4586ba4a4611d726b5014aa
workspace/unify/configure_import: 71d550661f7e9fe322b60e7e870aa2fd
@@ -3499,60 +3501,41 @@ checksums:
workspace/unify/connector_created_successfully: ea927316021fb2a41cc69ca3ec89d0aa
workspace/unify/connector_deleted_successfully: ea3c9842c5b8f75b02ecb9c80c74d780
workspace/unify/connector_duplicated_successfully: eb21ce42cdbef5fa38244206bf65fe4e
workspace/unify/connector_name: 7be634ca4ecf3e4d196a89eb79087ebf
workspace/unify/connector_name_hint: c4e2726c98fdb61eee89ea1e84193361
workspace/unify/connector_status_updated_successfully: 443fd63b27f15a81ff146375adac739f
workspace/unify/connector_updated_successfully: 11308c4a2881345209cefa06a3d90eab
workspace/unify/connectors: 4d6f256254573013a8714c2afe98dcc2
workspace/unify/create_mapping: cbe8c951e7819f574ca7d793920b2b60
workspace/unify/created_by: 6775c2fa7d495fea48f1ad816daea93b
workspace/unify/csv_advanced: 16e9b03eda6f6c1324cce54b96bbccd6
workspace/unify/csv_advanced_hint: 83206f839ef9fc3ff40c095c7f29cde0
workspace/unify/csv_at_least_one_row: 165bbc1853dde85c44eb5a587c52ce28
workspace/unify/csv_auto_mapped: 086acc319b5493b8f0abadbcfdb00c0f
workspace/unify/csv_auto_mapped_tooltip: be770b38dcce6f13cf1cb27e4a6b9344
workspace/unify/csv_auto_mapped_verify: d82b729fe42f41e06f3f410e50a7be07
workspace/unify/csv_basic_required: 65965f27a3888fb50a36657eb7ab3150
workspace/unify/csv_basic_required_hint: 4dec6b82d91aaed42fe21771b68d8d84
workspace/unify/csv_column_used_by: 80a98284fde45129f00c71f5dbb0601d
workspace/unify/csv_columns: 280c5ba0b19ae5fa6d42f4d05a1771cb
workspace/unify/csv_data_preview: 1b8670586f6ad3933c625711bad429d6
workspace/unify/csv_empty_column_headers: 6e9af154be54778cfca32296fbd23ecb
workspace/unify/csv_file_too_large: e94c7a7c26096aae9eddb2db30c5cfc1
workspace/unify/csv_files_only: 920612b537521b14c154f1ac9843e947
workspace/unify/csv_fixed_value_action: d5f0e61e17cb06416ddc17ac2109e261
workspace/unify/csv_fixed_value_label: fdadb8c4b9a99e245b1bb80e47ffef30
workspace/unify/csv_import: ef4060fef24c4fec064987b9d2a9fa4b
workspace/unify/csv_import_complete: e8b6306e62e10c128f6464176ba879dd
workspace/unify/csv_import_duplicate_warning: 56625e4613b93690e95661e5faaa4b27
workspace/unify/csv_inconsistent_columns: b308be183a41a581707eb5c4c0797ad6
workspace/unify/csv_max_records: 21ce7adae30821d40a553bcf37f39bbf
workspace/unify/csv_now_label: 1ae2b031b9443b56e1de70897677dbe6
workspace/unify/csv_pick_column_placeholder: e90ae509ccdedf368ff8054ae274a9bf
workspace/unify/csv_required_fields_missing: 7df71aeb1d6136b31221e6f5eadd8a7e
workspace/unify/csv_response_preview: 4b89c7162f8274fb06298ce3f9e5fcfc
workspace/unify/csv_rows_count: 5ef81805a6f576c41018938e256624da
workspace/unify/csv_sample_label: 92f4b17f95982b5f264c7f5a0bec74b1
workspace/unify/csv_source_context: 96e2f41e15b8272319817646e2fe8738
workspace/unify/csv_source_context_hint: eaf09edede7b8105712903992f8964ee
workspace/unify/csv_unmapped_columns: b94a8b5c3cf9df8859e5ac484a14969c
workspace/unify/csv_unmapped_columns_explainer: 58cdfc3c06c141f156288dc434f8e0ca
workspace/unify/custom_source_type: d931a8a74d3a5becd568e398107979da
workspace/unify/custom_source_type_placeholder: f139e3e5d70dbf426d7c6b5ab2b198cc
workspace/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
workspace/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
workspace/unify/discard_feedback_record_changes_description: 48ccde99858dcbeb4d679749d0f51941
workspace/unify/discard_feedback_record_changes_title: 52df2800f7b0e8a1d04c47113e019a3e
workspace/unify/dont_include: b9617c5f37c8b1fcf3173825cbd1683e
workspace/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
workspace/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353
workspace/unify/edit_csv_mapping: 4f3bad444664d58ffe8ace3dc9e200f9
workspace/unify/edit_source_connection: eb42476becc8de3de4ca9626828573f0
workspace/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3
workspace/unify/enter_value: 4f068bb59617975c1e546218373122cd
workspace/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8
workspace/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
workspace/unify/feedback_directory: 156fa9957e1ee5eadf1f44226a2365e4
workspace/unify/feedback_record_created_successfully: 0ff30472085f1313a5ad53837c83e7c1
workspace/unify/feedback_record_details: 823f3353db049a9d263ef31405054cda
workspace/unify/feedback_record_details_description: 0b6f908154161241ce6bdeb4a2acaecd
workspace/unify/feedback_record_fields: 88c0f13afeb88fe751f85e79b0f73064
workspace/unify/feedback_record_mcp: cdddbef2944489820fd7f376a49c2803
workspace/unify/feedback_record_updated_successfully: cb40ef4b924e21fa627ebe6809d1d826
workspace/unify/feedback_record_value_required: b54d4d86f82071a93dc979e8eb359cf0
@@ -3589,13 +3572,19 @@ checksums:
workspace/unify/metadata_value: 8d69be1f5a20d9473a33c35670dff216
workspace/unify/missing_feedback_source_title: 9ab1b8d54b4da72dd00ce03fe3b698b5
workspace/unify/no_feedback_directory_available: b9854a7e35eb20c157f994d23149f36c
workspace/unify/no_feedback_directory_linked_admin_description: 527a4782b743f3d3ad620e5702ff4768
workspace/unify/no_feedback_directory_linked_member_description: e799b17da03899cabd125c3cf672c10d
workspace/unify/no_feedback_directory_linked_title: b01a53d508aeeb1c8ad080ee07efcd04
workspace/unify/no_feedback_records: 16a905c40f6d47a5e8f93b3d8c6f6693
workspace/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
workspace/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
workspace/unify/optional: 396fb9a0472daf401c392bdc3e248943
workspace/unify/or_drag_and_drop: 6c7d6b05d39dcbfc710d35fcab25cb8c
workspace/unify/question_type_not_supported: 8d9f7554e3b509dfd5307d8d1fef08d7
workspace/unify/refresh_feedback_records: c111751e02a7dee57390ed7fb79cfcc6
workspace/unify/refreshing_feedback_records: 2a03b44510ebe19eea6473639e9a7222
workspace/unify/request_feedback_source: 51045caa2c81dee971d23a1841d19a7e
workspace/unify/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
workspace/unify/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
workspace/unify/search_feedback: db1e8dd05944bb928b96e3822aee3379
workspace/unify/select_a_survey_to_see_questions: 792eba3d2f6d210231a2266401111a20
@@ -3624,11 +3613,12 @@ checksums:
workspace/unify/set_value: b8a86f8da957ebd599ece4b1b1936a78
workspace/unify/setup_connection: cce7d9c488d737d04e70bed929a46f8a
workspace/unify/showing_count_loaded: f443aae08223b65fbd5521d6e69534a4
workspace/unify/showing_rows: e851514ed6c4d2e453d0098af2d773bd
workspace/unify/showing_rows: 83d3440314d1e6f2721e034369a3a131
workspace/unify/source: 45309626f464f4bda161ee783a4c8c80
workspace/unify/source_connect_csv_description: 2f9d1dd31668ac52578f16323157b746
workspace/unify/source_connect_feedback_record_mcp_description: a3f56e2a6e403f4021e83f1b1a466d95
workspace/unify/source_connect_formbricks_description: 77bda4e1d485d76770ba2221f1faf9ff
workspace/unify/source_fields: 1bae074990e64cbfd820a0b6462397be
workspace/unify/source_id: 134a9a7d473508c5623ac724a5ba4be9
workspace/unify/source_name: 157675beca12efcd8ec512c5256b1a61
workspace/unify/source_type: d1ff69af76c687eb189db72030717570
+7 -29
View File
@@ -28,7 +28,6 @@ import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-direc
import { listFeedbackRecords } from "@/modules/hub/service";
import type { FeedbackRecordListParams, FeedbackRecordListResponse } from "@/modules/hub/types";
import { importCsvData } from "./csv-import";
import { sanitizeCsvFieldMappings } from "./csv-mapping";
import { importHistoricalResponses } from "./import";
import {
TMappingsInput,
@@ -198,13 +197,7 @@ export const createConnectorWithMappingsAction = authenticatedActionClient
mappingsInput = await resolveFormbricksMappingsInput(formbricksMappings);
} else if (fieldMappings?.length) {
mappingsInput = {
type: "field",
mappings:
parsedInput.connectorInput.type === "csv"
? (sanitizeCsvFieldMappings(fieldMappings) ?? [])
: fieldMappings,
};
mappingsInput = { type: "field", mappings: fieldMappings };
}
return createConnectorWithMappings(
@@ -263,21 +256,7 @@ export const updateConnectorWithMappingsAction = authenticatedActionClient
mappingsInput = await resolveFormbricksMappingsInput(parsedInput.formbricksMappings);
} else if (parsedInput.fieldMappings && parsedInput.fieldMappings.length > 0) {
const connector = await prisma.connector.findUnique({
where: { id: parsedInput.connectorId, workspaceId: parsedInput.workspaceId },
select: { type: true },
});
if (!connector) {
throw new ResourceNotFoundError("Connector", parsedInput.connectorId);
}
mappingsInput = {
type: "field",
mappings:
connector.type === "csv"
? (sanitizeCsvFieldMappings(parsedInput.fieldMappings) ?? [])
: parsedInput.fieldMappings,
};
mappingsInput = { type: "field", mappings: parsedInput.fieldMappings };
}
return updateConnectorWithMappings(
@@ -339,14 +318,13 @@ export const duplicateConnectorAction = authenticatedActionClient
})),
};
} else if (source.fieldMappings.length > 0) {
const projected = source.fieldMappings.map((m) => ({
sourceFieldId: m.sourceFieldId,
targetFieldId: m.targetFieldId,
staticValue: m.staticValue ?? undefined,
}));
mappingsInput = {
type: "field",
mappings: source.type === "csv" ? (sanitizeCsvFieldMappings(projected) ?? []) : projected,
mappings: source.fieldMappings.map((m) => ({
sourceFieldId: m.sourceFieldId,
targetFieldId: m.targetFieldId,
staticValue: m.staticValue ?? undefined,
})),
};
}
@@ -1,34 +0,0 @@
import { describe, expect, test } from "vitest";
import { TConnectorFieldMappingCreateInput } from "@formbricks/types/connector";
import { sanitizeCsvFieldMappings } from "./csv-mapping";
describe("sanitizeCsvFieldMappings", () => {
test("drops user-controlled tenant_id and source_type mappings", () => {
const mappings: TConnectorFieldMappingCreateInput[] = [
{ sourceFieldId: "tenant", targetFieldId: "tenant_id" },
{ sourceFieldId: "type", targetFieldId: "source_type" },
{ sourceFieldId: "question", targetFieldId: "field_id" },
];
expect(sanitizeCsvFieldMappings(mappings)).toEqual([
{ sourceFieldId: "question", targetFieldId: "field_id" },
{ sourceFieldId: "", targetFieldId: "source_type", staticValue: "csv" },
]);
});
test("returns undefined for empty input", () => {
expect(sanitizeCsvFieldMappings(undefined)).toBeUndefined();
expect(sanitizeCsvFieldMappings([])).toBeUndefined();
});
test("returns only the static csv source_type mapping when input is all-protected", () => {
const mappings: TConnectorFieldMappingCreateInput[] = [
{ sourceFieldId: "tenant", targetFieldId: "tenant_id" },
{ sourceFieldId: "type", targetFieldId: "source_type" },
];
expect(sanitizeCsvFieldMappings(mappings)).toEqual([
{ sourceFieldId: "", targetFieldId: "source_type", staticValue: "csv" },
]);
});
});
-17
View File
@@ -1,17 +0,0 @@
import { TConnectorFieldMappingCreateInput } from "@formbricks/types/connector";
import {
CSV_HIDDEN_STATIC_MAPPINGS,
CSV_PROTECTED_TARGET_IDS,
} from "@/modules/ee/unify-feedback/sources/types";
export const sanitizeCsvFieldMappings = (
fieldMappings: TConnectorFieldMappingCreateInput[] | undefined
): TConnectorFieldMappingCreateInput[] | undefined => {
if (!fieldMappings?.length) return undefined;
const userMappings = fieldMappings.filter((mapping) =>
CSV_PROTECTED_TARGET_IDS.every((id) => mapping.targetFieldId !== id)
);
return [...userMappings, ...(CSV_HIDDEN_STATIC_MAPPINGS as TConnectorFieldMappingCreateInput[])];
};
+3 -134
View File
@@ -38,7 +38,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
const result = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
expect(result).not.toBeNull();
expect(result!.source_type).toBe("csv");
expect(result!.source_type).toBe("survey");
expect(result!.field_id).toBe("q1");
expect(result!.field_type).toBe("text");
expect(result!.value_text).toBe("Great product!");
@@ -188,7 +188,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
vi.useRealTimers();
});
test("ignores source_type mappings and uses csv", () => {
test("uses static value over source field", () => {
const mappings: TConnectorFieldMapping[] = [
makeMapping("question", "field_id"),
makeMapping("type_column", "source_type", "always_survey"),
@@ -198,7 +198,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
const row = { question: "q1", type_column: "review", timestamp: "2026-01-15" };
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
expect(result!.source_type).toBe("csv");
expect(result!.source_type).toBe("always_survey");
});
test("skips empty string values", () => {
@@ -283,134 +283,3 @@ describe("transformCsvRowsToFeedbackRecords", () => {
expect(skipped).toBe(0);
});
});
describe("response_value routing", () => {
const responseMappings = (fieldType: string): TConnectorFieldMapping[] => [
makeMapping("answer", "response_value"),
makeMapping("question", "field_id"),
makeMapping("", "source_type", "csv"),
makeMapping("", "field_type", fieldType),
makeMapping("timestamp", "collected_at"),
];
test("text routes to value_text", () => {
const result = transformCsvRowToFeedbackRecord(
{ answer: "great service", question: "q1", timestamp: "2026-01-15" },
responseMappings("text"),
TENANT
);
expect(result!.value_text).toBe("great service");
expect(result!.value_number).toBeUndefined();
});
test("categorical routes to value_text", () => {
const result = transformCsvRowToFeedbackRecord(
{ answer: "option_a", question: "q1", timestamp: "2026-01-15" },
responseMappings("categorical"),
TENANT
);
expect(result!.value_text).toBe("option_a");
});
test.each(["number", "nps", "csat", "ces", "rating"])("%s routes to value_number", (fieldType) => {
const result = transformCsvRowToFeedbackRecord(
{ answer: "9", question: "q1", timestamp: "2026-01-15" },
responseMappings(fieldType),
TENANT
);
expect(result!.value_number).toBe(9);
expect(result!.value_text).toBeUndefined();
});
test("boolean routes to value_boolean", () => {
const result = transformCsvRowToFeedbackRecord(
{ answer: "true", question: "q1", timestamp: "2026-01-15" },
responseMappings("boolean"),
TENANT
);
expect(result!.value_boolean).toBe(true);
});
test("date routes to value_date", () => {
const result = transformCsvRowToFeedbackRecord(
{ answer: "2026-03-01", question: "q1", timestamp: "2026-01-15" },
responseMappings("date"),
TENANT
);
expect(result!.value_date).toBe("2026-03-01T00:00:00.000Z");
});
test("invalid field_type causes the row to be skipped", () => {
const mappings: TConnectorFieldMapping[] = [
makeMapping("answer", "response_value"),
makeMapping("question", "field_id"),
makeMapping("", "source_type", "csv"),
makeMapping("", "field_type", "not_a_real_enum"),
makeMapping("timestamp", "collected_at"),
];
const result = transformCsvRowToFeedbackRecord(
{ answer: "x", question: "q1", timestamp: "2026-01-15" },
mappings,
TENANT
);
expect(result).toBeNull();
});
test("missing field_type causes the row to be skipped", () => {
const mappings: TConnectorFieldMapping[] = [
makeMapping("answer", "response_value"),
makeMapping("question", "field_id"),
makeMapping("", "source_type", "csv"),
makeMapping("timestamp", "collected_at"),
];
const result = transformCsvRowToFeedbackRecord(
{ answer: "x", question: "q1", timestamp: "2026-01-15" },
mappings,
TENANT
);
expect(result).toBeNull();
});
});
describe("tenant_id defense-in-depth", () => {
test("ignores a user-supplied tenant_id mapping and uses the connector value", () => {
const mappings: TConnectorFieldMapping[] = [
makeMapping("malicious", "tenant_id"),
makeMapping("feedback_text", "value_text"),
makeMapping("question", "field_id"),
makeMapping("", "source_type", "csv"),
makeMapping("", "field_type", "text"),
makeMapping("timestamp", "collected_at"),
];
const row = {
malicious: "stolen-tenant",
feedback_text: "x",
question: "q1",
timestamp: "2026-01-15",
};
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
expect(result!.tenant_id).toBe(TENANT);
expect(result!.tenant_id).not.toBe("stolen-tenant");
});
test("ignores a static tenant_id mapping", () => {
const mappings: TConnectorFieldMapping[] = [
makeMapping("", "tenant_id", "stolen-tenant"),
makeMapping("feedback_text", "value_text"),
makeMapping("question", "field_id"),
makeMapping("", "source_type", "csv"),
makeMapping("", "field_type", "text"),
makeMapping("timestamp", "collected_at"),
];
const result = transformCsvRowToFeedbackRecord(
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15" },
mappings,
TENANT
);
expect(result!.tenant_id).toBe(TENANT);
});
});
+16 -58
View File
@@ -1,11 +1,5 @@
import { randomUUID } from "crypto";
import {
TConnectorFieldMapping,
THubFieldType,
THubTargetField,
ZHubFieldType,
} from "@formbricks/types/connector";
import { routeResponseValueTarget } from "@/modules/ee/unify-feedback/sources/utils";
import { TConnectorFieldMapping, THubTargetField } from "@formbricks/types/connector";
import { FeedbackRecordCreateParams } from "@/modules/hub";
const NUMERIC_FIELDS = new Set<THubTargetField>(["value_number"]);
@@ -13,9 +7,6 @@ const BOOLEAN_FIELDS = new Set<THubTargetField>(["value_boolean"]);
const TIMESTAMP_FIELDS = new Set<THubTargetField>(["collected_at", "value_date"]);
const JSON_FIELDS = new Set<THubTargetField>(["metadata"]);
// ISO 8601 pattern: YYYY-MM-DDTHH:mm:ss.sssZ or YYYY-MM-DDTHH:mm:ssZ or YYYY-MM-DD
const ISO_8601_PATTERN = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?)?$/;
const coerceValue = (value: string, targetField: THubTargetField): string | number | boolean | undefined => {
const trimmed = value.trim();
if (trimmed === "") return undefined;
@@ -33,7 +24,6 @@ const coerceValue = (value: string, targetField: THubTargetField): string | numb
}
if (TIMESTAMP_FIELDS.has(targetField)) {
if (!ISO_8601_PATTERN.test(trimmed)) return undefined;
const date = new Date(trimmed);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
@@ -43,41 +33,28 @@ const coerceValue = (value: string, targetField: THubTargetField): string | numb
const resolveValue = (
row: Record<string, string>,
mapping: TConnectorFieldMapping,
effectiveTargetFieldId: THubTargetField
mapping: TConnectorFieldMapping
): string | number | boolean | undefined => {
if (mapping.staticValue) {
if (mapping.staticValue === "$now" && TIMESTAMP_FIELDS.has(effectiveTargetFieldId)) {
if (mapping.staticValue === "$now" && TIMESTAMP_FIELDS.has(mapping.targetFieldId)) {
return new Date().toISOString();
}
return coerceValue(mapping.staticValue, effectiveTargetFieldId);
return coerceValue(mapping.staticValue, mapping.targetFieldId);
}
const rawValue = row[mapping.sourceFieldId];
if (rawValue === undefined || rawValue === null) return undefined;
return coerceValue(rawValue, effectiveTargetFieldId);
};
const resolveFieldTypeForRow = (
row: Record<string, string>,
mappings: TConnectorFieldMapping[]
): THubFieldType | null => {
const mapping = mappings.find((m) => m.targetFieldId === "field_type");
if (!mapping) return null;
const raw = mapping.staticValue ?? row[mapping.sourceFieldId];
if (!raw) return null;
const parsed = ZHubFieldType.safeParse(raw.trim());
return parsed.success ? parsed.data : null;
return coerceValue(rawValue, mapping.targetFieldId);
};
/**
* Transform a single CSV row into a FeedbackRecord using field mappings.
*
* Returns null if field_id, field_type, or tenant_id are missing, or if a mapped submission_id
* resolves empty. Falls back to a random UUID for submission_id only when no mapping exists.
* Returns null if any of source_type, field_id, field_type, tenant_id are missing,
* or if submission_id is mapped but resolves empty for this row (would break
* idempotency on re-import). Falls back to a random UUID for submission_id only
* when no mapping for it exists.
*/
export const transformCsvRowToFeedbackRecord = (
row: Record<string, string>,
@@ -86,37 +63,18 @@ export const transformCsvRowToFeedbackRecord = (
): FeedbackRecordCreateParams | null => {
const record: Record<string, string | number | boolean | Record<string, unknown> | undefined> = {};
const safeMappings = mappings.filter(
(m) => m.targetFieldId !== "tenant_id" && m.targetFieldId !== "source_type"
);
record.source_type = "csv";
const fieldType = resolveFieldTypeForRow(row, safeMappings);
if (!fieldType) return null;
for (const mapping of safeMappings) {
let effectiveTargetFieldId: THubTargetField;
if (mapping.targetFieldId === "response_value") {
try {
effectiveTargetFieldId = routeResponseValueTarget(fieldType);
} catch {
return null;
}
} else {
effectiveTargetFieldId = mapping.targetFieldId;
}
const value = resolveValue(row, mapping, effectiveTargetFieldId);
for (const mapping of mappings) {
const value = resolveValue(row, mapping);
if (value === undefined) continue;
if (JSON_FIELDS.has(effectiveTargetFieldId)) {
if (JSON_FIELDS.has(mapping.targetFieldId)) {
try {
record[effectiveTargetFieldId] = typeof value === "string" ? JSON.parse(value) : value;
record[mapping.targetFieldId] = typeof value === "string" ? JSON.parse(value) : value;
} catch {
record[effectiveTargetFieldId] = { raw: value };
record[mapping.targetFieldId] = { raw: value };
}
} else {
record[effectiveTargetFieldId] = value;
record[mapping.targetFieldId] = value;
}
}
@@ -133,7 +91,7 @@ export const transformCsvRowToFeedbackRecord = (
}
if (!("submission_id" in record)) {
const submissionMapped = safeMappings.some((m) => m.targetFieldId === "submission_id");
const submissionMapped = mappings.some((m) => m.targetFieldId === "submission_id");
if (submissionMapped) {
return null;
}
+3 -3
View File
@@ -131,7 +131,7 @@ describe("transformResponseToFeedbackRecords", () => {
source_name: "Product Feedback",
value_text: "Great product!",
language: "en",
user_identifier: "user-42",
user_id: "user-42",
});
});
@@ -222,11 +222,11 @@ describe("transformResponseToFeedbackRecords", () => {
expect(result[0].language).toBeUndefined();
});
test("omits user_identifier when contact has no userId", () => {
test("omits user_id when contact has no userId", () => {
const response = { ...mockResponse, contact: null } as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].user_identifier).toBeUndefined();
expect(result[0].user_id).toBeUndefined();
});
test("transforms all mappings in a single call", () => {
+1 -1
View File
@@ -126,7 +126,7 @@ export function transformResponseToFeedbackRecords(
source_name: survey.name,
field_label: fieldLabel,
...(response.language && response.language !== "default" ? { language: response.language } : {}),
...(response.contact?.userId ? { user_identifier: response.contact.userId } : {}),
...(response.contact?.userId ? { user_id: response.contact.userId } : {}),
...valueFields,
};
+9 -16
View File
@@ -9,6 +9,7 @@ const setTestEnv = (overrides: Record<string, string | undefined> = {}) => {
DATABASE_URL: "https://example.com/db",
ENCRYPTION_KEY: "12345678901234567890123456789012",
HUB_API_URL: "https://hub.formbricks.local",
HUB_API_KEY: "test-hub-api-key",
CUBEJS_API_URL: "https://cube.formbricks.local",
CUBEJS_API_SECRET: "cube-secret",
...overrides,
@@ -128,44 +129,36 @@ describe("env", () => {
expect(env.CUBEJS_JWT_ISSUER).toBe("formbricks-web");
});
test("allows the Cube API secret to be omitted until analytics is used", async () => {
test("fails to load when the Cube API secret is missing", async () => {
setTestEnv({
CUBEJS_API_SECRET: undefined,
});
const { env } = await import("./env");
expect(env.CUBEJS_API_SECRET).toBeUndefined();
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("treats an empty Cube API secret from Docker Compose as omitted", async () => {
test("fails to load when the Cube API secret is empty", async () => {
setTestEnv({
CUBEJS_API_SECRET: "",
});
const { env } = await import("./env");
expect(env.CUBEJS_API_SECRET).toBeUndefined();
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("allows the Cube API URL to be omitted until analytics is used", async () => {
test("fails to load when the Cube API URL is missing", async () => {
setTestEnv({
CUBEJS_API_URL: undefined,
});
const { env } = await import("./env");
expect(env.CUBEJS_API_URL).toBeUndefined();
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("treats an empty Cube API URL as omitted", async () => {
test("fails to load when the Cube API URL is empty", async () => {
setTestEnv({
CUBEJS_API_URL: "",
});
const { env } = await import("./env");
expect(env.CUBEJS_API_URL).toBeUndefined();
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("fails to load when the Cube API URL is invalid", async () => {
+3 -4
View File
@@ -136,7 +136,6 @@ const ZSurveySchedulingLocalMinute = z.coerce.number().int().min(0).max(59);
const emptyStringToUndefined = (value: unknown) =>
typeof value === "string" && value.trim() === "" ? undefined : value;
const ZOptionalNonEmptyString = z.preprocess(emptyStringToUndefined, z.string().trim().min(1).optional());
const ZOptionalUrl = z.preprocess(emptyStringToUndefined, z.url().optional());
const parsedEnv = createEnv({
/*
@@ -190,14 +189,14 @@ const parsedEnv = createEnv({
AI_AZURE_API_KEY: z.string().optional(),
AI_AZURE_API_VERSION: z.string().optional(),
AI_AZURE_RESOURCE_NAME: z.string().optional(),
CUBEJS_API_SECRET: ZOptionalNonEmptyString,
CUBEJS_API_URL: ZOptionalUrl,
CUBEJS_API_SECRET: z.string().trim().min(1),
CUBEJS_API_URL: z.url(),
CUBEJS_JWT_AUDIENCE: ZOptionalNonEmptyString,
CUBEJS_JWT_ISSUER: ZOptionalNonEmptyString,
HTTP_PROXY: z.url().optional(),
HTTPS_PROXY: z.url().optional(),
HUB_API_URL: z.url(),
HUB_API_KEY: z.string().optional(),
HUB_API_KEY: z.string().trim().min(1),
IMPRINT_URL: z
.url()
.optional()
+21 -30
View File
@@ -1854,10 +1854,7 @@
"api_key_updated": "API-Schlüssel aktualisiert",
"delete_api_key_confirmation": "Alle Anwendungen, die diesen Schlüssel verwenden, können nicht mehr auf deine Formbricks-Daten zugreifen.",
"duplicate_access": "Doppelter Workspace-Zugriff ist nicht erlaubt",
"duplicate_directory_access": "Doppelter Zugriff auf Feedback-Verzeichnis nicht erlaubt",
"feedback_directory_access": "Feedback-Verzeichnis-Zugriff",
"no_api_keys_yet": "Du hast noch keine API-Schlüssel",
"no_directory_permissions_found": "Keine Berechtigungen für Feedback-Verzeichnisse gefunden",
"no_workspace_permissions_found": "Keine Workspace-Berechtigungen gefunden",
"organization_access": "Organisations-Zugriff",
"organization_access_description": "Wähle Lese- oder Schreibrechte für organisationsweite Ressourcen aus.",
@@ -1865,7 +1862,6 @@
"secret": "Geheimnis",
"unable_to_copy_api_key": "API-Schlüssel konnte nicht kopiert werden",
"unable_to_delete_api_key": "API-Schlüssel konnte nicht gelöscht werden",
"unknown_directory": "Unbekanntes Verzeichnis",
"unknown_workspace": "Unbekannter Arbeitsbereich",
"workspace_access": "Workspace-Zugriff"
},
@@ -2568,6 +2564,9 @@
"error_directory_name_required": "Verzeichnisname ist erforderlich.",
"error_directory_workspaces_invalid_org": "Einige der angegebenen Workspaces gehören nicht zu dieser Organisation.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"grant_access_confirm": "Workspace-Zugriff gewähren",
"grant_workspace_access_title": "Workspace-Zugriff bestätigen",
"grant_workspace_access_warning": "Alle aktuellen und zukünftigen Mitglieder der unten aufgeführten Workspaces erhalten Lesezugriff auf alle Feedback-Daten, die in \"{directoryName}\" geleitet werden einschließlich Daten, die von Connectors anderer verknüpfter Workspaces erfasst wurden. Diese Änderung kann nicht unbemerkt rückgängig gemacht werden.",
"nav_label": "Feedback-Verzeichnisse",
"no_access": "Du hast keine Berechtigung, Feedback-Verzeichnisse zu verwalten.",
"no_connectors": "Noch keine Connectoren mit diesem Verzeichnis verknüpft.",
@@ -2580,7 +2579,9 @@
"unarchive_workspace_conflict": "Dieses Verzeichnis kann nicht wiederhergestellt werden, weil ein oder mehrere zugewiesene Workspaces archiviert sind.",
"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_access": "Workspace-Zugriff",
"workspaces_already_linked": "Bereits verknüpfte Workspaces",
"workspaces_being_added": "Workspaces, denen Zugriff gewährt wird"
},
"general": {
"ai_data_analysis_enabled": "Datenanreicherung & -analyse (KI)",
@@ -3652,6 +3653,7 @@
"api_ingestion_settings_description": "Sende Feedback-Datensätze über die Management-API.",
"auto_generated": "Automatisch generiert",
"change_file": "Datei ändern",
"click_load_sample_csv": "Klick auf 'Beispiel-CSV laden', um Spalten zu sehen",
"click_to_upload": "Zum Hochladen klicken",
"collected_at": "Erfasst am",
"configure_import": "Import konfigurieren",
@@ -3659,60 +3661,41 @@
"connector_created_successfully": "Connector erfolgreich erstellt",
"connector_deleted_successfully": "Connector erfolgreich gelöscht",
"connector_duplicated_successfully": "Connector erfolgreich dupliziert",
"connector_name": "Connector-Name",
"connector_name_hint": "So wird dieser Connector in deinem Dashboard angezeigt. Wird automatisch aus dem hochgeladenen Dateinamen übernommen du kannst es jederzeit ändern.",
"connector_status_updated_successfully": "Connector-Status erfolgreich aktualisiert",
"connector_updated_successfully": "Connector erfolgreich aktualisiert",
"connectors": "Connectoren",
"create_mapping": "Zuordnung erstellen",
"created_by": "Erstellt von",
"csv_advanced": "Erweitert",
"csv_advanced_hint": "Weniger häufig verwendete Felder. Fülle sie aus, wenn relevant.",
"csv_at_least_one_row": "Die CSV-Datei muss mindestens eine Datenzeile enthalten.",
"csv_auto_mapped": "Automatisch zugeordnet",
"csv_auto_mapped_tooltip": "Wir haben dies von \"{column}\" zugeordnet, weil die Überschrift ähnlich aussah. Bitte bestätigen oder ändern.",
"csv_auto_mapped_verify": "Automatisch zugeordnet (bitte überprüfen)",
"csv_basic_required": "Basis (erforderlich)",
"csv_basic_required_hint": "Wähle eine CSV-Spalte oder setze einen festen Wert, der auf jede Zeile angewendet wird.",
"csv_column_used_by": "Zugeordnet zu: {target}",
"csv_columns": "CSV-Spalten",
"csv_data_preview": "Datenvorschau",
"csv_empty_column_headers": "Die CSV-Datei enthält leere Spaltenüberschriften. Alle Spalten müssen einen Namen haben.",
"csv_file_too_large": "Die CSV-Datei ist zu groß. Die maximale Größe beträgt 2 MB.",
"csv_files_only": "Nur CSV-Dateien",
"csv_fixed_value_action": "Festen Wert festlegen…",
"csv_fixed_value_label": "Fester Wert: {value}",
"csv_import": "CSV-Import",
"csv_import_complete": "CSV-Import abgeschlossen: {successes} erfolgreich, {failures} fehlgeschlagen, {skipped} übersprungen",
"csv_import_duplicate_warning": "Wenn Du die Daten zweimal importierst, entstehen doppelte Einträge.",
"csv_inconsistent_columns": "Zeile {row} hat inkonsistente Spalten. Alle Zeilen müssen die gleichen Überschriften haben.",
"csv_max_records": "Maximal {max} Einträge erlaubt.",
"csv_now_label": "Jetzt (Zeitpunkt des Imports verwenden)",
"csv_pick_column_placeholder": "Spalte wählen oder Wert festlegen…",
"csv_required_fields_missing": "Bitte ordne die erforderlichen Felder zu, bevor du speicherst: {fields}",
"csv_response_preview": "Beispiel: \"{sample}\" → gespeichert als {target}.",
"csv_rows_count": "{count, plural, one {# Zeile} other {# Zeilen}}",
"csv_sample_label": "CSV-Beispiel",
"csv_source_context": "Quellkontext",
"csv_source_context_hint": "Zeigt an, woher dieser Feedback-Stapel stammt.",
"csv_unmapped_columns": "Nicht zugeordnete Spalten ({count}): {columns}",
"csv_unmapped_columns_explainer": "Diese Spalten werden von keinem Feedback-Datensatz-Feld verwendet. Sie werden beim Import ignoriert.",
"custom_source_type": "Benutzerdefinierter Quelltyp",
"custom_source_type_placeholder": "Geben Sie den benutzerdefinierten Quelltyp ein",
"default_connector_name_csv": "CSV-Import",
"default_connector_name_formbricks": "Formbricks-Umfrage-Verbindung",
"discard_feedback_record_changes_description": "Ihre Änderungen gehen verloren, wenn Sie diese Schublade schließen.",
"discard_feedback_record_changes_title": "Nicht gespeicherte Änderungen verwerfen?",
"dont_include": "Dieses Feld nicht einbeziehen",
"drop_a_field_here": "Ziehe ein Feld hierher",
"drop_field_or": "Feld ablegen oder",
"edit_csv_mapping": "CSV-Zuordnung bearbeiten",
"edit_source_connection": "Quellverbindung bearbeiten",
"enter_name_for_source": "Gib einen Namen für diese Quelle ein",
"enter_value": "Wert eingeben...",
"enum": "Aufzählung",
"failed_to_load_feedback_records": "Feedback-Einträge konnten nicht geladen werden",
"feedback_date": "Aktuelles Datum",
"feedback_directory": "Feedback-Verzeichnis",
"feedback_record_created_successfully": "Feedback-Datensatz erfolgreich erstellt",
"feedback_record_details": "Details zum Feedback-Datensatz",
"feedback_record_details_description": "Überprüfen und aktualisieren Sie die Felder des Feedback-Datensatzes.",
"feedback_record_fields": "Feedback-Eintragsfelder",
"feedback_record_mcp": "Feedback-Datensatz MCP",
"feedback_record_updated_successfully": "Feedback-Datensatz erfolgreich aktualisiert",
"feedback_record_value_required": "Für den ausgewählten Feldtyp ist ein Wert erforderlich",
@@ -3749,13 +3732,20 @@
"metadata_value": "Metadatenwert",
"missing_feedback_source_title": "Feedback-Quelle fehlt?",
"no_feedback_directory_available": "Diesem Workspace ist kein Feedback-Verzeichnis zugewiesen. Erstelle oder weise zuerst eins zu.",
"no_feedback_directory_linked_admin_description": "Feedback-Verzeichnisse aggregieren Einträge aus verbundenen Workspaces. Diesem Workspace ist noch kein Verzeichnis zugeordnet weise eins in den Einstellungen für Feedback-Verzeichnisse zu, um mit dem Sammeln von Einträgen zu beginnen.",
"no_feedback_directory_linked_member_description": "Für diesen Workspace muss ein Feedback-Verzeichnis eingerichtet werden, bevor diese Funktion verfügbar ist. Bitte einen Organisationsinhaber oder Manager, eins zuzuweisen.",
"no_feedback_directory_linked_title": "Kein Feedback-Verzeichnis verknüpft",
"no_feedback_records": "Noch keine Feedback-Einträge vorhanden. Einträge erscheinen hier, sobald deine Konnektoren Daten senden.",
"no_formbricks_surveys_available_description": "In diesem Workspace gibt es noch keine Umfragen. <surveyLink>Erstelle eine neue Umfrage</surveyLink>, um eine als Feedback-Quelle zu verwenden.",
"no_source_fields_loaded": "Noch keine Quellfelder geladen",
"no_sources_connected": "Noch keine Quellen verbunden. Füge eine Quelle hinzu, um loszulegen.",
"optional": "Optional",
"or_drag_and_drop": "oder per Drag & Drop",
"question_type_not_supported": "Dieser Fragetyp wird nicht unterstützt",
"refresh_feedback_records": "Feedback-Einträge aktualisieren",
"refreshing_feedback_records": "Feedback-Einträge werden aktualisiert...",
"request_feedback_source": "Quellen-Integration anfragen",
"required": "Erforderlich",
"save_changes": "Änderungen speichern",
"search_feedback": "Feedback durchsuchen",
"select_a_survey_to_see_questions": "Wähle eine Umfrage aus, um ihre Fragen zu sehen",
@@ -3784,11 +3774,12 @@
"set_value": "Wert festlegen",
"setup_connection": "Verbindung einrichten",
"showing_count_loaded": "{count} Datensätze werden angezeigt",
"showing_rows": "Zeige {visible} von {total} Zeilen",
"showing_rows": "3 von {count} Zeilen werden angezeigt",
"source": "Quelle",
"source_connect_csv_description": "Feedback aus CSV-Dateien importieren",
"source_connect_feedback_record_mcp_description": "Sende Feedback-Datensätze über die MCP-Integration.",
"source_connect_formbricks_description": "Feedback aus Deinen Formbricks-Umfragen verbinden",
"source_fields": "Quellfelder",
"source_id": "Quell-ID",
"source_name": "Quellenname",
"source_type": "Quellentyp",
+21 -30
View File
@@ -1854,10 +1854,7 @@
"api_key_updated": "API Key updated",
"delete_api_key_confirmation": "Any applications using this key will no longer be able to access your Formbricks data.",
"duplicate_access": "Duplicate workspace access not allowed",
"duplicate_directory_access": "Duplicate feedback directory access not allowed",
"feedback_directory_access": "Feedback Directory Access",
"no_api_keys_yet": "You do not have any API keys yet",
"no_directory_permissions_found": "No feedback directory permissions found",
"no_workspace_permissions_found": "No Workspace permissions found",
"organization_access": "Organization Access",
"organization_access_description": "Select read or write privileges for organization-wide resources.",
@@ -1865,7 +1862,6 @@
"secret": "Secret",
"unable_to_copy_api_key": "Unable to copy API key",
"unable_to_delete_api_key": "Unable to delete API Key",
"unknown_directory": "Unknown directory",
"unknown_workspace": "Unknown workspace",
"workspace_access": "Workspace Access"
},
@@ -2568,6 +2564,9 @@
"error_directory_name_required": "Directory name is required.",
"error_directory_workspaces_invalid_org": "Some specified workspaces do not belong to this organization.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"grant_access_confirm": "Grant workspace access",
"grant_workspace_access_title": "Confirm workspace access grant",
"grant_workspace_access_warning": "All current and future members of the workspaces below will gain read access to all feedback data routed into \"{directoryName}\", including data ingested by other linked workspaces' connectors. This change cannot be silently undone.",
"nav_label": "Feedback Directories",
"no_access": "You do not have permission to manage feedback directories.",
"no_connectors": "No connectors linked to this directory yet.",
@@ -2580,7 +2579,9 @@
"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_access": "Workspace access",
"workspaces_already_linked": "Already linked workspaces",
"workspaces_being_added": "Workspaces being granted access"
},
"general": {
"ai_data_analysis_enabled": "Data enrichment & analysis (AI)",
@@ -3652,6 +3653,7 @@
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"auto_generated": "Auto-generated",
"change_file": "Change file",
"click_load_sample_csv": "Click 'Load sample CSV' to see columns",
"click_to_upload": "Click to upload",
"collected_at": "Collected At",
"configure_import": "Configure import",
@@ -3659,60 +3661,41 @@
"connector_created_successfully": "Connector created successfully",
"connector_deleted_successfully": "Connector deleted successfully",
"connector_duplicated_successfully": "Connector duplicated successfully",
"connector_name": "Connector Name",
"connector_name_hint": "How this connector appears in your dashboard. Auto-filled from the uploaded filename — edit anytime.",
"connector_status_updated_successfully": "Connector status updated successfully",
"connector_updated_successfully": "Connector updated successfully",
"connectors": "Connectors",
"create_mapping": "Create mapping",
"created_by": "Created by",
"csv_advanced": "Advanced",
"csv_advanced_hint": "Less common fields. Set them when relevant.",
"csv_at_least_one_row": "CSV must contain at least one data row.",
"csv_auto_mapped": "Auto-mapped",
"csv_auto_mapped_tooltip": "We mapped this from \"{column}\" because the header looked similar. Confirm or change it.",
"csv_auto_mapped_verify": "Auto-mapped (needs review)",
"csv_basic_required": "Basic (required)",
"csv_basic_required_hint": "Pick a CSV column, or set a fixed value applied to every row.",
"csv_column_used_by": "Mapped to: {target}",
"csv_columns": "CSV Columns",
"csv_data_preview": "Data preview",
"csv_empty_column_headers": "CSV contains empty column headers. All columns must have a name.",
"csv_file_too_large": "CSV file is too large. Maximum size is 2MB.",
"csv_files_only": "CSV files only",
"csv_fixed_value_action": "Set a fixed value…",
"csv_fixed_value_label": "Fixed value: {value}",
"csv_import": "CSV Import",
"csv_import_complete": "CSV import complete: {successes} succeeded, {failures} failed, {skipped} skipped",
"csv_import_duplicate_warning": "Importing data twice will create duplicate records.",
"csv_inconsistent_columns": "Row {row} has inconsistent columns. All rows must have the same headers.",
"csv_max_records": "Maximum {max} records allowed.",
"csv_now_label": "Now (use the import time)",
"csv_pick_column_placeholder": "Pick a column or set a value…",
"csv_required_fields_missing": "Please map required fields before saving: {fields}",
"csv_response_preview": "Sample: \"{sample}\" → stored as {target}.",
"csv_rows_count": "{count, plural, one {# row} other {# rows}}",
"csv_sample_label": "Sample CSV",
"csv_source_context": "Source Context",
"csv_source_context_hint": "Identifies where this batch of feedback came from.",
"csv_unmapped_columns": "Unmapped columns ({count}): {columns}",
"csv_unmapped_columns_explainer": "These columns aren't used by any Feedback Record field. They'll be ignored at import.",
"custom_source_type": "Custom source type",
"custom_source_type_placeholder": "Enter custom source type",
"default_connector_name_csv": "CSV Import",
"default_connector_name_formbricks": "Formbricks Survey Connection",
"discard_feedback_record_changes_description": "Your changes will be lost if you close this drawer.",
"discard_feedback_record_changes_title": "Discard unsaved changes?",
"dont_include": "Don't include this field",
"drop_a_field_here": "Drop a field here",
"drop_field_or": "Drop field or",
"edit_csv_mapping": "Edit CSV mapping",
"edit_source_connection": "Edit Source Connection",
"enter_name_for_source": "Enter a name for this source",
"enter_value": "Enter value...",
"enum": "enum",
"failed_to_load_feedback_records": "Failed to load feedback records",
"feedback_date": "Current date",
"feedback_directory": "Feedback Directory",
"feedback_record_created_successfully": "Feedback record created successfully",
"feedback_record_details": "Feedback record details",
"feedback_record_details_description": "Review and update feedback record fields.",
"feedback_record_fields": "Feedback Record Fields",
"feedback_record_mcp": "Feedback Record MCP",
"feedback_record_updated_successfully": "Feedback record updated successfully",
"feedback_record_value_required": "A value is required for the selected field type",
@@ -3749,13 +3732,20 @@
"metadata_value": "Metadata value",
"missing_feedback_source_title": "Missing feedback source?",
"no_feedback_directory_available": "No feedback directory assigned to this workspace. Create or assign one first.",
"no_feedback_directory_linked_admin_description": "Feedback directories aggregate records from connected workspaces. No directory is linked to this workspace yet — assign one from the Feedback Directory settings to start collecting records.",
"no_feedback_directory_linked_member_description": "A feedback directory needs to be set up for this workspace before this functionality is available. Ask an organization owner or manager to assign one.",
"no_feedback_directory_linked_title": "No feedback directory linked",
"no_feedback_records": "No feedback records yet. Records will appear here once your connectors start sending data.",
"no_formbricks_surveys_available_description": "There are no surveys in this workspace yet. <surveyLink>Create a new survey</surveyLink> to use it as a feedback source.",
"no_source_fields_loaded": "No source fields loaded yet",
"no_sources_connected": "No sources connected yet. Add a source to get started.",
"optional": "Optional",
"or_drag_and_drop": "or drag and drop",
"question_type_not_supported": "This question type is not supported",
"refresh_feedback_records": "Refresh feedback records",
"refreshing_feedback_records": "Refreshing feedback records...",
"request_feedback_source": "Request source integration",
"required": "Required",
"save_changes": "Save changes",
"search_feedback": "Search feedback",
"select_a_survey_to_see_questions": "Select a survey to see its questions",
@@ -3784,11 +3774,12 @@
"set_value": "set value",
"setup_connection": "Setup connection",
"showing_count_loaded": "Showing {count} records",
"showing_rows": "Showing {visible} of {total} rows",
"showing_rows": "Showing 3 of {count} rows",
"source": "source",
"source_connect_csv_description": "Import feedback from CSV files",
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
"source_connect_formbricks_description": "Connect feedback from your Formbricks surveys",
"source_fields": "Source Fields",
"source_id": "Source ID",
"source_name": "Source Name",
"source_type": "Source Type",
+21 -30
View File
@@ -1854,10 +1854,7 @@
"api_key_updated": "Clave API actualizada",
"delete_api_key_confirmation": "Cualquier aplicación que use esta clave ya no podrá acceder a tus datos de Formbricks.",
"duplicate_access": "No se permite el acceso duplicado al espacio de trabajo",
"duplicate_directory_access": "No se permite el acceso duplicado al directorio de feedback",
"feedback_directory_access": "Acceso al directorio de feedback",
"no_api_keys_yet": "Aún no tienes ninguna clave API",
"no_directory_permissions_found": "No se encontraron permisos para el directorio de feedback",
"no_workspace_permissions_found": "No se encontraron permisos del espacio de trabajo",
"organization_access": "Acceso a la organización",
"organization_access_description": "Selecciona privilegios de lectura o escritura para recursos de toda la organización.",
@@ -1865,7 +1862,6 @@
"secret": "Secreto",
"unable_to_copy_api_key": "No se puede copiar la clave de API",
"unable_to_delete_api_key": "No se puede eliminar la clave API",
"unknown_directory": "Directorio desconocido",
"unknown_workspace": "Espacio de trabajo desconocido",
"workspace_access": "Acceso al espacio de trabajo"
},
@@ -2568,6 +2564,9 @@
"error_directory_name_required": "El nombre del directorio es obligatorio.",
"error_directory_workspaces_invalid_org": "Algunos de los espacios de trabajo especificados no pertenecen a esta organización.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"grant_access_confirm": "Conceder acceso al espacio de trabajo",
"grant_workspace_access_title": "Confirmar concesión de acceso al espacio de trabajo",
"grant_workspace_access_warning": "Todos los miembros actuales y futuros de los espacios de trabajo siguientes obtendrán acceso de lectura a todos los datos de feedback dirigidos a \"{directoryName}\", incluidos los datos ingeridos por los conectores de otros espacios de trabajo vinculados. Este cambio no se puede deshacer de forma silenciosa.",
"nav_label": "Directorios de Feedback",
"no_access": "No tienes permiso para gestionar directorios de feedback.",
"no_connectors": "Aún no hay conectores vinculados a este directorio.",
@@ -2580,7 +2579,9 @@
"unarchive_workspace_conflict": "No se puede desarchivar este directorio porque uno o más espacios de trabajo asignados están archivados.",
"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_access": "Acceso al espacio de trabajo",
"workspaces_already_linked": "Espacios de trabajo ya vinculados",
"workspaces_being_added": "Espacios de trabajo a los que se concede acceso"
},
"general": {
"ai_data_analysis_enabled": "Enriquecimiento y análisis de datos (IA)",
@@ -3652,6 +3653,7 @@
"api_ingestion_settings_description": "Envía registros de feedback mediante la API de gestión.",
"auto_generated": "Generado automáticamente",
"change_file": "Cambiar archivo",
"click_load_sample_csv": "Haz clic en 'Cargar CSV de muestra' para ver las columnas",
"click_to_upload": "Haz clic para subir",
"collected_at": "Recopilado el",
"configure_import": "Configurar importación",
@@ -3659,60 +3661,41 @@
"connector_created_successfully": "Conector creado correctamente",
"connector_deleted_successfully": "Conector eliminado correctamente",
"connector_duplicated_successfully": "Conector duplicado correctamente",
"connector_name": "Nombre del conector",
"connector_name_hint": "Cómo aparece este conector en tu panel. Se rellena automáticamente con el nombre del archivo subido — edítalo cuando quieras.",
"connector_status_updated_successfully": "Estado del conector actualizado correctamente",
"connector_updated_successfully": "Conector actualizado correctamente",
"connectors": "Conectores",
"create_mapping": "Crear asignación",
"created_by": "Creado por",
"csv_advanced": "Avanzado",
"csv_advanced_hint": "Campos menos comunes. Configúralos cuando sean relevantes.",
"csv_at_least_one_row": "El CSV debe contener al menos una fila de datos.",
"csv_auto_mapped": "Mapeado automáticamente",
"csv_auto_mapped_tooltip": "Lo hemos mapeado desde \"{column}\" porque el encabezado parecía similar. Confírmalo o cámbialo.",
"csv_auto_mapped_verify": "Mapeado automáticamente (necesita revisión)",
"csv_basic_required": "Básico (obligatorio)",
"csv_basic_required_hint": "Elige una columna CSV o establece un valor fijo que se aplicará a todas las filas.",
"csv_column_used_by": "Mapeado a: {target}",
"csv_columns": "Columnas CSV",
"csv_data_preview": "Vista previa de datos",
"csv_empty_column_headers": "El CSV contiene encabezados de columna vacíos. Todas las columnas deben tener un nombre.",
"csv_file_too_large": "El archivo CSV es demasiado grande. El tamaño máximo es de 2 MB.",
"csv_files_only": "Solo archivos CSV",
"csv_fixed_value_action": "Establecer un valor fijo…",
"csv_fixed_value_label": "Valor fijo: {value}",
"csv_import": "Importación CSV",
"csv_import_complete": "Importación de CSV completada: {successes} correctas, {failures} fallidas, {skipped} omitidas",
"csv_import_duplicate_warning": "Importar datos dos veces creará registros duplicados.",
"csv_inconsistent_columns": "La fila {row} tiene columnas inconsistentes. Todas las filas deben tener los mismos encabezados.",
"csv_max_records": "Máximo de {max} registros permitidos.",
"csv_now_label": "Ahora (usar la hora de importación)",
"csv_pick_column_placeholder": "Elige una columna o establece un valor…",
"csv_required_fields_missing": "Por favor, mapea los campos obligatorios antes de guardar: {fields}",
"csv_response_preview": "Ejemplo: \"{sample}\" → almacenado como {target}.",
"csv_rows_count": "{count, plural, one {# fila} other {# filas}}",
"csv_sample_label": "CSV de ejemplo",
"csv_source_context": "Contexto de origen",
"csv_source_context_hint": "Identifica de dónde proviene este lote de feedback.",
"csv_unmapped_columns": "Columnas sin mapear ({count}): {columns}",
"csv_unmapped_columns_explainer": "Estas columnas no están siendo usadas por ningún campo de registro de feedback. Se ignorarán durante la importación.",
"custom_source_type": "Tipo de fuente personalizado",
"custom_source_type_placeholder": "Ingrese el tipo de fuente personalizado",
"default_connector_name_csv": "Importación CSV",
"default_connector_name_formbricks": "Conexión de encuesta de Formbricks",
"discard_feedback_record_changes_description": "Sus cambios se perderán si cierra este cajón.",
"discard_feedback_record_changes_title": "¿Descartar los cambios no guardados?",
"dont_include": "No incluir este campo",
"drop_a_field_here": "Suelta un campo aquí",
"drop_field_or": "Suelta el campo o",
"edit_csv_mapping": "Editar mapeo de CSV",
"edit_source_connection": "Editar conexión de origen",
"enter_name_for_source": "Introduce un nombre para este origen",
"enter_value": "Introduce un valor...",
"enum": "enum",
"failed_to_load_feedback_records": "Error al cargar los registros de comentarios",
"feedback_date": "Fecha actual",
"feedback_directory": "Directorio de feedback",
"feedback_record_created_successfully": "Registro de comentarios creado correctamente",
"feedback_record_details": "Detalles del registro de comentarios",
"feedback_record_details_description": "Revise y actualice los campos del registro de comentarios.",
"feedback_record_fields": "Campos de registro de comentarios",
"feedback_record_mcp": "MCP de registros de feedback",
"feedback_record_updated_successfully": "Registro de comentarios actualizado correctamente",
"feedback_record_value_required": "Se requiere un valor para el tipo de campo seleccionado",
@@ -3749,13 +3732,20 @@
"metadata_value": "Valor de metadatos",
"missing_feedback_source_title": "¿Falta alguna fuente de feedback?",
"no_feedback_directory_available": "No hay ningún directorio de feedback asignado a este espacio de trabajo. Primero crea o asigna uno.",
"no_feedback_directory_linked_admin_description": "Los directorios de feedback agregan registros de espacios de trabajo conectados. Todavía no hay ningún directorio vinculado a este espacio de trabajo: asigna uno desde la configuración de Directorio de feedback para empezar a recopilar registros.",
"no_feedback_directory_linked_member_description": "Es necesario configurar un directorio de feedback para este espacio de trabajo antes de que esta funcionalidad esté disponible. Solicita a un propietario o gestor de la organización que asigne uno.",
"no_feedback_directory_linked_title": "No hay ningún directorio de feedback vinculado",
"no_feedback_records": "Aún no hay registros de comentarios. Los registros aparecerán aquí una vez que tus conectores empiecen a enviar datos.",
"no_formbricks_surveys_available_description": "Todavía no hay encuestas en este espacio de trabajo. <surveyLink>Crea una nueva encuesta</surveyLink> para usar una como fuente de feedback.",
"no_source_fields_loaded": "Aún no se han cargado campos de origen",
"no_sources_connected": "Aún no hay fuentes conectadas. Añade una fuente para empezar.",
"optional": "Opcional",
"or_drag_and_drop": "o arrastra y suelta",
"question_type_not_supported": "Este tipo de pregunta no es compatible",
"refresh_feedback_records": "Actualizar los registros de comentarios",
"refreshing_feedback_records": "Actualizando registros de comentarios...",
"request_feedback_source": "Solicitar integración de fuente",
"required": "Obligatorio",
"save_changes": "Guardar cambios",
"search_feedback": "Buscar feedback",
"select_a_survey_to_see_questions": "Selecciona una encuesta para ver sus preguntas",
@@ -3784,11 +3774,12 @@
"set_value": "establecer valor",
"setup_connection": "Configurar conexión",
"showing_count_loaded": "Mostrando {count} registros",
"showing_rows": "Mostrando {visible} de {total} filas",
"showing_rows": "Mostrando 3 de {count} filas",
"source": "origen",
"source_connect_csv_description": "Importar feedback desde archivos CSV",
"source_connect_feedback_record_mcp_description": "Envía registros de feedback a través de la integración MCP.",
"source_connect_formbricks_description": "Conectar feedback de tus encuestas de Formbricks",
"source_fields": "Campos de origen",
"source_id": "ID de fuente",
"source_name": "Nombre de origen",
"source_type": "Tipo de fuente",
+21 -30
View File
@@ -1854,10 +1854,7 @@
"api_key_updated": "Clé API mise à jour",
"delete_api_key_confirmation": "Toute application utilisant cette clé ne pourra plus accéder à vos données Formbricks.",
"duplicate_access": "Accès en double à l'espace de travail non autorisé",
"duplicate_directory_access": "L'accès en double au répertoire de retours n'est pas autorisé",
"feedback_directory_access": "Accès au répertoire de retours",
"no_api_keys_yet": "Vous n'avez pas encore de clés API",
"no_directory_permissions_found": "Aucune autorisation de répertoire de retours trouvée",
"no_workspace_permissions_found": "Aucune autorisation d'espace de travail trouvée",
"organization_access": "Accès à l'organisation",
"organization_access_description": "Sélectionnez les privilèges de lecture ou d'écriture pour les ressources à l'échelle de l'organisation.",
@@ -1865,7 +1862,6 @@
"secret": "Secret",
"unable_to_copy_api_key": "Impossible de copier la clé API",
"unable_to_delete_api_key": "Impossible de supprimer la clé API",
"unknown_directory": "Répertoire inconnu",
"unknown_workspace": "Espace de travail inconnu",
"workspace_access": "Accès à l'espace de travail"
},
@@ -2568,6 +2564,9 @@
"error_directory_name_required": "Le nom du répertoire est requis.",
"error_directory_workspaces_invalid_org": "Certains espaces de travail spécifiés n'appartiennent pas à cette organisation.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"grant_access_confirm": "Accorder l'accès à l'espace de travail",
"grant_workspace_access_title": "Confirmer l'octroi d'accès à l'espace de travail",
"grant_workspace_access_warning": "Tous les membres actuels et futurs des espaces de travail ci-dessous obtiendront un accès en lecture à toutes les données de feedback acheminées vers \"{directoryName}\", y compris les données ingérées par les connecteurs d'autres espaces de travail liés. Cette modification ne peut pas être annulée discrètement.",
"nav_label": "Répertoires de feedback",
"no_access": "Tu n'as pas la permission de gérer les répertoires de retours.",
"no_connectors": "Aucun connecteur lié à ce répertoire pour le moment.",
@@ -2580,7 +2579,9 @@
"unarchive_workspace_conflict": "Impossible de désarchiver ce répertoire, car un ou plusieurs espaces de travail attribués sont archivés.",
"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_access": "Accès à lespace de travail",
"workspaces_already_linked": "Espaces de travail déjà liés",
"workspaces_being_added": "Espaces de travail en cours d'ajout"
},
"general": {
"ai_data_analysis_enabled": "Enrichissement et analyse des données (IA)",
@@ -3652,6 +3653,7 @@
"api_ingestion_settings_description": "Envoyer des enregistrements de feedback via l'API de gestion.",
"auto_generated": "Généré automatiquement",
"change_file": "Changer de fichier",
"click_load_sample_csv": "Clique sur « Charger un exemple CSV » pour voir les colonnes",
"click_to_upload": "Clique pour charger",
"collected_at": "Collecté le",
"configure_import": "Configurer l'importation",
@@ -3659,60 +3661,41 @@
"connector_created_successfully": "Connecteur créé avec succès",
"connector_deleted_successfully": "Connecteur supprimé avec succès",
"connector_duplicated_successfully": "Connecteur dupliqué avec succès",
"connector_name": "Nom du connecteur",
"connector_name_hint": "Comment ce connecteur apparaît dans ton tableau de bord. Rempli automatiquement à partir du nom du fichier importé — modifiable à tout moment.",
"connector_status_updated_successfully": "Statut du connecteur mis à jour avec succès",
"connector_updated_successfully": "Connecteur mis à jour avec succès",
"connectors": "Connecteurs",
"create_mapping": "Créer un mappage",
"created_by": "Créé par",
"csv_advanced": "Avancé",
"csv_advanced_hint": "Champs moins courants. Configure-les si nécessaire.",
"csv_at_least_one_row": "Le CSV doit contenir au moins une ligne de données.",
"csv_auto_mapped": "Mappé automatiquement",
"csv_auto_mapped_tooltip": "Nous avons mappé ceci depuis « {column} » car l'en-tête semblait similaire. Confirme ou modifie-le.",
"csv_auto_mapped_verify": "Mappé automatiquement (à vérifier)",
"csv_basic_required": "Basique (requis)",
"csv_basic_required_hint": "Choisis une colonne CSV, ou définis une valeur fixe appliquée à chaque ligne.",
"csv_column_used_by": "Mappé vers : {target}",
"csv_columns": "Colonnes CSV",
"csv_data_preview": "Aperçu des données",
"csv_empty_column_headers": "Le CSV contient des en-têtes de colonnes vides. Toutes les colonnes doivent avoir un nom.",
"csv_file_too_large": "Le fichier CSV est trop volumineux. La taille maximale est de 2 Mo.",
"csv_files_only": "Fichiers CSV uniquement",
"csv_fixed_value_action": "Définir une valeur fixe…",
"csv_fixed_value_label": "Valeur fixe : {value}",
"csv_import": "Importation CSV",
"csv_import_complete": "Importation CSV terminée: {successes} réussies, {failures} échouées, {skipped} ignorées",
"csv_import_duplicate_warning": "Importer les données deux fois créera des enregistrements en double.",
"csv_inconsistent_columns": "La ligne {row} a des colonnes incohérentes. Toutes les lignes doivent avoir les mêmes en-têtes.",
"csv_max_records": "Maximum {max} enregistrements autorisés.",
"csv_now_label": "Maintenant (utilise l'heure d'importation)",
"csv_pick_column_placeholder": "Choisis une colonne ou définis une valeur…",
"csv_required_fields_missing": "Merci de mapper les champs requis avant d'enregistrer : {fields}",
"csv_response_preview": "Exemple : « {sample} » → stocké comme {target}.",
"csv_rows_count": "{count, plural, one {# ligne} other {# lignes}}",
"csv_sample_label": "Exemple de CSV",
"csv_source_context": "Contexte de la source",
"csv_source_context_hint": "Identifie d'où provient ce lot de retours.",
"csv_unmapped_columns": "Colonnes non mappées ({count}) : {columns}",
"csv_unmapped_columns_explainer": "Ces colonnes ne sont utilisées par aucun champ d'enregistrement de retour. Elles seront ignorées lors de l'importation.",
"custom_source_type": "Type de source personnalisé",
"custom_source_type_placeholder": "Entrez le type de source personnalisé",
"default_connector_name_csv": "Importation CSV",
"default_connector_name_formbricks": "Connexion de sondage Formbricks",
"discard_feedback_record_changes_description": "Vos modifications seront perdues si vous fermez ce tiroir.",
"discard_feedback_record_changes_title": "Supprimer les modifications non enregistrées ?",
"dont_include": "Ne pas inclure ce champ",
"drop_a_field_here": "Déposez un champ ici",
"drop_field_or": "Déposez un champ ou",
"edit_csv_mapping": "Modifier le mappage CSV",
"edit_source_connection": "Modifier la connexion source",
"enter_name_for_source": "Entrez un nom pour cette source",
"enter_value": "Saisir une valeur...",
"enum": "enum",
"failed_to_load_feedback_records": "Échec du chargement des enregistrements de feedback",
"feedback_date": "Date actuelle",
"feedback_directory": "Répertoire de retours",
"feedback_record_created_successfully": "Enregistrement de commentaires créé avec succès",
"feedback_record_details": "Détails de l'enregistrement des commentaires",
"feedback_record_details_description": "Examiner et mettre à jour les champs denregistrement des commentaires.",
"feedback_record_fields": "Champs d'enregistrement de feedback",
"feedback_record_mcp": "MCP d'enregistrement de feedback",
"feedback_record_updated_successfully": "L'enregistrement des commentaires a été mis à jour avec succès",
"feedback_record_value_required": "Une valeur est requise pour le type de champ sélectionné",
@@ -3749,13 +3732,20 @@
"metadata_value": "Valeur des métadonnées",
"missing_feedback_source_title": "Il manque une source de feedback ?",
"no_feedback_directory_available": "Aucun répertoire de retours attribué à cet espace de travail. Crée-en un ou attribue-en un d'abord.",
"no_feedback_directory_linked_admin_description": "Les répertoires de feedback regroupent les enregistrements provenant des espaces de travail connectés. Aucun répertoire n'est encore lié à cet espace de travail — attribue-en un depuis les paramètres du répertoire de feedback pour commencer à collecter des enregistrements.",
"no_feedback_directory_linked_member_description": "Un répertoire de feedback doit être configuré pour cet espace de travail avant que cette fonctionnalité ne soit disponible. Demande à un propriétaire ou un gestionnaire de l'organisation d'en attribuer un.",
"no_feedback_directory_linked_title": "Aucun répertoire de feedback lié",
"no_feedback_records": "Aucun enregistrement de feedback pour le moment. Les enregistrements apparaîtront ici une fois que vos connecteurs commenceront à envoyer des données.",
"no_formbricks_surveys_available_description": "Il ny a pas encore de sondages dans cet espace de travail. <surveyLink>Créez une nouvelle enquête</surveyLink> pour en utiliser une comme source de feedback.",
"no_source_fields_loaded": "Aucun champ source chargé pour le moment",
"no_sources_connected": "Aucune source connectée pour le moment. Ajoutez une source pour commencer.",
"optional": "Facultatif",
"or_drag_and_drop": "ou glisser-déposer",
"question_type_not_supported": "Ce type de question n'est pas pris en charge",
"refresh_feedback_records": "Actualiser les enregistrements de retours",
"refreshing_feedback_records": "Actualisation des enregistrements de feedback...",
"request_feedback_source": "Demander une intégration de source",
"required": "Requis",
"save_changes": "Enregistrer les modifications",
"search_feedback": "Rechercher des retours",
"select_a_survey_to_see_questions": "Sélectionnez une enquête pour voir ses questions",
@@ -3784,11 +3774,12 @@
"set_value": "définir la valeur",
"setup_connection": "Configurer la connexion",
"showing_count_loaded": "Affichage de {count} enregistrements",
"showing_rows": "Affichage de {visible} lignes sur {total}",
"showing_rows": "Affichage de 3 sur {count} lignes",
"source": "source",
"source_connect_csv_description": "Importer des feedbacks depuis des fichiers CSV",
"source_connect_feedback_record_mcp_description": "Envoyer des enregistrements de feedback via l'intégration MCP.",
"source_connect_formbricks_description": "Connecter les feedbacks de vos enquêtes Formbricks",
"source_fields": "Champs source",
"source_id": "Identifiant de la source",
"source_name": "Nom de la source",
"source_type": "Type de source",
+21 -30
View File
@@ -1854,10 +1854,7 @@
"api_key_updated": "API-kulcs frissítve",
"delete_api_key_confirmation": "Az ezt a kulcsot használó bármely alkalmazás többé nem fog tudni hozzáférni a Formbricks adataihoz.",
"duplicate_access": "A kettőzött munkaterület-hozzáférés nem engedélyezett",
"duplicate_directory_access": "Duplikált visszajelzési könyvtár hozzáférés nem engedélyezett",
"feedback_directory_access": "Visszajelzési könyvtár hozzáférés",
"no_api_keys_yet": "Még nincs semmilyen API-kulcsa",
"no_directory_permissions_found": "Nem találhatók visszajelzési könyvtár jogosultságok",
"no_workspace_permissions_found": "Nem találhatók munkaterület-jogosultságok",
"organization_access": "Szervezeti hozzáférés",
"organization_access_description": "Olvasási vagy írási jogosultságok kiválasztása a teljes szervezetre vonatkozó erőforrásokhoz.",
@@ -1865,7 +1862,6 @@
"secret": "Titok",
"unable_to_copy_api_key": "Az API kulcs másolása nem lehetséges",
"unable_to_delete_api_key": "Nem lehet törölni az API-kulcsot",
"unknown_directory": "Ismeretlen könyvtár",
"unknown_workspace": "Ismeretlen munkaterület",
"workspace_access": "Munkaterület-hozzáférés"
},
@@ -2568,6 +2564,9 @@
"error_directory_name_required": "A könyvtár neve kötelező megadni.",
"error_directory_workspaces_invalid_org": "Egyes megadott munkaterületek nem ehhez a szervezethez tartoznak.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"grant_access_confirm": "Munkaterület-hozzáférés megadása",
"grant_workspace_access_title": "Munkaterület-hozzáférés megadásának megerősítése",
"grant_workspace_access_warning": "Az alább felsorolt munkaterületek összes jelenlegi és jövőbeli tagja olvasási hozzáférést kap a \"{directoryName}\" könyvtárba irányított összes visszajelzési adathoz, beleértve a más kapcsolt munkaterületek összekötői által betöltött adatokat is. Ezt a módosítást nem lehet észrevétlenül visszavonni.",
"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 csatlakozók társítva ehhez a könyvtárhoz.",
@@ -2580,7 +2579,9 @@
"unarchive_workspace_conflict": "A könyvtár nem állítható vissza, mert egy vagy több hozzárendelt munkaterület archiválva van.",
"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_access": "Munkaterület-hozzáférés",
"workspaces_already_linked": "Már kapcsolt munkaterületek",
"workspaces_being_added": "Hozzáférést kapó munkaterületek"
},
"general": {
"ai_data_analysis_enabled": "Adatgazdagítás és elemzés (AI)",
@@ -3652,6 +3653,7 @@
"api_ingestion_settings_description": "Visszajelzési rekordok küldése a Management API használatával.",
"auto_generated": "Automatikusan generált",
"change_file": "Fájl módosítása",
"click_load_sample_csv": "Kattintson a 'Minta CSV betöltése' gombra az oszlopok megtekintéséhez",
"click_to_upload": "Kattintson a feltöltéshez",
"collected_at": "Gyűjtve",
"configure_import": "Importálás konfigurálása",
@@ -3659,60 +3661,41 @@
"connector_created_successfully": "Csatlakozó sikeresen létrehozva",
"connector_deleted_successfully": "Csatlakozó sikeresen törölve",
"connector_duplicated_successfully": "Csatlakozó sikeresen duplikálva",
"connector_name": "Csatlakozó neve",
"connector_name_hint": "Így jelenik meg ez a csatlakozó az Ön irányítópultján. Automatikusan kitöltésre kerül a feltöltött fájlnévből — bármikor szerkeszthető.",
"connector_status_updated_successfully": "Csatlakozó állapota sikeresen frissítve",
"connector_updated_successfully": "Csatlakozó sikeresen frissítve",
"connectors": "Csatlakozók",
"create_mapping": "Leképezés létrehozása",
"created_by": "Létrehozta",
"csv_advanced": "Speciális",
"csv_advanced_hint": "Kevésbé gyakori mezők. Állítsa be őket, amikor szükséges.",
"csv_at_least_one_row": "A CSV-nek legalább egy adatsort kell tartalmaznia.",
"csv_auto_mapped": "Automatikusan leképezve",
"csv_auto_mapped_tooltip": "Ezt a(z) \"{column}\" oszlopból képeztük le, mivel a fejléc hasonlónak tűnt. Kérjük, erősítse meg vagy módosítsa.",
"csv_auto_mapped_verify": "Automatikusan leképezve (ellenőrzést igényel)",
"csv_basic_required": "Alapvető (kötelező)",
"csv_basic_required_hint": "Válasszon egy CSV-oszlopot, vagy állítson be egy rögzített értéket, amely minden sorra érvényes.",
"csv_column_used_by": "Leképezve ide: {target}",
"csv_columns": "CSV oszlopok",
"csv_data_preview": "Adatelőnézet",
"csv_empty_column_headers": "A CSV üres oszlopfejléceket tartalmaz. Minden oszlopnak rendelkeznie kell névvel.",
"csv_file_too_large": "A CSV fájl túl nagy. A maximális méret 2 MB.",
"csv_files_only": "Csak CSV fájlok",
"csv_fixed_value_action": "Rögzített érték beállítása…",
"csv_fixed_value_label": "Rögzített érték: {value}",
"csv_import": "CSV importálás",
"csv_import_complete": "CSV importálás befejezve: {successes} sikeres, {failures} sikertelen, {skipped} kihagyva",
"csv_import_duplicate_warning": "Az adatok kétszeri importálása duplikált rekordokat hoz létre.",
"csv_inconsistent_columns": "A(z) {row}. sor inkonzisztens oszlopokat tartalmaz. Minden sornak ugyanazokkal a fejlécekkel kell rendelkeznie.",
"csv_max_records": "Maximum {max} rekord engedélyezett.",
"csv_now_label": "Most (az importálás időpontjának használata)",
"csv_pick_column_placeholder": "Válasszon oszlopot vagy állítson be értéket…",
"csv_required_fields_missing": "Kérjük, a mentés előtt képezze le a kötelező mezőket: {fields}",
"csv_response_preview": "Minta: \"{sample}\" → tárolva mint {target}.",
"csv_rows_count": "{count, plural, one {# sor} other {# sor}}",
"csv_sample_label": "Minta CSV",
"csv_source_context": "Forráskontextus",
"csv_source_context_hint": "Meghatározza, honnan származik ez a visszajelzési köteg.",
"csv_unmapped_columns": "Le nem képezett oszlopok ({count}): {columns}",
"csv_unmapped_columns_explainer": "Ezeket az oszlopokat egyetlen visszajelzési rekord mező sem használja. Az importáláskor figyelmen kívül maradnak.",
"custom_source_type": "Egyéni forrástípus",
"custom_source_type_placeholder": "Adja meg az egyéni forrástípust",
"default_connector_name_csv": "CSV importálás",
"default_connector_name_formbricks": "Formbricks kérdőív kapcsolat",
"discard_feedback_record_changes_description": "A módosítások elvesznek, ha bezárja ezt a fiókot.",
"discard_feedback_record_changes_title": "Elveti a nem mentett módosításokat?",
"dont_include": "Ne vegye fel ezt a mezőt",
"drop_a_field_here": "Húzz ide egy mezőt",
"drop_field_or": "Húzz ide egy mezőt vagy",
"edit_csv_mapping": "CSV leképezés szerkesztése",
"edit_source_connection": "Forráskapcsolat szerkesztése",
"enter_name_for_source": "Adj nevet ennek a forrásnak",
"enter_value": "Érték megadása...",
"enum": "felsorolás",
"failed_to_load_feedback_records": "Nem sikerült betölteni a visszajelzési rekordokat",
"feedback_date": "Aktuális dátum",
"feedback_directory": "Visszajelzési könyvtár",
"feedback_record_created_successfully": "A visszajelzési rekord sikeresen létrehozva",
"feedback_record_details": "A visszajelzési rekord részletei",
"feedback_record_details_description": "Tekintse át és frissítse a visszajelzési rekordmezőket.",
"feedback_record_fields": "Visszajelzési rekord mezők",
"feedback_record_mcp": "Visszajelzési rekord MCP",
"feedback_record_updated_successfully": "A visszajelzési rekord sikeresen frissítve",
"feedback_record_value_required": "A kiválasztott mezőtípushoz értéket kell megadni",
@@ -3749,13 +3732,20 @@
"metadata_value": "A metaadat értéke",
"missing_feedback_source_title": "Hiányzik egy visszajelzési forrás?",
"no_feedback_directory_available": "Ehhez a munkaterülethez nincs visszajelzési könyvtár hozzárendelve. Hozzon létre vagy rendeljen hozzá egyet először.",
"no_feedback_directory_linked_admin_description": "A visszajelzési könyvtárak a kapcsolt munkaterületekről származó rekordokat összesítik. Ehhez a munkaterülethez még nincs könyvtár kapcsolva — rendeljen hozzá egyet a Visszajelzési könyvtár beállításokban a rekordok gyűjtésének megkezdéséhez.",
"no_feedback_directory_linked_member_description": "Ehhez a munkaterülethez be kell állítani egy visszajelzési könyvtárat, mielőtt ez a funkció elérhetővé válna. Kérje meg a szervezet tulajdonosát vagy vezetőjét, hogy rendeljen hozzá egyet.",
"no_feedback_directory_linked_title": "Nincs visszajelzési könyvtár kapcsolva",
"no_feedback_records": "Még nincsenek visszajelzési rekordok. A rekordok itt fognak megjelenni, amint a csatlakozók elkezdik küldeni az adatokat.",
"no_formbricks_surveys_available_description": "Ebben a munkaterületen még nincsenek kérdőívek. <surveyLink>Hozz létre egy új kérdőívet</surveyLink>, hogy visszajelzési forrásként használhass egyet.",
"no_source_fields_loaded": "Még nincsenek forrás mezők betöltve",
"no_sources_connected": "Még nincsenek források csatlakoztatva. Adj hozzá egy forrást a kezdéshez.",
"optional": "Elhagyható",
"or_drag_and_drop": "vagy húzd ide",
"question_type_not_supported": "Ez a kérdéstípus nem támogatott",
"refresh_feedback_records": "Visszajelzési rekordok frissítése",
"refreshing_feedback_records": "Visszajelzési rekordok frissítése...",
"request_feedback_source": "Forrásintegráció kérése",
"required": "Kötelező",
"save_changes": "Változtatások mentése",
"search_feedback": "Visszajelzések keresése",
"select_a_survey_to_see_questions": "Válassz egy kérdőívet a kérdések megtekintéséhez",
@@ -3784,11 +3774,12 @@
"set_value": "érték beállítása",
"setup_connection": "Kapcsolat beállítása",
"showing_count_loaded": "{count} rekord megjelenítése",
"showing_rows": "{visible} sor megjelenítve a(z) {total} sorból",
"showing_rows": "3 megjelenítve {count} sorból",
"source": "forrás",
"source_connect_csv_description": "Visszajelzések importálása CSV fájlokból",
"source_connect_feedback_record_mcp_description": "Visszajelzési rekordok küldése az MCP integráción keresztül.",
"source_connect_formbricks_description": "Visszajelzések csatlakoztatása a Formbricks kérdőívekből",
"source_fields": "Forrásmezők",
"source_id": "Forrásazonosító",
"source_name": "Forrásnév",
"source_type": "Forrás típus",
+21 -30
View File
@@ -1854,10 +1854,7 @@
"api_key_updated": "APIキーを更新しました",
"delete_api_key_confirmation": "このキーを使用しているアプリケーションは、Formbricksデータにアクセスできなくなります。",
"duplicate_access": "ワークスペースへの重複アクセスは許可されていません",
"duplicate_directory_access": "フィードバックディレクトリへの重複アクセスは許可されていません",
"feedback_directory_access": "フィードバックディレクトリアクセス",
"no_api_keys_yet": "まだAPIキーがありません",
"no_directory_permissions_found": "フィードバックディレクトリの権限が見つかりません",
"no_workspace_permissions_found": "ワークスペースの権限が見つかりません",
"organization_access": "組織アクセス",
"organization_access_description": "組織全体のリソースに対する読み取りまたは書き込み権限を選択してください。",
@@ -1865,7 +1862,6 @@
"secret": "シークレット",
"unable_to_copy_api_key": "APIキーをコピーできません",
"unable_to_delete_api_key": "APIキーを削除できません",
"unknown_directory": "不明なディレクトリ",
"unknown_workspace": "不明なワークスペース",
"workspace_access": "ワークスペースアクセス"
},
@@ -2568,6 +2564,9 @@
"error_directory_name_required": "ディレクトリ名は必須です。",
"error_directory_workspaces_invalid_org": "指定されたワークスペースの一部がこの組織に属していません。",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"grant_access_confirm": "ワークスペースのアクセス権を付与",
"grant_workspace_access_title": "ワークスペースのアクセス権付与を確認",
"grant_workspace_access_warning": "以下のワークスペースの現在および今後のメンバー全員が、他のリンクされたワークスペースのコネクタによって取り込まれたデータを含む、\"{directoryName}\"にルーティングされるすべてのフィードバックデータへの読み取りアクセス権を取得します。この変更は通知なしに元に戻すことはできません。",
"nav_label": "フィードバックディレクトリ",
"no_access": "フィードバックディレクトリを管理する権限がありません。",
"no_connectors": "このディレクトリにリンクされているコネクタはまだありません。",
@@ -2580,7 +2579,9 @@
"unarchive_workspace_conflict": "割り当てられているワークスペースの1つ以上がアーカイブされているため、このディレクトリをアーカイブ解除できません。",
"upgrade_prompt_description": "フィードバックレコードをディレクトリで整理し、適切なワークスペースにデータを振り分けられます。ProプランおよびScaleプランでご利用いただけます。",
"upgrade_prompt_title": "アップグレードしてフィードバックレコードディレクトリを利用",
"workspace_access": "ワークスペースアクセス"
"workspace_access": "ワークスペースアクセス",
"workspaces_already_linked": "既にリンクされているワークスペース",
"workspaces_being_added": "アクセス権が付与されるワークスペース"
},
"general": {
"ai_data_analysis_enabled": "データエンリッチメントと分析(AI)",
@@ -3652,6 +3653,7 @@
"api_ingestion_settings_description": "管理APIを使用してフィードバックレコードを送信します。",
"auto_generated": "自動生成",
"change_file": "ファイルを変更",
"click_load_sample_csv": "「サンプルCSVを読み込む」をクリックして列を表示",
"click_to_upload": "クリックしてアップロード",
"collected_at": "収集日時",
"configure_import": "インポートを設定",
@@ -3659,60 +3661,41 @@
"connector_created_successfully": "コネクタが正常に作成されました",
"connector_deleted_successfully": "コネクタが正常に削除されました",
"connector_duplicated_successfully": "コネクタが正常に複製されました",
"connector_name": "コネクタ名",
"connector_name_hint": "ダッシュボードに表示されるコネクタ名です。アップロードされたファイル名から自動入力されますが、いつでも編集できます。",
"connector_status_updated_successfully": "コネクタのステータスが正常に更新されました",
"connector_updated_successfully": "コネクタが正常に更新されました",
"connectors": "コネクタ",
"create_mapping": "マッピングを作成",
"created_by": "作成者",
"csv_advanced": "詳細設定",
"csv_advanced_hint": "あまり使われないフィールドです。必要に応じて設定してください。",
"csv_at_least_one_row": "CSVには少なくとも1行のデータが必要です。",
"csv_auto_mapped": "自動マッピング済み",
"csv_auto_mapped_tooltip": "ヘッダーが似ていたため、「{column}」から自動的にマッピングしました。確認するか、変更してください。",
"csv_auto_mapped_verify": "自動マッピング済み(要確認)",
"csv_basic_required": "基本(必須)",
"csv_basic_required_hint": "CSVの列を選択するか、すべての行に適用する固定値を設定してください。",
"csv_column_used_by": "マッピング先: {target}",
"csv_columns": "CSV列",
"csv_data_preview": "データプレビュー",
"csv_empty_column_headers": "CSVに空の列ヘッダーが含まれています。すべての列に名前が必要です。",
"csv_file_too_large": "CSVファイルが大きすぎます。最大サイズは2MBです。",
"csv_files_only": "CSVファイルのみ",
"csv_fixed_value_action": "固定値を設定…",
"csv_fixed_value_label": "固定値: {value}",
"csv_import": "CSVインポート",
"csv_import_complete": "CSVインポート完了: {successes}件成功、{failures}件失敗、{skipped}件スキップ",
"csv_import_duplicate_warning": "データを2回インポートすると、重複したレコードが作成されます。",
"csv_inconsistent_columns": "行 {row} の列が一致しません。すべての行は同じヘッダーを持つ必要があります。",
"csv_max_records": "最大 {max} 件のレコードまで許可されています。",
"csv_now_label": "現在(インポート時刻を使用)",
"csv_pick_column_placeholder": "列を選択するか値を設定…",
"csv_required_fields_missing": "保存する前に必須フィールドをマッピングしてください: {fields}",
"csv_response_preview": "サンプル: 「{sample}」→ {target} として保存されます。",
"csv_rows_count": "{count, plural, other {# 行}}",
"csv_sample_label": "CSVサンプル",
"csv_source_context": "ソースコンテキスト",
"csv_source_context_hint": "このフィードバックのバッチがどこから来たかを識別します。",
"csv_unmapped_columns": "未マッピングの列({count}件): {columns}",
"csv_unmapped_columns_explainer": "これらの列はフィードバックレコードのどのフィールドにも使用されていません。インポート時に無視されます。",
"custom_source_type": "カスタムソースタイプ",
"custom_source_type_placeholder": "カスタムソースタイプを入力してください",
"default_connector_name_csv": "CSVインポート",
"default_connector_name_formbricks": "Formbricks フォーム接続",
"discard_feedback_record_changes_description": "このドロワーを閉じると、変更内容は失われます。",
"discard_feedback_record_changes_title": "保存されていない変更を破棄しますか?",
"dont_include": "こフィールドを含めない",
"drop_a_field_here": "ここにフィールドをドロップ",
"drop_field_or": "フィールドをドロップまたは",
"edit_csv_mapping": "CSVマッピングを編集",
"edit_source_connection": "ソース接続を編集",
"enter_name_for_source": "このソースの名前を入力",
"enter_value": "値を入力...",
"enum": "列挙型",
"failed_to_load_feedback_records": "フィードバックレコードの読み込みに失敗しました",
"feedback_date": "現在の日付",
"feedback_directory": "フィードバックディレクトリ",
"feedback_record_created_successfully": "フィードバックレコードが正常に作成されました",
"feedback_record_details": "フィードバック記録の詳細",
"feedback_record_details_description": "フィードバック レコード フィールドを確認して更新します。",
"feedback_record_fields": "フィードバックレコードフィールド",
"feedback_record_mcp": "フィードバックレコードMCP",
"feedback_record_updated_successfully": "フィードバックレコードが正常に更新されました",
"feedback_record_value_required": "選択したフィールド タイプには値が必要です",
@@ -3749,13 +3732,20 @@
"metadata_value": "メタデータ値",
"missing_feedback_source_title": "フィードバックソースが見つかりませんか?",
"no_feedback_directory_available": "このワークスペースにはフィードバックディレクトリが割り当てられていません。まずディレクトリを作成または割り当ててください。",
"no_feedback_directory_linked_admin_description": "フィードバックディレクトリは、接続されたワークスペースからレコードを集約します。このワークスペースにはまだディレクトリがリンクされていません。レコードの収集を開始するには、フィードバックディレクトリ設定から割り当ててください。",
"no_feedback_directory_linked_member_description": "この機能を利用するには、このワークスペースにフィードバックディレクトリを設定する必要があります。組織のオーナーまたはマネージャーに割り当てを依頼してください。",
"no_feedback_directory_linked_title": "フィードバックディレクトリが未リンク",
"no_feedback_records": "フィードバックレコードはまだありません。コネクタがデータの送信を開始すると、ここにレコードが表示されます。",
"no_formbricks_surveys_available_description": "このワークスペースにはまだフォームがありません。フィードバックソースとして使用するには<surveyLink>新しいフォームを作成</surveyLink>してください。",
"no_source_fields_loaded": "ソースフィールドがまだ読み込まれていません",
"no_sources_connected": "ソースがまだ接続されていません。開始するにはソースを追加してください。",
"optional": "任意",
"or_drag_and_drop": "またはドラッグ&ドロップ",
"question_type_not_supported": "この質問タイプはサポートされていません",
"refresh_feedback_records": "フィードバック記録を更新",
"refreshing_feedback_records": "フィードバックレコードを更新中...",
"request_feedback_source": "ソース統合をリクエスト",
"required": "必須",
"save_changes": "変更を保存",
"search_feedback": "フィードバックを検索",
"select_a_survey_to_see_questions": "フォームを選択して質問を表示",
@@ -3784,11 +3774,12 @@
"set_value": "値を設定",
"setup_connection": "接続を設定",
"showing_count_loaded": "{count}件のレコードを表示中",
"showing_rows": "{total}行中{visible}行を表示",
"showing_rows": "{count}行中3行を表示",
"source": "ソース",
"source_connect_csv_description": "CSVファイルからフィードバックをインポート",
"source_connect_feedback_record_mcp_description": "MCP統合を通じてフィードバックレコードを送信します。",
"source_connect_formbricks_description": "Formbricksフォームからフィードバックを接続",
"source_fields": "ソースフィールド",
"source_id": "ソースID",
"source_name": "ソース名",
"source_type": "ソースタイプ",
+21 -30
View File
@@ -1854,10 +1854,7 @@
"api_key_updated": "API-sleutel bijgewerkt",
"delete_api_key_confirmation": "Alle applicaties die deze sleutel gebruiken, hebben geen toegang meer tot uw Formbricks-gegevens.",
"duplicate_access": "Dubbele workspace-toegang niet toegestaan",
"duplicate_directory_access": "Dubbele feedbackmaptoegang niet toegestaan",
"feedback_directory_access": "Feedbackmaptoegang",
"no_api_keys_yet": "U heeft nog geen API-sleutels",
"no_directory_permissions_found": "Geen feedbackmaprechten gevonden",
"no_workspace_permissions_found": "Geen Workspace-rechten gevonden",
"organization_access": "Organisatietoegang",
"organization_access_description": "Selecteer lees- of schrijfrechten voor organisatiebrede bronnen.",
@@ -1865,7 +1862,6 @@
"secret": "Geheim",
"unable_to_copy_api_key": "Kan API-sleutel niet kopiëren",
"unable_to_delete_api_key": "Kan API-sleutel niet verwijderen",
"unknown_directory": "Onbekende map",
"unknown_workspace": "Onbekende workspace",
"workspace_access": "Workspace-toegang"
},
@@ -2568,6 +2564,9 @@
"error_directory_name_required": "Mapnaam is verplicht.",
"error_directory_workspaces_invalid_org": "Sommige opgegeven werkruimtes behoren niet tot deze organisatie.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"grant_access_confirm": "Werkruimtetoegang verlenen",
"grant_workspace_access_title": "Bevestig verlenen van werkruimtetoegang",
"grant_workspace_access_warning": "Alle huidige en toekomstige leden van onderstaande werkruimtes krijgen leestoegang tot alle feedbackgegevens die naar \"{directoryName}\" worden doorgestuurd, inclusief gegevens die door connectoren van andere gekoppelde werkruimtes zijn ingevoerd. Deze wijziging kan niet ongemerkt ongedaan worden gemaakt.",
"nav_label": "Feedbackmappen",
"no_access": "Je hebt geen toestemming om feedbackmappen te beheren.",
"no_connectors": "Nog geen connectoren gekoppeld aan deze map.",
@@ -2580,7 +2579,9 @@
"unarchive_workspace_conflict": "Deze map kan niet worden gedearchiveerd omdat een of meer toegewezen workspaces zijn gearchiveerd.",
"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_access": "Workspace-toegang",
"workspaces_already_linked": "Reeds gekoppelde werkruimtes",
"workspaces_being_added": "Werkruimtes die toegang krijgen"
},
"general": {
"ai_data_analysis_enabled": "Dataverrijking & analyse (AI)",
@@ -3652,6 +3653,7 @@
"api_ingestion_settings_description": "Verstuur feedbackrecords via de Management API.",
"auto_generated": "Automatisch gegenereerd",
"change_file": "Bestand wijzigen",
"click_load_sample_csv": "Klik op 'Voorbeeld CSV laden' om kolommen te zien",
"click_to_upload": "Klik om te uploaden",
"collected_at": "Verzameld op",
"configure_import": "Import configureren",
@@ -3659,60 +3661,41 @@
"connector_created_successfully": "Connector succesvol aangemaakt",
"connector_deleted_successfully": "Connector succesvol verwijderd",
"connector_duplicated_successfully": "Connector succesvol gedupliceerd",
"connector_name": "Connectornaam",
"connector_name_hint": "Zo verschijnt deze connector in je dashboard. Wordt automatisch ingevuld op basis van de geüploade bestandsnaam — pas aan wanneer je wilt.",
"connector_status_updated_successfully": "Connectorstatus succesvol bijgewerkt",
"connector_updated_successfully": "Connector succesvol bijgewerkt",
"connectors": "Connectoren",
"create_mapping": "Koppeling aanmaken",
"created_by": "Gemaakt door",
"csv_advanced": "Geavanceerd",
"csv_advanced_hint": "Minder gebruikte velden. Stel ze in wanneer relevant.",
"csv_at_least_one_row": "CSV moet minimaal één datarij bevatten.",
"csv_auto_mapped": "Automatisch gekoppeld",
"csv_auto_mapped_tooltip": "We hebben dit gekoppeld aan \"{column}\" omdat de koptekst vergelijkbaar leek. Bevestig of wijzig dit.",
"csv_auto_mapped_verify": "Automatisch gekoppeld (controleren vereist)",
"csv_basic_required": "Basis (verplicht)",
"csv_basic_required_hint": "Kies een CSV-kolom, of stel een vaste waarde in die op elke rij wordt toegepast.",
"csv_column_used_by": "Gekoppeld aan: {target}",
"csv_columns": "CSV kolommen",
"csv_data_preview": "Gegevensvoorbeeld",
"csv_empty_column_headers": "CSV bevat lege kolomkoppen. Alle kolommen moeten een naam hebben.",
"csv_file_too_large": "CSV-bestand is te groot. Maximale grootte is 2MB.",
"csv_files_only": "Alleen CSV bestanden",
"csv_fixed_value_action": "Stel een vaste waarde in…",
"csv_fixed_value_label": "Vaste waarde: {value}",
"csv_import": "CSV import",
"csv_import_complete": "CSV-import voltooid: {successes} geslaagd, {failures} mislukt, {skipped} overgeslagen",
"csv_import_duplicate_warning": "Gegevens twee keer importeren zal dubbele records aanmaken.",
"csv_inconsistent_columns": "Rij {row} heeft inconsistente kolommen. Alle rijen moeten dezelfde headers hebben.",
"csv_max_records": "Maximaal {max} records toegestaan.",
"csv_now_label": "Nu (gebruik het importtijdstip)",
"csv_pick_column_placeholder": "Kies een kolom of stel een waarde in…",
"csv_required_fields_missing": "Koppel de verplichte velden voordat je opslaat: {fields}",
"csv_response_preview": "Voorbeeld: \"{sample}\" → opgeslagen als {target}.",
"csv_rows_count": "{count, plural, one {# rij} other {# rijen}}",
"csv_sample_label": "Voorbeeld-CSV",
"csv_source_context": "Broncontext",
"csv_source_context_hint": "Geeft aan waar deze batch feedback vandaan komt.",
"csv_unmapped_columns": "Niet-gekoppelde kolommen ({count}): {columns}",
"csv_unmapped_columns_explainer": "Deze kolommen worden niet gebruikt door een Feedback Record-veld. Ze worden genegeerd bij het importeren.",
"custom_source_type": "Aangepast brontype",
"custom_source_type_placeholder": "Voer een aangepast brontype in",
"default_connector_name_csv": "CSV import",
"default_connector_name_formbricks": "Formbricks Survey verbinding",
"discard_feedback_record_changes_description": "Als u deze lade sluit, gaan uw wijzigingen verloren.",
"discard_feedback_record_changes_title": "Niet-opgeslagen wijzigingen verwijderen?",
"dont_include": "Neem dit veld niet op",
"drop_a_field_here": "Zet hier een veld neer",
"drop_field_or": "Zet veld neer of",
"edit_csv_mapping": "CSV-mapping bewerken",
"edit_source_connection": "Bronverbinding bewerken",
"enter_name_for_source": "Voer een naam in voor deze bron",
"enter_value": "Voer waarde in...",
"enum": "enum",
"failed_to_load_feedback_records": "Kan feedbackrecords niet laden",
"feedback_date": "Huidige datum",
"feedback_directory": "Feedbackmap",
"feedback_record_created_successfully": "Feedbackrecord is succesvol aangemaakt",
"feedback_record_details": "Details van feedbackrecord",
"feedback_record_details_description": "Controleer en update de feedbackrecordvelden.",
"feedback_record_fields": "Feedbackrecordvelden",
"feedback_record_mcp": "Feedbackrecord MCP",
"feedback_record_updated_successfully": "Feedbackrecord is succesvol bijgewerkt",
"feedback_record_value_required": "Er is een waarde vereist voor het geselecteerde veldtype",
@@ -3749,13 +3732,20 @@
"metadata_value": "Metagegevenswaarde",
"missing_feedback_source_title": "Mis je een feedbackbron?",
"no_feedback_directory_available": "Geen feedbackmap toegewezen aan deze workspace. Maak er eerst een aan of wijs er een toe.",
"no_feedback_directory_linked_admin_description": "Feedbackmappen bundelen records van verbonden werkruimtes. Er is nog geen map gekoppeld aan deze werkruimte — wijs er een toe vanuit de instellingen voor feedbackmappen om records te verzamelen.",
"no_feedback_directory_linked_member_description": "Er moet eerst een feedbackmap worden ingesteld voor deze werkruimte voordat deze functionaliteit beschikbaar is. Vraag een organisatie-eigenaar of manager om er een toe te wijzen.",
"no_feedback_directory_linked_title": "Geen feedbackmap gekoppeld",
"no_feedback_records": "Nog geen feedbackrecords. Records verschijnen hier zodra je connectoren gegevens beginnen te verzenden.",
"no_formbricks_surveys_available_description": "Er zijn nog geen enquêtes in deze werkruimte. <surveyLink>Maak een nieuwe enquête</surveyLink> om er een als feedbackbron te gebruiken.",
"no_source_fields_loaded": "Nog geen bronvelden geladen",
"no_sources_connected": "Nog geen bronnen verbonden. Voeg een bron toe om te beginnen.",
"optional": "Optioneel",
"or_drag_and_drop": "of sleep en zet neer",
"question_type_not_supported": "Dit vraagtype wordt niet ondersteund",
"refresh_feedback_records": "Feedbackrecords verversen",
"refreshing_feedback_records": "Feedbackrecords vernieuwen...",
"request_feedback_source": "Bronintegratie aanvragen",
"required": "Vereist",
"save_changes": "Wijzigingen opslaan",
"search_feedback": "Zoek feedback",
"select_a_survey_to_see_questions": "Selecteer een enquête om de vragen te zien",
@@ -3784,11 +3774,12 @@
"set_value": "waarde instellen",
"setup_connection": "Verbinding instellen",
"showing_count_loaded": "Er worden {count} records weergegeven",
"showing_rows": "{visible} van {total} rijen weergegeven",
"showing_rows": "3 van {count} rijen weergegeven",
"source": "bron",
"source_connect_csv_description": "Importeer feedback uit CSV-bestanden",
"source_connect_feedback_record_mcp_description": "Verstuur feedbackrecords via de MCP-integratie.",
"source_connect_formbricks_description": "Verbind feedback van je Formbricks-enquêtes",
"source_fields": "Bronvelden",
"source_id": "Bron-ID",
"source_name": "Bronnaam",
"source_type": "Brontype",
+21 -30
View File
@@ -1854,10 +1854,7 @@
"api_key_updated": "Chave de API atualizada",
"delete_api_key_confirmation": "Quaisquer aplicativos que usem esta chave não poderão mais acessar seus dados do Formbricks.",
"duplicate_access": "Acesso duplicado ao workspace não permitido",
"duplicate_directory_access": "Acesso duplicado ao diretório de feedback não permitido",
"feedback_directory_access": "Acesso ao Diretório de Feedback",
"no_api_keys_yet": "Você ainda não tem nenhuma chave de API",
"no_directory_permissions_found": "Nenhuma permissão de diretório de feedback encontrada",
"no_workspace_permissions_found": "Nenhuma permissão de Workspace encontrada",
"organization_access": "Acesso à organização",
"organization_access_description": "Selecione privilégios de leitura ou escrita para recursos de toda a organização.",
@@ -1865,7 +1862,6 @@
"secret": "Segredo",
"unable_to_copy_api_key": "Não foi possível copiar a chave de API",
"unable_to_delete_api_key": "Não foi possível excluir a chave de API",
"unknown_directory": "Diretório desconhecido",
"unknown_workspace": "Workspace desconhecido",
"workspace_access": "Acesso ao workspace"
},
@@ -2568,6 +2564,9 @@
"error_directory_name_required": "O nome do diretório é obrigatório.",
"error_directory_workspaces_invalid_org": "Alguns espaços de trabalho especificados não pertencem a esta organização.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"grant_access_confirm": "Conceder acesso ao workspace",
"grant_workspace_access_title": "Confirmar concessão de acesso ao workspace",
"grant_workspace_access_warning": "Todos os membros atuais e futuros dos workspaces abaixo ganharão acesso de leitura a todos os dados de feedback direcionados para \"{directoryName}\", incluindo dados coletados por conectores de outros workspaces vinculados. Esta alteração não pode ser desfeita silenciosamente.",
"nav_label": "Diretórios de Feedback",
"no_access": "Você não tem permissão para gerenciar diretórios de feedback.",
"no_connectors": "Nenhum conector vinculado a este diretório ainda.",
@@ -2580,7 +2579,9 @@
"unarchive_workspace_conflict": "Não é possível desarquivar este diretório porque um ou mais workspaces atribuídos estão arquivados.",
"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_access": "Acesso ao workspace",
"workspaces_already_linked": "Workspaces já vinculados",
"workspaces_being_added": "Workspaces recebendo acesso"
},
"general": {
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
@@ -3652,6 +3653,7 @@
"api_ingestion_settings_description": "Envie registros de feedback usando a API de Gerenciamento.",
"auto_generated": "Gerado automaticamente",
"change_file": "Alterar arquivo",
"click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas",
"click_to_upload": "Clique para fazer upload",
"collected_at": "Coletado em",
"configure_import": "Configurar importação",
@@ -3659,60 +3661,41 @@
"connector_created_successfully": "Conector criado com sucesso",
"connector_deleted_successfully": "Conector excluído com sucesso",
"connector_duplicated_successfully": "Conector duplicado com sucesso",
"connector_name": "Nome do Conector",
"connector_name_hint": "Como esse conector aparece no seu painel. Preenchido automaticamente a partir do nome do arquivo enviado — edite quando quiser.",
"connector_status_updated_successfully": "Status do conector atualizado com sucesso",
"connector_updated_successfully": "Conector atualizado com sucesso",
"connectors": "Conectores",
"create_mapping": "Criar mapeamento",
"created_by": "Criado por",
"csv_advanced": "Avançado",
"csv_advanced_hint": "Campos menos comuns. Configure-os quando relevante.",
"csv_at_least_one_row": "O CSV deve conter pelo menos uma linha de dados.",
"csv_auto_mapped": "Mapeado automaticamente",
"csv_auto_mapped_tooltip": "Mapeamos isso de \"{column}\" porque o cabeçalho parecia similar. Confirme ou altere.",
"csv_auto_mapped_verify": "Mapeado automaticamente (precisa revisar)",
"csv_basic_required": "Básico (obrigatório)",
"csv_basic_required_hint": "Escolha uma coluna do CSV ou defina um valor fixo aplicado a todas as linhas.",
"csv_column_used_by": "Mapeado para: {target}",
"csv_columns": "Colunas CSV",
"csv_data_preview": "Prévia dos dados",
"csv_empty_column_headers": "O CSV contém cabeçalhos de coluna vazios. Todas as colunas devem ter um nome.",
"csv_file_too_large": "O arquivo CSV é muito grande. O tamanho máximo é 2MB.",
"csv_files_only": "Apenas arquivos CSV",
"csv_fixed_value_action": "Definir um valor fixo…",
"csv_fixed_value_label": "Valor fixo: {value}",
"csv_import": "Importação CSV",
"csv_import_complete": "Importação de CSV concluída: {successes} bem-sucedidas, {failures} falharam, {skipped} ignoradas",
"csv_import_duplicate_warning": "Importar dados duas vezes criará registros duplicados.",
"csv_inconsistent_columns": "A linha {row} possui colunas inconsistentes. Todas as linhas devem ter os mesmos cabeçalhos.",
"csv_max_records": "Máximo de {max} registros permitidos.",
"csv_now_label": "Agora (usar o horário da importação)",
"csv_pick_column_placeholder": "Escolha uma coluna ou defina um valor…",
"csv_required_fields_missing": "Por favor, mapeie os campos obrigatórios antes de salvar: {fields}",
"csv_response_preview": "Exemplo: \"{sample}\" → armazenado como {target}.",
"csv_rows_count": "{count, plural, one {# linha} other {# linhas}}",
"csv_sample_label": "Exemplo de CSV",
"csv_source_context": "Contexto de Origem",
"csv_source_context_hint": "Identifica de onde esse lote de feedback veio.",
"csv_unmapped_columns": "Colunas não mapeadas ({count}): {columns}",
"csv_unmapped_columns_explainer": "Essas colunas não são usadas por nenhum campo de Registro de Feedback. Elas serão ignoradas na importação.",
"custom_source_type": "Tipo de origem personalizado",
"custom_source_type_placeholder": "Insira o tipo de fonte personalizado",
"default_connector_name_csv": "Importação CSV",
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
"discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.",
"discard_feedback_record_changes_title": "Descartar alterações não salvas?",
"dont_include": "Não incluir este campo",
"drop_a_field_here": "Solte um campo aqui",
"drop_field_or": "Solte o campo ou",
"edit_csv_mapping": "Editar mapeamento CSV",
"edit_source_connection": "Editar conexão de origem",
"enter_name_for_source": "Digite um nome para esta origem",
"enter_value": "Digite o valor...",
"enum": "enum",
"failed_to_load_feedback_records": "Falha ao carregar registros de feedback",
"feedback_date": "Data atual",
"feedback_directory": "Diretório de Feedback",
"feedback_record_created_successfully": "Registro de feedback criado com sucesso",
"feedback_record_details": "Detalhes do registro de feedback",
"feedback_record_details_description": "Revise e atualize os campos de registro de feedback.",
"feedback_record_fields": "Campos do registro de feedback",
"feedback_record_mcp": "Registro de Feedback MCP",
"feedback_record_updated_successfully": "Registro de feedback atualizado com sucesso",
"feedback_record_value_required": "Um valor é obrigatório para o tipo de campo selecionado",
@@ -3749,13 +3732,20 @@
"metadata_value": "Valor dos metadados",
"missing_feedback_source_title": "Faltando alguma fonte de feedback?",
"no_feedback_directory_available": "Nenhum diretório de feedback atribuído a este workspace. Crie ou atribua um primeiro.",
"no_feedback_directory_linked_admin_description": "Diretórios de feedback agregam registros de workspaces conectados. Nenhum diretório está vinculado a este workspace ainda — configure um nas configurações de Diretório de Feedback para começar a coletar registros.",
"no_feedback_directory_linked_member_description": "Um diretório de feedback precisa ser configurado para este workspace antes que esta funcionalidade esteja disponível. Peça a um proprietário ou gerente da organização para configurar um.",
"no_feedback_directory_linked_title": "Nenhum diretório de feedback vinculado",
"no_feedback_records": "Nenhum registro de feedback ainda. Os registros aparecerão aqui assim que seus conectores começarem a enviar dados.",
"no_formbricks_surveys_available_description": "Ainda não há pesquisas neste workspace. <surveyLink>Crie uma nova pesquisa</surveyLink> para usar uma como fonte de feedback.",
"no_source_fields_loaded": "Nenhum campo de origem carregado ainda",
"no_sources_connected": "Nenhuma origem conectada ainda. Adicione uma origem para começar.",
"optional": "Opcional",
"or_drag_and_drop": "ou arraste e solte",
"question_type_not_supported": "Este tipo de pergunta não é suportado",
"refresh_feedback_records": "Atualizar registros de feedback",
"refreshing_feedback_records": "Atualizando registros de feedback...",
"request_feedback_source": "Solicitar integração de fonte",
"required": "Obrigatório",
"save_changes": "Salvar alterações",
"search_feedback": "Buscar feedback",
"select_a_survey_to_see_questions": "Selecione uma pesquisa para ver suas perguntas",
@@ -3784,11 +3774,12 @@
"set_value": "definir valor",
"setup_connection": "Configurar conexão",
"showing_count_loaded": "Mostrando {count} registros",
"showing_rows": "Mostrando {visible} de {total} linhas",
"showing_rows": "Mostrando 3 de {count} linhas",
"source": "fonte",
"source_connect_csv_description": "Importar feedback de arquivos CSV",
"source_connect_feedback_record_mcp_description": "Envie registros de feedback através da integração MCP.",
"source_connect_formbricks_description": "Conectar feedback das suas pesquisas Formbricks",
"source_fields": "Campos de origem",
"source_id": "ID da fonte",
"source_name": "Nome da origem",
"source_type": "Tipo de fonte",
+21 -30
View File
@@ -1854,10 +1854,7 @@
"api_key_updated": "Chave API atualizada",
"delete_api_key_confirmation": "Quaisquer aplicações que utilizem esta chave deixarão de poder aceder aos seus dados Formbricks.",
"duplicate_access": "Acesso duplicado ao workspace não permitido",
"duplicate_directory_access": "Não é permitido acesso duplicado ao diretório de feedback",
"feedback_directory_access": "Acesso ao Diretório de Feedback",
"no_api_keys_yet": "Ainda não tem nenhuma chave de API",
"no_directory_permissions_found": "Nenhuma permissão de diretório de feedback encontrada",
"no_workspace_permissions_found": "Não foram encontradas permissões de Espaço de Trabalho",
"organization_access": "Acesso à organização",
"organization_access_description": "Selecione privilégios de leitura ou escrita para recursos de toda a organização.",
@@ -1865,7 +1862,6 @@
"secret": "Segredo",
"unable_to_copy_api_key": "Não foi possível copiar a chave API",
"unable_to_delete_api_key": "Não foi possível eliminar a chave de API",
"unknown_directory": "Diretório desconhecido",
"unknown_workspace": "Área de trabalho desconhecida",
"workspace_access": "Acesso ao workspace"
},
@@ -2568,6 +2564,9 @@
"error_directory_name_required": "O nome do diretório é obrigatório.",
"error_directory_workspaces_invalid_org": "Algumas áreas de trabalho especificadas não pertencem a esta organização.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"grant_access_confirm": "Conceder acesso ao workspace",
"grant_workspace_access_title": "Confirmar concessão de acesso ao workspace",
"grant_workspace_access_warning": "Todos os membros atuais e futuros dos workspaces abaixo obterão acesso de leitura a todos os dados de feedback encaminhados para \"{directoryName}\", incluindo dados ingeridos por conectores de outros workspaces vinculados. Esta alteração não pode ser desfeita silenciosamente.",
"nav_label": "Diretórios de Feedback",
"no_access": "Não tens permissão para gerir diretórios de feedback.",
"no_connectors": "Ainda não há conectores associados a este diretório.",
@@ -2580,7 +2579,9 @@
"unarchive_workspace_conflict": "Não é possível desarquivar este diretório porque um ou mais workspaces atribuídos estão arquivados.",
"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_access": "Acesso ao workspace",
"workspaces_already_linked": "Workspaces já vinculados",
"workspaces_being_added": "Workspaces a receber acesso"
},
"general": {
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
@@ -3652,6 +3653,7 @@
"api_ingestion_settings_description": "Envia registos de feedback através da API de gestão.",
"auto_generated": "Gerado automaticamente",
"change_file": "Alterar ficheiro",
"click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas",
"click_to_upload": "Clique para carregar",
"collected_at": "Recolhido em",
"configure_import": "Configurar importação",
@@ -3659,60 +3661,41 @@
"connector_created_successfully": "Conector criado com sucesso",
"connector_deleted_successfully": "Conector eliminado com sucesso",
"connector_duplicated_successfully": "Conector duplicado com sucesso",
"connector_name": "Nome do Conector",
"connector_name_hint": "Como este conector aparece no teu painel. Preenchido automaticamente a partir do nome do ficheiro carregado — podes editar a qualquer momento.",
"connector_status_updated_successfully": "Estado do conector atualizado com sucesso",
"connector_updated_successfully": "Conector atualizado com sucesso",
"connectors": "Conectores",
"create_mapping": "Criar mapeamento",
"created_by": "Criado por",
"csv_advanced": "Avançado",
"csv_advanced_hint": "Campos menos comuns. Define-os quando forem relevantes.",
"csv_at_least_one_row": "O CSV deve conter pelo menos uma linha de dados.",
"csv_auto_mapped": "Mapeado automaticamente",
"csv_auto_mapped_tooltip": "Mapeámos isto a partir de \"{column}\" porque o cabeçalho parecia semelhante. Confirma ou altera.",
"csv_auto_mapped_verify": "Mapeado automaticamente (requer revisão)",
"csv_basic_required": "Básico (obrigatório)",
"csv_basic_required_hint": "Escolhe uma coluna CSV ou define um valor fixo aplicado a todas as linhas.",
"csv_column_used_by": "Mapeado para: {target}",
"csv_columns": "Colunas CSV",
"csv_data_preview": "Pré-visualização de dados",
"csv_empty_column_headers": "O CSV contém cabeçalhos de coluna vazios. Todas as colunas devem ter um nome.",
"csv_file_too_large": "O ficheiro CSV é demasiado grande. O tamanho máximo é 2MB.",
"csv_files_only": "Apenas ficheiros CSV",
"csv_fixed_value_action": "Definir um valor fixo…",
"csv_fixed_value_label": "Valor fixo: {value}",
"csv_import": "Importação CSV",
"csv_import_complete": "Importação de CSV concluída: {successes} com sucesso, {failures} falharam, {skipped} ignorados",
"csv_import_duplicate_warning": "Importar dados duas vezes irá criar registos duplicados.",
"csv_inconsistent_columns": "A linha {row} tem colunas inconsistentes. Todas as linhas devem ter os mesmos cabeçalhos.",
"csv_max_records": "Máximo de {max} registos permitidos.",
"csv_now_label": "Agora (usar a hora da importação)",
"csv_pick_column_placeholder": "Escolhe uma coluna ou define um valor…",
"csv_required_fields_missing": "Por favor, mapeia os campos obrigatórios antes de guardar: {fields}",
"csv_response_preview": "Exemplo: \"{sample}\" → armazenado como {target}.",
"csv_rows_count": "{count, plural, one {# linha} other {# linhas}}",
"csv_sample_label": "Exemplo de CSV",
"csv_source_context": "Contexto da Origem",
"csv_source_context_hint": "Identifica de onde veio este lote de feedback.",
"csv_unmapped_columns": "Colunas não mapeadas ({count}): {columns}",
"csv_unmapped_columns_explainer": "Estas colunas não são usadas por nenhum campo de Registo de Feedback. Serão ignoradas na importação.",
"custom_source_type": "Tipo de origem personalizado",
"custom_source_type_placeholder": "Insira o tipo de fonte personalizado",
"default_connector_name_csv": "Importação CSV",
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
"discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.",
"discard_feedback_record_changes_title": "Descartar alterações não salvas?",
"dont_include": "Não incluir este campo",
"drop_a_field_here": "Solte um campo aqui",
"drop_field_or": "Solte o campo ou",
"edit_csv_mapping": "Editar mapeamento CSV",
"edit_source_connection": "Editar ligação de origem",
"enter_name_for_source": "Introduz um nome para esta origem",
"enter_value": "Introduzir valor...",
"enum": "enum",
"failed_to_load_feedback_records": "Falha ao carregar registos de feedback",
"feedback_date": "Data atual",
"feedback_directory": "Diretório de Feedback",
"feedback_record_created_successfully": "Registro de feedback criado com sucesso",
"feedback_record_details": "Detalhes do registro de feedback",
"feedback_record_details_description": "Revise e atualize os campos de registro de feedback.",
"feedback_record_fields": "Campos de registo de feedback",
"feedback_record_mcp": "MCP de Registo de Feedback",
"feedback_record_updated_successfully": "Registro de feedback atualizado com sucesso",
"feedback_record_value_required": "Um valor é obrigatório para o tipo de campo selecionado",
@@ -3749,13 +3732,20 @@
"metadata_value": "Valor dos metadados",
"missing_feedback_source_title": "Falta alguma fonte de feedback?",
"no_feedback_directory_available": "Nenhum diretório de feedback atribuído a este workspace. Cria ou atribui um primeiro.",
"no_feedback_directory_linked_admin_description": "Os diretórios de feedback agregam registos de workspaces conectados. Ainda não há nenhum diretório vinculado a este workspace — atribui um nas definições de Diretório de Feedback para começares a recolher registos.",
"no_feedback_directory_linked_member_description": "É necessário configurar um diretório de feedback para este workspace antes de esta funcionalidade estar disponível. Pede a um proprietário ou gestor da organização para atribuir um.",
"no_feedback_directory_linked_title": "Nenhum diretório de feedback vinculado",
"no_feedback_records": "Ainda não há registos de feedback. Os registos aparecerão aqui assim que os teus conectores começarem a enviar dados.",
"no_formbricks_surveys_available_description": "Ainda não há inquéritos neste workspace. <surveyLink>Cria um novo inquérito</surveyLink> para usar um como fonte de feedback.",
"no_source_fields_loaded": "Ainda não foram carregados campos de origem",
"no_sources_connected": "Ainda não há origens ligadas. Adicione uma origem para começar.",
"optional": "Opcional",
"or_drag_and_drop": "ou arraste e largue",
"question_type_not_supported": "Este tipo de pergunta não é suportado",
"refresh_feedback_records": "Atualizar registos de feedback",
"refreshing_feedback_records": "A atualizar registos de feedback...",
"request_feedback_source": "Solicitar integração de fonte",
"required": "Obrigatório",
"save_changes": "Guardar alterações",
"search_feedback": "Pesquisar feedback",
"select_a_survey_to_see_questions": "Selecione um inquérito para ver as suas perguntas",
@@ -3784,11 +3774,12 @@
"set_value": "definir valor",
"setup_connection": "Configurar ligação",
"showing_count_loaded": "A mostrar {count} registos",
"showing_rows": "A mostrar {visible} de {total} linhas",
"showing_rows": "A mostrar 3 de {count} linhas",
"source": "fonte",
"source_connect_csv_description": "Importar feedback de ficheiros CSV",
"source_connect_feedback_record_mcp_description": "Envia registos de feedback através da integração MCP.",
"source_connect_formbricks_description": "Conectar feedback dos seus inquéritos Formbricks",
"source_fields": "Campos da fonte",
"source_id": "ID da fonte",
"source_name": "Nome da fonte",
"source_type": "Tipo de fonte",
+21 -30
View File
@@ -1854,10 +1854,7 @@
"api_key_updated": "Cheie API actualizată",
"delete_api_key_confirmation": "Orice aplicație care folosește această cheie nu va mai putea accesa datele dumneavoastră Formbricks.",
"duplicate_access": "Acces duplicat la spațiul de lucru nu este permis",
"duplicate_directory_access": "Accesul duplicat la directorul de feedback nu este permis",
"feedback_directory_access": "Acces la Directorul de Feedback",
"no_api_keys_yet": "Nu ai încă nicio cheie API",
"no_directory_permissions_found": "Nu au fost găsite permisiuni pentru directoare de feedback",
"no_workspace_permissions_found": "Nu s-au găsit permisiuni pentru Workspace",
"organization_access": "Acces organizație",
"organization_access_description": "Selectează privilegii de citire sau scriere pentru resursele la nivel de organizație.",
@@ -1865,7 +1862,6 @@
"secret": "Secret",
"unable_to_copy_api_key": "Nu se poate copia cheia API",
"unable_to_delete_api_key": "Nu se poate șterge cheia API",
"unknown_directory": "Director necunoscut",
"unknown_workspace": "Spațiu de lucru necunoscut",
"workspace_access": "Acces spațiu de lucru"
},
@@ -2568,6 +2564,9 @@
"error_directory_name_required": "Numele directorului este obligatoriu.",
"error_directory_workspaces_invalid_org": "Unele spații de lucru specificate nu aparțin acestei organizații.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"grant_access_confirm": "Acordă acces la spațiul de lucru",
"grant_workspace_access_title": "Confirmă acordarea accesului la spațiul de lucru",
"grant_workspace_access_warning": "Toți membrii actuali și viitori ai spațiilor de lucru de mai jos vor primi acces de citire la toate datele de feedback direcționate către \"{directoryName}\", inclusiv datele ingerate de conectorii altor spații de lucru conectate. Această modificare nu poate fi anulată în mod discret.",
"nav_label": "Directoare de feedback",
"no_access": "Nu ai permisiunea de a gestiona directoarele de feedback.",
"no_connectors": "Niciun conector asociat acestui director încă.",
@@ -2580,7 +2579,9 @@
"unarchive_workspace_conflict": "Acest director nu poate fi dezarhivat deoarece unul sau mai multe spații de lucru alocate sunt arhivate.",
"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_access": "Acces la spațiul de lucru",
"workspaces_already_linked": "Spații de lucru deja conectate",
"workspaces_being_added": "Spații de lucru cărora li se acordă acces"
},
"general": {
"ai_data_analysis_enabled": "Îmbogățire și analiză de date (AI)",
@@ -3652,6 +3653,7 @@
"api_ingestion_settings_description": "Trimite înregistrări de feedback folosind API-ul de management.",
"auto_generated": "Generat automat",
"change_file": "Schimbă fișierul",
"click_load_sample_csv": "Apasă pe „Încarcă CSV de exemplu” pentru a vedea coloanele",
"click_to_upload": "Apasă pentru a încărca",
"collected_at": "Colectat la",
"configure_import": "Configurează importul",
@@ -3659,60 +3661,41 @@
"connector_created_successfully": "Conector creat cu succes",
"connector_deleted_successfully": "Conector șters cu succes",
"connector_duplicated_successfully": "Conector duplicat cu succes",
"connector_name": "Numele conectorului",
"connector_name_hint": "Cum apare acest conector în tabloul tău de bord. Completat automat din numele fișierului încărcat — poți edita oricând.",
"connector_status_updated_successfully": "Statusul conectorului a fost actualizat cu succes",
"connector_updated_successfully": "Conector actualizat cu succes",
"connectors": "Conectori",
"create_mapping": "Creează mapare",
"created_by": "Creat de",
"csv_advanced": "Avansat",
"csv_advanced_hint": "Câmpuri mai puțin comune. Setează-le când sunt relevante.",
"csv_at_least_one_row": "CSV-ul trebuie să conțină cel puțin un rând de date.",
"csv_auto_mapped": "Mapat automat",
"csv_auto_mapped_tooltip": "Am mapat asta din \"{column}\" pentru că antetul părea similar. Confirmă sau modifică.",
"csv_auto_mapped_verify": "Mapat automat (necesită verificare)",
"csv_basic_required": "De bază (obligatoriu)",
"csv_basic_required_hint": "Alege o coloană CSV sau setează o valoare fixă aplicată fiecărui rând.",
"csv_column_used_by": "Mapat la: {target}",
"csv_columns": "Coloane CSV",
"csv_data_preview": "Previzualizare date",
"csv_empty_column_headers": "CSV-ul conține antete de coloană goale. Toate coloanele trebuie să aibă un nume.",
"csv_file_too_large": "Fișierul CSV este prea mare. Dimensiunea maximă este de 2 MB.",
"csv_files_only": "Doar fișiere CSV",
"csv_fixed_value_action": "Setează o valoare fixă…",
"csv_fixed_value_label": "Valoare fixă: {value}",
"csv_import": "Import CSV",
"csv_import_complete": "Import CSV finalizat: {successes} reușite, {failures} eșuate, {skipped} omise",
"csv_import_duplicate_warning": "Importarea datelor de două ori va crea înregistrări duplicate.",
"csv_inconsistent_columns": "Rândul {row} are coloane inconsistente. Toate rândurile trebuie să aibă aceleași antete.",
"csv_max_records": "Sunt permise maximum {max} înregistrări.",
"csv_now_label": "Acum (folosește momentul importului)",
"csv_pick_column_placeholder": "Alege o coloană sau setează o valoare…",
"csv_required_fields_missing": "Te rugăm să mapezi câmpurile obligatorii înainte de salvare: {fields}",
"csv_response_preview": "Exemplu: \"{sample}\" → stocat ca {target}.",
"csv_rows_count": "{count, plural, one {# rând} few {# rânduri} other {# de rânduri}}",
"csv_sample_label": "Exemplu CSV",
"csv_source_context": "Contextul sursei",
"csv_source_context_hint": "Identifică de unde provine acest lot de feedback.",
"csv_unmapped_columns": "Coloane nemapate ({count}): {columns}",
"csv_unmapped_columns_explainer": "Aceste coloane nu sunt folosite de niciun câmp din Înregistrarea de Feedback. Vor fi ignorate la import.",
"custom_source_type": "Tip sursă personalizat",
"custom_source_type_placeholder": "Introduceți tipul de sursă personalizat",
"default_connector_name_csv": "Import CSV",
"default_connector_name_formbricks": "Conexiune chestionar Formbricks",
"discard_feedback_record_changes_description": "Modificările dvs. se vor pierde dacă închideți acest sertar.",
"discard_feedback_record_changes_title": "Renunțați la modificările nesalvate?",
"dont_include": "Nu include acest câmp",
"drop_a_field_here": "Trage un câmp aici",
"drop_field_or": "Trage câmpul sau",
"edit_csv_mapping": "Editează maparea CSV",
"edit_source_connection": "Editează conexiunea sursei",
"enter_name_for_source": "Introdu un nume pentru această sursă",
"enter_value": "Introdu valoarea...",
"enum": "enum",
"failed_to_load_feedback_records": "Nu s-au putut încărca înregistrările de feedback",
"feedback_date": "Data curentă",
"feedback_directory": "Director de Feedback",
"feedback_record_created_successfully": "Înregistrare de feedback creată cu succes",
"feedback_record_details": "Detaliile înregistrării feedback-ului",
"feedback_record_details_description": "Examinați și actualizați câmpurile pentru înregistrarea de feedback.",
"feedback_record_fields": "Câmpuri înregistrare feedback",
"feedback_record_mcp": "MCP Înregistrări Feedback",
"feedback_record_updated_successfully": "Înregistrarea feedback-ului a fost actualizată cu succes",
"feedback_record_value_required": "Este necesară o valoare pentru tipul de câmp selectat",
@@ -3749,13 +3732,20 @@
"metadata_value": "Valoarea metadatelor",
"missing_feedback_source_title": "Lipsește o sursă de feedback?",
"no_feedback_directory_available": "Niciun director de feedback nu este atribuit acestui spațiu de lucru. Creează sau atribuie unul mai întâi.",
"no_feedback_directory_linked_admin_description": "Directoarele de feedback agregă înregistrări de la spațiile de lucru conectate. Niciun director nu este conectat la acest spațiu de lucru încă — atribuie unul din setările Directorului de feedback pentru a începe colectarea înregistrărilor.",
"no_feedback_directory_linked_member_description": "Trebuie configurat un director de feedback pentru acest spațiu de lucru înainte ca această funcționalitate să fie disponibilă. Solicită unui proprietar sau manager al organizației să atribuie unul.",
"no_feedback_directory_linked_title": "Niciun director de feedback conectat",
"no_feedback_records": "Nu există încă înregistrări de feedback. Înregistrările vor apărea aici după ce conectorii tăi vor începe să trimită date.",
"no_formbricks_surveys_available_description": "Nu există încă chestionare în acest spațiu de lucru. <surveyLink>Creează un chestionar nou</surveyLink> pentru a folosi unul ca sursă de feedback.",
"no_source_fields_loaded": "Nu au fost încă încărcate câmpuri sursă",
"no_sources_connected": "Nicio sursă conectată încă. Adaugă o sursă pentru a începe.",
"optional": "Opțional",
"or_drag_and_drop": "sau trage și lasă aici",
"question_type_not_supported": "Acest tip de întrebare nu este suportat",
"refresh_feedback_records": "Reîmprospătează înregistrările de feedback",
"refreshing_feedback_records": "Se actualizează înregistrările de feedback...",
"request_feedback_source": "Solicită integrarea sursei",
"required": "Obligatoriu",
"save_changes": "Salvează modificările",
"search_feedback": "Caută feedback",
"select_a_survey_to_see_questions": "Selectează un chestionar pentru a vedea întrebările",
@@ -3784,11 +3774,12 @@
"set_value": "setează valoare",
"setup_connection": "Configurează conexiunea",
"showing_count_loaded": "Se afișează {count} înregistrări",
"showing_rows": "Se afișează {visible} din {total} rânduri",
"showing_rows": "Se afișează 3 din {count} rânduri",
"source": "sursă",
"source_connect_csv_description": "Importă feedback din fișiere CSV",
"source_connect_feedback_record_mcp_description": "Trimite înregistrări de feedback prin integrarea MCP.",
"source_connect_formbricks_description": "Conectează feedback din sondajele Formbricks",
"source_fields": "Câmpuri sursă",
"source_id": "ID sursă",
"source_name": "Nume sursă",
"source_type": "Tip sursă",
+21 -30
View File
@@ -1854,10 +1854,7 @@
"api_key_updated": "API-ключ обновлён",
"delete_api_key_confirmation": "Любые приложения, использующие этот ключ, больше не смогут получить доступ к вашим данным Formbricks.",
"duplicate_access": "Дублированный доступ к рабочему пространству не разрешён",
"duplicate_directory_access": "Дублирование доступа к директории обратной связи запрещено",
"feedback_directory_access": "Доступ к директории обратной связи",
"no_api_keys_yet": "У вас ещё нет API-ключей",
"no_directory_permissions_found": "Разрешения для директорий обратной связи не найдены",
"no_workspace_permissions_found": "Разрешения для рабочего пространства не найдены",
"organization_access": "Доступ к организации",
"organization_access_description": "Выберите права на чтение или запись для ресурсов всей организации.",
@@ -1865,7 +1862,6 @@
"secret": "Секрет",
"unable_to_copy_api_key": "Не удалось скопировать API-ключ",
"unable_to_delete_api_key": "Не удалось удалить API-ключ",
"unknown_directory": "Неизвестный каталог",
"unknown_workspace": "Неизвестное рабочее пространство",
"workspace_access": "Доступ к рабочему пространству"
},
@@ -2568,6 +2564,9 @@
"error_directory_name_required": "Необходимо указать имя директории.",
"error_directory_workspaces_invalid_org": "Некоторые указанные рабочие пространства не принадлежат этой организации.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"grant_access_confirm": "Предоставить доступ к рабочему пространству",
"grant_workspace_access_title": "Подтверди предоставление доступа к рабочему пространству",
"grant_workspace_access_warning": "Все текущие и будущие участники рабочих пространств ниже получат доступ на чтение ко всем данным обратной связи, направляемым в \"{directoryName}\", включая данные, полученные через коннекторы других связанных рабочих пространств. Это изменение невозможно будет отменить незаметно.",
"nav_label": "Каталоги отзывов",
"no_access": "У тебя нет прав для управления директориями обратной связи.",
"no_connectors": "К этому каталогу пока не привязано ни одного коннектора.",
@@ -2580,7 +2579,9 @@
"unarchive_workspace_conflict": "Невозможно разархивировать этот каталог, потому что один или несколько назначенных рабочих пространств архивированы.",
"upgrade_prompt_description": "Организуй записи обратной связи в директории и направляй данные в нужное рабочее пространство. Доступно в тарифах Pro и Scale.",
"upgrade_prompt_title": "Обнови тариф, чтобы получить доступ к директориям записей обратной связи",
"workspace_access": "Доступ к рабочему пространству"
"workspace_access": "Доступ к рабочему пространству",
"workspaces_already_linked": "Уже связанные рабочие пространства",
"workspaces_being_added": "Рабочие пространства, которым предоставляется доступ"
},
"general": {
"ai_data_analysis_enabled": "Обогащение и анализ данных (ИИ)",
@@ -3652,6 +3653,7 @@
"api_ingestion_settings_description": "Отправляйте записи обратной связи через Management API.",
"auto_generated": "Автоматически генерируется",
"change_file": "Изменить файл",
"click_load_sample_csv": "Нажмите «Загрузить пример CSV», чтобы увидеть столбцы",
"click_to_upload": "Кликните для загрузки",
"collected_at": "Собрано",
"configure_import": "Настроить импорт",
@@ -3659,60 +3661,41 @@
"connector_created_successfully": "Коннектор успешно создан",
"connector_deleted_successfully": "Коннектор успешно удалён",
"connector_duplicated_successfully": "Коннектор успешно дублирован",
"connector_name": "Название коннектора",
"connector_name_hint": "Как этот коннектор отображается на твоей панели. Автоматически заполняется из имени загруженного файла — редактируй в любое время.",
"connector_status_updated_successfully": "Статус коннектора успешно обновлён",
"connector_updated_successfully": "Коннектор успешно обновлён",
"connectors": "Коннекторы",
"create_mapping": "Создать сопоставление",
"created_by": "Создано пользователем",
"csv_advanced": "Дополнительные",
"csv_advanced_hint": "Менее распространённые поля. Заполняй их при необходимости.",
"csv_at_least_one_row": "CSV должен содержать хотя бы одну строку с данными.",
"csv_auto_mapped": "Автоматически сопоставлено",
"csv_auto_mapped_tooltip": "Мы сопоставили это с \"{column}\", потому что заголовок выглядел похоже. Подтверди или измени.",
"csv_auto_mapped_verify": "Автоматически сопоставлено (требуется проверка)",
"csv_basic_required": "Основные (обязательные)",
"csv_basic_required_hint": "Выбери столбец CSV или задай фиксированное значение для всех строк.",
"csv_column_used_by": "Сопоставлено с: {target}",
"csv_columns": "Столбцы CSV",
"csv_data_preview": "Предпросмотр данных",
"csv_empty_column_headers": "В CSV есть пустые заголовки столбцов. У всех столбцов должно быть имя.",
"csv_file_too_large": "Файл CSV слишком большой. Максимальный размер — 2 МБ.",
"csv_files_only": "Только файлы CSV",
"csv_fixed_value_action": "Задать фиксированное значение…",
"csv_fixed_value_label": "Фиксированное значение: {value}",
"csv_import": "Импорт CSV",
"csv_import_complete": "Импорт CSV завершён: {successes} успешно, {failures} с ошибками, {skipped} пропущено",
"csv_import_duplicate_warning": "Импорт уже загруженных данных может создать дубликаты записей.",
"csv_inconsistent_columns": "В строке {row} несоответствие столбцов. Во всех строках должны быть одинаковые заголовки.",
"csv_max_records": "Допустимо не более {max} записей.",
"csv_now_label": "Сейчас (использовать время импорта)",
"csv_pick_column_placeholder": "Выбери столбец или задай значение…",
"csv_required_fields_missing": "Пожалуйста, сопоставь обязательные поля перед сохранением: {fields}",
"csv_response_preview": "Пример: \"{sample}\" → сохранено как {target}.",
"csv_rows_count": "{count, plural, one {# строка} few {# строки} many {# строк} other {# строк}}",
"csv_sample_label": "Пример CSV",
"csv_source_context": "Исходный контекст",
"csv_source_context_hint": "Указывает, откуда пришёл этот набор отзывов.",
"csv_unmapped_columns": "Несопоставленные столбцы ({count}): {columns}",
"csv_unmapped_columns_explainer": "Эти столбцы не используются ни одним полем записи отзывов. Они будут проигнорированы при импорте.",
"custom_source_type": "Пользовательский тип источника",
"custom_source_type_placeholder": "Введите собственный тип источника",
"default_connector_name_csv": "Импорт CSV",
"default_connector_name_formbricks": "Подключение опроса Formbricks",
"discard_feedback_record_changes_description": "Ваши изменения будут потеряны, если вы закроете этот ящик.",
"discard_feedback_record_changes_title": "Отменить несохраненные изменения?",
"dont_include": "Не включать это поле",
"drop_a_field_here": "Перетащи сюда поле",
"drop_field_or": "Перетащи поле или",
"edit_csv_mapping": "Редактировать сопоставление CSV",
"edit_source_connection": "Редактировать подключение источника",
"enter_name_for_source": "Введи имя для этого источника",
"enter_value": "Введите значение...",
"enum": "enum",
"failed_to_load_feedback_records": "Не удалось загрузить отзывы",
"feedback_date": "Текущая дата",
"feedback_directory": "Директория обратной связи",
"feedback_record_created_successfully": "Запись отзыва успешно создана",
"feedback_record_details": "Детали записи обратной связи",
"feedback_record_details_description": "Просмотрите и обновите поля записи отзыва.",
"feedback_record_fields": "Поля записи отзыва",
"feedback_record_mcp": "MCP для записей обратной связи",
"feedback_record_updated_successfully": "Запись отзыва успешно обновлена.",
"feedback_record_value_required": "Требуется значение для выбранного типа поля.",
@@ -3749,13 +3732,20 @@
"metadata_value": "Значение метаданных",
"missing_feedback_source_title": "Не нашли нужный источник обратной связи?",
"no_feedback_directory_available": "К этому рабочему пространству не привязана директория обратной связи. Сначала создай или привяжи её.",
"no_feedback_directory_linked_admin_description": "Директории обратной связи собирают записи из подключенных рабочих пространств. К этому рабочему пространству пока не привязана ни одна директория — назначь её в настройках Директории обратной связи, чтобы начать сбор записей.",
"no_feedback_directory_linked_member_description": "Для этого рабочего пространства нужно настроить директорию обратной связи, прежде чем эта функция станет доступна. Попроси владельца или менеджера организации назначить её.",
"no_feedback_directory_linked_title": "Директория обратной связи не привязана",
"no_feedback_records": "Пока нет записей отзывов. Они появятся здесь, когда коннекторы начнут отправлять данные.",
"no_formbricks_surveys_available_description": "В этом рабочем пространстве пока нет опросов. <surveyLink>Создайте новый опрос</surveyLink>, чтобы использовать один как источник обратной связи.",
"no_source_fields_loaded": "Поля источника ещё не загружены",
"no_sources_connected": "Нет подключённых источников. Добавьте источник, чтобы начать.",
"optional": "Необязательно",
"or_drag_and_drop": "или перетащите файл",
"question_type_not_supported": "Этот тип вопроса не поддерживается",
"refresh_feedback_records": "Обновить записи отзывов",
"refreshing_feedback_records": "Обновляем записи отзывов...",
"request_feedback_source": "Запросить интеграцию источника",
"required": "Обязательно",
"save_changes": "Сохранить изменения",
"search_feedback": "Искать обратную связь",
"select_a_survey_to_see_questions": "Выберите опрос, чтобы увидеть его вопросы",
@@ -3784,11 +3774,12 @@
"set_value": "установить значение",
"setup_connection": "Настроить подключение",
"showing_count_loaded": "Показано записей: {count}",
"showing_rows": "Показано {visible} из {total} строк",
"showing_rows": "Показано 3 из {count} строк",
"source": "источник",
"source_connect_csv_description": "Импортировать отзывы из CSV-файлов",
"source_connect_feedback_record_mcp_description": "Отправляйте записи обратной связи через интеграцию MCP.",
"source_connect_formbricks_description": "Подключить отзывы из ваших опросов Formbricks",
"source_fields": "Поля источника",
"source_id": "Идентификатор источника",
"source_name": "Имя источника",
"source_type": "Тип источника",
+21 -30
View File
@@ -1854,10 +1854,7 @@
"api_key_updated": "API-nyckel uppdaterad",
"delete_api_key_confirmation": "Alla applikationer som använder denna nyckel kommer inte längre att kunna komma åt din Formbricks-data.",
"duplicate_access": "Duplicerad arbetsyteåtkomst är inte tillåten",
"duplicate_directory_access": "Duplicerad åtkomst till feedback-katalog är inte tillåten",
"feedback_directory_access": "Åtkomst till feedback-katalog",
"no_api_keys_yet": "Du har inga API-nycklar ännu",
"no_directory_permissions_found": "Inga behörigheter för feedback-katalog hittades",
"no_workspace_permissions_found": "Inga behörigheter för arbetsytan hittades",
"organization_access": "Organisationsåtkomst",
"organization_access_description": "Välj läs- eller skrivbehörighet för resurser på organisationsnivå.",
@@ -1865,7 +1862,6 @@
"secret": "Hemlig",
"unable_to_copy_api_key": "Kunde inte kopiera API-nyckel",
"unable_to_delete_api_key": "Det gick inte att radera API-nyckeln",
"unknown_directory": "Okänd katalog",
"unknown_workspace": "Okänd arbetsyta",
"workspace_access": "Arbetsyteåtkomst"
},
@@ -2568,6 +2564,9 @@
"error_directory_name_required": "Katalognamn krävs.",
"error_directory_workspaces_invalid_org": "Vissa angivna arbetsytor tillhör inte denna organisation.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"grant_access_confirm": "Bevilja arbetsyteåtkomst",
"grant_workspace_access_title": "Bekräfta beviljande av arbetsyteåtkomst",
"grant_workspace_access_warning": "Alla nuvarande och framtida medlemmar i arbetsytorna nedan får läsåtkomst till all feedbackdata som dirigeras till \"{directoryName}\", inklusive data som samlas in av andra länkade arbetsytors anslutningar. Denna ändring kan inte ångras tyst.",
"nav_label": "Feedbackkataloger",
"no_access": "Du har inte behörighet att hantera feedback-kataloger.",
"no_connectors": "Inga kopplingar länkade till den här katalogen ännu.",
@@ -2580,7 +2579,9 @@
"unarchive_workspace_conflict": "Den här katalogen kan inte avarkiveras eftersom en eller flera tilldelade arbetsytor är arkiverade.",
"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_access": "Arbetsyteåtkomst",
"workspaces_already_linked": "Redan länkade arbetsytor",
"workspaces_being_added": "Arbetsytor som beviljas åtkomst"
},
"general": {
"ai_data_analysis_enabled": "Dataförbättring & analys (AI)",
@@ -3652,6 +3653,7 @@
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"auto_generated": "Automatiskt genererad",
"change_file": "Byt fil",
"click_load_sample_csv": "Klicka på 'Ladda exempel-CSV' för att se kolumner",
"click_to_upload": "Klicka för att ladda upp",
"collected_at": "Insamlad",
"configure_import": "Konfigurera import",
@@ -3659,60 +3661,41 @@
"connector_created_successfully": "Kopplingen skapades",
"connector_deleted_successfully": "Kopplingen togs bort",
"connector_duplicated_successfully": "Kopplingen har duplicerats",
"connector_name": "Namn på koppling",
"connector_name_hint": "Hur denna koppling visas i din instrumentpanel. Förifylls från det uppladdade filnamnet — redigera när som helst.",
"connector_status_updated_successfully": "Kopplingens status har uppdaterats",
"connector_updated_successfully": "Kopplingen uppdaterades",
"connectors": "Kopplingar",
"create_mapping": "Skapa mappning",
"created_by": "Skapad av",
"csv_advanced": "Avancerat",
"csv_advanced_hint": "Mindre vanliga fält. Ställ in dem när det är relevant.",
"csv_at_least_one_row": "CSV-filen måste innehålla minst en datarad.",
"csv_auto_mapped": "Automatiskt mappat",
"csv_auto_mapped_tooltip": "Vi mappade detta från \"{column}\" eftersom rubriken såg liknande ut. Bekräfta eller ändra den.",
"csv_auto_mapped_verify": "Automatiskt mappat (behöver granskning)",
"csv_basic_required": "Grundläggande (obligatoriskt)",
"csv_basic_required_hint": "Välj en CSV-kolumn eller ange ett fast värde som tillämpas på varje rad.",
"csv_column_used_by": "Mappat till: {target}",
"csv_columns": "CSV-kolumner",
"csv_data_preview": "Förhandsgranskning av data",
"csv_empty_column_headers": "CSV-filen innehåller tomma kolumnrubriker. Alla kolumner måste ha ett namn.",
"csv_file_too_large": "CSV-filen är för stor. Maxstorlek är 2 MB.",
"csv_files_only": "Endast CSV-filer",
"csv_fixed_value_action": "Ange ett fast värde…",
"csv_fixed_value_label": "Fast värde: {value}",
"csv_import": "CSV-import",
"csv_import_complete": "CSV-import klar: {successes} lyckades, {failures} misslyckades, {skipped} hoppades över",
"csv_import_duplicate_warning": "Om du importerar data två gånger kommer det att skapa dubbletter.",
"csv_inconsistent_columns": "Rad {row} har inkonsekventa kolumner. Alla rader måste ha samma rubriker.",
"csv_max_records": "Maximalt {max} poster tillåtna.",
"csv_now_label": "Nu (använd importtidpunkten)",
"csv_pick_column_placeholder": "Välj en kolumn eller ange ett värde…",
"csv_required_fields_missing": "Vänligen mappa obligatoriska fält innan du sparar: {fields}",
"csv_response_preview": "Exempel: \"{sample}\" → lagras som {target}.",
"csv_rows_count": "{count, plural, one {# rad} other {# rader}}",
"csv_sample_label": "Exempel-CSV",
"csv_source_context": "Källkontext",
"csv_source_context_hint": "Identifierar var denna batch av feedback kom ifrån.",
"csv_unmapped_columns": "Omappade kolumner ({count}): {columns}",
"csv_unmapped_columns_explainer": "Dessa kolumner används inte av något fält i feedbackposten. De kommer att ignoreras vid import.",
"custom_source_type": "Anpassad källtyp",
"custom_source_type_placeholder": "Ange anpassad källtyp",
"default_connector_name_csv": "CSV-import",
"default_connector_name_formbricks": "Formbricks Survey-anslutning",
"discard_feedback_record_changes_description": "Dina ändringar kommer att gå förlorade om du stänger den här lådan.",
"discard_feedback_record_changes_title": "Vill du ignorera osparade ändringar?",
"dont_include": "Ta inte med detta fält",
"drop_a_field_here": "Släpp ett fält här",
"drop_field_or": "Släpp fält eller",
"edit_csv_mapping": "Redigera CSV-mappning",
"edit_source_connection": "Redigera källans anslutning",
"enter_name_for_source": "Ange ett namn för denna källa",
"enter_value": "Ange värde...",
"enum": "enum",
"failed_to_load_feedback_records": "Det gick inte att ladda feedbackposter",
"feedback_date": "Aktuellt datum",
"feedback_directory": "Feedback-katalog",
"feedback_record_created_successfully": "Feedbackposten har skapats",
"feedback_record_details": "Feedbackpostdetaljer",
"feedback_record_details_description": "Granska och uppdatera fält för feedbackposter.",
"feedback_record_fields": "Fält för feedbackpost",
"feedback_record_mcp": "Feedback Record MCP",
"feedback_record_updated_successfully": "Feedbackposten har uppdaterats",
"feedback_record_value_required": "Ett värde krävs för den valda fälttypen",
@@ -3749,13 +3732,20 @@
"metadata_value": "Metadatavärde",
"missing_feedback_source_title": "Missing feedback source?",
"no_feedback_directory_available": "Ingen feedback-katalog är tilldelad till denna arbetsyta. Skapa eller tilldela en först.",
"no_feedback_directory_linked_admin_description": "Feedbackkataloger samlar poster från anslutna arbetsytor. Ingen katalog är länkad till denna arbetsyta ännu — tilldela en från inställningarna för feedbackkatalog för att börja samla in poster.",
"no_feedback_directory_linked_member_description": "En feedbackkatalog måste konfigureras för denna arbetsyta innan den här funktionen blir tillgänglig. Be en organisationsägare eller chef att tilldela en.",
"no_feedback_directory_linked_title": "Ingen feedbackkatalog länkad",
"no_feedback_records": "Inga feedbackposter ännu. Poster visas här när dina connectors börjar skicka data.",
"no_formbricks_surveys_available_description": "Det finns inga enkäter i denna arbetsyta ännu. <surveyLink>Skapa en ny enkät</surveyLink> för att använda en som feedbackkälla.",
"no_source_fields_loaded": "Inga källfält har laddats än",
"no_sources_connected": "Inga källor är anslutna än. Lägg till en källa för att komma igång.",
"optional": "Valfritt",
"or_drag_and_drop": "eller dra och släpp",
"question_type_not_supported": "Den här frågetypen stöds inte",
"refresh_feedback_records": "Uppdatera feedbackposter",
"refreshing_feedback_records": "Uppdaterar feedbackposter...",
"request_feedback_source": "Request source integration",
"required": "Obligatoriskt",
"save_changes": "Spara ändringar",
"search_feedback": "Sök feedback",
"select_a_survey_to_see_questions": "Välj en enkät för att se dess frågor",
@@ -3784,11 +3774,12 @@
"set_value": "ange värde",
"setup_connection": "Ställ in anslutning",
"showing_count_loaded": "Visar {count} poster",
"showing_rows": "Visar {visible} av {total} rader",
"showing_rows": "Visar 3 av {count} rader",
"source": "källa",
"source_connect_csv_description": "Importera feedback från CSV-filer",
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
"source_connect_formbricks_description": "Anslut feedback från dina Formbricks-enkäter",
"source_fields": "Källfält",
"source_id": "Käll-ID",
"source_name": "Källnamn",
"source_type": "Källtyp",
+21 -30
View File
@@ -1854,10 +1854,7 @@
"api_key_updated": "API anahtarı güncellendi",
"delete_api_key_confirmation": "Bu anahtarı kullanan tüm uygulamalar artık Formbricks verilerine erişemeyecek.",
"duplicate_access": "Yinelenen çalışma alanı erişimine izin verilmiyor",
"duplicate_directory_access": "Yinelenen geri bildirim dizini erişimine izin verilmiyor",
"feedback_directory_access": "Geri Bildirim Dizini Erişimi",
"no_api_keys_yet": "Henüz hiç API anahtarınız yok",
"no_directory_permissions_found": "Geri bildirim dizini izni bulunamadı",
"no_workspace_permissions_found": "Çalışma Alanı izni bulunamadı",
"organization_access": "Organizasyon Erişimi",
"organization_access_description": "Organizasyon genelindeki kaynaklar için okuma veya yazma yetkilerini seçin.",
@@ -1865,7 +1862,6 @@
"secret": "Gizli Anahtar",
"unable_to_copy_api_key": "API anahtarı kopyalanamıyor",
"unable_to_delete_api_key": "API anahtarı silinemiyor",
"unknown_directory": "Bilinmeyen dizin",
"unknown_workspace": "Bilinmeyen çalışma alanı",
"workspace_access": "Çalışma Alanı Erişimi"
},
@@ -2568,6 +2564,9 @@
"error_directory_name_required": "Dizin adı gereklidir.",
"error_directory_workspaces_invalid_org": "Belirtilen çalışma alanlarından bazıları bu organizasyona ait değil.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"grant_access_confirm": "Çalışma alanı erişimi ver",
"grant_workspace_access_title": "Çalışma alanı erişim iznini onayla",
"grant_workspace_access_warning": "Aşağıdaki çalışma alanlarının mevcut ve gelecekteki tüm üyeleri, diğer bağlı çalışma alanlarının bağlayıcıları tarafından toplanan veriler de dahil olmak üzere \"{directoryName}\" dizinine yönlendirilen tüm geri bildirim verilerine okuma erişimi kazanacak. Bu değişiklik sessizce geri alınamaz.",
"nav_label": "Geri Bildirim Dizinleri",
"no_access": "Geri bildirim dizinlerini yönetme yetkin yok.",
"no_connectors": "Bu dizine henüz bağlı bağlayıcı yok.",
@@ -2580,7 +2579,9 @@
"unarchive_workspace_conflict": "Atanmış çalışma alanlarından biri veya daha fazlası arşivlendiği için bu dizin arşivden çıkarılamaz.",
"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_access": "Çalışma alanı erişimi",
"workspaces_already_linked": "Zaten bağlı çalışma alanları",
"workspaces_being_added": "Erişim verilen çalışma alanları"
},
"general": {
"ai_data_analysis_enabled": "Veri zenginleştirme ve analiz (Yapay Zeka)",
@@ -3652,6 +3653,7 @@
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"auto_generated": "Otomatik olarak oluşturuldu",
"change_file": "Dosyayı değiştir",
"click_load_sample_csv": "Sütunları görmek için 'Örnek CSV yükle'ye tıkla",
"click_to_upload": "Yüklemek için tıkla",
"collected_at": "Toplandığı Tarih",
"configure_import": "İçe aktarmayı yapılandır",
@@ -3659,60 +3661,41 @@
"connector_created_successfully": "Bağlayıcı başarıyla oluşturuldu",
"connector_deleted_successfully": "Bağlayıcı başarıyla silindi",
"connector_duplicated_successfully": "Bağlayıcı başarıyla kopyalandı",
"connector_name": "Bağlayıcı Adı",
"connector_name_hint": "Bu bağlayıcının kontrol panelinizde nasıl görüneceği. Yüklenen dosya adından otomatik doldurulur — istediğin zaman düzenleyebilirsin.",
"connector_status_updated_successfully": "Bağlayıcı durumu başarıyla güncellendi",
"connector_updated_successfully": "Bağlayıcı başarıyla güncellendi",
"connectors": "Bağlayıcılar",
"create_mapping": "Eşleştirme oluştur",
"created_by": "Oluşturan",
"csv_advanced": "Gelişmiş",
"csv_advanced_hint": "Daha az kullanılan alanlar. Gerektiğinde bunları ayarla.",
"csv_at_least_one_row": "CSV en az bir veri satırı içermelidir.",
"csv_auto_mapped": "Otomatik eşlendi",
"csv_auto_mapped_tooltip": "Başlık benzer göründüğü için bunu \"{column}\" sütunundan eşledik. Onayla veya değiştir.",
"csv_auto_mapped_verify": "Otomatik eşlendi (inceleme gerekli)",
"csv_basic_required": "Temel (zorunlu)",
"csv_basic_required_hint": "Bir CSV sütunu seç veya her satıra uygulanacak sabit bir değer belirle.",
"csv_column_used_by": "Şuraya eşlendi: {target}",
"csv_columns": "CSV Sütunları",
"csv_data_preview": "Veri önizlemesi",
"csv_empty_column_headers": "CSV boş sütun başlıkları içeriyor. Tüm sütunların bir adı olmalıdır.",
"csv_file_too_large": "CSV dosyası çok büyük. Maksimum boyut 2MB'dir.",
"csv_files_only": "Yalnızca CSV dosyaları",
"csv_fixed_value_action": "Sabit bir değer belirle…",
"csv_fixed_value_label": "Sabit değer: {value}",
"csv_import": "CSV İçe Aktarma",
"csv_import_complete": "CSV içe aktarma tamamlandı: {successes} başarılı, {failures} başarısız, {skipped} atlandı",
"csv_import_duplicate_warning": "Verileri iki kez içe aktarmak yinelenen kayıtlar oluşturacaktır.",
"csv_inconsistent_columns": "Satır {row} tutarsız sütunlara sahip. Tüm satırlar aynı başlıklara sahip olmalıdır.",
"csv_max_records": "Maksimum {max} kayda izin verilir.",
"csv_now_label": "Şimdi (içe aktarma zamanını kullan)",
"csv_pick_column_placeholder": "Bir sütun seç veya değer belirle…",
"csv_required_fields_missing": "Kaydetmeden önce lütfen gerekli alanları eşle: {fields}",
"csv_response_preview": "Örnek: \"{sample}\" → {target} olarak saklandı.",
"csv_rows_count": "{count, plural, one {# satır} other {# satır}}",
"csv_sample_label": "Örnek CSV",
"csv_source_context": "Kaynak Bağlamı",
"csv_source_context_hint": "Bu geri bildirim grubunun nereden geldiğini tanımlar.",
"csv_unmapped_columns": "Eşlenmemiş sütunlar ({count}): {columns}",
"csv_unmapped_columns_explainer": "Bu sütunlar herhangi bir Geri Bildirim Kaydı alanı tarafından kullanılmıyor. İçe aktarma sırasında göz ardı edilecekler.",
"custom_source_type": "Özel kaynak türü",
"custom_source_type_placeholder": "Özel kaynak türünü girin",
"default_connector_name_csv": "CSV İçe Aktarma",
"default_connector_name_formbricks": "Formbricks Anket Bağlantısı",
"discard_feedback_record_changes_description": "Bu çekmeceyi kapatırsanız değişiklikleriniz kaybolacak.",
"discard_feedback_record_changes_title": "Kaydedilmemiş değişiklikler silinsin mi?",
"dont_include": "Bu alanı dahil etme",
"drop_a_field_here": "Buraya bir alan bırakın",
"drop_field_or": "Alan bırakın veya",
"edit_csv_mapping": "CSV eşlemesini düzenle",
"edit_source_connection": "Kaynak Bağlantısını Düzenle",
"enter_name_for_source": "Bu kaynak için bir ad girin",
"enter_value": "Değer girin...",
"enum": "enum",
"failed_to_load_feedback_records": "Geri bildirim kayıtları yüklenemedi",
"feedback_date": "Geçerli tarih",
"feedback_directory": "Geri Bildirim Dizini",
"feedback_record_created_successfully": "Geri bildirim kaydı başarıyla oluşturuldu",
"feedback_record_details": "Geri bildirim kaydı ayrıntıları",
"feedback_record_details_description": "Geri bildirim kayıt alanlarını inceleyin ve güncelleyin.",
"feedback_record_fields": "Geri Bildirim Kayıt Alanları",
"feedback_record_mcp": "Feedback Record MCP",
"feedback_record_updated_successfully": "Geri bildirim kaydı başarıyla güncellendi",
"feedback_record_value_required": "Seçilen alan türü için bir değer gerekli",
@@ -3749,13 +3732,20 @@
"metadata_value": "Meta veri değeri",
"missing_feedback_source_title": "Missing feedback source?",
"no_feedback_directory_available": "Bu çalışma alanına atanmış geri bildirim dizini yok. Önce bir tane oluştur veya ata.",
"no_feedback_directory_linked_admin_description": "Geri bildirim dizinleri, bağlı çalışma alanlarından kayıtları toplar. Bu çalışma alanına henüz bir dizin bağlanmadı — kayıtları toplamaya başlamak için Geri Bildirim Dizini ayarlarından bir tane ata.",
"no_feedback_directory_linked_member_description": "Bu işlevin kullanılabilmesi için önce bu çalışma alanı için bir geri bildirim dizini kurulması gerekiyor. Bir organizasyon sahibinden veya yöneticisinden bir tane atamasını iste.",
"no_feedback_directory_linked_title": "Bağlı geri bildirim dizini yok",
"no_feedback_records": "Henüz geri bildirim kaydı yok. Bağlayıcıların veri göndermeye başlamasıyla kayıtlar burada görünecek.",
"no_formbricks_surveys_available_description": "Bu çalışma alanında henüz anket yok. Geri bildirim kaynağı olarak kullanmak için <surveyLink>Yeni bir anket oluştur</surveyLink>.",
"no_source_fields_loaded": "Henüz kaynak alan yüklenmedi",
"no_sources_connected": "Henüz bağlı kaynak yok. Başlamak için bir kaynak ekle.",
"optional": "İsteğe bağlı",
"or_drag_and_drop": "veya sürükle bırak",
"question_type_not_supported": "Bu soru türü desteklenmiyor",
"refresh_feedback_records": "Geri bildirim kayıtlarını yenile",
"refreshing_feedback_records": "Geri bildirim kayıtları yenileniyor...",
"request_feedback_source": "Request source integration",
"required": "Gerekli",
"save_changes": "Değişiklikleri kaydet",
"search_feedback": "Geri bildirim ara",
"select_a_survey_to_see_questions": "Sorularını görmek için bir anket seç",
@@ -3784,11 +3774,12 @@
"set_value": "değer belirle",
"setup_connection": "Bağlantıyı kur",
"showing_count_loaded": "{count} kayıt gösteriliyor",
"showing_rows": "{total} satırdan {visible} tanesi gösteriliyor",
"showing_rows": "{count} satırdan 3'ü gösteriliyor",
"source": "kaynak",
"source_connect_csv_description": "CSV dosyalarından geri bildirim içe aktar",
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
"source_connect_formbricks_description": "Formbricks anketlerinizdeki geri bildirimleri bağlayın",
"source_fields": "Kaynak Alanları",
"source_id": "Kaynak kimliği",
"source_name": "Kaynak Adı",
"source_type": "Kaynak Türü",
+21 -30
View File
@@ -1854,10 +1854,7 @@
"api_key_updated": "API 密钥已更新",
"delete_api_key_confirmation": "使用此密钥的任何应用将无法再访问您的 Formbricks 数据。",
"duplicate_access": "不允许重复的工作区访问权限",
"duplicate_directory_access": "不允许重复的反馈目录访问权限",
"feedback_directory_access": "反馈目录访问权限",
"no_api_keys_yet": "您还没有任何 API 密钥",
"no_directory_permissions_found": "未找到反馈目录权限",
"no_workspace_permissions_found": "未找到工作区权限",
"organization_access": "组织访问权限",
"organization_access_description": "为组织范围的资源选择读取或写入权限。",
@@ -1865,7 +1862,6 @@
"secret": "密钥",
"unable_to_copy_api_key": "无法复制 API 密钥",
"unable_to_delete_api_key": "无法删除 API 密钥",
"unknown_directory": "未知目录",
"unknown_workspace": "未知工作区",
"workspace_access": "工作区访问权限"
},
@@ -2568,6 +2564,9 @@
"error_directory_name_required": "目录名称为必填项。",
"error_directory_workspaces_invalid_org": "某些指定的工作区不属于此组织。",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"grant_access_confirm": "授权工作区访问权限",
"grant_workspace_access_title": "确认授权工作区访问",
"grant_workspace_access_warning": "下方所有当前及未来的工作区成员都将获得对“{directoryName}”中所有反馈数据的读取权限,包括其他已关联工作区连接器导入的数据。此更改无法静默撤销。",
"nav_label": "反馈目录",
"no_access": "你没有管理反馈目录的权限。",
"no_connectors": "此目录尚未链接任何连接器。",
@@ -2580,7 +2579,9 @@
"unarchive_workspace_conflict": "无法取消归档该目录,因为一个或多个已分配工作区已归档。",
"upgrade_prompt_description": "将反馈记录整理到目录中,并将数据路由到正确的工作空间。专业版和规模版方案可用。",
"upgrade_prompt_title": "升级以解锁反馈记录目录",
"workspace_access": "工作区访问权限"
"workspace_access": "工作区访问权限",
"workspaces_already_linked": "已关联的工作区",
"workspaces_being_added": "将被授权访问的工作区"
},
"general": {
"ai_data_analysis_enabled": "数据增强与分析(AI",
@@ -3652,6 +3653,7 @@
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"auto_generated": "自动生成",
"change_file": "更换文件",
"click_load_sample_csv": "点击“加载示例 CSV”查看列",
"click_to_upload": "点击上传",
"collected_at": "收集时间",
"configure_import": "配置导入",
@@ -3659,60 +3661,41 @@
"connector_created_successfully": "连接器创建成功",
"connector_deleted_successfully": "连接器删除成功",
"connector_duplicated_successfully": "连接器复制成功",
"connector_name": "连接器名称",
"connector_name_hint": "该连接器在你的仪表盘中的显示名称。会自动根据上传的文件名填写,你可以随时编辑。",
"connector_status_updated_successfully": "连接器状态更新成功",
"connector_updated_successfully": "连接器更新成功",
"connectors": "连接器",
"create_mapping": "创建映射",
"created_by": "由 创建",
"csv_advanced": "高级设置",
"csv_advanced_hint": "不常用的字段,按需设置。",
"csv_at_least_one_row": "CSV 文件中至少要有一行数据。",
"csv_auto_mapped": "自动映射",
"csv_auto_mapped_tooltip": "我们从 “{column}” 映射过来,因为表头相似。请确认或修改。",
"csv_auto_mapped_verify": "自动映射(需审核)",
"csv_basic_required": "基础(必填)",
"csv_basic_required_hint": "选择一个 CSV 列,或设置一个用于所有行的固定值。",
"csv_column_used_by": "映射到:{target}",
"csv_columns": "CSV 列",
"csv_data_preview": "数据预览",
"csv_empty_column_headers": "CSV 文件包含空的列标题。所有列都必须有名称。",
"csv_file_too_large": "CSV 文件过大,最大支持 2MB。",
"csv_files_only": "仅限 CSV 文件",
"csv_fixed_value_action": "设置固定值…",
"csv_fixed_value_label": "固定值:{value}",
"csv_import": "CSV 导入",
"csv_import_complete": "CSV 导入完成:{successes} 个成功,{failures} 个失败,{skipped} 个跳过",
"csv_import_duplicate_warning": "重复导入数据会产生重复记录。",
"csv_inconsistent_columns": "第 {row} 行的列数不一致。所有行必须有相同的表头。",
"csv_max_records": "最多允许 {max} 条记录。",
"csv_now_label": "当前(使用导入时间)",
"csv_pick_column_placeholder": "选择列或设置值…",
"csv_required_fields_missing": "请在保存前映射必填字段:{fields}",
"csv_response_preview": "示例:“{sample}” → 存储为 {target}。",
"csv_rows_count": "{count, plural, other {# 行}}",
"csv_sample_label": "CSV 示例",
"csv_source_context": "来源上下文",
"csv_source_context_hint": "标识这批反馈的来源。",
"csv_unmapped_columns": "未映射列({count}):{columns}",
"csv_unmapped_columns_explainer": "这些列未被任何反馈记录字段使用,导入时将被忽略。",
"custom_source_type": "自定义源类型",
"custom_source_type_placeholder": "输入自定义来源类型",
"default_connector_name_csv": "CSV 导入",
"default_connector_name_formbricks": "Formbricks 调查连接",
"discard_feedback_record_changes_description": "如果关闭此抽屉,您的更改将会丢失。",
"discard_feedback_record_changes_title": "放弃未保存的更改?",
"dont_include": "不包含该字段",
"drop_a_field_here": "将字段拖到这里",
"drop_field_or": "拖放字段或",
"edit_csv_mapping": "编辑 CSV 映射",
"edit_source_connection": "编辑源连接",
"enter_name_for_source": "为此来源输入名称",
"enter_value": "请输入值...",
"enum": "枚举",
"failed_to_load_feedback_records": "加载反馈记录失败",
"feedback_date": "当前日期",
"feedback_directory": "反馈目录",
"feedback_record_created_successfully": "反馈记录创建成功",
"feedback_record_details": "反馈记录详情",
"feedback_record_details_description": "查看并更新反馈记录字段。",
"feedback_record_fields": "反馈记录字段",
"feedback_record_mcp": "Feedback Record MCP",
"feedback_record_updated_successfully": "反馈记录更新成功",
"feedback_record_value_required": "所选字段类型需要一个值",
@@ -3749,13 +3732,20 @@
"metadata_value": "元数据值",
"missing_feedback_source_title": "Missing feedback source?",
"no_feedback_directory_available": "此工作区未分配反馈目录。请先创建或分配一个。",
"no_feedback_directory_linked_admin_description": "反馈目录会汇总已连接工作区的记录。此工作区尚未关联任何目录,请在反馈目录设置中分配,以开始收集记录。",
"no_feedback_directory_linked_member_description": "在该功能可用前,需要先为此工作区设置反馈目录。请联系组织所有者或管理员进行分配。",
"no_feedback_directory_linked_title": "未关联反馈目录",
"no_feedback_records": "暂无反馈记录。当你的连接器开始发送数据后,记录会显示在这里。",
"no_formbricks_surveys_available_description": "此工作区还没有调查。<surveyLink>创建新调查</surveyLink>,以将其用作反馈来源。",
"no_source_fields_loaded": "尚未加载源字段",
"no_sources_connected": "还没有连接数据源。添加一个数据源开始吧。",
"optional": "可选",
"or_drag_and_drop": "或拖放",
"question_type_not_supported": "不支持此问题类型",
"refresh_feedback_records": "刷新反馈记录",
"refreshing_feedback_records": "正在刷新反馈记录…",
"request_feedback_source": "Request source integration",
"required": "必填",
"save_changes": "保存更改",
"search_feedback": "搜索反馈",
"select_a_survey_to_see_questions": "请选择一个调查以查看其问题",
@@ -3784,11 +3774,12 @@
"set_value": "设置值",
"setup_connection": "设置连接",
"showing_count_loaded": "显示 {count} 条记录",
"showing_rows": "显示 {visible} 行,共 {total} 行",
"showing_rows": "显示 {count} 行中的 3 行",
"source": "source",
"source_connect_csv_description": "从 CSV 文件导入反馈",
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
"source_connect_formbricks_description": "连接来自你 Formbricks 调查的反馈",
"source_fields": "来源字段",
"source_id": "源ID",
"source_name": "来源名称",
"source_type": "来源类型",
+21 -30
View File
@@ -1854,10 +1854,7 @@
"api_key_updated": "API 金鑰已更新",
"delete_api_key_confirmation": "使用此金鑰的任何應用程式將無法再存取您的 Formbricks 資料。",
"duplicate_access": "不允許重複工作區存取",
"duplicate_directory_access": "不允許重複的意見回饋目錄存取權限",
"feedback_directory_access": "意見回饋目錄存取權限",
"no_api_keys_yet": "您目前尚未有任何 API 金鑰",
"no_directory_permissions_found": "找不到意見回饋目錄權限",
"no_workspace_permissions_found": "找不到工作區權限",
"organization_access": "組織存取",
"organization_access_description": "請選擇組織層級資源的讀取或寫入權限。",
@@ -1865,7 +1862,6 @@
"secret": "密鑰",
"unable_to_copy_api_key": "無法複製 API 金鑰",
"unable_to_delete_api_key": "無法刪除 API 金鑰",
"unknown_directory": "未知目錄",
"unknown_workspace": "未知工作區",
"workspace_access": "工作區存取"
},
@@ -2568,6 +2564,9 @@
"error_directory_name_required": "目錄名稱為必填項目。",
"error_directory_workspaces_invalid_org": "部分指定的工作區不屬於此組織。",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"grant_access_confirm": "授予工作區存取權限",
"grant_workspace_access_title": "確認授予工作區存取權限",
"grant_workspace_access_warning": "以下工作區的所有現有及未來成員將獲得對導入「{directoryName}」的所有意見回饋資料的讀取權限,包括其他已連結工作區的連接器所擷取的資料。此變更無法在不留痕跡的情況下撤銷。",
"nav_label": "意見回饋目錄",
"no_access": "你沒有權限管理意見回饋目錄。",
"no_connectors": "此目錄尚未連結任何連接器。",
@@ -2580,7 +2579,9 @@
"unarchive_workspace_conflict": "無法取消封存此目錄,因為一個或多個已指派工作區已封存。",
"upgrade_prompt_description": "將回饋記錄整理至目錄中,並將資料導向正確的工作區。專業版和企業版方案提供此功能。",
"upgrade_prompt_title": "升級以解鎖回饋記錄目錄功能",
"workspace_access": "工作區存取權限"
"workspace_access": "工作區存取權限",
"workspaces_already_linked": "已連結的工作區",
"workspaces_being_added": "正在授予存取權限的工作區"
},
"general": {
"ai_data_analysis_enabled": "資料增強與分析(AI",
@@ -3652,6 +3653,7 @@
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"auto_generated": "自動生成",
"change_file": "更換檔案",
"click_load_sample_csv": "點擊「載入範例 CSV」以查看欄位",
"click_to_upload": "點擊以上傳",
"collected_at": "收集時間",
"configure_import": "設定匯入",
@@ -3659,60 +3661,41 @@
"connector_created_successfully": "連接器建立成功",
"connector_deleted_successfully": "連接器刪除成功",
"connector_duplicated_successfully": "連接器複製成功",
"connector_name": "連接器名稱",
"connector_name_hint": "此連接器在你的儀表板中顯示的名稱。會自動從上傳的檔案名稱填入,隨時可以編輯。",
"connector_status_updated_successfully": "連接器狀態更新成功",
"connector_updated_successfully": "連接器更新成功",
"connectors": "連接器",
"create_mapping": "建立對應關係",
"created_by": "建立者",
"csv_advanced": "進階",
"csv_advanced_hint": "較少使用的欄位。需要時再設定。",
"csv_at_least_one_row": "CSV 必須至少包含一筆資料列。",
"csv_auto_mapped": "自動對應",
"csv_auto_mapped_tooltip": "我們從「{column}」對應了這個欄位,因為標題看起來很相似。請確認或變更。",
"csv_auto_mapped_verify": "自動對應(需要檢查)",
"csv_basic_required": "基本(必填)",
"csv_basic_required_hint": "選擇一個 CSV 欄位,或設定一個套用到每一列的固定值。",
"csv_column_used_by": "已對應至:{target}",
"csv_columns": "CSV 欄位",
"csv_data_preview": "資料預覽",
"csv_empty_column_headers": "CSV 包含空白的欄位標題。所有欄位都必須有名稱。",
"csv_file_too_large": "CSV 檔案過大,最大限制為 2MB。",
"csv_files_only": "僅限 CSV 檔案",
"csv_fixed_value_action": "設定固定值…",
"csv_fixed_value_label": "固定值:{value}",
"csv_import": "CSV 匯入",
"csv_import_complete": "CSV 匯入完成:{successes} 筆成功,{failures} 筆失敗,{skipped} 筆略過",
"csv_import_duplicate_warning": "匯入已經匯入過的資料,可能會產生重複紀錄。",
"csv_inconsistent_columns": "第 {row} 列的欄位數不一致。所有列必須有相同的標題。",
"csv_max_records": "最多允許 {max} 筆紀錄。",
"csv_now_label": "現在(使用匯入時間)",
"csv_pick_column_placeholder": "選擇欄位或設定值…",
"csv_required_fields_missing": "請在儲存前對應必填欄位:{fields}",
"csv_response_preview": "範例:「{sample}」→ 儲存為 {target}。",
"csv_rows_count": "{count, plural, other {# 列}}",
"csv_sample_label": "CSV 範例",
"csv_source_context": "來源情境",
"csv_source_context_hint": "識別這批意見回饋的來源。",
"csv_unmapped_columns": "未對應的欄位({count} 個):{columns}",
"csv_unmapped_columns_explainer": "這些欄位沒有被任何意見回饋記錄欄位使用。匯入時會被忽略。",
"custom_source_type": "自訂來源類型",
"custom_source_type_placeholder": "輸入自訂來源類型",
"default_connector_name_csv": "CSV 匯入",
"default_connector_name_formbricks": "Formbricks 問卷連線",
"discard_feedback_record_changes_description": "如果關閉此抽屜,您的變更將會遺失。",
"discard_feedback_record_changes_title": "放棄未儲存的變更?",
"dont_include": "不包含此欄位",
"drop_a_field_here": "請將欄位拖曳到這裡",
"drop_field_or": "拖曳欄位或",
"edit_csv_mapping": "編輯 CSV 對應",
"edit_source_connection": "編輯來源連線",
"enter_name_for_source": "請輸入此來源的名稱",
"enter_value": "請輸入值……",
"enum": "enum",
"failed_to_load_feedback_records": "載入回饋紀錄失敗",
"feedback_date": "目前日期",
"feedback_directory": "意見回饋目錄",
"feedback_record_created_successfully": "回饋記錄創建成功",
"feedback_record_details": "反饋記錄詳情",
"feedback_record_details_description": "查看並更新回饋記錄欄位。",
"feedback_record_fields": "回饋紀錄欄位",
"feedback_record_mcp": "Feedback Record MCP",
"feedback_record_updated_successfully": "回饋記錄更新成功",
"feedback_record_value_required": "所選欄位類型需要一個值",
@@ -3749,13 +3732,20 @@
"metadata_value": "元資料值",
"missing_feedback_source_title": "Missing feedback source?",
"no_feedback_directory_available": "此工作區未指派意見回饋目錄。請先建立或指派一個。",
"no_feedback_directory_linked_admin_description": "意見回饋目錄會彙整來自已連接工作區的記錄。此工作區尚未連結任何目錄 — 請從意見回饋目錄設定中指定一個目錄,以開始收集記錄。",
"no_feedback_directory_linked_member_description": "此工作區需要先設定意見回饋目錄,才能使用此功能。請請組織擁有者或管理員指定一個目錄。",
"no_feedback_directory_linked_title": "未連結意見回饋目錄",
"no_feedback_records": "目前尚無回饋紀錄。當你的連接器開始傳送資料時,紀錄會顯示在這裡。",
"no_formbricks_surveys_available_description": "此工作區尚無問卷。<surveyLink>建立新問卷</surveyLink>,以將其用作回饋來源。",
"no_source_fields_loaded": "尚未載入來源欄位",
"no_sources_connected": "尚未連接任何來源。請新增來源以開始使用。",
"optional": "選填",
"or_drag_and_drop": "或拖曳檔案",
"question_type_not_supported": "不支援此題型",
"refresh_feedback_records": "重新整理回饋紀錄",
"refreshing_feedback_records": "正在更新回饋紀錄…",
"request_feedback_source": "Request source integration",
"required": "必填",
"save_changes": "儲存變更",
"search_feedback": "搜尋意見回饋",
"select_a_survey_to_see_questions": "請選擇問卷以查看其問題",
@@ -3784,11 +3774,12 @@
"set_value": "設定值",
"setup_connection": "設定連線",
"showing_count_loaded": "顯示 {count} 筆記錄",
"showing_rows": "顯示 {total} 列中的 {visible} 列",
"showing_rows": "顯示 {count} 筆資料中的 3 筆",
"source": "來源",
"source_connect_csv_description": "從 CSV 檔案匯入回饋",
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
"source_connect_formbricks_description": "連接來自你 Formbricks 問卷的回饋",
"source_fields": "來源欄位",
"source_id": "來源ID",
"source_name": "來源名稱",
"source_type": "來源類型",
-5
View File
@@ -54,11 +54,6 @@ export const authenticateApiKeyFromHeaders = async (
workspaceId: workspacePermission.workspaceId,
workspaceName: workspacePermission.workspace.name,
})),
feedbackDirectoryPermissions: (apiKeyData.apiKeyFeedbackDirectories ?? []).map((directoryPermission) => ({
permission: directoryPermission.permission,
feedbackDirectoryId: directoryPermission.feedbackDirectoryId,
feedbackDirectoryName: directoryPermission.feedbackDirectory.name,
})),
apiKeyId: apiKeyData.id,
organizationId: apiKeyData.organizationId,
organizationAccess: apiKeyData.organizationAccess,
@@ -74,7 +74,6 @@ describe("authenticateRequest", () => {
workspaceName: "Workspace 2",
},
],
feedbackDirectoryPermissions: [],
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
@@ -120,7 +119,6 @@ describe("authenticateRequest", () => {
expect(result.data).toEqual({
type: "apiKey",
workspacePermissions: [],
feedbackDirectoryPermissions: [],
apiKeyId: "org-api-key-id",
organizationId: "org-id",
organizationAccess: {
@@ -201,17 +199,6 @@ describe("authenticateRequest", () => {
},
},
apiKeyWorkspaces: [],
apiKeyFeedbackDirectories: [
{
feedbackDirectoryId: "clxx1234567890123456789012",
apiKeyId: "bearer-api-key-id",
permission: "read",
feedbackDirectory: {
id: "clxx1234567890123456789012",
name: "Directory 1",
},
},
],
} as unknown as TApiKeyWithEnvironmentAndWorkspace);
const result = await authenticateRequest(request);
@@ -221,13 +208,6 @@ describe("authenticateRequest", () => {
expect(result.data).toEqual({
type: "apiKey",
workspacePermissions: [],
feedbackDirectoryPermissions: [
{
feedbackDirectoryId: "clxx1234567890123456789012",
feedbackDirectoryName: "Directory 1",
permission: "read",
},
],
apiKeyId: "bearer-api-key-id",
organizationId: "org-id",
organizationAccess: {
@@ -57,6 +57,7 @@ describe("executeTenantScopedQuery", () => {
vi.stubEnv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public");
vi.stubEnv("ENCRYPTION_KEY", "12345678901234567890123456789012");
vi.stubEnv("HUB_API_URL", "https://hub.formbricks.local");
vi.stubEnv("HUB_API_KEY", "test-hub-api-key");
vi.stubEnv("CUBEJS_API_URL", "https://cube.example.com");
vi.stubEnv("CUBEJS_API_SECRET", "cube-secret");
vi.stubEnv("CUBEJS_JWT_AUDIENCE", "formbricks-cube-test");
@@ -156,32 +157,17 @@ describe("executeTenantScopedQuery", () => {
expect(cubejs).toHaveBeenCalledWith(expect.any(String), { apiUrl: fullUrl });
});
test("throws a configuration error when Cube env is missing", async () => {
test("fails at env validation when Cube env is missing", async () => {
vi.unstubAllEnvs();
vi.stubEnv("NODE_ENV", "test");
vi.stubEnv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public");
vi.stubEnv("ENCRYPTION_KEY", "12345678901234567890123456789012");
vi.stubEnv("HUB_API_URL", "https://hub.formbricks.local");
vi.stubEnv("HUB_API_KEY", "test-hub-api-key");
vi.stubEnv("CUBEJS_API_URL", undefined);
vi.stubEnv("CUBEJS_API_SECRET", undefined);
const { CUBE_CONFIGURATION_ERROR_MESSAGE } = await import("./cube-config");
const { executeTenantScopedQuery } = await import("./cube-client");
await expect(executeTenantScopedQuery(scopedInput)).rejects.toThrow(CUBE_CONFIGURATION_ERROR_MESSAGE);
expect(mockLoggerError).toHaveBeenCalledWith(expect.any(Error), "Cube query configuration failed");
expect(mockQueueAuditEventWithoutRequest).toHaveBeenCalledWith(
expect.objectContaining({
action: "queried",
targetType: "cubeQuery",
status: "failure",
newObject: expect.objectContaining({
tenantId: "frd-1",
feedbackDirectoryId: "frd-1",
workspaceId: "workspace-1",
errorName: "ConfigurationError",
}),
})
);
await expect(import("./cube-client")).rejects.toThrow("Invalid environment variables");
});
test("logs Cube runtime failures and returns a generic query execution error", async () => {
@@ -12,6 +12,7 @@ const setTestEnv = (overrides: Record<string, string | undefined> = {}) => {
DATABASE_URL: "https://example.com/db",
ENCRYPTION_KEY: "12345678901234567890123456789012",
HUB_API_URL: "https://hub.formbricks.local",
HUB_API_KEY: "test-hub-api-key",
CUBEJS_API_URL: "https://cube.formbricks.local",
CUBEJS_API_SECRET: "cube-secret",
CUBEJS_JWT_AUDIENCE: "formbricks-cube-test",
@@ -96,23 +97,19 @@ describe("cube-config", () => {
expect(getCubeApiCredentials().apiUrl).toBe("https://cube.formbricks.local/cubejs-api/v1");
});
test("throws a configuration error when CUBEJS_API_URL is missing", async () => {
test("fails at env validation when CUBEJS_API_URL is missing", async () => {
setTestEnv({
CUBEJS_API_URL: undefined,
});
const { CUBE_CONFIGURATION_ERROR_MESSAGE, getCubeApiCredentials } = await import("./cube-config");
expect(() => getCubeApiCredentials()).toThrow(CUBE_CONFIGURATION_ERROR_MESSAGE);
await expect(import("./cube-config")).rejects.toThrow("Invalid environment variables");
});
test("throws a configuration error when CUBEJS_API_SECRET is missing", async () => {
test("fails at env validation when CUBEJS_API_SECRET is missing", async () => {
setTestEnv({
CUBEJS_API_SECRET: undefined,
});
const { CUBE_CONFIGURATION_ERROR_MESSAGE, getCubeApiCredentials } = await import("./cube-config");
expect(() => getCubeApiCredentials()).toThrow(CUBE_CONFIGURATION_ERROR_MESSAGE);
await expect(import("./cube-config")).rejects.toThrow("Invalid environment variables");
});
});
@@ -36,6 +36,16 @@ describe("cube queryRewrite", () => {
expect(() => queryRewrite({ measures: ["FeedbackRecords.count"] }, {})).toThrow(
/missing tenantId security context/
);
const logPayload = vi.mocked(console.log).mock.calls[0][0];
const parsed = JSON.parse(logPayload);
expect(parsed).toMatchObject({
type: "audit",
event: "cube.query",
status: "failure",
errorName: "Error",
errorMessage: "Cube query rejected: missing tenantId security context",
});
});
test("rejects Cube startup without an API secret", () => {
@@ -97,6 +107,18 @@ describe("cube queryRewrite", () => {
).toThrow(/tenant filters are enforced by Cube/);
});
test("rejects caller-supplied TopicsUnnested tenant filters", () => {
expect(() =>
queryRewrite(
{
measures: ["TopicsUnnested.count"],
filters: [{ member: "TopicsUnnested.tenantId", operator: "equals", values: ["workspace-2"] }],
},
{ securityContext }
)
).toThrow(/tenant filters are enforced by Cube/);
});
test("logs sanitized failure audit metadata for rejected tenant filters", () => {
expect(() =>
queryRewrite(
@@ -197,6 +219,19 @@ describe("cube queryRewrite", () => {
expect(query.filters).toHaveLength(1);
});
test("appends only the TopicsUnnested tenant filter for TopicsUnnested queries", () => {
const query = {
measures: ["TopicsUnnested.count"],
dimensions: ["TopicsUnnested.topic"],
};
const rewrittenQuery = queryRewrite(query, { securityContext });
expect(rewrittenQuery.filters).toEqual([
{ member: "TopicsUnnested.tenantId", operator: "equals", values: ["frd-1"] },
]);
});
test("logs sanitized Cube audit metadata without raw filter values", () => {
queryRewrite(
{
@@ -221,6 +256,7 @@ describe("cube queryRewrite", () => {
source: "charts.executeQueryAction",
});
expect(parsed.members).toContain("FeedbackRecords.tenantId");
expect(parsed.members).not.toContain("TopicsUnnested.tenantId");
expect(logPayload).not.toContain("secret-value");
});
});
@@ -4,7 +4,9 @@ import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
import { hasWorkspaceFeedbackRecords } from "@/modules/ee/analysis/lib/feedback-records";
import { hasFeedbackRecordsInDirectories } from "@/modules/ee/analysis/lib/feedback-records";
import { NoFeedbackDirectoryEmptyState } from "@/modules/ee/feedback-directory/components/no-feedback-directory-empty-state";
import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory";
import { getIsDashboardsEnabled } from "@/modules/ee/license-check/lib/utils";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
@@ -35,7 +37,7 @@ interface DashboardsListPageProps {
export const DashboardsListPage = async ({ workspaceId }: Readonly<DashboardsListPageProps>) => {
const t = await getTranslate();
const { isReadOnly, organization } = await getWorkspaceAuth(workspaceId);
const { isReadOnly, organization, isOwner, isManager } = await getWorkspaceAuth(workspaceId);
const isDashboardsAllowed = await getIsDashboardsEnabled(organization.id);
if (!isDashboardsAllowed) {
@@ -66,10 +68,23 @@ export const DashboardsListPage = async ({ workspaceId }: Readonly<DashboardsLis
);
}
const [hasFeedbackRecords, connectors] = await Promise.all([
hasWorkspaceFeedbackRecords(workspaceId),
const [frds, connectors] = await Promise.all([
getFeedbackDirectoriesByWorkspaceId(workspaceId),
getConnectorsWithMappings(workspaceId),
]);
if (frds.length === 0) {
return (
<AnalysisPageLayout
pageTitle={t("common.analysis")}
workspaceId={workspaceId}
cta={isReadOnly ? undefined : <CreateDashboardButton workspaceId={workspaceId} disabled={true} />}>
<NoFeedbackDirectoryEmptyState workspaceId={workspaceId} isOwnerOrManager={isOwner || isManager} />
</AnalysisPageLayout>
);
}
const hasFeedbackRecords = await hasFeedbackRecordsInDirectories(frds.map((frd) => frd.id));
const dashboardsPromise = hasFeedbackRecords ? getDashboards(workspaceId) : null;
return (
@@ -51,8 +51,8 @@ export const FEEDBACK_FIELDS = {
description: "Emotion extracted from metadata JSONB field",
},
{
id: "FeedbackRecords.userIdentifier",
label: "User Identifier",
id: "FeedbackRecords.userId",
label: "User ID",
type: "string",
description: "Identifier of the user who provided feedback",
},
@@ -189,7 +189,7 @@ export function getTranslatedFieldLabel(id: string, t: TFunction): string {
"FeedbackRecords.sourceName": t("workspace.analysis.charts.field_label_source_name"),
"FeedbackRecords.fieldType": t("workspace.analysis.charts.field_label_field_type"),
"FeedbackRecords.emotion": t("workspace.analysis.charts.field_label_emotion"),
"FeedbackRecords.userIdentifier": t("workspace.analysis.charts.field_label_user_identifier"),
"FeedbackRecords.userId": t("workspace.analysis.charts.field_label_user_identifier"),
"FeedbackRecords.responseId": t("workspace.analysis.charts.field_label_response_id"),
"FeedbackRecords.npsValue": t("workspace.analysis.charts.field_label_nps_value"),
"FeedbackRecords.collectedAt": t("workspace.analysis.charts.field_label_collected_at"),
@@ -68,6 +68,10 @@ export const FeedbackDirectorySettingsModal = ({
const [pendingSubmitData, setPendingSubmitData] = useState<TFeedbackDirectoryUpdateInput | null>(null);
const [connectorsToPauseCount, setConnectorsToPauseCount] = useState(0);
const [confirmAddDialogOpen, setConfirmAddDialogOpen] = useState(false);
const [pendingAddData, setPendingAddData] = useState<TFeedbackDirectoryUpdateInput | null>(null);
const [addedWorkspaceIds, setAddedWorkspaceIds] = useState<string[]>([]);
const workspaceAccessMap = useMemo(
() => new Map(workspaceAccessByWorkspace.map((assignment) => [assignment.workspaceId, assignment])),
[workspaceAccessByWorkspace]
@@ -114,10 +118,23 @@ export const FeedbackDirectorySettingsModal = ({
reset,
} = form;
const workspaceNameById = useMemo(() => {
const map = new Map(orgWorkspaces.map((workspace) => [workspace.id, workspace.name]));
directory?.workspaces.forEach((workspace) => {
if (!map.has(workspace.workspaceId)) {
map.set(workspace.workspaceId, workspace.workspaceName);
}
});
return map;
}, [orgWorkspaces, directory?.workspaces]);
const closeModal = () => {
setConfirmPauseDialogOpen(false);
setPendingSubmitData(null);
setConnectorsToPauseCount(0);
setConfirmAddDialogOpen(false);
setPendingAddData(null);
setAddedWorkspaceIds([]);
reset();
setOpen(false);
};
@@ -168,7 +185,7 @@ export const FeedbackDirectorySettingsModal = ({
}
};
const handleSubmitForm: SubmitHandler<TFeedbackDirectoryUpdateInput> = async (data) => {
const proceedAfterAddConfirm = async (data: TFeedbackDirectoryUpdateInput) => {
if (!isEdit || !directory) {
await submitDirectory(data, false);
return;
@@ -195,6 +212,34 @@ export const FeedbackDirectorySettingsModal = ({
await submitDirectory(data, false);
};
const handleConfirmAddAndContinue = async () => {
if (!pendingAddData) return;
setConfirmAddDialogOpen(false);
setAddedWorkspaceIds([]);
const data = pendingAddData;
setPendingAddData(null);
await proceedAfterAddConfirm(data);
};
const handleSubmitForm: SubmitHandler<TFeedbackDirectoryUpdateInput> = async (data) => {
const updatedWorkspaceIds = data.workspaceIds ?? [];
const newlyAddedWorkspaceIds = updatedWorkspaceIds.filter(
(workspaceId) => !initialWorkspaceIds.includes(workspaceId)
);
if (newlyAddedWorkspaceIds.length > 0) {
setPendingAddData(data);
setAddedWorkspaceIds(newlyAddedWorkspaceIds);
setConfirmAddDialogOpen(true);
return;
}
await proceedAfterAddConfirm(data);
};
return (
<Dialog open={open} onOpenChange={(newOpen) => (newOpen ? setOpen(true) : closeModal())}>
<DialogContent>
@@ -318,6 +363,65 @@ export const FeedbackDirectorySettingsModal = ({
</FormProvider>
</DialogContent>
{confirmAddDialogOpen && (
<Dialog open={confirmAddDialogOpen} onOpenChange={setConfirmAddDialogOpen}>
<DialogContent width="narrow" hideCloseButton={true} disableCloseOnOutsideClick={true}>
<DialogHeader>
<div className="flex items-center gap-2">
<CircleAlert className="h-4 w-4 text-red-600" />
<DialogTitle>
{t("workspace.settings.feedback_directories.grant_workspace_access_title")}
</DialogTitle>
</div>
</DialogHeader>
<DialogBody className="space-y-3">
<p className="text-sm text-slate-700">
{t("workspace.settings.feedback_directories.grant_workspace_access_warning", {
directoryName: form.watch("name") || directory?.name || "",
})}
</p>
<div className="space-y-1">
<p className="text-sm font-medium text-slate-900">
{t("workspace.settings.feedback_directories.workspaces_being_added")}
</p>
<ul className="list-disc space-y-0.5 pl-5 text-sm text-slate-700">
{addedWorkspaceIds.map((id) => (
<li key={id}>{workspaceNameById.get(id) ?? id}</li>
))}
</ul>
</div>
{isEdit && initialWorkspaceIds.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium text-slate-900">
{t("workspace.settings.feedback_directories.workspaces_already_linked")}
</p>
<ul className="list-disc space-y-0.5 pl-5 text-sm text-slate-700">
{initialWorkspaceIds.map((id) => (
<li key={id}>{workspaceNameById.get(id) ?? id}</li>
))}
</ul>
</div>
)}
</DialogBody>
<DialogFooter>
<Button
variant="secondary"
onClick={() => {
setConfirmAddDialogOpen(false);
setPendingAddData(null);
setAddedWorkspaceIds([]);
}}
disabled={isSubmitting}>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={handleConfirmAddAndContinue} loading={isSubmitting}>
{t("workspace.settings.feedback_directories.grant_access_confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{confirmPauseDialogOpen && (
<Dialog open={confirmPauseDialogOpen} onOpenChange={setConfirmPauseDialogOpen}>
<DialogContent width="narrow" hideCloseButton={true} disableCloseOnOutsideClick={true}>
@@ -0,0 +1,39 @@
import { FolderOpenIcon } from "lucide-react";
import Link from "next/link";
import { getTranslate } from "@/lingodotdev/server";
import { Button } from "@/modules/ui/components/button";
interface NoFeedbackDirectoryEmptyStateProps {
workspaceId: string;
isOwnerOrManager: boolean;
}
export const NoFeedbackDirectoryEmptyState = async ({
workspaceId,
isOwnerOrManager,
}: Readonly<NoFeedbackDirectoryEmptyStateProps>) => {
const t = await getTranslate();
return (
<div className="mx-auto flex max-w-xl flex-col items-center gap-4 py-16 text-center">
<FolderOpenIcon className="h-8 w-8 text-slate-400" />
<div className="space-y-2">
<h3 className="text-base font-semibold text-slate-900">
{t("workspace.unify.no_feedback_directory_linked_title")}
</h3>
<p className="text-balance text-sm text-slate-600">
{isOwnerOrManager
? t("workspace.unify.no_feedback_directory_linked_admin_description")
: t("workspace.unify.no_feedback_directory_linked_member_description")}
</p>
</div>
{isOwnerOrManager && (
<Button asChild size="sm">
<Link href={`/workspaces/${workspaceId}/settings/organization/feedback-directories`}>
{t("workspace.unify.go_to_feedback_directories")}
</Link>
</Button>
)}
</div>
);
};
@@ -117,7 +117,7 @@ export const createFeedbackRecordAction = authenticatedActionClient
value_date: recordInput.value_date,
metadata: recordInput.metadata,
language: recordInput.language,
user_identifier: recordInput.user_identifier,
user_id: recordInput.user_id,
};
const createResult = await createFeedbackRecord(createParams);
@@ -163,8 +163,8 @@ export const updateFeedbackRecordAction = authenticatedActionClient
...(updateInput.value_date !== undefined && { value_date: updateInput.value_date ?? undefined }),
...(updateInput.language !== undefined && { language: updateInput.language ?? undefined }),
...(updateInput.metadata !== undefined && { metadata: updateInput.metadata }),
...(updateInput.user_identifier !== undefined && {
user_identifier: updateInput.user_identifier ?? undefined,
...(updateInput.user_id !== undefined && {
user_id: updateInput.user_id ?? undefined,
}),
};
@@ -244,7 +244,7 @@ export const FeedbackRecordFormDrawer = ({
...buildCreateValueFields(values),
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
language: values.language?.trim() || undefined,
user_identifier: values.user_identifier?.trim() || undefined,
user_id: values.user_id?.trim() || undefined,
},
});
@@ -270,7 +270,7 @@ export const FeedbackRecordFormDrawer = ({
recordId,
updateInput: {
language: values.language?.trim() || null,
user_identifier: values.user_identifier?.trim() || null,
user_id: values.user_id?.trim() || null,
metadata: { ...preservedMetadata, ...metadata },
...getUpdateValueField(values),
},
@@ -694,7 +694,7 @@ export const FeedbackRecordFormDrawer = ({
/>
<FormField
control={form.control}
name="user_identifier"
name="user_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.user_identifier")}</FormLabel>
@@ -10,6 +10,7 @@ import { UnifyConfigNavigation } from "./unify-config-navigation";
interface FeedbackRecordsPageClientProps {
workspaceId: string;
initialRecords: FeedbackRecordData[];
initialCursors: Record<string, string>;
frdMap: Record<string, string>;
csvSources: { id: string; name: string }[];
canWrite: boolean;
@@ -18,6 +19,7 @@ interface FeedbackRecordsPageClientProps {
export function FeedbackRecordsPageClient({
workspaceId,
initialRecords,
initialCursors,
frdMap,
csvSources,
canWrite,
@@ -33,6 +35,7 @@ export function FeedbackRecordsPageClient({
<FeedbackRecordsTable
workspaceId={workspaceId}
initialRecords={initialRecords}
initialCursors={initialCursors}
frdMap={frdMap}
csvSources={csvSources}
canWrite={canWrite}
@@ -63,6 +63,7 @@ function truncate(str: string, maxLen: number): string {
interface FeedbackRecordsTableProps {
workspaceId: string;
initialRecords: FeedbackRecordData[];
initialCursors: Record<string, string>;
frdMap: Record<string, string>;
csvSources: { id: string; name: string }[];
canWrite: boolean;
@@ -71,19 +72,24 @@ interface FeedbackRecordsTableProps {
export const FeedbackRecordsTable = ({
workspaceId,
initialRecords,
initialCursors,
frdMap,
csvSources,
canWrite,
}: Readonly<FeedbackRecordsTableProps>) => {
const { t, i18n } = useTranslation();
const [records, setRecords] = useState<FeedbackRecordData[]>(initialRecords);
const [cursors, setCursors] = useState<Record<string, string>>(initialCursors);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [drawerMode, setDrawerMode] = useState<"create" | "edit">("edit");
const [drawerRecordId, setDrawerRecordId] = useState<string | undefined>();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | null>(null);
const hasMore = Object.keys(cursors).length > 0;
const directories = useMemo(
() =>
Object.entries(frdMap)
@@ -91,43 +97,92 @@ export const FeedbackRecordsTable = ({
.sort((a, b) => a.name.localeCompare(b.name)),
[frdMap]
);
const handleRefresh = async () => {
if (isRefreshing) return;
setIsRefreshing(true);
setError(null);
const toastId = toast.loading(t("workspace.unify.refreshing_feedback_records"));
type FetchResult =
| { ok: true; records: FeedbackRecordData[]; newCursors: Record<string, string> }
| { ok: false; errorMessage: string };
const fetchRecords = async (mode: "refresh" | "loadMore"): Promise<FetchResult> => {
const directoryIds = Object.keys(frdMap);
const frdIdsToFetch = mode === "refresh" ? directoryIds : directoryIds.filter((id) => cursors[id]);
if (frdIdsToFetch.length === 0) {
return { ok: true, records: [], newCursors: {} };
}
const results = await Promise.all(
directoryIds.map((frdId) =>
frdIdsToFetch.map((frdId) =>
listFeedbackRecordsAction({
workspaceId,
frdId,
limit: RECORDS_PER_PAGE,
...(mode === "loadMore" && cursors[frdId] ? { cursor: cursors[frdId] } : {}),
})
)
);
if (results.some((result) => !result?.data)) {
const firstErrorResult = results.find((result) => !result?.data);
const errorMessage = firstErrorResult ? getFormattedErrorMessage(firstErrorResult) : undefined;
toast.error(errorMessage ?? t("workspace.unify.failed_to_load_feedback_records"), {
id: toastId,
});
const firstFailure = results.find((result) => !result?.data);
if (firstFailure) {
return {
ok: false,
errorMessage:
getFormattedErrorMessage(firstFailure) ?? t("workspace.unify.failed_to_load_feedback_records"),
};
}
const fetchedRecords = results.flatMap((result) => result?.data?.data ?? []);
const newCursors: Record<string, string> = {};
for (let i = 0; i < frdIdsToFetch.length; i++) {
const nextCursor = results[i]?.data?.next_cursor;
if (nextCursor) {
newCursors[frdIdsToFetch[i]] = nextCursor;
}
}
return { ok: true, records: fetchedRecords, newCursors };
};
const handleRefresh = async () => {
if (isRefreshing || isLoadingMore) return;
setIsRefreshing(true);
setError(null);
const toastId = toast.loading(t("workspace.unify.refreshing_feedback_records"));
const result = await fetchRecords("refresh");
if (!result.ok) {
toast.error(result.errorMessage, { id: toastId });
setIsRefreshing(false);
return;
}
const successfulRecords = results.flatMap((result) => result?.data?.data ?? []);
const mergedRecords = successfulRecords
.toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
.slice(0, RECORDS_PER_PAGE);
const mergedRecords = result.records.toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1));
setRecords(mergedRecords);
setCursors(result.newCursors);
setIsRefreshing(false);
toast.success(t("workspace.unify.feedback_records_refreshed"), { id: toastId });
};
const handleLoadMore = async () => {
if (isLoadingMore || isRefreshing || !hasMore) return;
setIsLoadingMore(true);
const result = await fetchRecords("loadMore");
if (!result.ok) {
toast.error(result.errorMessage);
setIsLoadingMore(false);
return;
}
setRecords((prev) =>
[...prev, ...result.records].toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
);
setCursors(result.newCursors);
setIsLoadingMore(false);
};
if (error) {
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
@@ -213,7 +268,7 @@ export const FeedbackRecordsTable = ({
variant="secondary"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
disabled={isRefreshing || isLoadingMore}
aria-label={t("workspace.unify.refresh_feedback_records")}>
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
@@ -261,6 +316,19 @@ export const FeedbackRecordsTable = ({
</table>
</div>
</div>
{hasMore && (
<div className="flex justify-center">
<Button
variant="secondary"
size="sm"
onClick={handleLoadMore}
disabled={isLoadingMore || isRefreshing}
loading={isLoadingMore}>
{t("common.load_more")}
</Button>
</div>
)}
</div>
<FeedbackRecordFormDrawer
@@ -365,8 +433,8 @@ const FeedbackRecordRow = ({
<span>{value}</span>
)}
</td>
<td className="max-w-[120px] truncate px-4 py-3 text-slate-500" title={record.user_identifier}>
{record.user_identifier ?? "—"}
<td className="max-w-[120px] truncate px-4 py-3 text-slate-500" title={record.user_id}>
{record.user_id ?? "—"}
</td>
</tr>
);
@@ -50,7 +50,7 @@ export const ZFeedbackRecordFormValues = z.object({
value_boolean: z.boolean().optional(),
value_date: z.string().optional(),
language: z.string().optional(),
user_identifier: z.string().optional(),
user_id: z.string().optional(),
metadataEntries: z.array(ZMetadataEntry),
});
@@ -74,7 +74,7 @@ export const getCreateDefaults = (directories: { id: string; name: string }[]):
value_boolean: undefined,
value_date: "",
language: "",
user_identifier: "",
user_id: "",
metadataEntries: [],
};
};
@@ -107,7 +107,7 @@ export const mapRecordToValues = (record: FeedbackRecordData): TFeedbackRecordFo
value_boolean: record.value_boolean,
value_date: record.value_date ? toLocalDateTimeInput(record.value_date) : "",
language: record.language ?? "",
user_identifier: record.user_identifier ?? "",
user_id: record.user_id ?? "",
metadataEntries,
};
};
+30 -6
View File
@@ -2,21 +2,22 @@ import { notFound } from "next/navigation";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { NoFeedbackDirectoryEmptyState } from "@/modules/ee/feedback-directory/components/no-feedback-directory-empty-state";
import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory";
import { getIsUnifyFeedbackEnabled } from "@/modules/ee/license-check/lib/utils";
import { UnifyConfigNavigation } from "@/modules/ee/unify-feedback/components/unify-config-navigation";
import { listFeedbackRecords } from "@/modules/hub/service";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { FeedbackRecordsPageClient } from "./components/feedback-records-page-client";
import { UnifyConfigNavigation } from "./components/unify-config-navigation";
const INITIAL_PAGE_SIZE = 50;
export const UnifyFeedbackRecordsPage = async (
export default async function UnifyFeedbackRecordsPage(
props: Readonly<{ params: Promise<{ workspaceId: string }> }>
) => {
) {
const t = await getTranslate();
const params = await props.params;
@@ -70,6 +71,20 @@ export const UnifyFeedbackRecordsPage = async (
getConnectorsWithMappings(params.workspaceId),
]);
if (frds.length === 0) {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("workspace.unify.feedback_records")}>
<UnifyConfigNavigation workspaceId={params.workspaceId} activeId="feedback-records" />
</PageHeader>
<NoFeedbackDirectoryEmptyState
workspaceId={params.workspaceId}
isOwnerOrManager={isOwner || isManager}
/>
</PageContentWrapper>
);
}
const results = await Promise.all(
frds.map((frd) => listFeedbackRecords({ tenant_id: frd.id, limit: INITIAL_PAGE_SIZE }))
);
@@ -79,8 +94,16 @@ export const UnifyFeedbackRecordsPage = async (
const merged = successfulResults
.flatMap((r) => r.data?.data ?? [])
.toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
.slice(0, INITIAL_PAGE_SIZE);
.toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1));
// Build per-FRD cursor map so the client can paginate
const initialCursors: Record<string, string> = {};
for (let i = 0; i < frds.length; i++) {
const cursor = results[i]?.data?.next_cursor;
if (cursor) {
initialCursors[frds[i].id] = cursor;
}
}
const frdMap = Object.fromEntries(frds.map((f) => [f.id, f.name]));
const csvSources = connectors
@@ -91,9 +114,10 @@ export const UnifyFeedbackRecordsPage = async (
<FeedbackRecordsPageClient
workspaceId={params.workspaceId}
initialRecords={merged}
initialCursors={initialCursors}
frdMap={frdMap}
csvSources={csvSources}
canWrite={canWrite}
/>
);
};
}
@@ -1,14 +1,16 @@
"use client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Trans, useTranslation } from "react-i18next";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { TConnectorOptionId, getConnectorOptions } from "../utils";
interface ConnectorTypeSelectorProps {
selectedType: TConnectorOptionId | null;
onSelectType: (type: TConnectorOptionId) => void;
workspaceId: string;
surveyCount: number;
}
const getOptionClassName = (
@@ -27,43 +29,54 @@ const getOptionClassName = (
return "border-slate-200 hover:border-slate-300 hover:bg-slate-50";
};
export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<ConnectorTypeSelectorProps>) {
export function ConnectorTypeSelector({
selectedType,
onSelectType,
workspaceId,
surveyCount,
}: Readonly<ConnectorTypeSelectorProps>) {
const { t } = useTranslation();
const connectorOptions = getConnectorOptions(t);
return (
<div className="space-y-3">
<div className="space-y-2">
{connectorOptions.map((option) => (
<button
key={option.id}
type="button"
disabled={option.disabled}
onClick={() => onSelectType(option.id)}
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
selectedType,
option.id,
option.disabled
)}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
</div>
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
</div>
<div
className={`ml-3 h-4 w-4 rounded-full border-2 ${
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
}`}>
{selectedType === option.id && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-1.5 w-1.5 rounded-full bg-white" />
{connectorOptions.map((option) => {
const showNoSurveysAlert =
surveyCount === 0 && option.id === "formbricks_survey" && selectedType === "formbricks_survey";
return (
<div key={option.id} className="space-y-2">
<button
type="button"
disabled={option.disabled}
onClick={() => onSelectType(option.id)}
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
selectedType,
option.id,
option.disabled
)}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
</div>
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
</div>
)}
<div
className={`ml-3 h-4 w-4 rounded-full border-2 ${
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
}`}>
{selectedType === option.id && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-1.5 w-1.5 rounded-full bg-white" />
</div>
)}
</div>
</button>
{showNoSurveysAlert && <NoFormbricksSurveysAlert workspaceId={workspaceId} />}
</div>
</button>
))}
);
})}
</div>
<Alert variant="outbound" size="small">
<AlertTitle>{t("workspace.unify.missing_feedback_source_title")}</AlertTitle>
@@ -80,3 +93,20 @@ export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<C
</div>
);
}
const NoFormbricksSurveysAlert = ({ workspaceId }: Readonly<{ workspaceId: string }>) => {
return (
<Alert variant="info" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
<Trans
i18nKey="workspace.unify.no_formbricks_surveys_available_description"
components={{
surveyLink: (
<Link href={`/workspaces/${workspaceId}/surveys/templates`} className="font-medium underline" />
),
}}
/>
</AlertDescription>
</Alert>
);
};
@@ -3,7 +3,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2Icon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -43,8 +43,6 @@ import {
} from "@/modules/ui/components/select";
import { Switch } from "@/modules/ui/components/switch";
import {
CSV_HIDDEN_STATIC_MAPPINGS,
CSV_PROTECTED_TARGET_IDS,
TCreateConnectorStep,
TFieldMapping,
TFormbricksConnectorForm,
@@ -55,8 +53,9 @@ import {
import {
TConnectorOptionId,
TEnumValidationError,
areAllRequiredCsvFieldsMapped,
areAllRequiredFieldsMapped,
isConnectorNameValid,
parseCSVColumnsToFields,
toggleQuestionId,
validateEnumMappings,
} from "../utils";
@@ -157,7 +156,6 @@ export const CreateConnectorModal = ({
const [isImporting, setIsImporting] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories[0]?.id ?? null);
const userEditedConnectorNameRef = useRef(false);
const formbricksValues = formbricksForm.watch();
const selectedSurveyId = formbricksValues.surveyId;
@@ -226,7 +224,6 @@ export const CreateConnectorModal = ({
setEnumValidationErrors([]);
setResponseCountBySurvey({});
setCsvConnectorName("");
userEditedConnectorNameRef.current = false;
setIsImporting(false);
setIsCreating(false);
setSelectedDirectoryId(directories[0]?.id ?? null);
@@ -353,15 +350,6 @@ export const CreateConnectorModal = ({
const handleCreateCsvConnector = async () => {
if (!selectedDirectoryId || !isConnectorNameValid(csvConnectorName)) return;
const requiredCheck = areAllRequiredCsvFieldsMapped(mappings);
if (!requiredCheck.valid) {
toast.error(
t("workspace.unify.csv_required_fields_missing", { fields: requiredCheck.missing.join(", ") })
);
return;
}
if (csvParsedData.length > 0) {
const errors = validateEnumMappings(mappings, csvParsedData);
if (errors.length > 0) {
@@ -373,17 +361,11 @@ export const CreateConnectorModal = ({
setIsCreating(true);
// Strip any user-supplied tenant_id and merge hidden static mappings (source_type=csv).
const userMappings = mappings.filter((m) =>
CSV_PROTECTED_TARGET_IDS.every((id) => m.targetFieldId !== id)
);
const fieldMappings = [...userMappings, ...CSV_HIDDEN_STATIC_MAPPINGS];
const connectorId = await onCreateConnector({
name: csvConnectorName.trim(),
type: "csv",
feedbackDirectoryId: selectedDirectoryId,
fieldMappings,
fieldMappings: mappings.length > 0 ? mappings : undefined,
});
if (connectorId && csvParsedData.length > 0) {
@@ -396,16 +378,13 @@ export const CreateConnectorModal = ({
};
const isCsvValid = selectedType === "csv" && sourceFields.length > 0;
const areCsvRequiredFieldsMapped = areAllRequiredCsvFieldsMapped(mappings).valid;
const areCsvRequiredFieldsMapped = areAllRequiredFieldsMapped(mappings);
const handleSuggestConnectorName = (name: string) => {
if (userEditedConnectorNameRef.current) return;
setCsvConnectorName(name);
};
const handleCsvConnectorNameChange = (value: string) => {
userEditedConnectorNameRef.current = true;
setCsvConnectorName(value);
const handleLoadSourceFields = () => {
if (selectedType === "csv") {
const fields = parseCSVColumnsToFields("timestamp,customer_id,rating,feedback_text,category");
setSourceFields(fields);
}
};
return (
@@ -437,7 +416,12 @@ export const CreateConnectorModal = ({
<div className="py-4">
{currentStep === "selectType" && (
<ConnectorTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
<ConnectorTypeSelector
selectedType={selectedType}
onSelectType={setSelectedType}
surveyCount={surveys.length}
workspaceId={workspaceId}
/>
)}
{currentStep === "mapping" && selectedType === "formbricks_survey" && (
@@ -534,14 +518,13 @@ export const CreateConnectorModal = ({
{currentStep === "mapping" && selectedType === "csv" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="connectorName">{t("workspace.unify.connector_name")}</Label>
<Label htmlFor="connectorName">{t("workspace.unify.source_name")}</Label>
<Input
id="connectorName"
value={csvConnectorName}
onChange={(event) => handleCsvConnectorNameChange(event.target.value)}
onChange={(event) => setCsvConnectorName(event.target.value)}
placeholder={t("workspace.unify.enter_name_for_source")}
/>
<p className="text-xs text-slate-500">{t("workspace.unify.connector_name_hint")}</p>
</div>
{directories.length === 0 && <NoFeedbackDirectoryAlert workspaceId={workspaceId} t={t} />}
@@ -555,8 +538,8 @@ export const CreateConnectorModal = ({
setEnumValidationErrors([]);
}}
onSourceFieldsChange={setSourceFields}
onLoadSampleCSV={handleLoadSourceFields}
onParsedDataChange={setCsvParsedData}
onSuggestConnectorName={handleSuggestConnectorName}
/>
</div>
@@ -600,7 +583,9 @@ export const CreateConnectorModal = ({
</Button>
)}
{currentStep === "selectType" ? (
<Button onClick={handleNextStep} disabled={!selectedType}>
<Button
onClick={handleNextStep}
disabled={!selectedType || (selectedType === "formbricks_survey" && surveys.length === 0)}>
{getNextStepButtonLabel(selectedType, t)}
</Button>
) : (
@@ -1,20 +1,14 @@
"use client";
import { parse } from "csv-parse/sync";
import { ArrowUpFromLineIcon, ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ArrowUpFromLineIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { SAMPLE_CSV_COLUMNS, TFieldMapping, TSourceField, createFeedbackCSVDataSchema } from "../types";
import {
TMappingConfidence,
autoMapCsvSourceFields,
parseCSVColumnsToFields,
titleizeFromFileName,
validateCsvFile,
} from "../utils";
import { TFieldMapping, TSourceField, createFeedbackCSVDataSchema } from "../types";
import { validateCsvFile } from "../utils";
import { MappingUI } from "./mapping-ui";
interface CsvConnectorUIProps {
@@ -22,8 +16,8 @@ interface CsvConnectorUIProps {
mappings: TFieldMapping[];
onMappingsChange: (mappings: TFieldMapping[]) => void;
onSourceFieldsChange: (fields: TSourceField[]) => void;
onLoadSampleCSV: () => void;
onParsedDataChange?: (data: Record<string, string>[]) => void;
onSuggestConnectorName?: (name: string) => void;
}
export function CsvConnectorUI({
@@ -31,32 +25,14 @@ export function CsvConnectorUI({
mappings,
onMappingsChange,
onSourceFieldsChange,
onLoadSampleCSV,
onParsedDataChange,
onSuggestConnectorName,
}: Readonly<CsvConnectorUIProps>) {
}: CsvConnectorUIProps) {
const { t } = useTranslation();
const [csvFile, setCsvFile] = useState<File | null>(null);
const [csvPreview, setCsvPreview] = useState<string[][]>([]);
const [csvTotalRows, setCsvTotalRows] = useState(0);
const [showMapping, setShowMapping] = useState(false);
const [csvError, setCsvError] = useState("");
const [confidenceByTargetId, setConfidenceByTargetId] = useState<Record<string, TMappingConfidence>>({});
const [previewOpen, setPreviewOpen] = useState(true);
const [sampleRow, setSampleRow] = useState<Record<string, string> | undefined>(undefined);
const userEditedSourceNameRef = useRef(false);
const lastAutoSourceNameRef = useRef<string | undefined>(undefined);
useEffect(() => {
const sourceNameMapping = mappings.find((m) => m.targetFieldId === "source_name");
const current = sourceNameMapping?.staticValue ?? sourceNameMapping?.sourceFieldId;
if (
lastAutoSourceNameRef.current !== undefined &&
(current !== lastAutoSourceNameRef.current || current === undefined)
) {
userEditedSourceNameRef.current = true;
}
}, [mappings]);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target?.files?.[0];
@@ -65,61 +41,6 @@ export function CsvConnectorUI({
}
};
const handleUserMappingsChange = (newMappings: TFieldMapping[]) => {
const oldByTarget = new Map(mappings.map((m) => [m.targetFieldId, m]));
const newByTarget = new Map(newMappings.map((m) => [m.targetFieldId, m]));
const changedIds = new Set<string>();
for (const [id, m] of newByTarget) {
const prev = oldByTarget.get(id);
if (!prev || prev.sourceFieldId !== m.sourceFieldId || prev.staticValue !== m.staticValue) {
changedIds.add(id);
}
}
for (const id of oldByTarget.keys()) {
if (!newByTarget.has(id)) changedIds.add(id);
}
if (changedIds.size > 0) {
setConfidenceByTargetId((prev) => {
const next = { ...prev };
for (const id of changedIds) delete next[id];
return next;
});
}
onMappingsChange(newMappings);
};
const applyAutoMapping = (fields: TSourceField[], sampleRow: Record<string, string>, fileName: string) => {
const { mappings: autoMappings, confidence } = autoMapCsvSourceFields({
sourceFields: fields,
sampleRow,
fileName,
});
const autoSourceNameStatic = autoMappings.find((m) => m.targetFieldId === "source_name")?.staticValue;
if (userEditedSourceNameRef.current) {
const existingSourceName = mappings.find((m) => m.targetFieldId === "source_name");
if (existingSourceName) {
const filtered = autoMappings.filter((m) => m.targetFieldId !== "source_name");
onMappingsChange([...filtered, existingSourceName]);
const nextConfidence = { ...confidence };
delete nextConfidence.source_name;
setConfidenceByTargetId(nextConfidence);
} else {
onMappingsChange(autoMappings);
setConfidenceByTargetId(confidence);
}
} else {
onMappingsChange(autoMappings);
setConfidenceByTargetId(confidence);
}
lastAutoSourceNameRef.current = autoSourceNameStatic;
onSuggestConnectorName?.(titleizeFromFileName(fileName));
};
const processCSVFile = (file: File) => {
setCsvError("");
@@ -152,7 +73,6 @@ export function CsvConnectorUI({
];
setCsvFile(file);
setCsvPreview(preview);
setCsvTotalRows(validRecords.length);
const fields: TSourceField[] = headers.map((header) => ({
id: header,
@@ -162,10 +82,6 @@ export function CsvConnectorUI({
}));
onSourceFieldsChange(fields);
onParsedDataChange?.(validRecords);
setSampleRow(validRecords[0]);
applyAutoMapping(fields, validRecords[0], file.name);
setShowMapping(true);
} catch (error) {
const message = error instanceof Error ? error.message : t("common.failed_to_parse_csv");
@@ -190,106 +106,67 @@ export function CsvConnectorUI({
};
const handleLoadSample = () => {
const fields = parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
const synthSampleRow = Object.fromEntries(fields.map((f) => [f.id, f.sampleValue ?? ""])) as Record<
string,
string
>;
onSourceFieldsChange(fields);
onParsedDataChange?.([]);
setSampleRow(synthSampleRow);
setCsvPreview([fields.map((f) => f.id), fields.map((f) => f.sampleValue ?? "")]);
setCsvTotalRows(1);
applyAutoMapping(fields, synthSampleRow, "sample-feedback.csv");
onLoadSampleCSV();
setShowMapping(true);
};
if (showMapping && sourceFields.length > 0) {
const sourceLabel = csvFile?.name ?? t("workspace.unify.csv_sample_label");
return (
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-800">{sourceLabel}</span>
<Badge
text={t("workspace.unify.csv_rows_count", { count: csvTotalRows })}
type="gray"
size="tiny"
/>
{csvFile && (
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-800">{csvFile.name}</span>
<Badge text={`${csvPreview.length - 1} rows`} type="gray" size="tiny" />
</div>
<Button
variant="secondary"
size="sm"
onClick={() => {
setCsvFile(null);
setCsvPreview([]);
setCsvError("");
setShowMapping(false);
onSourceFieldsChange([]);
onParsedDataChange?.([]);
}}>
{t("workspace.unify.change_file")}
</Button>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => {
setCsvFile(null);
setCsvPreview([]);
setCsvTotalRows(0);
setCsvError("");
setShowMapping(false);
setConfidenceByTargetId({});
setSampleRow(undefined);
userEditedSourceNameRef.current = false;
lastAutoSourceNameRef.current = undefined;
onSourceFieldsChange([]);
onParsedDataChange?.([]);
}}>
{t("workspace.unify.change_file")}
</Button>
</div>
)}
{csvPreview.length > 0 && (
<div className="overflow-hidden rounded-lg border border-slate-200">
<button
type="button"
onClick={() => setPreviewOpen((v) => !v)}
className="flex w-full items-center gap-1 bg-slate-50 px-3 py-2 text-left text-xs font-medium text-slate-700 hover:bg-slate-100">
{previewOpen ? (
<ChevronDownIcon className="h-3 w-3" />
) : (
<ChevronRightIcon className="h-3 w-3" />
)}
{t("workspace.unify.csv_data_preview")}
{(() => {
const visible = Math.min(3, Math.max(csvPreview.length - 1, 0));
if (visible >= csvTotalRows) return null;
return (
<span className="text-slate-500">
({t("workspace.unify.showing_rows", { visible, total: csvTotalRows })})
</span>
);
})()}
</button>
{previewOpen && (
<>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50">
<tr>
{csvPreview[0]?.map((header, i) => (
<th
key={`${header}-${i}`}
className="px-3 py-2 text-left font-medium text-slate-700">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{csvPreview.slice(1, 4).map((row, rowIndex) => (
<tr key={`${rowIndex}-${row.join("|")}`} className="border-t border-slate-100">
{row.map((cell, cellIndex) => (
<td
key={`${csvPreview[0]?.[cellIndex] ?? cellIndex}-${cellIndex}`}
className="px-3 py-2 text-slate-600">
{cell || <span className="text-slate-300"></span>}
</td>
))}
</tr>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50">
<tr>
{csvPreview[0]?.map((header, i) => (
<th key={`${header}-${i}`} className="px-3 py-2 text-left font-medium text-slate-700">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{csvPreview.slice(1, 4).map((row, rowIndex) => (
<tr key={`${rowIndex}-${row.join("|")}`} className="border-t border-slate-100">
{row.map((cell, cellIndex) => (
<td
key={`${csvPreview[0]?.[cellIndex] ?? cellIndex}-${cellIndex}`}
className="px-3 py-2 text-slate-600">
{cell || <span className="text-slate-300"></span>}
</td>
))}
</tbody>
</table>
</div>
</>
</tr>
))}
</tbody>
</table>
</div>
{csvPreview.length > 4 && (
<div className="border-t border-slate-100 bg-slate-50 px-3 py-1.5 text-center text-xs text-slate-500">
{t("workspace.unify.showing_rows", { count: csvPreview.length - 1 })}
</div>
)}
</div>
)}
@@ -297,13 +174,9 @@ export function CsvConnectorUI({
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={handleUserMappingsChange}
onMappingsChange={onMappingsChange}
connectorType="csv"
confidenceByTargetId={confidenceByTargetId}
sampleRow={sampleRow}
/>
<UnmappedColumnsFooter sourceFields={sourceFields} mappings={mappings} />
</div>
);
}
@@ -347,27 +220,3 @@ export function CsvConnectorUI({
</div>
);
}
interface UnmappedColumnsFooterProps {
sourceFields: TSourceField[];
mappings: TFieldMapping[];
}
const UnmappedColumnsFooter = ({ sourceFields, mappings }: Readonly<UnmappedColumnsFooterProps>) => {
const { t } = useTranslation();
const claimed = new Set(mappings.map((m) => m.sourceFieldId).filter((id): id is string => Boolean(id)));
const unmapped = sourceFields.filter((f) => !claimed.has(f.id));
if (unmapped.length === 0) return null;
return (
<div className="rounded-md border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
<p className="font-medium">
{t("workspace.unify.csv_unmapped_columns", {
count: unmapped.length,
columns: unmapped.map((c) => c.name).join(", "),
})}
</p>
<p className="mt-0.5 text-slate-500">{t("workspace.unify.csv_unmapped_columns_explainer")}</p>
</div>
);
};
@@ -3,7 +3,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { Button } from "@/modules/ui/components/button";
@@ -33,8 +32,6 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import {
CSV_HIDDEN_STATIC_MAPPINGS,
CSV_PROTECTED_TARGET_IDS,
SAMPLE_CSV_COLUMNS,
TFieldMapping,
TFormbricksConnectorForm,
@@ -43,7 +40,7 @@ import {
ZFormbricksConnectorForm,
} from "../types";
import {
areAllRequiredCsvFieldsMapped,
areAllRequiredFieldsMapped,
isConnectorNameValid,
parseCSVColumnsToFields,
toggleQuestionId,
@@ -190,27 +187,13 @@ export const EditConnectorModal = ({
const handleUpdateCsvConnector = async () => {
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
const requiredCheck = areAllRequiredCsvFieldsMapped(mappings);
if (!requiredCheck.valid) {
toast.error(
t("workspace.unify.csv_required_fields_missing", { fields: requiredCheck.missing.join(", ") })
);
return;
}
setIsUpdating(true);
const userMappings = mappings.filter((m) =>
CSV_PROTECTED_TARGET_IDS.every((id) => m.targetFieldId !== id)
);
const fieldMappings = [...userMappings, ...CSV_HIDDEN_STATIC_MAPPINGS];
await onUpdateConnector({
connectorId: connector.id,
workspaceId: connector.workspaceId,
name: csvConnectorName.trim(),
surveyMappings: undefined,
fieldMappings,
fieldMappings: mappings.length > 0 ? mappings : undefined,
});
setIsUpdating(false);
handleOpenChange(false);
@@ -237,7 +220,7 @@ export const EditConnectorModal = ({
}
if (connector.type === "csv") {
return !isConnectorNameValid(csvConnectorName) || !areAllRequiredCsvFieldsMapped(mappings).valid;
return !isConnectorNameValid(csvConnectorName) || !areAllRequiredFieldsMapped(mappings);
}
return true;
@@ -1,364 +1,358 @@
"use client";
import {
AlertTriangleIcon,
ClockIcon,
MinusCircleIcon,
PencilIcon,
SparklesIcon,
TextCursorInputIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useDraggable, useDroppable } from "@dnd-kit/core";
import { ChevronDownIcon, GripVerticalIcon, PencilIcon, XIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { cn } from "@/modules/ui/lib/utils";
import { TFieldMapping, TSourceField, TTargetField } from "../types";
export type TAutoMapState = "high" | "medium" | "low";
interface AutoMappedBadgeProps {
state: TAutoMapState;
sourceColumn?: string;
interface DraggableSourceFieldProps {
field: TSourceField;
isMapped: boolean;
}
const AUTO_MAP_BADGE_STYLES: Record<TAutoMapState, string> = {
high: "bg-indigo-50 text-indigo-700",
medium: "bg-amber-50 text-amber-800",
low: "bg-orange-100 text-orange-800",
const getSourceFieldStateClass = (isDragging: boolean, isMapped: boolean): string => {
if (isDragging) return "border-brand-dark bg-slate-100 opacity-50";
if (isMapped) return "border-green-300 bg-green-50 text-green-800";
return "border-slate-200 bg-white hover:border-slate-300";
};
export const AutoMappedBadge = ({ state, sourceColumn }: AutoMappedBadgeProps) => {
const { t } = useTranslation();
const isHigh = state === "high";
const Icon = isHigh ? SparklesIcon : AlertTriangleIcon;
const className = cn(
"ml-1 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium",
AUTO_MAP_BADGE_STYLES[state]
);
const label = isHigh ? t("workspace.unify.csv_auto_mapped") : t("workspace.unify.csv_auto_mapped_verify");
export const DraggableSourceField = ({ field, isMapped }: DraggableSourceFieldProps) => {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: field.id,
data: field,
});
if (!sourceColumn) {
return (
<span className={className}>
<Icon className="h-3 w-3" />
{label}
</span>
);
}
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: undefined;
return (
<TooltipRenderer tooltipContent={t("workspace.unify.csv_auto_mapped_tooltip", { column: sourceColumn })}>
<span className={className}>
<Icon className="h-3 w-3" />
{label}
</span>
</TooltipRenderer>
);
};
const SENTINEL = {
COLUMN_PREFIX: "__col__:",
ENUM_PREFIX: "__enum__:",
STATIC_NOW: "__static_now__",
EDIT_FIXED: "__edit_fixed__",
CLEAR: "__clear__",
} as const;
const GROUP_LABEL_CLASS = "px-2 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-500";
interface FormTargetFieldProps {
field: TTargetField;
mapping: TFieldMapping | null;
sourceFields: TSourceField[];
allMappings: TFieldMapping[];
targetNameById: Record<string, string>;
onChange: (next: TFieldMapping | null) => void;
autoMapState?: TAutoMapState;
autoMapSourceColumn?: string;
preview?: string;
}
export const FormTargetField = ({
field,
mapping,
sourceFields,
allMappings,
targetNameById,
onChange,
autoMapState,
autoMapSourceColumn,
preview,
}: FormTargetFieldProps) => {
const { t } = useTranslation();
const [isEditingFixed, setIsEditingFixed] = useState(false);
const [draftFixedValue, setDraftFixedValue] = useState("");
const hasMapping = Boolean(mapping?.sourceFieldId || mapping?.staticValue);
const isEnum = field.type === "enum" && Boolean(field.enumValues?.length);
const isTimestamp = field.type === "timestamp";
const otherUsageByColumn = useMemo(() => {
const map: Record<string, string> = {};
for (const m of allMappings) {
if (!m.sourceFieldId) continue;
if (m.targetFieldId === field.id) continue;
map[m.sourceFieldId] = targetNameById[m.targetFieldId] ?? m.targetFieldId;
}
return map;
}, [allMappings, field.id, targetNameById]);
const selectValue = useMemo(() => {
if (mapping?.sourceFieldId) return `${SENTINEL.COLUMN_PREFIX}${mapping.sourceFieldId}`;
if (mapping?.staticValue === "$now") return SENTINEL.STATIC_NOW;
if (isEnum && mapping?.staticValue) return `${SENTINEL.ENUM_PREFIX}${mapping.staticValue}`;
if (mapping?.staticValue !== undefined && mapping?.staticValue !== "") {
return SENTINEL.EDIT_FIXED;
}
return "";
}, [mapping, isEnum]);
const openFixedValueEditor = () => {
setDraftFixedValue(mapping?.staticValue && mapping.staticValue !== "$now" ? mapping.staticValue : "");
setIsEditingFixed(true);
};
const handleSelectChange = (value: string) => {
if (value.startsWith(SENTINEL.COLUMN_PREFIX)) {
onChange({
targetFieldId: field.id,
sourceFieldId: value.slice(SENTINEL.COLUMN_PREFIX.length),
});
setIsEditingFixed(false);
return;
}
if (value === SENTINEL.STATIC_NOW) {
onChange({ targetFieldId: field.id, staticValue: "$now" });
setIsEditingFixed(false);
return;
}
if (value.startsWith(SENTINEL.ENUM_PREFIX)) {
onChange({
targetFieldId: field.id,
staticValue: value.slice(SENTINEL.ENUM_PREFIX.length),
});
setIsEditingFixed(false);
return;
}
if (value === SENTINEL.EDIT_FIXED) {
openFixedValueEditor();
return;
}
if (value === SENTINEL.CLEAR) {
onChange(null);
setIsEditingFixed(false);
}
};
const handleSaveFixed = () => {
const trimmed = draftFixedValue.trim();
if (trimmed) {
onChange({ targetFieldId: field.id, staticValue: trimmed });
} else if (!field.required) {
onChange(null);
}
setIsEditingFixed(false);
};
return (
<div className="rounded-md border border-slate-200 bg-white p-3">
<div className="flex items-baseline gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
{isEnum && <span className="text-xs text-slate-400">{t("workspace.unify.enum")}</span>}
{hasMapping && autoMapState && (
<AutoMappedBadge state={autoMapState} sourceColumn={autoMapSourceColumn} />
)}
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={`flex cursor-grab items-center gap-2 rounded-md border p-2 text-sm transition-colors ${getSourceFieldStateClass(isDragging, isMapped)}`}>
<GripVerticalIcon className="h-4 w-4 text-slate-400" />
<div className="flex-1 truncate">
<span className="font-medium">{field.name}</span>
<span className="ml-2 text-xs text-slate-500">({field.type})</span>
</div>
<p className="mt-0.5 text-xs text-slate-500">{field.description}</p>
<div className="mt-2">
{isEditingFixed ? (
<div className="flex items-center gap-2">
<Input
autoFocus
value={draftFixedValue}
onChange={(e) => setDraftFixedValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSaveFixed();
}
if (e.key === "Escape") {
e.preventDefault();
setIsEditingFixed(false);
}
}}
placeholder={t("workspace.unify.set_value")}
className="h-9"
/>
<Button size="sm" onClick={handleSaveFixed}>
{t("common.done")}
</Button>
<Button size="sm" variant="ghost" onClick={() => setIsEditingFixed(false)}>
{t("common.cancel")}
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<div className="flex-1">
<Select value={selectValue || undefined} onValueChange={handleSelectChange}>
<SelectTrigger className="h-9 w-full bg-white">
<SelectValue
placeholder={
isEnum
? t("workspace.unify.select_a_value")
: t("workspace.unify.csv_pick_column_placeholder")
}
/>
</SelectTrigger>
<SelectContent>
{isEnum && field.enumValues ? (
<>
<SelectGroup>
<SelectLabel className={GROUP_LABEL_CLASS}>
{t("workspace.unify.enum_values")}
</SelectLabel>
{field.enumValues.map((enumValue) => (
<SelectItem key={enumValue} value={`${SENTINEL.ENUM_PREFIX}${enumValue}`}>
{enumValue}
</SelectItem>
))}
</SelectGroup>
{sourceFields.length > 0 && (
<>
<SelectSeparator />
<SelectGroup>
<SelectLabel className={GROUP_LABEL_CLASS}>
{t("workspace.unify.csv_columns")}
</SelectLabel>
{sourceFields.map((column) => {
const otherUsage = otherUsageByColumn[column.id];
return (
<SelectItem key={column.id} value={`${SENTINEL.COLUMN_PREFIX}${column.id}`}>
<span className="flex w-full items-center gap-2">
<span className="text-slate-900">{column.name}</span>
{otherUsage && (
<span className="ml-auto rounded bg-slate-100 px-1.5 py-0.5 text-xs font-normal text-slate-500">
{t("workspace.unify.csv_column_used_by", { target: otherUsage })}
</span>
)}
</span>
</SelectItem>
);
})}
</SelectGroup>
</>
)}
{!field.required && hasMapping && (
<>
<SelectSeparator />
<SelectItem value={SENTINEL.CLEAR}>
<span className="inline-flex items-center gap-2 text-slate-900">
<MinusCircleIcon className="h-3.5 w-3.5" />
{t("workspace.unify.dont_include")}
</span>
</SelectItem>
</>
)}
</>
) : (
<>
{sourceFields.length > 0 && (
<SelectGroup>
<SelectLabel className={GROUP_LABEL_CLASS}>
{t("workspace.unify.csv_columns")}
</SelectLabel>
{sourceFields.map((column) => {
const otherUsage = otherUsageByColumn[column.id];
return (
<SelectItem key={column.id} value={`${SENTINEL.COLUMN_PREFIX}${column.id}`}>
<span className="flex w-full items-center gap-2">
<span className="text-slate-900">{column.name}</span>
{otherUsage && (
<span className="ml-auto rounded bg-slate-100 px-1.5 py-0.5 text-xs font-normal text-slate-500">
{t("workspace.unify.csv_column_used_by", { target: otherUsage })}
</span>
)}
</span>
</SelectItem>
);
})}
</SelectGroup>
)}
{isTimestamp && (
<>
<SelectSeparator />
<SelectItem value={SENTINEL.STATIC_NOW}>
<span className="inline-flex items-center gap-2 text-slate-900">
<ClockIcon className="h-3.5 w-3.5" />
{t("workspace.unify.csv_now_label")}
</span>
</SelectItem>
</>
)}
<SelectSeparator />
<SelectItem value={SENTINEL.EDIT_FIXED}>
<span className="inline-flex items-center gap-2 text-slate-900">
<TextCursorInputIcon className="h-3.5 w-3.5" />
{mapping?.staticValue && mapping.staticValue !== "$now"
? t("workspace.unify.csv_fixed_value_label", {
value: truncate(mapping.staticValue, 40),
})
: t("workspace.unify.csv_fixed_value_action")}
</span>
</SelectItem>
{!field.required && hasMapping && (
<>
<SelectSeparator />
<SelectItem value={SENTINEL.CLEAR}>
<span className="inline-flex items-center gap-2 text-slate-900">
<MinusCircleIcon className="h-3.5 w-3.5" />
{t("workspace.unify.dont_include")}
</span>
</SelectItem>
</>
)}
</>
)}
</SelectContent>
</Select>
</div>
{!isEnum && mapping?.staticValue && mapping.staticValue !== "$now" && (
<Button
size="sm"
variant="secondary"
onClick={openFixedValueEditor}
aria-label={t("workspace.unify.csv_fixed_value_action")}
className="shrink-0">
<PencilIcon className="h-3.5 w-3.5" />
{t("common.edit")}
</Button>
)}
</div>
)}
</div>
{preview && <p className="mt-2 text-xs text-slate-500">{preview}</p>}
{field.sampleValue && (
<span className="max-w-24 truncate text-xs text-slate-400">{field.sampleValue}</span>
)}
</div>
);
};
const truncate = (value: string, max: number): string =>
value.length > max ? `${value.slice(0, max - 1)}` : value;
const getMappingStateClass = (isActive: boolean, hasMapping: unknown): string => {
if (isActive) return "border-brand-dark bg-slate-100";
if (hasMapping) return "border-green-300 bg-green-50";
return "border-dashed border-slate-300 bg-slate-50";
};
interface RemoveMappingButtonProps {
onClick: () => void;
variant: "green" | "blue";
}
const RemoveMappingButton = ({ onClick, variant }: RemoveMappingButtonProps) => {
const colorClass = variant === "green" ? "hover:bg-green-100" : "hover:bg-blue-100";
const iconClass = variant === "green" ? "text-green-600" : "text-blue-600";
return (
<button type="button" onClick={onClick} className={`ml-1 rounded p-0.5 ${colorClass}`}>
<XIcon className={`h-3 w-3 ${iconClass}`} />
</button>
);
};
interface EnumTargetFieldContentProps {
field: TTargetField;
mappedSourceField: TSourceField | null;
mapping: TFieldMapping | null;
onRemoveMapping: () => void;
onStaticValueChange: (value: string) => void;
t: (key: string) => string;
}
const EnumTargetFieldContent = ({
field,
mappedSourceField,
mapping,
onRemoveMapping,
onStaticValueChange,
t,
}: EnumTargetFieldContentProps) => {
return (
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
<span className="text-xs text-slate-400">{t("workspace.unify.enum")}</span>
</div>
{mappedSourceField && !mapping?.staticValue ? (
<div className="flex items-center gap-1">
<span className="text-xs text-green-700">&larr; {mappedSourceField.name}</span>
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
</div>
) : (
<Select value={mapping?.staticValue || ""} onValueChange={onStaticValueChange}>
<SelectTrigger className="h-8 w-full bg-white">
<SelectValue placeholder={t("workspace.unify.select_a_value")} />
</SelectTrigger>
<SelectContent>
{field.enumValues?.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
};
interface StringTargetFieldContentProps {
field: TTargetField;
mappedSourceField: TSourceField | null;
mapping: TFieldMapping | null;
hasMapping: unknown;
onRemoveMapping: () => void;
onStaticValueChange: (value: string) => void;
t: (key: string) => string;
}
const StringTargetFieldContent = ({
field,
mappedSourceField,
mapping,
hasMapping,
onRemoveMapping,
onStaticValueChange,
t,
}: StringTargetFieldContentProps) => {
const [isEditingStatic, setIsEditingStatic] = useState(false);
const [customValue, setCustomValue] = useState("");
return (
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
</div>
{mappedSourceField && !mapping?.staticValue && (
<div className="flex items-center gap-1">
<span className="text-xs text-green-700"> {mappedSourceField.name}</span>
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
</div>
)}
{mapping?.staticValue && !mappedSourceField && (
<div className="flex items-center gap-1">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
= &ldquo;{mapping.staticValue}&rdquo;
</span>
<RemoveMappingButton onClick={onRemoveMapping} variant="blue" />
</div>
)}
{isEditingStatic && !hasMapping && (
<div className="flex items-center gap-1">
<Input
type="text"
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
placeholder={
field.exampleStaticValues
? `e.g., ${field.exampleStaticValues[0]}`
: t("workspace.unify.enter_value")
}
className="h-7 text-xs"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && customValue.trim()) {
onStaticValueChange(customValue.trim());
setCustomValue("");
setIsEditingStatic(false);
}
if (e.key === "Escape") {
setCustomValue("");
setIsEditingStatic(false);
}
}}
/>
<button
type="button"
onClick={() => {
if (customValue.trim()) {
onStaticValueChange(customValue.trim());
setCustomValue("");
}
setIsEditingStatic(false);
}}
className="rounded p-1 text-slate-500 hover:bg-slate-200">
<ChevronDownIcon className="h-3 w-3" />
</button>
</div>
)}
{!hasMapping && !isEditingStatic && (
<div className="flex flex-wrap items-center gap-1">
<span className="text-xs text-slate-400">{t("workspace.unify.drop_field_or")}</span>
<button
type="button"
onClick={() => setIsEditingStatic(true)}
className="flex items-center gap-1 rounded px-1 py-0.5 text-xs text-slate-500 hover:bg-slate-200">
<PencilIcon className="h-3 w-3" />
{t("workspace.unify.set_value")}
</button>
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
<>
<span className="text-xs text-slate-300">|</span>
{field.exampleStaticValues.slice(0, 3).map((val) => (
<button
key={val}
type="button"
onClick={() => onStaticValueChange(val)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
{val}
</button>
))}
</>
)}
</div>
)}
</div>
);
};
interface DroppableTargetFieldProps {
field: TTargetField;
mappedSourceField: TSourceField | null;
mapping: TFieldMapping | null;
onRemoveMapping: () => void;
onStaticValueChange: (value: string) => void;
isOver?: boolean;
}
export const DroppableTargetField = ({
field,
mappedSourceField,
mapping,
onRemoveMapping,
onStaticValueChange,
isOver,
}: DroppableTargetFieldProps) => {
const { t } = useTranslation();
const { setNodeRef, isOver: isOverCurrent } = useDroppable({
id: field.id,
data: field,
});
const isActive = isOver || isOverCurrent;
const hasMapping = mappedSourceField || mapping?.staticValue;
const containerClass = cn(
"flex items-center gap-2 rounded-md border p-2 text-sm transition-colors",
getMappingStateClass(!!isActive, hasMapping)
);
if (field.type === "enum" && field.enumValues) {
return (
<div ref={setNodeRef} className={containerClass}>
<EnumTargetFieldContent
field={field}
mappedSourceField={mappedSourceField}
mapping={mapping}
onRemoveMapping={onRemoveMapping}
onStaticValueChange={onStaticValueChange}
t={t}
/>
</div>
);
}
if (field.type === "string") {
return (
<div ref={setNodeRef} className={containerClass}>
<StringTargetFieldContent
field={field}
mappedSourceField={mappedSourceField}
mapping={mapping}
hasMapping={hasMapping}
onRemoveMapping={onRemoveMapping}
onStaticValueChange={onStaticValueChange}
t={t}
/>
</div>
);
}
// Helper to get display label for static values
const getStaticValueLabel = (value: string) => {
if (value === "$now") return t("workspace.unify.feedback_date");
return value;
};
return (
<div ref={setNodeRef} className={containerClass}>
<div className="flex flex-1 flex-col">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
<span className="text-xs text-slate-400">({field.type})</span>
</div>
{mappedSourceField && !mapping?.staticValue && (
<div className="mt-1 flex items-center gap-1">
<span className="text-xs text-green-700"> {mappedSourceField.name}</span>
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
</div>
)}
{mapping?.staticValue && !mappedSourceField && (
<div className="mt-1 flex items-center gap-1">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
= {getStaticValueLabel(mapping.staticValue)}
</span>
<RemoveMappingButton onClick={onRemoveMapping} variant="blue" />
</div>
)}
{!hasMapping && (
<div className="mt-1 flex flex-wrap items-center gap-1">
<span className="text-xs text-slate-400">{t("workspace.unify.drop_a_field_here")}</span>
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
<>
<span className="text-xs text-slate-300">|</span>
{field.exampleStaticValues.map((val) => (
<button
key={val}
type="button"
onClick={() => onStaticValueChange(val)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
{getStaticValueLabel(val)}
</button>
))}
</>
)}
</div>
)}
</div>
</div>
);
};
@@ -1,197 +1,146 @@
"use client";
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TConnectorType, THubFieldType, ZHubFieldType } from "@formbricks/types/connector";
import { CSV_FIELD_GROUPS, CSV_TARGET_FIELDS, TFieldMapping, TSourceField, TTargetField } from "../types";
import { TMappingConfidence, routeResponseValueTarget } from "../utils";
import { FormTargetField, TAutoMapState } from "./mapping-field";
import { TConnectorType } from "@formbricks/types/connector";
import { FEEDBACK_RECORD_FIELDS, TFieldMapping, TSourceField } from "../types";
import { DraggableSourceField, DroppableTargetField } from "./mapping-field";
interface MappingUIProps {
sourceFields: TSourceField[];
mappings: TFieldMapping[];
onMappingsChange: (mappings: TFieldMapping[]) => void;
connectorType: TConnectorType;
confidenceByTargetId?: Record<string, TMappingConfidence>;
sampleRow?: Record<string, string>;
}
const toAutoMapState = (confidence?: TMappingConfidence): TAutoMapState | undefined => {
if (confidence === "high" || confidence === "medium" || confidence === "low") return confidence;
return undefined;
};
export function MappingUI({
sourceFields,
mappings,
onMappingsChange,
connectorType,
confidenceByTargetId,
sampleRow,
}: MappingUIProps) {
switch (connectorType) {
case "csv":
return (
<CsvMappingForm
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={onMappingsChange}
confidenceByTargetId={confidenceByTargetId}
sampleRow={sampleRow}
/>
);
default:
return null;
}
}
interface CsvMappingFormProps {
sourceFields: TSourceField[];
mappings: TFieldMapping[];
onMappingsChange: (mappings: TFieldMapping[]) => void;
confidenceByTargetId?: Record<string, TMappingConfidence>;
sampleRow?: Record<string, string>;
}
const CsvMappingForm = ({
sourceFields,
mappings,
onMappingsChange,
confidenceByTargetId,
sampleRow,
}: CsvMappingFormProps) => {
export function MappingUI({ sourceFields, mappings, onMappingsChange, connectorType }: MappingUIProps) {
const { t } = useTranslation();
const [advancedOpen, setAdvancedOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
const fieldsById = new Map(CSV_TARGET_FIELDS.map((f) => [f.id, f]));
const targetNameById = useMemo(() => Object.fromEntries(CSV_TARGET_FIELDS.map((f) => [f.id, f.name])), []);
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
const optionalFields = FEEDBACK_RECORD_FIELDS.filter((f) => !f.required);
const upsertMapping = (next: TFieldMapping) => {
const filtered = mappings.filter((m) => m.targetFieldId !== next.targetFieldId);
onMappingsChange([...filtered, next]);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const removeMapping = (targetFieldId: string) => {
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over) return;
const sourceFieldId = active.id as string;
const targetFieldId = over.id as string;
const newMappings = mappings.filter(
(m) => m.sourceFieldId !== sourceFieldId && m.targetFieldId !== targetFieldId
);
onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]);
};
const handleRemoveMapping = (targetFieldId: string) => {
onMappingsChange(mappings.filter((m) => m.targetFieldId !== targetFieldId));
};
const handleChange = (targetFieldId: string, next: TFieldMapping | null) => {
if (next === null) removeMapping(targetFieldId);
else upsertMapping(next);
const handleStaticValueChange = (targetFieldId: string, staticValue: string) => {
const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId);
onMappingsChange([...newMappings, { targetFieldId, staticValue }]);
};
const responseValueMapping = mappings.find((m) => m.targetFieldId === "response_value");
const fieldTypeMapping = mappings.find((m) => m.targetFieldId === "field_type");
const responseValuePreview = computeResponseValuePreview({
responseValueMapping,
fieldTypeMapping,
sampleRow,
sourceFields,
t,
});
const getSourceFieldById = (id: string) => sourceFields.find((f) => f.id === id);
const renderField = (target: TTargetField) => {
const mapping = mappings.find((m) => m.targetFieldId === target.id) ?? null;
const autoMapState = toAutoMapState(confidenceByTargetId?.[target.id]);
const sourceColumnName = mapping?.sourceFieldId
? sourceFields.find((s) => s.id === mapping.sourceFieldId)?.name
: undefined;
const isResponseValue = target.id === "response_value";
return (
<FormTargetField
key={target.id}
field={target}
mapping={mapping}
sourceFields={sourceFields}
allMappings={mappings}
targetNameById={targetNameById}
onChange={(next) => handleChange(target.id, next)}
autoMapState={autoMapState}
autoMapSourceColumn={sourceColumnName}
preview={isResponseValue ? responseValuePreview : undefined}
/>
);
const getMappingForTarget = (targetFieldId: string) => {
return mappings.find((m) => m.targetFieldId === targetFieldId) ?? null;
};
const renderGroup = (ids: readonly string[]) =>
ids
.map((id) => fieldsById.get(id))
.filter((f): f is TTargetField => Boolean(f))
.map(renderField);
const getMappedSourceField = (targetFieldId: string) => {
const mapping = getMappingForTarget(targetFieldId);
return mapping?.sourceFieldId ? getSourceFieldById(mapping.sourceFieldId) : null;
};
const isSourceFieldMapped = (sourceFieldId: string) =>
mappings.some((m) => m.sourceFieldId === sourceFieldId);
const activeField = activeId ? getSourceFieldById(activeId) : null;
return (
<div className="space-y-6">
<div className="space-y-2">
<div>
<p className="text-sm font-semibold text-slate-800">{t("workspace.unify.csv_basic_required")}</p>
<p className="text-xs text-slate-500">{t("workspace.unify.csv_basic_required_hint")}</p>
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="grid grid-cols-2 gap-6">
{/* Source Fields Panel */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">
{connectorType === "csv" ? t("workspace.unify.csv_columns") : t("workspace.unify.source_fields")}
</h4>
{sourceFields.length === 0 ? (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">
{connectorType === "csv"
? t("workspace.unify.click_load_sample_csv")
: t("workspace.unify.no_source_fields_loaded")}
</p>
</div>
) : (
<div className="space-y-2">
{sourceFields.map((field) => (
<DraggableSourceField key={field.id} field={field} isMapped={isSourceFieldMapped(field.id)} />
))}
</div>
)}
</div>
{/* Target Fields Panel */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">
{t("workspace.unify.feedback_record_fields")}
</h4>
{/* Required Fields */}
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
{t("workspace.unify.required")}
</p>
{requiredFields.map((field) => (
<DroppableTargetField
key={field.id}
field={field}
mappedSourceField={getMappedSourceField(field.id) ?? null}
mapping={getMappingForTarget(field.id)}
onRemoveMapping={() => handleRemoveMapping(field.id)}
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
/>
))}
</div>
{/* Optional Fields */}
<div className="mt-4 space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
{t("workspace.unify.optional")}
</p>
{optionalFields.map((field) => (
<DroppableTargetField
key={field.id}
field={field}
mappedSourceField={getMappedSourceField(field.id) ?? null}
mapping={getMappingForTarget(field.id)}
onRemoveMapping={() => handleRemoveMapping(field.id)}
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
/>
))}
</div>
</div>
<div className="space-y-2">{renderGroup(CSV_FIELD_GROUPS.basic)}</div>
</div>
<div className="space-y-2">
<div>
<p className="text-sm font-semibold text-slate-800">{t("workspace.unify.csv_source_context")}</p>
<p className="text-xs text-slate-500">{t("workspace.unify.csv_source_context_hint")}</p>
</div>
<div className="space-y-2">{renderGroup(CSV_FIELD_GROUPS.sourceContext)}</div>
</div>
<div className="space-y-2">
<button
type="button"
onClick={() => setAdvancedOpen((v) => !v)}
className="flex w-full items-start gap-2 rounded text-left">
<span className="mt-0.5 text-slate-500">
{advancedOpen ? (
<ChevronDownIcon className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</span>
<span>
<span className="block text-sm font-semibold text-slate-800">
{t("workspace.unify.csv_advanced")}
</span>
<span className="block text-xs text-slate-500">{t("workspace.unify.csv_advanced_hint")}</span>
</span>
</button>
{advancedOpen && <div className="space-y-2">{renderGroup(CSV_FIELD_GROUPS.advanced)}</div>}
</div>
</div>
<DragOverlay>
{activeField ? (
<div className="rounded-md border border-brand-dark bg-white p-2 text-sm shadow-lg">
<span className="font-medium">{activeField.name}</span>
<span className="ml-2 text-xs text-slate-500">({activeField.type})</span>
</div>
) : null}
</DragOverlay>
</DndContext>
);
};
interface ComputePreviewArgs {
responseValueMapping: TFieldMapping | undefined;
fieldTypeMapping: TFieldMapping | undefined;
sampleRow: Record<string, string> | undefined;
sourceFields: TSourceField[];
t: ReturnType<typeof useTranslation>["t"];
}
const computeResponseValuePreview = ({
responseValueMapping,
fieldTypeMapping,
sampleRow,
sourceFields,
t,
}: ComputePreviewArgs): string | undefined => {
if (!responseValueMapping?.sourceFieldId) return undefined;
const fieldTypeRaw = fieldTypeMapping?.staticValue ?? "";
const parsed = ZHubFieldType.safeParse(fieldTypeRaw);
if (!parsed.success) return undefined;
const fieldType: THubFieldType = parsed.data;
const target = routeResponseValueTarget(fieldType);
const sample =
sampleRow?.[responseValueMapping.sourceFieldId] ??
sourceFields.find((f) => f.id === responseValueMapping.sourceFieldId)?.sampleValue ??
"";
const localizedTarget = t(`workspace.unify.fields.${target.replace("value_", "")}`);
return t("workspace.unify.csv_response_preview", {
sample,
target: localizedTarget,
});
};
@@ -167,7 +167,7 @@ export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
exampleStaticValues: ["en", "de", "fr", "es", "pt", "ja", "zh"],
},
{
id: "user_identifier",
id: "user_id",
name: "User Identifier",
type: "string",
required: false,
@@ -175,49 +175,6 @@ export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
},
];
export const CSV_RESPONSE_VALUE_TARGET: TTargetField = {
id: "response_value",
name: "Response",
type: "string",
required: true,
description:
"The user's actual answer or value. We'll store it in the right format (text, number, boolean, or date) based on Field Type.",
};
const CSV_HIDDEN_TARGET_IDS = [
"tenant_id",
"source_type",
"value_text",
"value_number",
"value_boolean",
"value_date",
];
export const CSV_TARGET_FIELDS: TTargetField[] = [
...FEEDBACK_RECORD_FIELDS.filter((f) => CSV_HIDDEN_TARGET_IDS.every((id) => f.id !== id)),
CSV_RESPONSE_VALUE_TARGET,
];
export const CSV_FIELD_GROUPS = {
basic: ["collected_at", "field_id", "field_label", "field_type", "response_value"],
sourceContext: ["source_id", "source_name"],
advanced: [
"submission_id",
"field_group_id",
"field_group_label",
"language",
"user_identifier",
"metadata",
],
} as const;
export const CSV_PROTECTED_TARGET_IDS = ["tenant_id", "source_type"] as const;
export const CSV_HIDDEN_STATIC_MAPPINGS: TFieldMapping[] = [
{ sourceFieldId: "", targetFieldId: "source_type", staticValue: "csv" },
];
export const CSV_REQUIRED_UI_FIELDS = ["field_id", "field_label", "field_type", "response_value"];
export const SAMPLE_CSV_COLUMNS = "timestamp,customer_id,rating,feedback_text,category";
export const MAX_CSV_VALUES = {
@@ -1,15 +1,10 @@
import { describe, expect, test } from "vitest";
import { ZHubFieldType } from "@formbricks/types/connector";
import { CSV_HIDDEN_STATIC_MAPPINGS, MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
import { MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
import {
areAllRequiredCsvFieldsMapped,
autoMapCsvSourceFields,
areAllRequiredFieldsMapped,
getConnectorOptions,
inferFieldType,
isConnectorNameValid,
parseCSVColumnsToFields,
routeResponseValueTarget,
titleizeFromFileName,
toggleQuestionId,
validateCsvFile,
} from "./utils";
@@ -151,253 +146,59 @@ describe("isConnectorNameValid", () => {
});
});
describe("areAllRequiredCsvFieldsMapped", () => {
const fullMappings: TFieldMapping[] = [
describe("areAllRequiredFieldsMapped", () => {
const requiredMappings: TFieldMapping[] = [
{ targetFieldId: "collected_at", sourceFieldId: "ts" },
{ targetFieldId: "source_type", staticValue: "csv" },
{ targetFieldId: "field_id", sourceFieldId: "qid" },
{ targetFieldId: "field_label", sourceFieldId: "label" },
{ targetFieldId: "field_type", staticValue: "text" },
{ targetFieldId: "response_value", sourceFieldId: "answer" },
];
test("returns valid=true and missing=[] when every required UI field is resolved", () => {
expect(areAllRequiredCsvFieldsMapped(fullMappings)).toEqual({ valid: true, missing: [] });
test("returns true when all required fields have a sourceFieldId or staticValue", () => {
expect(areAllRequiredFieldsMapped(requiredMappings)).toBe(true);
});
test.each(["field_id", "field_label", "field_type", "response_value"])(
"returns valid=false and lists %s when missing",
(missingId) => {
const partial = fullMappings.filter((m) => m.targetFieldId !== missingId);
const result = areAllRequiredCsvFieldsMapped(partial);
expect(result.valid).toBe(false);
expect(result.missing).toContain(missingId);
}
);
test("returns false when a required field is missing entirely", () => {
const missing = requiredMappings.slice(0, 3);
expect(areAllRequiredFieldsMapped(missing)).toBe(false);
});
test("treats whitespace-only staticValue as unmapped", () => {
test("returns false when a required mapping has neither sourceFieldId nor staticValue", () => {
const incomplete: TFieldMapping[] = [...requiredMappings.slice(0, 3), { targetFieldId: "field_type" }];
expect(areAllRequiredFieldsMapped(incomplete)).toBe(false);
});
test("ignores mappings for non-required target fields", () => {
const withOptionals: TFieldMapping[] = [
...requiredMappings,
{ targetFieldId: "tenant_id", sourceFieldId: "tenant" },
{ targetFieldId: "unknown_field", sourceFieldId: "anything" },
];
expect(areAllRequiredFieldsMapped(withOptionals)).toBe(true);
});
test("returns false for empty mappings array", () => {
expect(areAllRequiredFieldsMapped([])).toBe(false);
});
test("treats empty staticValue and missing sourceFieldId as unmapped", () => {
const incomplete: TFieldMapping[] = [
...fullMappings.filter((m) => m.targetFieldId !== "field_type"),
{ targetFieldId: "field_type", staticValue: " " },
{ targetFieldId: "collected_at", sourceFieldId: "ts" },
{ targetFieldId: "source_type", sourceFieldId: "", staticValue: "" },
{ targetFieldId: "field_id", sourceFieldId: "qid" },
{ targetFieldId: "field_type", staticValue: "text" },
];
expect(areAllRequiredCsvFieldsMapped(incomplete).missing).toContain("field_type");
expect(areAllRequiredFieldsMapped(incomplete)).toBe(false);
});
test("treats invalid static field_type as unmapped", () => {
const invalidFieldType: TFieldMapping[] = [
...fullMappings.filter((m) => m.targetFieldId !== "field_type"),
{ targetFieldId: "field_type", staticValue: "not_a_field_type" },
test("counts required field as mapped when only staticValue is set", () => {
const onlyStatic: TFieldMapping[] = [
{ targetFieldId: "collected_at", staticValue: "2026-01-01" },
{ targetFieldId: "source_type", staticValue: "csv" },
{ targetFieldId: "field_id", staticValue: "id" },
{ targetFieldId: "field_type", staticValue: "text" },
];
expect(areAllRequiredCsvFieldsMapped(invalidFieldType).missing).toContain("field_type");
});
test("does not require collected_at (defaults to $now)", () => {
expect(areAllRequiredCsvFieldsMapped(fullMappings).missing).not.toContain("collected_at");
});
});
describe("CSV_HIDDEN_STATIC_MAPPINGS", () => {
test("persists source_type=csv with a valid static mapping shape", () => {
expect(CSV_HIDDEN_STATIC_MAPPINGS).toEqual([
{ sourceFieldId: "", targetFieldId: "source_type", staticValue: "csv" },
]);
});
});
describe("titleizeFromFileName", () => {
test.each([
["feedback.csv", "Feedback"],
["q1-2026-survey.csv", "Q1 2026 Survey"],
["customer_feedback_data.csv", "Customer Feedback Data"],
["Mixed Case File.CSV", "Mixed Case File"],
["nps results", "Nps Results"],
["", ""],
])("titleizes %s to %s", (input, expected) => {
expect(titleizeFromFileName(input)).toBe(expected);
});
});
describe("inferFieldType", () => {
test("detects integer numbers from samples", () => {
expect(inferFieldType({ samples: ["3", "5", "10"] })).toBe("number");
});
test("detects floating-point numbers from samples", () => {
expect(inferFieldType({ samples: ["3.14", "-2.5"] })).toBe("number");
});
test("detects booleans from samples", () => {
expect(inferFieldType({ samples: ["true", "false", "yes", "no"] })).toBe("boolean");
});
test("detects ISO dates from samples", () => {
expect(inferFieldType({ samples: ["2026-01-01", "2026-02-15T10:00:00Z"] })).toBe("date");
});
test("falls back to text for arbitrary strings", () => {
expect(inferFieldType({ samples: ["hello", "world"] })).toBe("text");
});
test("returns text for empty samples", () => {
expect(inferFieldType({ samples: [] })).toBe("text");
expect(inferFieldType({ samples: ["", " "] })).toBe("text");
});
test("name hint wins over sample sniff (rating column with garbage samples)", () => {
expect(inferFieldType({ columnName: "rating", samples: ["asdf", "qwer"] })).toBe("rating");
});
test.each([
["nps", "nps"],
["nps_score", "nps"],
["csat", "csat"],
["ces", "ces"],
["stars", "rating"],
["score", "rating"],
["comment", "text"],
["category", "categorical"],
["is_promoter", "boolean"],
["has_responded", "boolean"],
["submitted_at", "date"],
])("name hint %s → %s", (columnName, expected) => {
expect(inferFieldType({ columnName, samples: [] })).toBe(expected);
});
test("name with no hint falls back to sample sniffing", () => {
expect(inferFieldType({ columnName: "anonymous", samples: ["42"] })).toBe("number");
});
});
describe("routeResponseValueTarget", () => {
test.each([
["text", "value_text"],
["categorical", "value_text"],
["number", "value_number"],
["nps", "value_number"],
["csat", "value_number"],
["ces", "value_number"],
["rating", "value_number"],
["boolean", "value_boolean"],
["date", "value_date"],
] as const)("routes %s to %s", (fieldType, expected) => {
expect(routeResponseValueTarget(fieldType)).toBe(expected);
});
test("covers every THubFieldType enum value", () => {
for (const fieldType of ZHubFieldType.options) {
expect(() => routeResponseValueTarget(fieldType)).not.toThrow();
}
});
});
describe("autoMapCsvSourceFields", () => {
const buildSourceFields = (names: string[]): TSourceField[] =>
names.map((name) => ({ id: name, name, type: "string", sampleValue: "" }));
test("maps timestamp column to collected_at with high confidence", () => {
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["timestamp", "answer"]),
sampleRow: { timestamp: "2026-01-01", answer: "yes" },
fileName: "feedback.csv",
});
const mapping = result.mappings.find((m) => m.targetFieldId === "collected_at");
expect(mapping?.sourceFieldId).toBe("timestamp");
expect(result.confidence.collected_at).toBe("high");
});
test("falls back to $now when no timestamp column is present", () => {
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["question", "answer"]),
sampleRow: { question: "q1", answer: "yes" },
fileName: "feedback.csv",
});
const mapping = result.mappings.find((m) => m.targetFieldId === "collected_at");
expect(mapping?.staticValue).toBe("$now");
expect(result.confidence.collected_at).toBe("high");
});
test("maps email to user_identifier with medium confidence", () => {
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["email", "answer"]),
sampleRow: { email: "x@y.com", answer: "yes" },
fileName: "feedback.csv",
});
const mapping = result.mappings.find((m) => m.targetFieldId === "user_identifier");
expect(mapping?.sourceFieldId).toBe("email");
expect(result.confidence.user_identifier).toBe("medium");
});
test("prepopulates source_name from titleized filename", () => {
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["question", "answer"]),
sampleRow: { question: "q1", answer: "yes" },
fileName: "Q1-2026-survey.csv",
});
const mapping = result.mappings.find((m) => m.targetFieldId === "source_name");
expect(mapping?.staticValue).toBe("Q1 2026 Survey");
expect(result.confidence.source_name).toBe("high");
});
test("keeps source_name filename-derived even when the CSV has a source_name column", () => {
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["source_name", "question", "answer"]),
sampleRow: { source_name: "malicious", question: "q1", answer: "yes" },
fileName: "trusted-file.csv",
});
const mapping = result.mappings.find((m) => m.targetFieldId === "source_name");
expect(mapping).toEqual({ targetFieldId: "source_name", staticValue: "Trusted File" });
});
test("ambiguous column claimed by highest-confidence target", () => {
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["question_id", "id"]),
sampleRow: { question_id: "q1", id: "u1" },
fileName: "x.csv",
});
const fieldIdMapping = result.mappings.find((m) => m.targetFieldId === "field_id");
expect(fieldIdMapping?.sourceFieldId).toBe("question_id");
expect(result.confidence.field_id).toBe("high");
});
test("maps realistic QA headers without leaving required basics unresolved", () => {
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["timestamp", "email", "question", "answer", "language", "score"]),
sampleRow: {
timestamp: "2026-01-01",
email: "person@example.com",
question: "How satisfied are you?",
answer: "Great",
language: "en",
score: "9",
},
fileName: "google-forms-export.csv",
});
const validation = areAllRequiredCsvFieldsMapped(result.mappings);
expect(validation).toEqual({ valid: true, missing: [] });
expect(result.mappings.find((m) => m.targetFieldId === "field_id")?.sourceFieldId).toBe("question");
expect(result.mappings.find((m) => m.targetFieldId === "field_label")?.sourceFieldId).toBe("question");
expect(result.confidence.field_id).toBe("low");
});
test("infers field_type as static via sample sniffing when name has no hint", () => {
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["question", "value"]),
sampleRow: { question: "q1", value: "42" },
fileName: "x.csv",
});
const fieldTypeMapping = result.mappings.find((m) => m.targetFieldId === "field_type");
expect(fieldTypeMapping?.staticValue).toBe("number");
expect(result.confidence.field_type).toBe("medium");
});
test("infers field_type as 'rating' (high confidence) when response_value column is named 'rating'", () => {
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["question", "rating"]),
sampleRow: { question: "q1", rating: "garbage" },
fileName: "x.csv",
});
const mapping = result.mappings.find((m) => m.targetFieldId === "field_type");
expect(mapping?.staticValue).toBe("rating");
expect(result.confidence.field_type).toBe("high");
expect(areAllRequiredFieldsMapped(onlyStatic)).toBe(true);
});
});
@@ -1,13 +1,6 @@
import { TFunction } from "i18next";
import { TConnectorType, THubFieldType, ZHubFieldType } from "@formbricks/types/connector";
import {
CSV_REQUIRED_UI_FIELDS,
CSV_TARGET_FIELDS,
FEEDBACK_RECORD_FIELDS,
MAX_CSV_VALUES,
TFieldMapping,
TSourceField,
} from "./types";
import { TConnectorType, THubFieldType } from "@formbricks/types/connector";
import { FEEDBACK_RECORD_FIELDS, MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
export type TConnectorOptionId = TConnectorType | "api_ingestion" | "feedback_record_mcp";
@@ -59,6 +52,10 @@ export interface TEnumValidationError {
allowedValues: string[];
}
/**
* Validates that CSV columns mapped to enum target fields contain only allowed values.
* Returns an array of validation errors (empty if all valid).
*/
export const validateEnumMappings = (
mappings: TFieldMapping[],
csvData: Record<string, string>[]
@@ -95,6 +92,24 @@ export const validateEnumMappings = (
export const isConnectorNameValid = (name: string): boolean => name.trim().length > 0;
export const areAllRequiredFieldsMapped = (mappings: TFieldMapping[]): boolean => {
const requiredFieldIds = new Set(
FEEDBACK_RECORD_FIELDS.filter((field) => field.required).map((field) => field.id)
);
for (const mapping of mappings) {
if (!requiredFieldIds.has(mapping.targetFieldId)) {
continue;
}
if (mapping.sourceFieldId || mapping.staticValue) {
requiredFieldIds.delete(mapping.targetFieldId);
}
}
return requiredFieldIds.size === 0;
};
export const toggleQuestionId = (currentSelection: string[], questionId: string): string[] => {
return currentSelection.includes(questionId)
? currentSelection.filter((id) => id !== questionId)
@@ -116,253 +131,3 @@ export const validateCsvFile = (
}
return { valid: true };
};
export type TMappingConfidence = "high" | "medium" | "low";
export const titleizeFromFileName = (fileName: string): string => {
const base = fileName.replace(/\.csv$/i, "");
const words = base.split(/[_\-\s]+/).filter(Boolean);
if (words.length === 0) return base;
return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(" ");
};
export const CSV_COLUMN_ALIASES: Record<string, { high: RegExp[]; medium: RegExp[] }> = {
collected_at: {
high: [/^(timestamp|collected_at|submitted_at)$/i],
medium: [/^(created_at|date|time|datetime)$/i],
},
field_id: {
high: [/^(field_id|question_id|q_id)$/i],
medium: [/^(id|key)$/i],
},
field_label: {
high: [/^(field_label|question|label|question_text)$/i],
medium: [/^(name|title|prompt)$/i],
},
response_value: {
high: [/^(response|answer|value|response_value)$/i],
medium: [/^(score|rating|feedback)$/i],
},
source_id: {
high: [/^(source_id|survey_id|form_id)$/i],
medium: [],
},
language: {
high: [/^(language|lang|locale)$/i],
medium: [],
},
user_identifier: {
high: [/^(user_id|user_identifier|customer_id)$/i],
medium: [/^(email|user|customer)$/i],
},
metadata: {
high: [/^metadata$/i],
medium: [],
},
};
export const FIELD_TYPE_NAME_HINTS: Array<{ pattern: RegExp; type: THubFieldType }> = [
{ pattern: /^(rating|stars|score)$/i, type: "rating" },
{ pattern: /^(nps|nps_score|net_promoter)$/i, type: "nps" },
{ pattern: /^csat$/i, type: "csat" },
{ pattern: /^ces$/i, type: "ces" },
{ pattern: /^(number|count|amount|qty|quantity)$/i, type: "number" },
{ pattern: /^(comment|feedback|answer|response|text)$/i, type: "text" },
{ pattern: /^(category|choice|option|select)$/i, type: "categorical" },
{ pattern: /^(is_|has_|did_)/i, type: "boolean" },
{ pattern: /^(date|submitted_at|completed_at)$/i, type: "date" },
];
export const inferFieldType = ({
columnName,
samples,
}: {
columnName?: string;
samples: string[];
}): THubFieldType => {
if (columnName) {
for (const hint of FIELD_TYPE_NAME_HINTS) {
if (hint.pattern.test(columnName)) return hint.type;
}
}
const cleaned = samples.map((s) => s?.trim()).filter((s): s is string => Boolean(s));
if (cleaned.length === 0) return "text";
const isBool = cleaned.every((s) => /^(true|false|yes|no|0|1)$/i.test(s));
if (isBool) return "boolean";
const isNumber = cleaned.every((s) => !Number.isNaN(Number.parseFloat(s)) && /^-?\d+(\.\d+)?$/.test(s));
if (isNumber) return "number";
const isDate = cleaned.every((s) => !Number.isNaN(new Date(s).getTime()));
if (isDate) return "date";
return "text";
};
export const routeResponseValueTarget = (
fieldType: THubFieldType
): "value_text" | "value_number" | "value_boolean" | "value_date" => {
switch (fieldType) {
case "text":
case "categorical":
return "value_text";
case "number":
case "nps":
case "csat":
case "ces":
case "rating":
return "value_number";
case "boolean":
return "value_boolean";
case "date":
return "value_date";
default: {
const _exhaustive: never = fieldType;
throw new Error(`Unhandled field_type for response_value routing: ${String(_exhaustive)}`);
}
}
};
interface TAutoMapResult {
mappings: TFieldMapping[];
confidence: Record<string, TMappingConfidence>;
}
interface TAutoMapInput {
sourceFields: TSourceField[];
sampleRow: Record<string, string>;
fileName: string;
}
const findBestSourceMatch = (
targetId: string,
sourceFields: TSourceField[]
): { sourceField: TSourceField; confidence: TMappingConfidence } | null => {
const aliases = CSV_COLUMN_ALIASES[targetId];
if (!aliases) return null;
for (const pattern of aliases.high) {
const match = sourceFields.find((f) => pattern.test(f.name));
if (match) return { sourceField: match, confidence: "high" };
}
for (const pattern of aliases.medium) {
const match = sourceFields.find((f) => pattern.test(f.name));
if (match) return { sourceField: match, confidence: "medium" };
}
const idToken = targetId.split("_").pop() ?? targetId;
const fuzzy = sourceFields.find((f) => f.name.toLowerCase().includes(idToken.toLowerCase()));
if (fuzzy) return { sourceField: fuzzy, confidence: "low" };
return null;
};
export const autoMapCsvSourceFields = ({
sourceFields,
sampleRow,
fileName,
}: TAutoMapInput): TAutoMapResult => {
const mappings: TFieldMapping[] = [];
const confidence: Record<string, TMappingConfidence> = {};
const claimedSources = new Set<string>();
const orderedTargets = CSV_TARGET_FIELDS.map((t) => t.id);
for (const targetId of orderedTargets) {
const aliases = CSV_COLUMN_ALIASES[targetId];
if (!aliases) continue;
for (const pattern of aliases.high) {
const match = sourceFields.find((f) => !claimedSources.has(f.id) && pattern.test(f.name));
if (match) {
mappings.push({ targetFieldId: targetId, sourceFieldId: match.id });
confidence[targetId] = "high";
claimedSources.add(match.id);
break;
}
}
}
for (const targetId of orderedTargets) {
if (confidence[targetId]) continue;
const aliases = CSV_COLUMN_ALIASES[targetId];
if (!aliases) continue;
for (const pattern of aliases.medium) {
const match = sourceFields.find((f) => !claimedSources.has(f.id) && pattern.test(f.name));
if (match) {
mappings.push({ targetFieldId: targetId, sourceFieldId: match.id });
confidence[targetId] = "medium";
claimedSources.add(match.id);
break;
}
}
}
for (const targetId of orderedTargets) {
if (confidence[targetId]) continue;
const remaining = sourceFields.filter((f) => !claimedSources.has(f.id));
const guess = findBestSourceMatch(targetId, remaining);
if (guess && guess.confidence === "low") {
mappings.push({ targetFieldId: targetId, sourceFieldId: guess.sourceField.id });
confidence[targetId] = "low";
claimedSources.add(guess.sourceField.id);
}
}
if (!confidence.collected_at) {
mappings.push({ targetFieldId: "collected_at", staticValue: "$now" });
confidence.collected_at = "high";
}
mappings.push({ targetFieldId: "source_name", staticValue: titleizeFromFileName(fileName) });
confidence.source_name = "high";
if (!confidence.field_id) {
const labelMapping = mappings.find((m) => m.targetFieldId === "field_label" && m.sourceFieldId);
if (labelMapping?.sourceFieldId) {
mappings.push({ targetFieldId: "field_id", sourceFieldId: labelMapping.sourceFieldId });
confidence.field_id = "low";
}
}
if (!confidence.field_type) {
const responseMapping = mappings.find((m) => m.targetFieldId === "response_value");
if (responseMapping?.sourceFieldId) {
const sourceField = sourceFields.find((f) => f.id === responseMapping.sourceFieldId);
const inferred = inferFieldType({
columnName: sourceField?.name,
samples: [sampleRow[responseMapping.sourceFieldId] ?? ""],
});
mappings.push({ targetFieldId: "field_type", staticValue: inferred });
const nameHinted = sourceField?.name
? FIELD_TYPE_NAME_HINTS.some((h) => h.pattern.test(sourceField.name))
: false;
confidence.field_type = nameHinted ? "high" : "medium";
}
}
return { mappings, confidence };
};
export const areAllRequiredCsvFieldsMapped = (
mappings: TFieldMapping[]
): { valid: boolean; missing: string[] } => {
const missing: string[] = [];
for (const requiredId of CSV_REQUIRED_UI_FIELDS) {
const mapping = mappings.find((m) => m.targetFieldId === requiredId);
const resolved = Boolean(mapping?.sourceFieldId || mapping?.staticValue?.trim());
if (!resolved) {
missing.push(requiredId);
continue;
}
if (
requiredId === "field_type" &&
mapping?.staticValue &&
!ZHubFieldType.safeParse(mapping.staticValue).success
) {
missing.push(requiredId);
}
}
return { valid: missing.length === 0, missing };
};
@@ -1,6 +1,7 @@
import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { NoFeedbackDirectoryEmptyState } from "@/modules/ee/feedback-directory/components/no-feedback-directory-empty-state";
import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory";
import { getIsUnifyFeedbackEnabled } from "@/modules/ee/license-check/lib/utils";
import { UnifyConfigNavigation } from "@/modules/ee/unify-feedback/components/unify-config-navigation";
@@ -61,6 +62,21 @@ export const UnifyTopicsSubtopicsPage = async (
}
const directories = await getFeedbackDirectoriesByWorkspaceId(params.workspaceId);
if (directories.length === 0) {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("workspace.unify.feedback_records")}>
<UnifyConfigNavigation workspaceId={params.workspaceId} activeId="topics-subtopics" />
</PageHeader>
<NoFeedbackDirectoryEmptyState
workspaceId={params.workspaceId}
isOwnerOrManager={isOwner || isManager}
/>
</PageContentWrapper>
);
}
const directoryMap = Object.fromEntries(directories.map((directory) => [directory.id, directory.name]));
return <TopicsSubtopicsPreview workspaceId={params.workspaceId} directoryMap={directoryMap} />;
+2 -2
View File
@@ -35,7 +35,7 @@ export const ZFeedbackRecordCreateInput = z.object({
value_date: z.iso.datetime().optional(),
metadata: ZFeedbackRecordMetadata.optional(),
language: z.string().optional(),
user_identifier: z.string().optional(),
user_id: z.string().optional(),
});
export type TFeedbackRecordCreateInput = z.infer<typeof ZFeedbackRecordCreateInput>;
@@ -48,7 +48,7 @@ export const ZFeedbackRecordUpdateInput = z
value_date: z.iso.datetime().optional().nullable(),
language: z.string().optional().nullable(),
metadata: ZFeedbackRecordMetadata.optional(),
user_identifier: z.string().optional().nullable(),
user_id: z.string().optional().nullable(),
})
.refine(
(value) => Object.values(value).some((entry) => entry !== undefined),
+120 -23
View File
@@ -129,13 +129,6 @@ describe("authorizeEnvoyRequest", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [
{
feedbackDirectoryId,
feedbackDirectoryName: "Directory 1",
permission: "write",
},
],
});
const response = await authorizeEnvoyRequest(
@@ -162,7 +155,6 @@ describe("authorizeEnvoyRequest", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
const response = await authorizeEnvoyRequest(
@@ -193,7 +185,6 @@ describe("authorizeEnvoyRequest", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
mockGetFeedbackRecordTenant.mockResolvedValue({
data: null,
@@ -223,7 +214,6 @@ describe("authorizeEnvoyRequest", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
mockGetFeedbackRecordTenant.mockResolvedValue({
data: null,
@@ -326,15 +316,14 @@ describe("authorizeEnvoyRequest", () => {
});
});
test("returns 403 when an API key lacks directory permission", async () => {
test("returns 403 when an API key has no matching workspace or org-level access", async () => {
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
organizationAccess: { accessControl: { read: false, write: false } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
const response = await authorizeEnvoyRequest(
@@ -349,6 +338,124 @@ describe("authorizeEnvoyRequest", () => {
expect(response.status).toBe(403);
});
test("allows API key with workspace write permission on a linked workspace", async () => {
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: false, write: false } },
workspacePermissions: [
{
workspaceId: "workspace_1",
workspaceName: "Workspace 1",
permission: "write",
},
],
});
const response = await authorizeEnvoyRequest(
createRequest(`http://localhost/api/envoy-auth/v1/feedback-records?tenant_id=${feedbackDirectoryId}`, {
method: "POST",
headers: {
"x-api-key": "fbk_test",
"content-type": "application/json",
},
body: JSON.stringify({ tenant_id: feedbackDirectoryId }),
})
);
expect(response.status).toBe(200);
});
test("returns 403 when API key has read-only workspace permission for a write op", async () => {
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: false, write: false } },
workspacePermissions: [
{
workspaceId: "workspace_1",
workspaceName: "Workspace 1",
permission: "read",
},
],
});
const response = await authorizeEnvoyRequest(
createRequest("http://localhost/api/envoy-auth/api/v3/feedbackRecords", {
method: "POST",
headers: {
"x-api-key": "fbk_test",
"content-type": "application/json",
},
body: JSON.stringify({ tenant_id: feedbackDirectoryId }),
})
);
expect(response.status).toBe(403);
});
test("returns 403 when FRD has no workspace links and API key has no org-level access", async () => {
mockGetFeedbackDirectoryAuthContext.mockResolvedValue({
organizationId: "org_1",
workspaceIds: [],
isArchived: false,
});
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: false, write: false } },
workspacePermissions: [
{
workspaceId: "workspace_1",
workspaceName: "Workspace 1",
permission: "manage",
},
],
});
const response = await authorizeEnvoyRequest(
createRequest(`http://localhost/api/envoy-auth/v1/feedback-records?tenant_id=${feedbackDirectoryId}`, {
headers: {
"x-api-key": "fbk_test",
},
})
);
expect(response.status).toBe(403);
});
test("allows API key with org-level read access for a read op even without workspace match", async () => {
mockGetFeedbackDirectoryAuthContext.mockResolvedValue({
organizationId: "org_1",
workspaceIds: [],
isArchived: false,
});
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: false } },
workspacePermissions: [],
});
const response = await authorizeEnvoyRequest(
createRequest(`http://localhost/api/envoy-auth/v1/feedback-records?tenant_id=${feedbackDirectoryId}`, {
headers: {
"x-api-key": "fbk_test",
},
})
);
expect(response.status).toBe(200);
});
test("returns 403 when unify feedback entitlement is disabled", async () => {
mockGetIsUnifyFeedbackEnabled.mockResolvedValue(false);
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
@@ -358,13 +465,6 @@ describe("authorizeEnvoyRequest", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [
{
feedbackDirectoryId,
feedbackDirectoryName: "Directory 1",
permission: "write",
},
],
});
const response = await authorizeEnvoyRequest(
@@ -407,7 +507,6 @@ describe("authorizeEnvoyRequest", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
const response = await authorizeEnvoyRequest(
@@ -432,7 +531,6 @@ describe("authorizeEnvoyRequest", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
const response = await authorizeEnvoyRequest(
@@ -455,7 +553,6 @@ describe("authorizeEnvoyRequest", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
const response = await authorizeEnvoyRequest(
@@ -165,23 +165,29 @@ const getFeedbackRecordsGatewayJwtFromHeaders = (headers: Headers): string | nul
return getBearerTokenFromHeaders(headers);
};
const hasFeedbackDirectoryPermission = (
const hasApiKeyImplicitFeedbackDirectoryAccess = (
authentication: TAuthenticationApiKey,
feedbackDirectoryId: string,
workspaceIds: string[],
requiredPermission: TFeedbackRecordsGatewayPermission
): boolean => {
const feedbackDirectoryPermission = authentication.feedbackDirectoryPermissions.find(
(permission) => permission.feedbackDirectoryId === feedbackDirectoryId
);
const orgAccessControl = authentication.organizationAccess?.accessControl;
if (orgAccessControl?.write) {
return true;
}
if (orgAccessControl?.read && requiredPermission === "read") {
return true;
}
if (!feedbackDirectoryPermission) {
const matchingWeights = authentication.workspacePermissions
.filter((permission) => workspaceIds.includes(permission.workspaceId))
.map((permission) => apiKeyPermissionWeight[permission.permission]);
if (matchingWeights.length === 0) {
return false;
}
return (
apiKeyPermissionWeight[feedbackDirectoryPermission.permission] >=
gatewayPermissionToApiKeyPermissionWeight[requiredPermission]
);
const maxWeight = Math.max(...matchingWeights);
return maxWeight >= gatewayPermissionToApiKeyPermissionWeight[requiredPermission];
};
const resolveTenantId = async (
@@ -264,7 +270,11 @@ const authorizeGatewayRequest = async (
}
if (principal.type === "apiKey") {
return hasFeedbackDirectoryPermission(principal.authentication, feedbackDirectoryId, requiredPermission)
return hasApiKeyImplicitFeedbackDirectoryAccess(
principal.authentication,
feedbackDirectory.workspaceIds,
requiredPermission
)
? { allowed: true }
: { allowed: false };
}
@@ -7,10 +7,7 @@ import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganizationAccess } from "@formbricks/types/api-key";
import {
TOrganizationFeedbackDirectory,
TOrganizationWorkspace,
} from "@/modules/organization/settings/api-keys/types/api-keys";
import { TOrganizationWorkspace } from "@/modules/organization/settings/api-keys/types/api-keys";
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
@@ -40,14 +37,9 @@ interface AddApiKeyModalProps {
permission: ApiKeyPermission;
workspaceId: string;
}>;
feedbackDirectoryPermissions: Array<{
permission: ApiKeyPermission;
feedbackDirectoryId: string;
}>;
organizationAccess: TOrganizationAccess;
}) => Promise<void>;
workspaces: TOrganizationWorkspace[];
feedbackDirectories: TOrganizationFeedbackDirectory[];
isCreatingAPIKey: boolean;
}
@@ -56,23 +48,12 @@ interface WorkspaceOption {
name: string;
}
interface FeedbackDirectoryOption {
id: string;
name: string;
}
interface PermissionRecord {
workspaceId: string;
permission: ApiKeyPermission;
workspaceName: string;
}
interface DirectoryPermissionRecord {
feedbackDirectoryId: string;
permission: ApiKeyPermission;
feedbackDirectoryName: string;
}
const permissionOptions = [ApiKeyPermission.read, ApiKeyPermission.write, ApiKeyPermission.manage];
export const AddApiKeyModal = ({
@@ -80,7 +61,6 @@ export const AddApiKeyModal = ({
setOpen,
onSubmit,
workspaces,
feedbackDirectories,
isCreatingAPIKey,
}: AddApiKeyModalProps) => {
const { t } = useTranslation();
@@ -109,34 +89,14 @@ export const AddApiKeyModal = ({
return {};
};
const getInitialDirectoryPermission = (): DirectoryPermissionRecord | null => {
if (feedbackDirectories.length > 0) {
return {
feedbackDirectoryId: feedbackDirectories[0].id,
permission: ApiKeyPermission.read,
feedbackDirectoryName: feedbackDirectories[0].name,
};
}
return null;
};
// Initialize with one permission by default
const [selectedPermissions, setSelectedPermissions] = useState<Record<string, PermissionRecord>>({});
const [selectedDirectoryPermissions, setSelectedDirectoryPermissions] = useState<
Record<string, DirectoryPermissionRecord>
>({});
const [nextDirectoryPermissionId, setNextDirectoryPermissionId] = useState(0);
const workspaceOptions: WorkspaceOption[] = workspaces.map((workspace) => ({
id: workspace.id,
name: workspace.name,
}));
const directoryOptions: FeedbackDirectoryOption[] = feedbackDirectories.map((directory) => ({
id: directory.id,
name: directory.name,
}));
const removePermission = (index: number) => {
const updatedPermissions = { ...selectedPermissions };
delete updatedPermissions[`permission-${index}`];
@@ -179,59 +139,12 @@ export const AddApiKeyModal = ({
}
};
const removeDirectoryPermission = (key: string) => {
setSelectedDirectoryPermissions((prev) =>
Object.fromEntries(Object.entries(prev).filter(([k]) => k !== key))
);
};
const addDirectoryPermission = () => {
const initial = getInitialDirectoryPermission();
if (initial) {
setSelectedDirectoryPermissions({
...selectedDirectoryPermissions,
[`directory-permission-${nextDirectoryPermissionId}`]: initial,
});
setNextDirectoryPermissionId((id) => id + 1);
}
};
const updateDirectoryPermissionLevel = (key: string, permission: ApiKeyPermission) => {
setSelectedDirectoryPermissions({
...selectedDirectoryPermissions,
[key]: {
...selectedDirectoryPermissions[key],
permission,
},
});
};
const updateDirectorySelection = (key: string, directoryId: string) => {
const directory = feedbackDirectories.find((d) => d.id === directoryId);
if (directory) {
setSelectedDirectoryPermissions({
...selectedDirectoryPermissions,
[key]: {
...selectedDirectoryPermissions[key],
feedbackDirectoryId: directoryId,
feedbackDirectoryName: directory.name,
},
});
}
};
const checkForDuplicatePermissions = () => {
const permissions = Object.values(selectedPermissions);
const uniquePermissions = new Set(permissions.map((p) => p.workspaceId));
return uniquePermissions.size !== permissions.length;
};
const checkForDuplicateDirectoryPermissions = () => {
const permissions = Object.values(selectedDirectoryPermissions);
const unique = new Set(permissions.map((p) => p.feedbackDirectoryId));
return unique.size !== permissions.length;
};
const submitAPIKey = async () => {
const data = getValues();
@@ -240,33 +153,20 @@ export const AddApiKeyModal = ({
return;
}
if (checkForDuplicateDirectoryPermissions()) {
toast.error(t("workspace.api_keys.duplicate_directory_access"));
return;
}
// Convert permissions to the format expected by the API
const workspacePermissions = Object.values(selectedPermissions).map((permission) => ({
permission: permission.permission,
workspaceId: permission.workspaceId,
}));
const feedbackDirectoryPermissions = Object.values(selectedDirectoryPermissions).map((p) => ({
permission: p.permission,
feedbackDirectoryId: p.feedbackDirectoryId,
}));
await onSubmit({
label: data.label,
workspacePermissions,
feedbackDirectoryPermissions,
organizationAccess: selectedOrganizationAccess,
});
reset();
setSelectedPermissions({});
setSelectedDirectoryPermissions({});
setNextDirectoryPermissionId(0);
setSelectedOrganizationAccess(defaultOrganizationAccess);
};
@@ -278,14 +178,13 @@ export const AddApiKeyModal = ({
// Check if at least one workspace permission is set or one organization access toggle is ON
const hasWorkspaceAccess = Object.keys(selectedPermissions).length > 0;
const hasDirectoryAccess = Object.keys(selectedDirectoryPermissions).length > 0;
const hasOrganizationAccess = Object.values(selectedOrganizationAccess).some((accessGroup) =>
Object.values(accessGroup).some((value) => value === true)
);
// Disable submit if no access rights are granted
return !(hasWorkspaceAccess || hasDirectoryAccess || hasOrganizationAccess);
return !(hasWorkspaceAccess || hasOrganizationAccess);
};
const setSelectedOrganizationAccessValue = (key: string, accessType: string, value: boolean) => {
@@ -403,95 +302,6 @@ export const AddApiKeyModal = ({
</div>
</div>
<div className="space-y-2">
<Label>{t("workspace.api_keys.feedback_directory_access")}</Label>
<div className="space-y-2">
{Object.keys(selectedDirectoryPermissions).map((key) => {
const permission = selectedDirectoryPermissions[key];
return (
<div key={key} className="flex items-center gap-2">
{/* Directory dropdown */}
<div className="w-1/2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left">
{permission.feedbackDirectoryName}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[300px] min-w-[8rem] overflow-y-auto">
{directoryOptions.map((option) => (
<DropdownMenuItem
key={option.id}
onClick={() => {
updateDirectorySelection(key, option.id);
}}>
{option.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Permission level dropdown */}
<div className="w-1/2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{permission.permission}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem] capitalize">
{permissionOptions.map((option) => (
<DropdownMenuItem
key={option}
onClick={() => {
updateDirectoryPermissionLevel(key, option);
}}>
{option}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Delete button */}
<button type="button" className="p-2" onClick={() => removeDirectoryPermission(key)}>
<Trash2Icon className={"h-5 w-5 text-slate-500 hover:text-red-500"} />
</button>
</div>
);
})}
<Button
id="add_directory_permission__button"
type="button"
variant="outline"
onClick={addDirectoryPermission}
disabled={feedbackDirectories.length === 0}
data-testid="add_directory_permission__button__test">
<span className="mr-2">+</span> {t("workspace.settings.api_keys.add_permission")}
</Button>
</div>
</div>
<div className="space-y-4">
<Label>{t("workspace.api_keys.organization_access")}</Label>
{Object.keys(selectedOrganizationAccess).map((key) => (
@@ -531,8 +341,6 @@ export const AddApiKeyModal = ({
setOpen(false);
reset();
setSelectedPermissions({});
setSelectedDirectoryPermissions({});
setNextDirectoryPermissionId(0);
}}>
{t("common.cancel")}
</Button>
@@ -1,9 +1,6 @@
import { TUserLocale } from "@formbricks/types/user";
import { getApiKeysWithEnvironmentPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import {
TOrganizationFeedbackDirectory,
TOrganizationWorkspace,
} from "@/modules/organization/settings/api-keys/types/api-keys";
import { TOrganizationWorkspace } from "@/modules/organization/settings/api-keys/types/api-keys";
import { EditAPIKeys } from "./edit-api-keys";
interface ApiKeyListProps {
@@ -11,16 +8,9 @@ interface ApiKeyListProps {
locale: TUserLocale;
isReadOnly: boolean;
workspaces: TOrganizationWorkspace[];
feedbackDirectories: TOrganizationFeedbackDirectory[];
}
export const ApiKeyList = async ({
organizationId,
locale,
isReadOnly,
workspaces,
feedbackDirectories,
}: ApiKeyListProps) => {
export const ApiKeyList = async ({ organizationId, locale, isReadOnly, workspaces }: ApiKeyListProps) => {
const apiKeys = await getApiKeysWithEnvironmentPermissions(organizationId);
return (
@@ -30,7 +20,6 @@ export const ApiKeyList = async ({
locale={locale}
isReadOnly={isReadOnly}
workspaces={workspaces}
feedbackDirectories={feedbackDirectories}
/>
);
};
@@ -13,7 +13,6 @@ import { ViewPermissionModal } from "@/modules/organization/settings/api-keys/co
import {
TApiKeyUpdateInput,
TApiKeyWithEnvironmentPermission,
TOrganizationFeedbackDirectory,
TOrganizationWorkspace,
} from "@/modules/organization/settings/api-keys/types/api-keys";
import { Button } from "@/modules/ui/components/button";
@@ -27,7 +26,6 @@ interface EditAPIKeysProps {
locale: TUserLocale;
isReadOnly: boolean;
workspaces: TOrganizationWorkspace[];
feedbackDirectories: TOrganizationFeedbackDirectory[];
}
export const EditAPIKeys = ({
@@ -36,7 +34,6 @@ export const EditAPIKeys = ({
locale,
isReadOnly,
workspaces,
feedbackDirectories,
}: EditAPIKeysProps) => {
const { t } = useTranslation();
const [isAddAPIKeyModalOpen, setIsAddAPIKeyModalOpen] = useState(false);
@@ -76,10 +73,6 @@ export const EditAPIKeys = ({
permission: ApiKeyPermission;
workspaceId: string;
}>;
feedbackDirectoryPermissions: Array<{
permission: ApiKeyPermission;
feedbackDirectoryId: string;
}>;
organizationAccess: TOrganizationAccess;
}): Promise<void> => {
setIsLoading(true);
@@ -88,7 +81,6 @@ export const EditAPIKeys = ({
apiKeyData: {
label: data.label,
workspacePermissions: data.workspacePermissions,
feedbackDirectoryPermissions: data.feedbackDirectoryPermissions,
organizationAccess: data.organizationAccess,
},
});
@@ -245,7 +237,6 @@ export const EditAPIKeys = ({
setOpen={setIsAddAPIKeyModalOpen}
onSubmit={handleAddAPIKey}
workspaces={workspaces}
feedbackDirectories={feedbackDirectories}
isCreatingAPIKey={isLoading}
/>
{activeKey && (
@@ -255,7 +246,6 @@ export const EditAPIKeys = ({
onSubmit={handleUpdateAPIKey}
apiKey={activeKey}
workspaces={workspaces}
feedbackDirectories={feedbackDirectories}
isUpdating={isLoading}
/>
)}
@@ -9,7 +9,6 @@ import { type TOrganizationWorkspace } from "@/modules/ee/teams/team-list/types/
import {
type TApiKeyUpdateInput,
type TApiKeyWithEnvironmentPermission,
TOrganizationFeedbackDirectory,
ZApiKeyUpdateInput,
} from "@/modules/organization/settings/api-keys/types/api-keys";
import { Button } from "@/modules/ui/components/button";
@@ -32,7 +31,6 @@ interface ViewPermissionModalProps {
onSubmit: (data: TApiKeyUpdateInput) => Promise<void>;
apiKey: TApiKeyWithEnvironmentPermission;
workspaces: TOrganizationWorkspace[];
feedbackDirectories: TOrganizationFeedbackDirectory[];
isUpdating: boolean;
}
@@ -42,7 +40,6 @@ export const ViewPermissionModal = ({
onSubmit,
apiKey,
workspaces,
feedbackDirectories,
isUpdating,
}: ViewPermissionModalProps) => {
const { register, getValues, handleSubmit, reset, watch } = useForm<TApiKeyUpdateInput>({
@@ -76,11 +73,6 @@ export const ViewPermissionModal = ({
return name ?? `${t("workspace.api_keys.unknown_workspace")} (${workspaceId})`;
};
const getDirectoryName = (directoryId: string) => {
const name = feedbackDirectories.find((d) => d.id === directoryId)?.name;
return name ?? `${t("workspace.api_keys.unknown_directory")} (${directoryId})`;
};
const updateApiKey = async () => {
const data = getValues();
await onSubmit(data);
@@ -154,50 +146,6 @@ export const ViewPermissionModal = ({
})}
</div>
</div>
<div className="space-y-2">
<Label>{t("workspace.api_keys.feedback_directory_access")}</Label>
{apiKey.apiKeyFeedbackDirectories?.length === 0 && (
<div className="text-center text-sm">
{t("workspace.api_keys.no_directory_permissions_found")}
</div>
)}
<div className="space-y-2">
{apiKey.apiKeyFeedbackDirectories?.map((permission) => (
<div key={permission.feedbackDirectoryId} className="flex items-center gap-2">
<div className="w-1/2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left">
{getDirectoryName(permission.feedbackDirectoryId)}
</span>
</span>
</button>
</DropdownMenuTrigger>
</DropdownMenu>
</div>
<div className="w-1/2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{permission.permission}
</span>
</span>
</button>
</DropdownMenuTrigger>
</DropdownMenu>
</div>
</div>
))}
</div>
</div>
<div className="space-y-4">
<Label>{t("workspace.api_keys.organization_access")}</Label>
{Object.keys(organizationAccess).map((key) => (
@@ -6,7 +6,7 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TOrganizationAccess } from "@formbricks/types/api-key";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError } from "@formbricks/types/errors";
import { CONTROL_HASH } from "@/lib/constants";
import { hashSecret, hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate";
@@ -38,12 +38,6 @@ export const getApiKeysWithEnvironmentPermissions = reactCache(
workspaceId: true,
},
},
apiKeyFeedbackDirectories: {
select: {
permission: true,
feedbackDirectoryId: true,
},
},
},
});
return apiKeys;
@@ -71,16 +65,6 @@ export const getApiKeyWithPermissions = reactCache(
},
},
},
apiKeyFeedbackDirectories: {
include: {
feedbackDirectory: {
select: {
id: true,
name: true,
},
},
},
},
};
// Try v2 format first (fbk_{secret})
@@ -172,10 +156,6 @@ export const createApiKey = async (
workspaceId: string;
permission: ApiKeyPermission;
}>;
feedbackDirectoryPermissions?: Array<{
feedbackDirectoryId: string;
permission: ApiKeyPermission;
}>;
organizationAccess: TOrganizationAccess;
}
): Promise<TApiKeyWithEnvironmentPermission & { actualKey: string }> => {
@@ -191,22 +171,7 @@ export const createApiKey = async (
// 2. bcrypt hash
const hashedKey = await hashSecret(secret, 12);
const {
workspacePermissions,
feedbackDirectoryPermissions,
organizationAccess,
...apiKeyDataWithoutPermissions
} = apiKeyData;
if (feedbackDirectoryPermissions && feedbackDirectoryPermissions.length > 0) {
const directoryIds = feedbackDirectoryPermissions.map((p) => p.feedbackDirectoryId);
const ownedCount = await prisma.feedbackDirectory.count({
where: { id: { in: directoryIds }, organizationId, isArchived: false },
});
if (ownedCount !== directoryIds.length) {
throw new ResourceNotFoundError("FeedbackDirectory", null);
}
}
const { workspacePermissions, organizationAccess, ...apiKeyDataWithoutPermissions } = apiKeyData;
// Create the API key
const result = await prisma.apiKey.create({
@@ -227,20 +192,9 @@ export const createApiKey = async (
},
}
: {}),
...(feedbackDirectoryPermissions && feedbackDirectoryPermissions.length > 0
? {
apiKeyFeedbackDirectories: {
create: feedbackDirectoryPermissions.map((dirPerm) => ({
permission: dirPerm.permission,
feedbackDirectoryId: dirPerm.feedbackDirectoryId,
})),
},
}
: {}),
},
include: {
apiKeyWorkspaces: true,
apiKeyFeedbackDirectories: true,
},
});
@@ -1,7 +1,7 @@
import { ApiKey, ApiKeyPermission, Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError } from "@formbricks/types/errors";
import { TApiKeyWithEnvironmentPermission } from "../types/api-keys";
import {
createApiKey,
@@ -36,7 +36,6 @@ const mockApiKeyWithEnvironments: TApiKeyWithEnvironmentPermission = {
permission: ApiKeyPermission.manage,
},
],
apiKeyFeedbackDirectories: [],
};
// Mock modules before tests
@@ -50,9 +49,6 @@ vi.mock("@formbricks/database", () => ({
create: vi.fn(),
update: vi.fn(),
},
feedbackDirectory: {
count: vi.fn(),
},
},
}));
@@ -119,12 +115,6 @@ describe("API Key Management", () => {
workspaceId: true,
},
},
apiKeyFeedbackDirectories: {
select: {
permission: true,
feedbackDirectoryId: true,
},
},
createdAt: true,
id: true,
label: true,
@@ -340,7 +330,7 @@ describe("API Key Management", () => {
await expect(getApiKeyWithPermissions("fbk_testSecret123")).rejects.toThrow(errToThrow);
});
test("includes apiKeyFeedbackDirectories with nested directory in v2 lookup", async () => {
test("uses workspace include without feedback directory relations in v2 lookup", async () => {
vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({
...mockApiKey,
lastUsedAt: new Date(Date.now() - 1000 * 10),
@@ -358,18 +348,11 @@ describe("API Key Management", () => {
},
},
},
apiKeyFeedbackDirectories: {
include: {
feedbackDirectory: {
select: { id: true, name: true },
},
},
},
},
});
});
test("includes apiKeyFeedbackDirectories with nested directory in legacy lookup", async () => {
test("uses workspace include without feedback directory relations in legacy lookup", async () => {
vi.mocked(prisma.apiKey.findFirst).mockResolvedValueOnce({
...mockApiKey,
lastUsedAt: new Date(Date.now() - 1000 * 10),
@@ -387,40 +370,9 @@ describe("API Key Management", () => {
},
},
},
apiKeyFeedbackDirectories: {
include: {
feedbackDirectory: {
select: { id: true, name: true },
},
},
},
},
});
});
test("returns directory permissions on the api key payload", async () => {
const payload = {
...mockApiKey,
lastUsedAt: new Date(Date.now() - 1000 * 10),
apiKeyWorkspaces: [],
apiKeyFeedbackDirectories: [
{
id: "dir-perm-1",
apiKeyId: "apikey123",
feedbackDirectoryId: "dir1",
permission: ApiKeyPermission.read,
createdAt: new Date(),
updatedAt: new Date(),
feedbackDirectory: { id: "dir1", name: "Directory 1" },
},
],
};
vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce(payload as any);
const result = await getApiKeyWithPermissions("fbk_testSecret123");
expect(result?.apiKeyFeedbackDirectories).toEqual(payload.apiKeyFeedbackDirectories);
});
});
describe("deleteApiKey", () => {
@@ -495,7 +447,6 @@ describe("API Key Management", () => {
}),
include: {
apiKeyWorkspaces: true,
apiKeyFeedbackDirectories: true,
},
});
});
@@ -512,108 +463,6 @@ describe("API Key Management", () => {
expect(prisma.apiKey.create).toHaveBeenCalled();
});
test("creates an API key with feedback directory permissions", async () => {
vi.mocked(prisma.feedbackDirectory.count).mockResolvedValueOnce(2);
vi.mocked(prisma.apiKey.create).mockResolvedValueOnce(mockApiKey);
await createApiKey("org123", "user123", {
...mockApiKeyData,
feedbackDirectoryPermissions: [
{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.read },
{ feedbackDirectoryId: "dir2", permission: ApiKeyPermission.write },
],
});
expect(prisma.feedbackDirectory.count).toHaveBeenCalledWith({
where: { id: { in: ["dir1", "dir2"] }, organizationId: "org123", isArchived: false },
});
expect(prisma.apiKey.create).toHaveBeenCalledWith({
data: expect.objectContaining({
apiKeyFeedbackDirectories: {
create: [
{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.read },
{ feedbackDirectoryId: "dir2", permission: ApiKeyPermission.write },
],
},
}),
include: {
apiKeyWorkspaces: true,
apiKeyFeedbackDirectories: true,
},
});
});
test("omits apiKeyFeedbackDirectories when feedbackDirectoryPermissions is empty", async () => {
vi.mocked(prisma.apiKey.create).mockResolvedValueOnce(mockApiKey);
await createApiKey("org123", "user123", {
...mockApiKeyData,
feedbackDirectoryPermissions: [],
});
const callArg = vi.mocked(prisma.apiKey.create).mock.calls[0][0] as { data: Record<string, unknown> };
expect(callArg.data.apiKeyFeedbackDirectories).toBeUndefined();
});
test("creates an API key with both workspace and directory permissions", async () => {
vi.mocked(prisma.feedbackDirectory.count).mockResolvedValueOnce(1);
vi.mocked(prisma.apiKey.create).mockResolvedValueOnce(mockApiKey);
await createApiKey("org123", "user123", {
...mockApiKeyData,
workspacePermissions: [{ workspaceId: "workspace123", permission: ApiKeyPermission.manage }],
feedbackDirectoryPermissions: [{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.manage }],
});
expect(prisma.apiKey.create).toHaveBeenCalledWith({
data: expect.objectContaining({
apiKeyWorkspaces: {
create: [{ workspaceId: "workspace123", permission: ApiKeyPermission.manage }],
},
apiKeyFeedbackDirectories: {
create: [{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.manage }],
},
}),
include: {
apiKeyWorkspaces: true,
apiKeyFeedbackDirectories: true,
},
});
});
test("rejects when a feedbackDirectoryId is not owned by the organization", async () => {
vi.mocked(prisma.feedbackDirectory.count).mockResolvedValueOnce(1);
await expect(
createApiKey("org123", "user123", {
...mockApiKeyData,
feedbackDirectoryPermissions: [
{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.read },
{ feedbackDirectoryId: "foreign-dir", permission: ApiKeyPermission.read },
],
})
).rejects.toThrow(ResourceNotFoundError);
expect(prisma.feedbackDirectory.count).toHaveBeenCalledWith({
where: { id: { in: ["dir1", "foreign-dir"] }, organizationId: "org123", isArchived: false },
});
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
test("rejects create input with duplicate feedbackDirectoryId", async () => {
await expect(
createApiKey("org123", "user123", {
...mockApiKeyData,
feedbackDirectoryPermissions: [
{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.read },
{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.manage },
],
})
).rejects.toThrow();
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
test("rejects create input with duplicate workspaceId", async () => {
await expect(
createApiKey("org123", "user123", {
@@ -1,94 +0,0 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { TOrganizationFeedbackDirectory } from "../types/api-keys";
import { getFeedbackDirectoriesByOrganizationId } from "./feedback-directories";
const mockDirectories: TOrganizationFeedbackDirectory[] = [
{
id: "dir1",
name: "Directory 1",
},
{
id: "dir2",
name: "Directory 2",
},
];
vi.mock("@formbricks/database", () => ({
prisma: {
feedbackDirectory: {
findMany: vi.fn(),
},
},
}));
describe("Feedback Directories Management", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getFeedbackDirectoriesByOrganizationId", () => {
test("retrieves non-archived directories by organization ID successfully", async () => {
vi.mocked(prisma.feedbackDirectory.findMany).mockResolvedValueOnce(
mockDirectories as unknown as Awaited<ReturnType<typeof prisma.feedbackDirectory.findMany>>
);
const result = await getFeedbackDirectoriesByOrganizationId("org123");
expect(result).toEqual(mockDirectories);
expect(prisma.feedbackDirectory.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org123",
isArchived: false,
},
select: {
id: true,
name: true,
},
orderBy: {
createdAt: "desc",
},
});
});
test("returns empty array when no directories exist", async () => {
vi.mocked(prisma.feedbackDirectory.findMany).mockResolvedValueOnce([]);
const result = await getFeedbackDirectoriesByOrganizationId("org123");
expect(result).toEqual([]);
expect(prisma.feedbackDirectory.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org123",
isArchived: false,
},
select: {
id: true,
name: true,
},
orderBy: {
createdAt: "desc",
},
});
});
test("throws DatabaseError on prisma known request error", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: "P2002",
clientVersion: "0.0.1",
});
vi.mocked(prisma.feedbackDirectory.findMany).mockRejectedValueOnce(errToThrow);
await expect(getFeedbackDirectoriesByOrganizationId("org123")).rejects.toThrow(DatabaseError);
});
test("bubbles up unexpected errors", async () => {
const unexpectedError = new Error("Unexpected error");
vi.mocked(prisma.feedbackDirectory.findMany).mockRejectedValueOnce(unexpectedError);
await expect(getFeedbackDirectoriesByOrganizationId("org123")).rejects.toThrow(unexpectedError);
});
});
});
@@ -1,34 +0,0 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { TOrganizationFeedbackDirectory } from "@/modules/organization/settings/api-keys/types/api-keys";
export const getFeedbackDirectoriesByOrganizationId = reactCache(
async (organizationId: string): Promise<TOrganizationFeedbackDirectory[]> => {
try {
const directories = await prisma.feedbackDirectory.findMany({
where: {
organizationId,
isArchived: false,
},
select: {
id: true,
name: true,
},
orderBy: {
createdAt: "desc",
},
});
return directories;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
@@ -2,7 +2,6 @@ import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/comp
import { DEFAULT_LOCALE } from "@/lib/constants";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getFeedbackDirectoriesByOrganizationId } from "@/modules/organization/settings/api-keys/lib/feedback-directories";
import { getWorkspacesByOrganizationId } from "@/modules/organization/settings/api-keys/lib/workspaces";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -15,9 +14,8 @@ export const APIKeysPage = async (props: { params: Promise<{ workspaceId: string
const { currentUserMembership, organization, session } = await getWorkspaceAuth(params.workspaceId);
const [workspaces, feedbackDirectories, locale] = await Promise.all([
const [workspaces, locale] = await Promise.all([
getWorkspacesByOrganizationId(organization.id),
getFeedbackDirectoriesByOrganizationId(organization.id),
getUserLocale(session.user.id),
]);
@@ -36,7 +34,6 @@ export const APIKeysPage = async (props: { params: Promise<{ workspaceId: string
locale={locale ?? DEFAULT_LOCALE}
isReadOnly={!canAccessApiKeys}
workspaces={workspaces}
feedbackDirectories={feedbackDirectories}
/>
</SettingsCard>
</PageContentWrapper>
@@ -8,16 +8,10 @@ export const ZApiKeyWorkspacePermission = z.object({
permission: z.enum(ApiKeyPermission),
});
export const ZApiKeyFeedbackDirectoryPermission = z.object({
feedbackDirectoryId: z.string(),
permission: z.enum(ApiKeyPermission),
});
export const ZApiKeyCreateInput = z
.object({
label: z.string(),
workspacePermissions: z.array(ZApiKeyWorkspacePermission).optional(),
feedbackDirectoryPermissions: z.array(ZApiKeyFeedbackDirectoryPermission).optional(),
organizationAccess: ZOrganizationAccess,
})
.refine(
@@ -27,17 +21,6 @@ export const ZApiKeyCreateInput = z
return new Set(ids).size === ids.length;
},
{ message: "Duplicate workspace permissions are not allowed", path: ["workspacePermissions"] }
)
.refine(
(data) => {
if (!data.feedbackDirectoryPermissions) return true;
const ids = data.feedbackDirectoryPermissions.map((p) => p.feedbackDirectoryId);
return new Set(ids).size === ids.length;
},
{
message: "Duplicate feedback directory permissions are not allowed",
path: ["feedbackDirectoryPermissions"],
}
);
export type TApiKeyCreateInput = z.infer<typeof ZApiKeyCreateInput>;
@@ -61,23 +44,13 @@ export type TOrganizationWorkspace = z.infer<typeof OrganizationWorkspace>;
export type TApiKeyWorkspacePermission = z.infer<typeof ZApiKeyWorkspacePermission>;
export type TApiKeyFeedbackDirectoryPermission = z.infer<typeof ZApiKeyFeedbackDirectoryPermission>;
export interface TApiKeyWithEnvironmentPermission extends Pick<
ApiKey,
"id" | "label" | "createdAt" | "organizationAccess"
> {
apiKeyWorkspaces: TApiKeyWorkspacePermission[];
apiKeyFeedbackDirectories: TApiKeyFeedbackDirectoryPermission[];
}
export const OrganizationFeedbackDirectory = z.object({
id: z.string(),
name: z.string(),
});
export type TOrganizationFeedbackDirectory = z.infer<typeof OrganizationFeedbackDirectory>;
const ZApiKeyWorkspaceWithWorkspace = z.object({
id: z.string(),
createdAt: z.date(),
@@ -88,19 +61,6 @@ const ZApiKeyWorkspaceWithWorkspace = z.object({
workspace: ZWorkspace.pick({ id: true, name: true }),
});
const ZApiKeyFeedbackDirectoryWithDirectory = z.object({
id: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
apiKeyId: z.string(),
feedbackDirectoryId: z.string(),
permission: z.enum(ApiKeyPermission),
feedbackDirectory: z.object({
id: z.string(),
name: z.string(),
}),
});
const ZApiKey = z.object({
id: z.string(),
createdAt: z.date(),
@@ -116,7 +76,6 @@ const ZApiKey = z.object({
export const ZApiKeyWithEnvironmentAndWorkspace = ZApiKey.extend({
apiKeyWorkspaces: z.array(ZApiKeyWorkspaceWithWorkspace),
apiKeyFeedbackDirectories: z.array(ZApiKeyFeedbackDirectoryWithDirectory),
});
export type TApiKeyWithEnvironmentAndWorkspace = z.infer<typeof ZApiKeyWithEnvironmentAndWorkspace>;
@@ -1,7 +1,7 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { ChevronDown, ChevronUp } from "lucide-react";
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/cn";
@@ -28,32 +28,6 @@ const SelectTrigger = React.forwardRef<
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
{...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
{...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
@@ -65,16 +39,18 @@ const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = R
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-100 bg-white text-slate-700 shadow-md animate-in fade-in-80 dark:bg-slate-700 dark:text-slate-300",
position === "popper" &&
"max-h-[var(--radix-select-content-available-height)] data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn("p-1", position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]")}>
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
@@ -122,8 +98,6 @@ export {
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
@@ -31,22 +31,11 @@ import {
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import {
getFeedbackDirectoriesByOrganizationIdAction,
getTeamsByOrganizationIdAction,
} from "@/modules/workspaces/settings/actions";
import { getTeamsByOrganizationIdAction } from "@/modules/workspaces/settings/actions";
const ZCreateWorkspaceForm = z.object({
name: ZWorkspace.shape.name,
teamIds: z.array(z.string()).optional(),
feedbackDirectoryId: z.string().optional(),
});
type TCreateWorkspaceForm = z.infer<typeof ZCreateWorkspaceForm>;
@@ -68,26 +57,20 @@ export const CreateWorkspaceModal = ({
const router = useRouter();
const [organizationTeams, setOrganizationTeams] = useState<TOrganizationTeam[]>([]);
const [feedbackDirectories, setFeedbackDirectories] = useState<{ id: string; name: string }[]>([]);
const form = useForm<TCreateWorkspaceForm>({
resolver: zodResolver(ZCreateWorkspaceForm),
defaultValues: {
name: "",
teamIds: [],
feedbackDirectoryId: undefined,
},
});
const { getValues, setValue } = form;
useEffect(() => {
if (!open) return;
const fetchModalData = async () => {
const [teamsResponse, directoriesResponse] = await Promise.all([
getTeamsByOrganizationIdAction({ organizationId }),
getFeedbackDirectoriesByOrganizationIdAction({ organizationId }),
]);
const teamsResponse = await getTeamsByOrganizationIdAction({ organizationId });
if (teamsResponse?.data) {
setOrganizationTeams(teamsResponse.data);
@@ -95,26 +78,9 @@ export const CreateWorkspaceModal = ({
const errorMessage = getFormattedErrorMessage(teamsResponse);
toast.error(errorMessage);
}
if (directoriesResponse?.data) {
setFeedbackDirectories(directoriesResponse.data);
const selectedFeedbackDirectory = getValues("feedbackDirectoryId");
const isSelectedDirectoryAvailable = directoriesResponse.data.some(
(directory) => directory.id === selectedFeedbackDirectory
);
if (directoriesResponse.data.length === 0) {
setValue("feedbackDirectoryId", undefined);
} else if (!selectedFeedbackDirectory || !isSelectedDirectoryAvailable) {
setValue("feedbackDirectoryId", directoriesResponse.data[0].id);
}
} else {
const errorMessage = getFormattedErrorMessage(directoriesResponse);
toast.error(errorMessage);
}
};
fetchModalData();
}, [open, organizationId, getValues, setValue]);
}, [open, organizationId]);
const { isSubmitting } = form.formState;
@@ -129,7 +95,6 @@ export const CreateWorkspaceModal = ({
data: {
name: data.name,
teamIds: data.teamIds || [],
feedbackDirectoryId: data.feedbackDirectoryId,
},
});
@@ -176,40 +141,6 @@ export const CreateWorkspaceModal = ({
)}
/>
<FormField
control={form.control}
name="feedbackDirectoryId"
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.feedback_directory")}</FormLabel>
<FormControl>
<Select
value={field.value ?? ""}
onValueChange={field.onChange}
disabled={feedbackDirectories.length === 0}>
<SelectTrigger>
<SelectValue
placeholder={
feedbackDirectories.length > 0
? t("workspace.unify.select_feedback_directory")
: t("workspace.unify.no_feedback_directory_available")
}
/>
</SelectTrigger>
<SelectContent>
{feedbackDirectories.map((directory) => (
<SelectItem key={directory.id} value={directory.id}>
{directory.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>
)}
/>
{isAccessControlAllowed && organizationTeams.length > 0 && (
<FormField
control={form.control}
@@ -11,7 +11,6 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getWorkspace } from "@/lib/workspace/service";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getFeedbackDirectories } from "@/modules/ee/feedback-directory/lib/feedback-directory";
import { getRemoveBrandingPermission } from "@/modules/ee/license-check/lib/utils";
import { updateWorkspace } from "@/modules/workspaces/settings/lib/workspace";
@@ -97,25 +96,3 @@ export const getTeamsByOrganizationIdAction = authenticatedActionClient
const teams = await getTeamsByOrganizationId(parsedInput.organizationId);
return teams;
});
const ZGetFeedbackDirectoriesByOrganizationIdAction = z.object({
organizationId: ZId,
});
export const getFeedbackDirectoriesByOrganizationIdAction = authenticatedActionClient
.inputSchema(ZGetFeedbackDirectoriesByOrganizationIdAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const directories = await getFeedbackDirectories(parsedInput.organizationId);
return directories.filter((directory) => !directory.isArchived).map(({ id, name }) => ({ id, name }));
});
@@ -49,6 +49,13 @@ vi.mock("@formbricks/database", () => ({
},
}));
const expectNoFrdSideEffects = () => {
expect(prisma.feedbackDirectory.upsert).not.toHaveBeenCalled();
expect(prisma.feedbackDirectory.findFirst).not.toHaveBeenCalled();
expect(prisma.feedbackDirectoryWorkspace.count).not.toHaveBeenCalled();
expect(prisma.feedbackDirectoryWorkspace.create).not.toHaveBeenCalled();
};
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
@@ -98,97 +105,40 @@ describe("workspace lib", () => {
});
describe("createWorkspace", () => {
test("creates workspace and revalidates cache", async () => {
test("creates workspace with team links and no FRD side-effects", async () => {
const createdWorkspace = { ...baseWorkspace, id: "p2" };
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
vi.mocked(prisma.workspaceTeam.createMany).mockResolvedValueOnce({} as any);
vi.mocked(prisma.feedbackDirectory.upsert).mockResolvedValueOnce({ id: "frd-1" } as any);
vi.mocked(prisma.feedbackDirectoryWorkspace.count).mockResolvedValueOnce(0);
vi.mocked(prisma.feedbackDirectoryWorkspace.create).mockResolvedValueOnce({} as any);
const result = await createWorkspace("org1", { name: "Workspace 1", teamIds: ["t1"] });
expect(result).toEqual(createdWorkspace);
expect(prisma.workspace.create).toHaveBeenCalled();
expect(prisma.workspaceTeam.createMany).toHaveBeenCalled();
expect(prisma.feedbackDirectory.upsert).toHaveBeenCalled();
expectNoFrdSideEffects();
});
test("creates workspace and links default FRD when first workspace", async () => {
test("creates workspace without teams and does not auto-link any FRD", async () => {
const createdWorkspace = { ...baseWorkspace, id: "p3" };
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
vi.mocked(prisma.feedbackDirectory.upsert).mockResolvedValueOnce({ id: "frd-1" } as any);
vi.mocked(prisma.feedbackDirectoryWorkspace.count).mockResolvedValueOnce(0);
vi.mocked(prisma.feedbackDirectoryWorkspace.create).mockResolvedValueOnce({} as any);
await createWorkspace("org1", { name: "Workspace No Teams" });
expect(prisma.feedbackDirectory.upsert).toHaveBeenCalledWith({
where: {
organizationId_name: { organizationId: "org1", name: "Default Feedback Directory" },
},
create: { name: "Default Feedback Directory", organizationId: "org1" },
update: {},
select: { id: true },
});
expect(prisma.feedbackDirectoryWorkspace.count).toHaveBeenCalledWith({
where: { feedbackDirectoryId: "frd-1" },
});
expect(prisma.feedbackDirectoryWorkspace.create).toHaveBeenCalledWith({
data: { feedbackDirectoryId: "frd-1", workspaceId: "p3" },
});
});
test("creates workspace and links selected feedback directory when provided", async () => {
const createdWorkspace = { ...baseWorkspace, id: "p-selected" };
vi.mocked(prisma.feedbackDirectory.findFirst).mockResolvedValueOnce({
id: "frd-selected",
} as any);
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
vi.mocked(prisma.feedbackDirectoryWorkspace.create).mockResolvedValueOnce({} as any);
const result = await createWorkspace("org1", {
name: "Workspace with Selected Directory",
feedbackDirectoryId: "frd-selected",
});
const result = await createWorkspace("org1", { name: "Workspace No Teams" });
expect(result).toEqual(createdWorkspace);
expect(prisma.feedbackDirectory.findFirst).toHaveBeenCalledWith({
where: {
id: "frd-selected",
organizationId: "org1",
isArchived: false,
},
select: { id: true },
});
expect(prisma.feedbackDirectoryWorkspace.create).toHaveBeenCalledWith({
data: { feedbackDirectoryId: "frd-selected", workspaceId: "p-selected" },
});
expect(prisma.feedbackDirectory.upsert).not.toHaveBeenCalled();
expect(prisma.workspaceTeam.createMany).not.toHaveBeenCalled();
expectNoFrdSideEffects();
});
test("skips FRD link when default FRD already has links", async () => {
test("does not upsert a Default Feedback Directory under any flow", async () => {
const createdWorkspace = { ...baseWorkspace, id: "p4" };
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
vi.mocked(prisma.feedbackDirectory.upsert).mockResolvedValueOnce({ id: "frd-1" } as any);
vi.mocked(prisma.feedbackDirectoryWorkspace.count).mockResolvedValueOnce(1);
await createWorkspace("org1", { name: "Second Workspace" });
expect(prisma.feedbackDirectory.upsert).not.toHaveBeenCalled();
expect(prisma.feedbackDirectoryWorkspace.create).not.toHaveBeenCalled();
});
test("throws InvalidInputError when selected feedback directory is invalid", async () => {
vi.mocked(prisma.feedbackDirectory.findFirst).mockResolvedValueOnce(null);
await expect(
createWorkspace("org1", {
name: "Workspace with Invalid Directory",
feedbackDirectoryId: "frd-missing",
})
).rejects.toThrow(InvalidInputError);
expect(prisma.workspace.create).not.toHaveBeenCalled();
});
test("throws ValidationError if name is missing", async () => {
await expect(createWorkspace("org1", {})).rejects.toThrow(ValidationError);
});
@@ -3,7 +3,7 @@ import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { logger } from "@formbricks/logger";
import { ZId, ZString } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
import { TWorkspace, TWorkspaceUpdateInput, ZWorkspaceUpdateInput } from "@formbricks/types/workspace";
import { validateInputs } from "@/lib/utils/validate";
@@ -29,14 +29,6 @@ const selectWorkspace = {
customHeadScripts: true,
};
type TCreateWorkspaceInput = Partial<TWorkspaceUpdateInput> & {
feedbackDirectoryId?: string;
};
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.partial().extend({
feedbackDirectoryId: ZId.optional(),
});
export const updateWorkspace = async (
workspaceId: string,
inputWorkspace: TWorkspaceUpdateInput
@@ -64,32 +56,17 @@ export const updateWorkspace = async (
export const createWorkspace = async (
organizationId: string,
workspaceInput: TCreateWorkspaceInput
workspaceInput: TWorkspaceUpdateInput
): Promise<TWorkspace> => {
validateInputs([organizationId, ZString], [workspaceInput, ZCreateWorkspaceInput]);
validateInputs([organizationId, ZId], [workspaceInput, ZWorkspaceUpdateInput]);
if (!workspaceInput.name) {
throw new ValidationError("Workspace Name is required");
}
const { teamIds, feedbackDirectoryId, ...data } = workspaceInput;
const { teamIds, ...data } = workspaceInput;
try {
if (feedbackDirectoryId) {
const feedbackDirectory = await prisma.feedbackDirectory.findFirst({
where: {
id: feedbackDirectoryId,
organizationId,
isArchived: false,
},
select: { id: true },
});
if (!feedbackDirectory) {
throw new InvalidInputError("FEEDBACK_DIRECTORY_NOT_FOUND");
}
}
const workspace = await prisma.workspace.create({
data: {
config: {
@@ -112,41 +89,6 @@ export const createWorkspace = async (
});
}
if (feedbackDirectoryId) {
await prisma.feedbackDirectoryWorkspace.create({
data: {
feedbackDirectoryId,
workspaceId: workspace.id,
},
});
return workspace;
}
// Ensure default FRD exists + link to first workspace atomically
const defaultFrd = await prisma.feedbackDirectory.upsert({
where: {
organizationId_name: { organizationId, name: "Default Feedback Directory" },
},
create: { name: "Default Feedback Directory", organizationId },
update: {},
select: { id: true },
});
// Link only if this is the first workspace (no existing links for this FRD)
const existingLinks = await prisma.feedbackDirectoryWorkspace.count({
where: { feedbackDirectoryId: defaultFrd.id },
});
if (existingLinks === 0) {
await prisma.feedbackDirectoryWorkspace.create({
data: {
feedbackDirectoryId: defaultFrd.id,
workspaceId: workspace.id,
},
});
}
return workspace;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
+1 -1
View File
@@ -29,7 +29,7 @@
"@formbricks/cache": "workspace:*",
"@formbricks/database": "workspace:*",
"@formbricks/email": "workspace:*",
"@formbricks/hub": "0.4.3",
"@formbricks/hub": "0.5.0",
"@formbricks/i18n-utils": "workspace:*",
"@formbricks/js-core": "workspace:*",
"@formbricks/jobs": "workspace:*",
+44
View File
@@ -7,6 +7,10 @@ set -eu
DEFAULT_DATABASE_URL="postgresql://test:test@localhost:5432/formbricks"
DEFAULT_ENCRYPTION_KEY="0123456789abcdef0123456789abcdef"
DEFAULT_REDIS_URL="redis://localhost:6379"
DEFAULT_HUB_API_URL="http://localhost:4000"
DEFAULT_HUB_API_KEY="build-time-placeholder"
DEFAULT_CUBEJS_API_URL="http://localhost:4000"
DEFAULT_CUBEJS_API_SECRET="build-time-placeholder"
if [ -f "/run/secrets/database_url" ]; then
IFS= read -r DATABASE_URL < /run/secrets/database_url || true
@@ -35,6 +39,42 @@ if [ -z "${REDIS_URL:-}" ]; then
fi
export REDIS_URL
if [ -f "/run/secrets/hub_api_url" ]; then
IFS= read -r HUB_API_URL < /run/secrets/hub_api_url || true
fi
if [ -z "${HUB_API_URL:-}" ]; then
HUB_API_URL="${DEFAULT_HUB_API_URL}"
echo "⚠️ HUB_API_URL secret not found or empty. Using build-time fallback value."
fi
export HUB_API_URL
if [ -f "/run/secrets/hub_api_key" ]; then
IFS= read -r HUB_API_KEY < /run/secrets/hub_api_key || true
fi
if [ -z "${HUB_API_KEY:-}" ]; then
HUB_API_KEY="${DEFAULT_HUB_API_KEY}"
echo "⚠️ HUB_API_KEY secret not found or empty. Using build-time fallback value."
fi
export HUB_API_KEY
if [ -f "/run/secrets/cubejs_api_url" ]; then
IFS= read -r CUBEJS_API_URL < /run/secrets/cubejs_api_url || true
fi
if [ -z "${CUBEJS_API_URL:-}" ]; then
CUBEJS_API_URL="${DEFAULT_CUBEJS_API_URL}"
echo "⚠️ CUBEJS_API_URL secret not found or empty. Using build-time fallback value."
fi
export CUBEJS_API_URL
if [ -f "/run/secrets/cubejs_api_secret" ]; then
IFS= read -r CUBEJS_API_SECRET < /run/secrets/cubejs_api_secret || true
fi
if [ -z "${CUBEJS_API_SECRET:-}" ]; then
CUBEJS_API_SECRET="${DEFAULT_CUBEJS_API_SECRET}"
echo "⚠️ CUBEJS_API_SECRET secret not found or empty. Using build-time fallback value."
fi
export CUBEJS_API_SECRET
if [ -f "/run/secrets/posthog_key" ]; then
IFS= read -r POSTHOG_KEY < /run/secrets/posthog_key || true
fi
@@ -68,6 +108,10 @@ fi
echo " DATABASE_URL: $([ -n "${DATABASE_URL:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " ENCRYPTION_KEY: $([ -n "${ENCRYPTION_KEY:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " REDIS_URL: $([ -n "${REDIS_URL:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " HUB_API_URL: $([ -n "${HUB_API_URL:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " HUB_API_KEY: $([ -n "${HUB_API_KEY:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " CUBEJS_API_URL: $([ -n "${CUBEJS_API_URL:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " CUBEJS_API_SECRET: $([ -n "${CUBEJS_API_SECRET:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " SENTRY_AUTH_TOKEN: $([ -n "${SENTRY_AUTH_TOKEN:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " POSTHOG_KEY: $([ -n "${POSTHOG_KEY:-}" ] && printf '[SET]' || printf '[NOT SET]')"
echo " TARGETARCH: $([ -n "${TARGETARCH:-}" ] && printf '%s' "${TARGETARCH}" || printf '[NOT SET]')"
+83 -2
View File
@@ -48,12 +48,47 @@ The intended defaults are:
## Cube.js for XM Suite v5
This chart does not deploy Cube.js. XM Suite v5 dashboard and analysis features require an external Cube instance.
XM Suite v5 dashboard and analysis features require Cube.js. Set `cube.enabled=true` to deploy an
internal Cube service from this chart, or provide an external Cube endpoint.
- Set `deployment.env.CUBEJS_API_URL` to your Cube endpoint.
- For chart-managed Cube, set `deployment.env.CUBEJS_API_URL` to `http://formbricks-cube:4000`
when using the default release name.
- For external Cube, set `deployment.env.CUBEJS_API_URL` to your Cube endpoint.
- Provide `CUBEJS_API_SECRET` through your existing secret management flow, such as the generated app secret override or `deployment.envFrom`.
- Provide `CUBEJS_DB_*` connection variables to the Cube deployment through `cube.envFrom` or `cube.env`.
- Keep `cube.replicas=1` while `cube.env.CUBEJS_CACHE_AND_QUEUE_DRIVER` is `memory`. Configure Cube Store before running multiple Cube replicas.
- Keep Hub enabled. Cube should point at the same feedback records database that Hub writes to, unless you intentionally split that storage.
## Hub worker and self-hosted embeddings
The chart deploys Hub API and, by default, a `hub-worker` deployment. Hub API is insert-only for River jobs; webhook dispatch and embedding jobs are processed by `hub-worker`.
Self-hosted embeddings are disabled by default. Set `hub.embeddings.enabled=true` to deploy an internal Hugging Face Text Embeddings Inference (TEI) service and wire Hub API plus Hub worker to it through the OpenAI-compatible endpoint added in Hub:
```yaml
hub:
worker:
enabled: true
embeddings:
enabled: true
model: Alibaba-NLP/gte-multilingual-base
servedModelName: Alibaba-NLP/gte-multilingual-base
```
The generated Hub embedding configuration is:
- `EMBEDDING_PROVIDER=openai`
- `EMBEDDING_MODEL=<hub.embeddings.servedModelName or hub.embeddings.model>`
- `EMBEDDING_BASE_URL=http://<release>-hub-embeddings:8080/v1`
- `EMBEDDING_PROVIDER_API_KEY` from a dedicated embeddings Secret
The TEI service is internal-only (`ClusterIP`) and not exposed through ingress. For private or gated models, provide `hub.embeddings.huggingFace.token` or set `hub.embeddings.huggingFace.existingSecret`.
When TEI auth is enabled, configure the shared key through `hub.embeddings.auth.apiKey` or `hub.embeddings.auth.existingSecret`; the chart manages both TEI `API_KEY` and Hub `EMBEDDING_PROVIDER_API_KEY` from that source.
Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If you scale the embeddings runtime above one replica while persistence is enabled, the cache PVC must support `ReadWriteMany`; otherwise set `hub.embeddings.persistence.enabled=false` or provide a compatible `existingClaim`.
## Values
| Key | Type | Default | Description |
@@ -139,7 +174,40 @@ This chart does not deploy Cube.js. XM Suite v5 dashboard and analysis features
| externalSecret.secretStore.name | string | `"aws-secrets-manager"` | |
| formbricks.publicUrl | string | `""` | |
| formbricks.webappUrl | string | `""` | |
| hub.autoscaling.enabled | bool | `false` | |
| hub.autoscaling.maxReplicas | int | `3` | |
| hub.autoscaling.minReplicas | int | `1` | |
| hub.enabled | bool | `true` | |
| hub.embeddings.auth.enabled | bool | `true` | |
| hub.embeddings.auth.existingSecret | string | `""` | |
| hub.embeddings.auth.secretKey | string | `"EMBEDDING_PROVIDER_API_KEY"` | |
| hub.embeddings.autoscaling.enabled | bool | `false` | |
| hub.embeddings.autoscaling.maxReplicas | int | `2` | |
| hub.embeddings.autoscaling.minReplicas | int | `1` | |
| hub.embeddings.baseUrl | string | `""` | Defaults to the internal TEI service URL ending in `/v1`. |
| hub.embeddings.enabled | bool | `false` | |
| hub.embeddings.huggingFace.existingSecret | string | `""` | |
| hub.embeddings.huggingFace.token | string | `""` | |
| hub.embeddings.huggingFace.tokenKey | string | `"HF_TOKEN"` | |
| hub.embeddings.image.pullPolicy | string | `"IfNotPresent"` | |
| hub.embeddings.image.repository | string | `"ghcr.io/huggingface/text-embeddings-inference"` | |
| hub.embeddings.image.tag | string | `"cpu-1.9"` | |
| hub.embeddings.maxConcurrent | string | `"5"` | |
| hub.embeddings.model | string | `"Alibaba-NLP/gte-multilingual-base"` | |
| hub.embeddings.persistence.enabled | bool | `true` | |
| hub.embeddings.persistence.mountPath | string | `"/data"` | |
| hub.embeddings.persistence.size | string | `"10Gi"` | |
| hub.embeddings.pdb.enabled | bool | `false` | |
| hub.embeddings.port | int | `8080` | |
| hub.embeddings.prometheusPort | int | `9000` | |
| hub.embeddings.replicas | int | `1` | |
| hub.embeddings.resources.limits.memory | string | `"8Gi"` | |
| hub.embeddings.resources.requests.cpu | string | `"4"` | |
| hub.embeddings.resources.requests.memory | string | `"8Gi"` | |
| hub.embeddings.runtime | string | `"tei"` | |
| hub.embeddings.servedModelName | string | `""` | Defaults to `hub.embeddings.model`. |
| hub.embeddings.service.port | int | `8080` | |
| hub.embeddings.service.type | string | `"ClusterIP"` | |
| hub.env | object | `{}` | |
| hub.existingSecret | string | `""` | |
| hub.image.digest | string | `"sha256:14db7b3d285b6e9165b55693f9b83d08beff840a255fd77dd12882ee0a62f5cb"` | When set, takes precedence over tag (immutable pin). |
@@ -149,10 +217,23 @@ This chart does not deploy Cube.js. XM Suite v5 dashboard and analysis features
| hub.migration.activeDeadlineSeconds | int | `900` | |
| hub.migration.backoffLimit | int | `3` | |
| hub.migration.ttlSecondsAfterFinished | int | `300` | |
| hub.pdb.enabled | bool | `false` | |
| hub.replicas | int | `1` | |
| hub.resources.limits.memory | string | `"512Mi"` | |
| hub.resources.requests.cpu | string | `"100m"` | |
| hub.resources.requests.memory | string | `"256Mi"` | |
| hub.worker.autoscaling.enabled | bool | `false` | |
| hub.worker.autoscaling.maxReplicas | int | `5` | |
| hub.worker.autoscaling.minReplicas | int | `1` | |
| hub.worker.enabled | bool | `true` | |
| hub.worker.env | object | `{}` | |
| hub.worker.pdb.enabled | bool | `false` | |
| hub.worker.replicas | int | `1` | |
| hub.worker.resources.limits.memory | string | `"512Mi"` | |
| hub.worker.resources.requests.cpu | string | `"100m"` | |
| hub.worker.resources.requests.memory | string | `"256Mi"` | |
| hub.worker.waitForApi.enabled | bool | `true` | |
| hub.worker.waitForApi.maxAttempts | int | `120` | 120 attempts at 5s intervals = 10 minutes. |
| ingress.annotations | object | `{}` | |
| ingress.enabled | bool | `false` | |
| ingress.hosts[0].host | string | `"k8s.formbricks.com"` | |
+200
View File
@@ -0,0 +1,200 @@
/* eslint-env es2022 */
const TENANT_MEMBERS = ["FeedbackRecords.tenantId", "TopicsUnnested.tenantId"];
const REQUIRED_SCOPE = "xm:cube:query";
function assertRequiredEnvironmentVariable(name) {
const value = process.env[name];
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${name} is required to run Cube`);
}
}
assertRequiredEnvironmentVariable("CUBEJS_API_SECRET");
function getStringClaim(securityContext, claim) {
const value = securityContext?.[claim];
if (typeof value !== "string") {
return null;
}
const trimmedValue = value.trim();
return trimmedValue.length > 0 ? trimmedValue : null;
}
function getRequiredStringClaim(securityContext, claim) {
const value = getStringClaim(securityContext, claim);
if (!value) {
throw new Error(`Cube query rejected: missing ${claim} security context`);
}
return value;
}
function collectFilterMembers(filters) {
if (!Array.isArray(filters)) {
return [];
}
return filters.flatMap((filter) => [
...(typeof filter?.member === "string" ? [filter.member] : []),
...(typeof filter?.dimension === "string" ? [filter.dimension] : []),
...collectFilterMembers(filter?.and),
...collectFilterMembers(filter?.or),
]);
}
function collectOrderMembers(order) {
if (!order) {
return [];
}
if (Array.isArray(order)) {
return order
.map((orderEntry) => (Array.isArray(orderEntry) ? orderEntry[0] : null))
.filter((member) => typeof member === "string");
}
if (typeof order === "object") {
return Object.keys(order);
}
return [];
}
function collectTimeDimensionMembers(timeDimensions) {
if (!Array.isArray(timeDimensions)) {
return [];
}
return timeDimensions
.map((timeDimension) => timeDimension?.dimension)
.filter((dimension) => typeof dimension === "string");
}
function collectQueryMembers(query) {
const cubeQuery = query ?? {};
const members = [
...(Array.isArray(cubeQuery.measures) ? cubeQuery.measures : []),
...(Array.isArray(cubeQuery.dimensions) ? cubeQuery.dimensions : []),
...(Array.isArray(cubeQuery.segments) ? cubeQuery.segments : []),
...collectTimeDimensionMembers(cubeQuery.timeDimensions),
...collectFilterMembers(cubeQuery.filters),
...collectOrderMembers(cubeQuery.order),
].filter((member) => typeof member === "string");
return Array.from(new Set(members)).sort((a, b) => a.localeCompare(b));
}
function assertValidSecurityContext(securityContext) {
const tenantId = getRequiredStringClaim(securityContext, "tenantId");
const feedbackDirectoryId = getRequiredStringClaim(securityContext, "feedbackDirectoryId");
const workspaceId = getRequiredStringClaim(securityContext, "workspaceId");
const scope = getRequiredStringClaim(securityContext, "scope");
if (scope !== REQUIRED_SCOPE) {
throw new Error("Cube query rejected: invalid Cube query scope");
}
if (tenantId !== feedbackDirectoryId) {
throw new Error("Cube query rejected: tenantId/feedbackDirectoryId mismatch");
}
return {
tenantId,
feedbackDirectoryId,
workspaceId,
organizationId: getRequiredStringClaim(securityContext, "organizationId"),
userId: getRequiredStringClaim(securityContext, "userId"),
requestId: getRequiredStringClaim(securityContext, "jti"),
source: getRequiredStringClaim(securityContext, "source"),
};
}
function assertNoCallerTenantMember(query) {
for (const member of collectQueryMembers(query)) {
if (TENANT_MEMBERS.includes(member)) {
throw new Error("Cube query rejected: tenant filters are enforced by Cube");
}
}
}
function logCubeQueryAuditEvent(context, query, { error, status = "success" } = {}) {
const errorName = error instanceof Error ? error.name : undefined;
const errorMessage = error instanceof Error ? error.message : error ? String(error) : undefined;
console.log(
JSON.stringify({
type: "audit",
event: "cube.query",
status,
timestamp: new Date().toISOString(),
tenantId: context.tenantId,
feedbackDirectoryId: context.feedbackDirectoryId,
workspaceId: context.workspaceId,
organizationId: context.organizationId,
userId: context.userId,
requestId: context.requestId,
source: context.source,
members: collectQueryMembers(query),
...(errorName ? { errorName } : {}),
...(errorMessage ? { errorMessage } : {}),
})
);
}
function logCubeQuerySecurityContextFailure(query, error) {
console.log(
JSON.stringify({
type: "audit",
event: "cube.query",
status: "failure",
timestamp: new Date().toISOString(),
members: collectQueryMembers(query),
errorName: error instanceof Error ? error.name : undefined,
errorMessage: error instanceof Error ? error.message : String(error),
})
);
}
function queryRewrite(query, rewriteContext) {
const cubeQuery = query ?? {};
let context;
try {
context = assertValidSecurityContext(rewriteContext?.securityContext);
} catch (error) {
logCubeQuerySecurityContextFailure(cubeQuery, error);
throw error;
}
try {
assertNoCallerTenantMember(cubeQuery);
} catch (error) {
logCubeQueryAuditEvent(context, cubeQuery, { error, status: "failure" });
throw error;
}
const queriedCubePrefixes = new Set(collectQueryMembers(cubeQuery).map((member) => member.split(".")[0]));
const rewrittenQuery = {
...cubeQuery,
filters: [
...(Array.isArray(cubeQuery.filters) ? cubeQuery.filters : []),
...TENANT_MEMBERS.filter((member) => queriedCubePrefixes.has(member.split(".")[0])).map(
(member) => ({
member,
operator: "equals",
values: [context.tenantId],
})
),
],
};
logCubeQueryAuditEvent(context, rewrittenQuery);
return rewrittenQuery;
}
module.exports = {
queryRewrite,
};
@@ -0,0 +1,172 @@
// This schema maps to the `feedback_records` table owned by the Formbricks Hub Postgres.
// If the Hub changes column names, types, or the metadata JSONB shape (e.g. the `topics` array),
// this schema must be updated to match.
cube(`FeedbackRecords`, {
sql: `SELECT * FROM feedback_records`,
measures: {
count: {
type: `count`,
description: `Total number of feedback responses`,
},
promoterCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 9` }],
description: `Number of promoters (NPS score 9-10)`,
},
detractorCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6` }],
description: `Number of detractors (NPS score 0-6)`,
},
passiveCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
description: `Number of passives (NPS score 7-8)`,
},
npsScore: {
type: `number`,
sql: `
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(
(
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
COUNT(CASE WHEN ${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
/ COUNT(*)::numeric
) * 100,
2
)
END
`,
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
},
averageScore: {
type: `avg`,
sql: `${CUBE}.value_number`,
description: `Average NPS score`,
},
},
dimensions: {
id: {
sql: `id`,
type: `string`,
primaryKey: true,
},
sentiment: {
sql: `sentiment`,
type: `string`,
description: `Sentiment extracted from metadata JSONB field`,
},
sourceType: {
sql: `source_type`,
type: `string`,
description: `Source type of the feedback (e.g., nps_campaign, survey)`,
},
sourceName: {
sql: `source_name`,
type: `string`,
description: `Human-readable name of the source`,
},
fieldType: {
sql: `field_type`,
type: `string`,
description: `Type of feedback field (e.g., nps, text, rating)`,
},
collectedAt: {
sql: `collected_at`,
type: `time`,
description: `Timestamp when the feedback was collected`,
},
npsValue: {
sql: `value_number`,
type: `number`,
description: `Raw NPS score value (0-10)`,
},
responseId: {
sql: `response_id`,
type: `string`,
description: `Unique identifier linking related feedback records`,
},
userId: {
sql: `user_id`,
type: `string`,
description: `Identifier of the user who provided feedback`,
},
emotion: {
sql: `emotion`,
type: `string`,
description: `Emotion extracted from metadata JSONB field`,
},
tenantId: {
sql: `tenant_id`,
type: `string`,
description: `Tenant ID linking to FeedbackDirectory`,
},
},
joins: {
TopicsUnnested: {
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
relationship: `hasMany`,
},
},
});
cube(`TopicsUnnested`, {
sql: `
SELECT
fr.id as feedback_record_id,
fr.tenant_id,
topic_elem.topic
FROM feedback_records fr
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
`,
measures: {
count: {
type: `count`,
},
},
dimensions: {
id: {
sql: `md5(feedback_record_id || '::' || topic)`,
type: `string`,
primaryKey: true,
},
feedbackRecordId: {
sql: `feedback_record_id`,
type: `string`,
},
tenantId: {
sql: `tenant_id`,
type: `string`,
description: `Tenant ID for row-level security scoping`,
},
topic: {
sql: `topic`,
type: `string`,
description: `Individual topic from the topics array`,
},
},
});
+116 -7
View File
@@ -15,6 +15,14 @@ Hub resource name: base name truncated to 59 chars then "-hub" so the suffix is
{{- printf "%s-hub" $base | trimSuffix "-" }}
{{- end }}
{{/*
Cube.js resource name.
*/}}
{{- define "formbricks.cubeName" -}}
{{- $base := include "formbricks.name" . | trunc 58 | trimSuffix "-" }}
{{- printf "%s-cube" $base | trimSuffix "-" }}
{{- end }}
{{/*
Define the application version to be used in labels.
@@ -114,6 +122,105 @@ hub-worker) must use this helper so they cannot drift apart.
{{- end -}}
{{- end }}
{{/*
Hub worker resource name.
*/}}
{{- define "formbricks.hubWorkerName" -}}
{{- $base := include "formbricks.name" . | trunc 52 | trimSuffix "-" }}
{{- printf "%s-hub-worker" $base | trimSuffix "-" }}
{{- end }}
{{/*
Hub embeddings runtime resource name.
*/}}
{{- define "formbricks.hubEmbeddingsName" -}}
{{- $base := include "formbricks.name" . | trunc 48 | trimSuffix "-" }}
{{- printf "%s-hub-embeddings" $base | trimSuffix "-" }}
{{- end }}
{{/*
Secret used by Hub and the embeddings runtime for the embeddings API key.
*/}}
{{- define "formbricks.hubEmbeddingsSecretName" -}}
{{- default (printf "%s-secret" (include "formbricks.hubEmbeddingsName" .)) .Values.hub.embeddings.auth.existingSecret -}}
{{- end }}
{{/*
Secret used by the embeddings runtime for Hugging Face access.
*/}}
{{- define "formbricks.hubEmbeddingsHuggingFaceSecretName" -}}
{{- default (include "formbricks.hubEmbeddingsSecretName" .) .Values.hub.embeddings.huggingFace.existingSecret -}}
{{- end }}
{{/*
Model name Hub sends to the OpenAI-compatible embeddings endpoint.
*/}}
{{- define "formbricks.hubEmbeddingsServedModelName" -}}
{{- default .Values.hub.embeddings.model .Values.hub.embeddings.servedModelName -}}
{{- end }}
{{/*
OpenAI-compatible embeddings base URL used by Hub.
*/}}
{{- define "formbricks.hubEmbeddingsBaseURL" -}}
{{- if .Values.hub.embeddings.baseUrl -}}
{{- .Values.hub.embeddings.baseUrl -}}
{{- else -}}
{{- printf "http://%s:%v/v1" (include "formbricks.hubEmbeddingsName" .) (.Values.hub.embeddings.service.port | default .Values.hub.embeddings.port) -}}
{{- end -}}
{{- end }}
{{/*
Embedding API key value for the generated embeddings secret.
*/}}
{{- define "formbricks.hubEmbeddingsApiKey" -}}
{{- $secretName := include "formbricks.hubEmbeddingsSecretName" . }}
{{- $secretKey := .Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" }}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace $secretName) }}
{{- if and $secret (index $secret.data $secretKey) }}
{{- index $secret.data $secretKey | b64dec -}}
{{- else if .Values.hub.embeddings.auth.apiKey }}
{{- .Values.hub.embeddings.auth.apiKey -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end }}
{{/*
Shared Hub embedding env. These values are managed from hub.embeddings when the
self-hosted runtime is enabled so Hub API and Hub worker cannot drift.
*/}}
{{- define "formbricks.hubEmbeddingEnv" -}}
{{- $root := .root -}}
{{- if $root.Values.hub.embeddings.enabled }}
- name: EMBEDDING_PROVIDER
value: "openai"
- name: EMBEDDING_MODEL
value: {{ include "formbricks.hubEmbeddingsServedModelName" $root | quote }}
- name: EMBEDDING_BASE_URL
value: {{ include "formbricks.hubEmbeddingsBaseURL" $root | quote }}
- name: EMBEDDING_PROVIDER_API_KEY
valueFrom:
secretKeyRef:
name: {{ include "formbricks.hubEmbeddingsSecretName" $root }}
key: {{ $root.Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" }}
- name: EMBEDDING_MAX_CONCURRENT
value: {{ $root.Values.hub.embeddings.maxConcurrent | quote }}
- name: EMBEDDING_NORMALIZE
value: {{ $root.Values.hub.embeddings.normalize | quote }}
{{- end }}
{{- end }}
{{/*
Returns true when an env var is managed by hub.embeddings and should not be rendered from hub.env/worker.env.
*/}}
{{- define "formbricks.hubEmbeddingEnvManaged" -}}
{{- $key := .key -}}
{{- if has $key (list "EMBEDDING_PROVIDER" "EMBEDDING_MODEL" "EMBEDDING_BASE_URL" "EMBEDDING_PROVIDER_API_KEY" "EMBEDDING_MAX_CONCURRENT" "EMBEDDING_NORMALIZE") -}}
true
{{- end -}}
{{- end }}
{{- define "formbricks.postgresAdminPassword" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
@@ -142,13 +249,15 @@ hub-worker) must use this helper so they cannot drift apart.
{{- end -}}
{{- end }}
{{- define "formbricks.cronSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- if $secret }}
{{- index $secret.data "CRON_SECRET" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- define "formbricks.cronSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- if and $secret (index $secret.data "CRON_SECRET") }}
{{- index $secret.data "CRON_SECRET" | b64dec -}}
{{- else if $secret }}
{{- fail (printf "Secret %q exists in namespace %q but is missing CRON_SECRET" (include "formbricks.appSecretName" .) .Release.Namespace) -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end }}
{{- define "formbricks.encryptionKey" -}}
@@ -0,0 +1,19 @@
{{- if .Values.cube.enabled }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "formbricks.cubeName" . }}-config
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: cube
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
data:
cube.js: |-
{{ .Files.Get "cube/cube.js" | indent 4 }}
FeedbackRecords.js: |-
{{ .Files.Get "cube/schema/FeedbackRecords.js" | indent 4 }}
{{- end }}
@@ -0,0 +1,124 @@
{{- $cubeCacheDriver := get (.Values.cube.env | default dict) "CUBEJS_CACHE_AND_QUEUE_DRIVER" | default "" | toString | trim | lower }}
{{- if and .Values.cube.enabled (gt (int .Values.cube.replicas) 1) (eq $cubeCacheDriver "memory") }}
{{- fail "cube.env.CUBEJS_CACHE_AND_QUEUE_DRIVER=memory is only supported when cube.replicas=1. Use Cube Store for multiple Cube replicas." }}
{{- end }}
{{- if .Values.cube.enabled }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "formbricks.cubeName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: cube
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
replicas: {{ .Values.cube.replicas }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: cube
annotations:
checksum/config: {{ include (print $.Template.BasePath "/cube-configmap.yaml") . | sha256sum }}
spec:
{{- if .Values.cube.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.cube.imagePullSecrets | nindent 8 }}
{{- end }}
{{- with .Values.cube.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.cube.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.cube.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.cube.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: cube
image: "{{ .Values.cube.image.repository }}:{{ .Values.cube.image.tag }}"
imagePullPolicy: {{ .Values.cube.image.pullPolicy }}
{{- with .Values.cube.containerSecurityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.cube.port }}
protocol: TCP
{{- with .Values.cube.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.cube.readinessProbe }}
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.cube.envFrom }}
envFrom:
{{- range $value := .Values.cube.envFrom }}
{{- if (eq .type "configmap") }}
- configMapRef:
{{- if .name }}
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
{{- else if .nameSuffix }}
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
{{- else }}
name: {{ template "formbricks.name" $ }}
{{- end }}
{{- end }}
{{- if (eq .type "secret") }}
- secretRef:
{{- if .name }}
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
{{- else if .nameSuffix }}
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
{{- else }}
name: {{ template "formbricks.name" $ }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
env:
{{- range $key, $value := .Values.cube.env }}
- name: {{ include "formbricks.tplvalues.render" ( dict "value" $key "context" $ ) }}
{{- if kindIs "string" $value }}
value: {{ include "formbricks.tplvalues.render" ( dict "value" $value "context" $ ) | quote }}
{{- else }}
{{- toYaml $value | nindent 14 }}
{{- end }}
{{- end }}
volumeMounts:
- name: cube-config
mountPath: /cube/conf/cube.js
subPath: cube.js
readOnly: true
- name: cube-config
mountPath: /cube/conf/model/FeedbackRecords.js
subPath: FeedbackRecords.js
readOnly: true
{{- if .Values.cube.resources }}
resources:
{{- toYaml .Values.cube.resources | nindent 12 }}
{{- end }}
volumes:
- name: cube-config
configMap:
name: {{ include "formbricks.cubeName" . }}-config
{{- end }}
@@ -0,0 +1,24 @@
{{- if .Values.cube.enabled }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "formbricks.cubeName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: cube
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
type: {{ .Values.cube.service.type }}
selector:
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
ports:
- name: http
port: {{ .Values.cube.service.port }}
targetPort: http
protocol: TCP
{{- end }}
@@ -14,7 +14,9 @@ metadata:
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
{{- if not .Values.hub.autoscaling.enabled }}
replicas: {{ .Values.hub.replicas | default 1 }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
@@ -37,6 +39,7 @@ spec:
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
command:
- sh
- -c
@@ -53,6 +56,7 @@ spec:
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
ports:
- name: http
containerPort: 8080
@@ -66,10 +70,13 @@ spec:
secretKeyRef:
name: {{ include "formbricks.hubSecretName" . }}
key: HUB_API_KEY
{{- include "formbricks.hubEmbeddingEnv" (dict "root" $ "env" .Values.hub.env) | nindent 12 }}
{{- range $key, $value := .Values.hub.env }}
{{- if not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key))) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
{{- if .Values.hub.resources }}
resources:
{{- toYaml .Values.hub.resources | nindent 12 }}
@@ -0,0 +1,168 @@
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled }}
{{- $embeddingsReplicas := int (.Values.hub.embeddings.replicas | default 1) -}}
{{- $embeddingsMaxReplicas := int (.Values.hub.embeddings.autoscaling.maxReplicas | default 1) -}}
{{- if and .Values.hub.embeddings.persistence.enabled (not .Values.hub.embeddings.persistence.existingClaim) (not (has "ReadWriteMany" .Values.hub.embeddings.persistence.accessModes)) (or (gt $embeddingsReplicas 1) (and .Values.hub.embeddings.autoscaling.enabled (gt $embeddingsMaxReplicas 1))) }}
{{- fail "hub.embeddings persistence with multiple replicas requires persistence.accessModes to include ReadWriteMany, or set hub.embeddings.persistence.enabled=false/use a ReadWriteMany existingClaim" }}
{{- end }}
{{- if and .Values.hub.embeddings.auth.existingSecret .Values.hub.embeddings.huggingFace.token (not .Values.hub.embeddings.huggingFace.existingSecret) }}
{{- fail "hub.embeddings.huggingFace.token cannot be stored when hub.embeddings.auth.existingSecret is set; put HF_TOKEN in the existing auth secret or set hub.embeddings.huggingFace.existingSecret" }}
{{- end }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "formbricks.hubEmbeddingsName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
{{- if and .Values.hub.embeddings.persistence.enabled (not (has "ReadWriteMany" .Values.hub.embeddings.persistence.accessModes)) }}
strategy:
type: Recreate
{{- else }}
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
{{- end }}
{{- if not .Values.hub.embeddings.autoscaling.enabled }}
replicas: {{ .Values.hub.embeddings.replicas | default 1 }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
{{- with .Values.hub.embeddings.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.embeddings.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.hub.embeddings.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.embeddings.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.embeddings.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.embeddings.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.embeddings.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.deployment.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
{{- end }}
containers:
- name: hub-embeddings
image: "{{ .Values.hub.embeddings.image.repository }}:{{ .Values.hub.embeddings.image.tag }}"
imagePullPolicy: {{ .Values.hub.embeddings.image.pullPolicy }}
{{- with .Values.hub.embeddings.command }}
command:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.hub.embeddings.args }}
args:
{{- toYaml .Values.hub.embeddings.args | nindent 12 }}
{{- else }}
args:
- --model-id
- {{ .Values.hub.embeddings.model | quote }}
- --port
- {{ .Values.hub.embeddings.port | quote }}
- --huggingface-hub-cache
- {{ .Values.hub.embeddings.persistence.mountPath | quote }}
- --served-model-name
- {{ include "formbricks.hubEmbeddingsServedModelName" . | quote }}
{{- with .Values.hub.embeddings.revision }}
- --revision
- {{ . | quote }}
{{- end }}
{{- with .Values.hub.embeddings.extraArgs }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.hub.embeddings.port }}
protocol: TCP
- name: metrics
containerPort: {{ .Values.hub.embeddings.prometheusPort }}
protocol: TCP
{{- if or .Values.hub.embeddings.auth.enabled .Values.hub.embeddings.huggingFace.existingSecret .Values.hub.embeddings.huggingFace.token (gt (len .Values.hub.embeddings.env) 0) }}
env:
{{- if .Values.hub.embeddings.auth.enabled }}
- name: API_KEY
valueFrom:
secretKeyRef:
name: {{ include "formbricks.hubEmbeddingsSecretName" . }}
key: {{ .Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" | quote }}
{{- end }}
{{- if or .Values.hub.embeddings.huggingFace.existingSecret .Values.hub.embeddings.huggingFace.token }}
- name: HF_TOKEN
valueFrom:
secretKeyRef:
name: {{ include "formbricks.hubEmbeddingsHuggingFaceSecretName" . }}
key: {{ .Values.hub.embeddings.huggingFace.tokenKey | default "HF_TOKEN" | quote }}
{{- end }}
{{- range $key, $value := .Values.hub.embeddings.env }}
{{- if not (or (and $.Values.hub.embeddings.auth.enabled (eq $key "API_KEY")) (and (or $.Values.hub.embeddings.huggingFace.existingSecret $.Values.hub.embeddings.huggingFace.token) (eq $key "HF_TOKEN"))) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- with .Values.hub.embeddings.probes.startupProbe }}
startupProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.hub.embeddings.probes.readinessProbe }}
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.hub.embeddings.probes.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.hub.embeddings.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.hub.embeddings.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.hub.embeddings.persistence.enabled }}
volumeMounts:
- name: model-cache
mountPath: {{ .Values.hub.embeddings.persistence.mountPath }}
{{- end }}
{{- if .Values.hub.embeddings.persistence.enabled }}
volumes:
- name: model-cache
persistentVolumeClaim:
claimName: {{ default (include "formbricks.hubEmbeddingsName" .) .Values.hub.embeddings.persistence.existingClaim }}
{{- end }}
{{- end }}
@@ -0,0 +1,23 @@
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled .Values.hub.embeddings.persistence.enabled (not .Values.hub.embeddings.persistence.existingClaim) }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "formbricks.hubEmbeddingsName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
accessModes:
{{- toYaml .Values.hub.embeddings.persistence.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.hub.embeddings.persistence.size }}
{{- with .Values.hub.embeddings.persistence.storageClass }}
storageClassName: {{ . | quote }}
{{- end }}
{{- end }}
@@ -0,0 +1,20 @@
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled (not .Values.hub.embeddings.auth.existingSecret) }}
---
apiVersion: v1
kind: Secret
metadata:
name: {{ include "formbricks.hubEmbeddingsSecretName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
type: Opaque
data:
{{ .Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" | quote }}: {{ include "formbricks.hubEmbeddingsApiKey" . | b64enc }}
{{- if and (not .Values.hub.embeddings.huggingFace.existingSecret) .Values.hub.embeddings.huggingFace.token }}
{{ .Values.hub.embeddings.huggingFace.tokenKey | default "HF_TOKEN" | quote }}: {{ .Values.hub.embeddings.huggingFace.token | b64enc }}
{{- end }}
{{- end }}
@@ -0,0 +1,35 @@
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "formbricks.hubEmbeddingsName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.embeddings.service.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.embeddings.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.hub.embeddings.service.type }}
selector:
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
ports:
- name: http
port: {{ .Values.hub.embeddings.service.port }}
targetPort: http
protocol: TCP
- name: metrics
port: {{ .Values.hub.embeddings.prometheusPort }}
targetPort: metrics
protocol: TCP
{{- end }}
+102
View File
@@ -0,0 +1,102 @@
{{- if and .Values.hub.enabled .Values.hub.autoscaling.enabled }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "formbricks.hubname" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.autoscaling.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.autoscaling.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "formbricks.hubname" . }}
minReplicas: {{ .Values.hub.autoscaling.minReplicas }}
maxReplicas: {{ .Values.hub.autoscaling.maxReplicas }}
metrics:
{{- toYaml .Values.hub.autoscaling.metrics | nindent 4 }}
{{- with .Values.hub.autoscaling.behavior }}
behavior:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
{{- if and .Values.hub.enabled .Values.hub.worker.enabled .Values.hub.worker.autoscaling.enabled }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "formbricks.hubWorkerName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-worker
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.worker.autoscaling.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.worker.autoscaling.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "formbricks.hubWorkerName" . }}
minReplicas: {{ .Values.hub.worker.autoscaling.minReplicas }}
maxReplicas: {{ .Values.hub.worker.autoscaling.maxReplicas }}
metrics:
{{- toYaml .Values.hub.worker.autoscaling.metrics | nindent 4 }}
{{- with .Values.hub.worker.autoscaling.behavior }}
behavior:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled .Values.hub.embeddings.autoscaling.enabled }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "formbricks.hubEmbeddingsName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.embeddings.autoscaling.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.embeddings.autoscaling.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "formbricks.hubEmbeddingsName" . }}
minReplicas: {{ .Values.hub.embeddings.autoscaling.minReplicas }}
maxReplicas: {{ .Values.hub.embeddings.autoscaling.maxReplicas }}
metrics:
{{- toYaml .Values.hub.embeddings.autoscaling.metrics | nindent 4 }}
{{- with .Values.hub.embeddings.autoscaling.behavior }}
behavior:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
+129
View File
@@ -0,0 +1,129 @@
{{- if and .Values.hub.enabled .Values.hub.pdb.enabled }}
{{- $hasMinAvailable := not (kindIs "invalid" .Values.hub.pdb.minAvailable) -}}
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.hub.pdb.maxUnavailable) -}}
{{- if and $hasMinAvailable $hasMaxUnavailable }}
{{- fail "hub.pdb.minAvailable and hub.pdb.maxUnavailable are mutually exclusive; set only one" }}
{{- end }}
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
{{- fail "hub.pdb.enabled is true but neither hub.pdb.minAvailable nor hub.pdb.maxUnavailable is set; set exactly one" }}
{{- end }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "formbricks.hubname" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.pdb.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.pdb.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if $hasMinAvailable }}
minAvailable: {{ .Values.hub.pdb.minAvailable }}
{{- end }}
{{- if $hasMaxUnavailable }}
maxUnavailable: {{ .Values.hub.pdb.maxUnavailable }}
{{- end }}
{{- with .Values.hub.pdb.unhealthyPodEvictionPolicy }}
unhealthyPodEvictionPolicy: {{ . }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- if and .Values.hub.enabled .Values.hub.worker.enabled .Values.hub.worker.pdb.enabled }}
{{- $hasMinAvailable := not (kindIs "invalid" .Values.hub.worker.pdb.minAvailable) -}}
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.hub.worker.pdb.maxUnavailable) -}}
{{- if and $hasMinAvailable $hasMaxUnavailable }}
{{- fail "hub.worker.pdb.minAvailable and hub.worker.pdb.maxUnavailable are mutually exclusive; set only one" }}
{{- end }}
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
{{- fail "hub.worker.pdb.enabled is true but neither hub.worker.pdb.minAvailable nor hub.worker.pdb.maxUnavailable is set; set exactly one" }}
{{- end }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "formbricks.hubWorkerName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-worker
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.worker.pdb.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.worker.pdb.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if $hasMinAvailable }}
minAvailable: {{ .Values.hub.worker.pdb.minAvailable }}
{{- end }}
{{- if $hasMaxUnavailable }}
maxUnavailable: {{ .Values.hub.worker.pdb.maxUnavailable }}
{{- end }}
{{- with .Values.hub.worker.pdb.unhealthyPodEvictionPolicy }}
unhealthyPodEvictionPolicy: {{ . }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled .Values.hub.embeddings.pdb.enabled }}
{{- $hasMinAvailable := not (kindIs "invalid" .Values.hub.embeddings.pdb.minAvailable) -}}
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.hub.embeddings.pdb.maxUnavailable) -}}
{{- if and $hasMinAvailable $hasMaxUnavailable }}
{{- fail "hub.embeddings.pdb.minAvailable and hub.embeddings.pdb.maxUnavailable are mutually exclusive; set only one" }}
{{- end }}
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
{{- fail "hub.embeddings.pdb.enabled is true but neither hub.embeddings.pdb.minAvailable nor hub.embeddings.pdb.maxUnavailable is set; set exactly one" }}
{{- end }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "formbricks.hubEmbeddingsName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.embeddings.pdb.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.embeddings.pdb.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if $hasMinAvailable }}
minAvailable: {{ .Values.hub.embeddings.pdb.minAvailable }}
{{- end }}
{{- if $hasMaxUnavailable }}
maxUnavailable: {{ .Values.hub.embeddings.pdb.maxUnavailable }}
{{- end }}
{{- with .Values.hub.embeddings.pdb.unhealthyPodEvictionPolicy }}
unhealthyPodEvictionPolicy: {{ . }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
@@ -0,0 +1,108 @@
{{- if and .Values.hub.enabled .Values.hub.worker.enabled }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "formbricks.hubWorkerName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-worker
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
{{- if not .Values.hub.worker.autoscaling.enabled }}
replicas: {{ .Values.hub.worker.replicas | default 1 }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-worker
spec:
{{- with .Values.hub.worker.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.worker.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.worker.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.worker.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.deployment.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
{{- end }}
{{- if .Values.hub.worker.waitForApi.enabled }}
initContainers:
- name: wait-for-hub-api
image: {{ include "formbricks.hubImage" . }}
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
command:
- sh
- -c
- |
attempts=0
max_attempts={{ .Values.hub.worker.waitForApi.maxAttempts | default 120 }}
until wget --no-verbose --tries=1 --spider http://{{ include "formbricks.hubname" . }}:8080/health; do
attempts=$((attempts+1))
if [ "$attempts" -ge "$max_attempts" ]; then
echo "Hub API health check timed out after $((max_attempts * 5)) seconds"
exit 1
fi
echo "Waiting for Hub API migrations and health check..."
sleep 5
done
{{- end }}
containers:
- name: hub-worker
image: {{ include "formbricks.hubImage" . }}
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
command:
- /app/hub-worker
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
envFrom:
- secretRef:
name: {{ include "formbricks.hubSecretName" . }}
{{- if or .Values.hub.embeddings.enabled (gt (len .Values.hub.env) 0) (gt (len .Values.hub.worker.env) 0) }}
env:
{{- $workerEnv := merge (dict) .Values.hub.env .Values.hub.worker.env }}
{{- include "formbricks.hubEmbeddingEnv" (dict "root" $ "env" $workerEnv) | nindent 12 }}
{{- range $key, $value := .Values.hub.env }}
{{- if and (not (hasKey $.Values.hub.worker.env $key)) (not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key)))) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
{{- range $key, $value := .Values.hub.worker.env }}
{{- if not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key))) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- if .Values.hub.worker.resources }}
resources:
{{- toYaml .Values.hub.worker.resources | nindent 12 }}
{{- end }}
{{- end }}
+317 -5
View File
@@ -557,6 +557,75 @@ serviceMonitor:
path: /metrics
port: metrics
##########################################################
# Cube.js Analytics Configuration
##########################################################
cube:
# Optional internal Cube.js service for XM Suite v5 analytics.
enabled: false
replicas: 1
image:
repository: "cubejs/cube"
tag: "v1.6.6"
pullPolicy: IfNotPresent
imagePullSecrets: []
port: 4000
service:
type: ClusterIP
port: 4000
# Secret values such as CUBEJS_API_SECRET and CUBEJS_DB_* should be supplied
# through envFrom or another secret-management flow.
envFrom: []
env:
CUBEJS_DB_TYPE: "postgres"
CUBEJS_DEFAULT_API_SCOPES: "meta,data"
# Keep the in-memory cache/queue driver at one Cube replica only. The chart
# fails rendering when this remains "memory" and cube.replicas is greater than 1.
CUBEJS_CACHE_AND_QUEUE_DRIVER: "memory"
CUBEJS_JWT_ISSUER: "formbricks-web"
CUBEJS_JWT_AUDIENCE: "formbricks-cube"
containerSecurityContext:
readOnlyRootFilesystem: false
runAsNonRoot: true
runAsUser: 1000
livenessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
readinessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
resources:
limits:
memory: 1Gi
requests:
memory: 512Mi
cpu: "250m"
nodeSelector: {}
tolerations: []
affinity: {}
topologySpreadConstraints: []
##########################################################
# Hub API Configuration
# Formbricks Hub image: ghcr.io/formbricks/hub
@@ -571,10 +640,10 @@ hub:
# Pinned by digest for immutable, reproducible deployments. When digest is set it takes
# precedence over tag, and deployment, init container, and migration job all resolve to the
# same immutable image. Update on each Hub release.
# Current digest corresponds to ghcr.io/formbricks/hub:0.2.0.
digest: "sha256:14db7b3d285b6e9165b55693f9b83d08beff840a255fd77dd12882ee0a62f5cb"
# Current digest corresponds to ghcr.io/formbricks/hub:0.3.0.
digest: "sha256:6c39b1143527137e881df785a5b668625a1fe3edb05485bb5ded19f813c8de88"
# Tag is a fallback for dev/non-prod when digest is cleared; keep aligned with the digest above.
tag: "0.2.0"
tag: "0.3.0"
pullPolicy: IfNotPresent
# Optional override for the secret Hub reads from.
@@ -588,8 +657,251 @@ hub:
# Optional env vars (non-secret). Use existingSecret for secret values such as DATABASE_URL and HUB_API_KEY.
env: {}
# Helm does not deploy Cube. XM Suite v5 analytics requires operators to provide an external Cube instance,
# set deployment.env.CUBEJS_API_URL, and supply CUBEJS_API_SECRET via an existing secret.
# Optional autoscaling for the Hub API deployment.
autoscaling:
enabled: false
additionalLabels: {}
annotations: {}
minReplicas: 1
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior: {}
# Optional PDB for the Hub API deployment. Disabled by default because a single
# Hub replica with minAvailable: 1 blocks voluntary node drains.
pdb:
enabled: false
additionalLabels: {}
annotations: {}
minAvailable: 1
# maxUnavailable: 1
# unhealthyPodEvictionPolicy: AlwaysAllow
worker:
# Hub async jobs (webhook dispatch, embeddings) run in hub-worker. Keep this
# enabled unless another worker deployment processes the same River queues.
enabled: true
replicas: 1
# Optional env vars (non-secret) added only to hub-worker.
env: {}
waitForApi:
# Avoid starting workers before the API service is healthy during installs/upgrades.
enabled: true
# 120 attempts * 5 seconds = 10 minutes.
maxAttempts: 120
resources:
limits:
memory: 512Mi
requests:
memory: 256Mi
cpu: "100m"
autoscaling:
enabled: false
additionalLabels: {}
annotations: {}
minReplicas: 1
maxReplicas: 5
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Pods
value: 1
periodSeconds: 120
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Pods
value: 2
periodSeconds: 60
# Disabled by default because the default worker replica count is 1.
pdb:
enabled: false
additionalLabels: {}
annotations: {}
minAvailable: 1
# maxUnavailable: 1
# unhealthyPodEvictionPolicy: AlwaysAllow
nodeSelector: {}
tolerations: []
affinity: {}
topologySpreadConstraints: []
embeddings:
# Optional self-hosted OpenAI-compatible embeddings runtime for Hub.
enabled: false
runtime: tei
model: Alibaba-NLP/gte-multilingual-base
revision: ""
# Defaults to `model` when empty. Used by TEI OpenAI-compatible responses
# and as Hub's EMBEDDING_MODEL.
servedModelName: ""
# Defaults to http://<release>-hub-embeddings:<service.port>/v1 when empty.
baseUrl: ""
maxConcurrent: "5"
normalize: "false"
replicas: 1
image:
repository: "ghcr.io/huggingface/text-embeddings-inference"
tag: "cpu-1.9"
pullPolicy: IfNotPresent
command: []
# When empty, the chart renders TEI args from model, servedModelName, port,
# revision, and persistence.mountPath. Set this to fully override args.
args: []
extraArgs:
- --dtype
- float16
env: {}
port: 8080
prometheusPort: 9000
service:
type: ClusterIP
port: 8080
annotations: {}
additionalLabels: {}
auth:
# TEI can enforce bearer-token auth with API_KEY. Hub always receives the
# same key as EMBEDDING_PROVIDER_API_KEY because the OpenAI-compatible Hub
# provider requires an API key.
enabled: true
existingSecret: ""
secretKey: EMBEDDING_PROVIDER_API_KEY
apiKey: ""
huggingFace:
# Required only for private/gated models unless the model is pre-cached.
existingSecret: ""
tokenKey: HF_TOKEN
token: ""
persistence:
enabled: true
existingClaim: ""
storageClass: ""
accessModes:
- ReadWriteOnce
size: 10Gi
mountPath: /data
resources:
requests:
cpu: "4"
memory: 8Gi
limits:
memory: 8Gi
autoscaling:
enabled: false
additionalLabels: {}
annotations: {}
minReplicas: 1
maxReplicas: 2
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 600
policies:
- type: Pods
value: 1
periodSeconds: 300
scaleUp:
stabilizationWindowSeconds: 120
policies:
- type: Pods
value: 1
periodSeconds: 120
# Disabled by default because the default embeddings replica count is 1.
pdb:
enabled: false
additionalLabels: {}
annotations: {}
minAvailable: 1
# maxUnavailable: 1
# unhealthyPodEvictionPolicy: AlwaysAllow
probes:
startupProbe:
failureThreshold: 60
periodSeconds: 10
tcpSocket:
port: http
readinessProbe:
failureThreshold: 6
periodSeconds: 10
timeoutSeconds: 5
tcpSocket:
port: http
livenessProbe:
failureThreshold: 6
periodSeconds: 20
timeoutSeconds: 5
tcpSocket:
port: http
podAnnotations: {}
podLabels: {}
podSecurityContext: {}
# Keep empty by default because upstream model-serving images may define
# their own user and need write access to the model cache path.
securityContext: {}
nodeSelector: {}
tolerations: []
affinity: {}
topologySpreadConstraints: []
# XM Suite v5 analytics also requires Cube. Use cube.enabled=true to deploy
# the internal chart-managed Cube service, or set deployment.env.CUBEJS_API_URL
# to an operator-managed Cube endpoint.
# Upgrade migration job runs goose + river before Helm upgrades Hub resources.
# Fresh installs run the same migrations through the Hub deployment init container.
+2 -2
View File
@@ -51,7 +51,7 @@ services:
# Keep hub, hub-migrate, and any future hub-worker on the same tag — they share one image and
# drift breaks migrations or job processing.
hub-migrate:
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.2.0}
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.3.0}
restart: "no"
entrypoint: ["sh", "-c"]
command:
@@ -66,7 +66,7 @@ services:
# Formbricks Hub API (ghcr.io/formbricks/hub). Shares the same Postgres database as Formbricks by default.
hub:
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.2.0}
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.3.0}
depends_on:
hub-migrate:
condition: service_completed_successfully
+34 -8
View File
@@ -1,6 +1,6 @@
/* eslint-env es2022 */
const TENANT_MEMBER = "FeedbackRecords.tenantId";
const TENANT_MEMBERS = ["FeedbackRecords.tenantId", "TopicsUnnested.tenantId"];
const REQUIRED_SCOPE = "xm:cube:query";
function assertRequiredEnvironmentVariable(name) {
@@ -114,7 +114,7 @@ function assertValidSecurityContext(securityContext) {
function assertNoCallerTenantMember(query) {
for (const member of collectQueryMembers(query)) {
if (member === TENANT_MEMBER) {
if (TENANT_MEMBERS.includes(member)) {
throw new Error("Cube query rejected: tenant filters are enforced by Cube");
}
}
@@ -122,6 +122,7 @@ function assertNoCallerTenantMember(query) {
function logCubeQueryAuditEvent(context, query, { error, status = "success" } = {}) {
const errorName = error instanceof Error ? error.name : undefined;
const errorMessage = error instanceof Error ? error.message : error ? String(error) : undefined;
console.log(
JSON.stringify({
@@ -138,13 +139,35 @@ function logCubeQueryAuditEvent(context, query, { error, status = "success" } =
source: context.source,
members: collectQueryMembers(query),
...(errorName ? { errorName } : {}),
...(errorMessage ? { errorMessage } : {}),
})
);
}
function logCubeQuerySecurityContextFailure(query, error) {
console.log(
JSON.stringify({
type: "audit",
event: "cube.query",
status: "failure",
timestamp: new Date().toISOString(),
members: collectQueryMembers(query),
errorName: error instanceof Error ? error.name : undefined,
errorMessage: error instanceof Error ? error.message : String(error),
})
);
}
function queryRewrite(query, rewriteContext) {
const cubeQuery = query ?? {};
const context = assertValidSecurityContext(rewriteContext?.securityContext);
let context;
try {
context = assertValidSecurityContext(rewriteContext?.securityContext);
} catch (error) {
logCubeQuerySecurityContextFailure(cubeQuery, error);
throw error;
}
try {
assertNoCallerTenantMember(cubeQuery);
@@ -153,15 +176,18 @@ function queryRewrite(query, rewriteContext) {
throw error;
}
const queriedCubePrefixes = new Set(collectQueryMembers(cubeQuery).map((member) => member.split(".")[0]));
const rewrittenQuery = {
...cubeQuery,
filters: [
...(Array.isArray(cubeQuery.filters) ? cubeQuery.filters : []),
{
member: TENANT_MEMBER,
operator: "equals",
values: [context.tenantId],
},
...TENANT_MEMBERS.filter((member) => queriedCubePrefixes.has(member.split(".")[0])).map(
(member) => ({
member,
operator: "equals",
values: [context.tenantId],
})
),
],
};

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