mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-10 11:09:23 -05:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72f4e93432 | |||
| 9007502804 | |||
| d84589452c | |||
| 43aaed3923 | |||
| 550bfc6a6c | |||
| 2c22b00ec6 | |||
| d64fb546d3 | |||
| f4ca7c46ef | |||
| c252d8c4c9 | |||
| 2bec3b040d | |||
| 3c49b33dad | |||
| 0f2f3d337e | |||
| 4d1df795ad | |||
| 3ce2998d0d | |||
| b9a6520e10 | |||
| 55bb9a525e | |||
| 11055f812e | |||
| ecf3aacca3 | |||
| a0f3d2a651 | |||
| 16bbd7a447 | |||
| a276aa6d34 | |||
| d192fbf839 | |||
| c5d52df9b7 | |||
| 550e859a2d | |||
| 6fb9cf28b1 | |||
| 8c47cdba73 | |||
| e6b6f5e6d3 | |||
| 6218153351 | |||
| 9ef4be270b |
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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[])];
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 à l’espace de travail"
|
||||
"workspace_access": "Accès à l’espace 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 d’enregistrement 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 n’y 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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ü",
|
||||
|
||||
@@ -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": "来源类型",
|
||||
|
||||
@@ -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": "來源類型",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+105
-1
@@ -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}>
|
||||
|
||||
+39
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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">← {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">
|
||||
= “{mapping.staticValue}”
|
||||
</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} />;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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]')"
|
||||
|
||||
@@ -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"` | |
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user