From acae0a2acf58bd57f360ea89cb4e4d0219ceb345 Mon Sep 17 00:00:00 2001 From: enzogtrujillo Date: Tue, 28 Jul 2026 04:13:56 -0300 Subject: [PATCH] feat(conversations): add opt-in merge for custom attributes (#15119) Adds an opt-in merge flag to the conversation custom attributes endpoint so integrations can update only the keys they send instead of replacing the whole hash, matching how the contacts endpoint already behaves. Also adds a destroy_custom_attributes endpoint to remove specific keys, mirroring the contacts convention. Replace stays the default, so existing integrations are unaffected. How to test POST /conversations/:id/custom_attributes com { "custom_attributes": {"a":1} }, then { "custom_attributes": {"b":2}, "merge": true } results in {a:1, b:2}; no merge, results in {b:2}. POST /conversations/:id/destroy_custom_attributes with { "custom_attributes": ["a"] } removes only a. --------- Co-authored-by: Vishnu Narayanan --- .../v1/accounts/conversations_controller.rb | 6 +- .../conversation_custom_attributes_concern.rb | 15 +++ .../destroy_custom_attributes.json.jbuilder | 1 + config/routes.rb | 1 + .../accounts/conversations_controller_spec.rb | 55 +++++++++++ .../conversation/custom_attributes.yml | 7 ++ .../destroy_custom_attributes.yml | 47 ++++++++++ swagger/paths/index.yml | 7 ++ swagger/swagger.json | 92 +++++++++++++++++++ swagger/tag_groups/application_swagger.json | 92 +++++++++++++++++++ 10 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 app/controllers/concerns/conversation_custom_attributes_concern.rb create mode 100644 app/views/api/v1/accounts/conversations/destroy_custom_attributes.json.jbuilder create mode 100644 swagger/paths/application/conversation/destroy_custom_attributes.yml diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 1b3933d99..b464f90d3 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -2,6 +2,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro include Events::Types include DateRangeHelper include HmacConcern + include ConversationCustomAttributesConcern before_action :conversation, except: [:index, :meta, :search, :create, :filter] before_action :inbox, :contact, :contact_inbox, only: [:create] @@ -129,11 +130,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro update_last_seen_on_conversation(last_seen_at, true) end - def custom_attributes - @conversation.custom_attributes = params.permit(custom_attributes: {})[:custom_attributes] - @conversation.save! - end - def destroy authorize @conversation, :destroy? ::Conversations::DeleteService.new(conversation: @conversation, user: Current.user, ip: request.ip).perform diff --git a/app/controllers/concerns/conversation_custom_attributes_concern.rb b/app/controllers/concerns/conversation_custom_attributes_concern.rb new file mode 100644 index 000000000..835395e0c --- /dev/null +++ b/app/controllers/concerns/conversation_custom_attributes_concern.rb @@ -0,0 +1,15 @@ +module ConversationCustomAttributesConcern + def custom_attributes + attributes = params.permit(custom_attributes: {})[:custom_attributes] + # When `merge` is truthy, only the keys sent are updated and the rest are kept, matching the contacts endpoint. + # Replace stays the default so existing integrations are unaffected. + attributes = @conversation.custom_attributes.merge(attributes || {}) if ActiveModel::Type::Boolean.new.cast(params[:merge]) + @conversation.custom_attributes = attributes + @conversation.save! + end + + def destroy_custom_attributes + @conversation.custom_attributes = @conversation.custom_attributes.excluding(params[:custom_attributes]) + @conversation.save! + end +end diff --git a/app/views/api/v1/accounts/conversations/destroy_custom_attributes.json.jbuilder b/app/views/api/v1/accounts/conversations/destroy_custom_attributes.json.jbuilder new file mode 100644 index 000000000..1ca512802 --- /dev/null +++ b/app/views/api/v1/accounts/conversations/destroy_custom_attributes.json.jbuilder @@ -0,0 +1 @@ +json.custom_attributes @conversation.custom_attributes diff --git a/config/routes.rb b/config/routes.rb index 3655561af..538330b70 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -173,6 +173,7 @@ Rails.application.routes.draw do post :update_last_seen post :unread post :custom_attributes + post :destroy_custom_attributes get :attachments get :inbox_assistant get :reporting_events if ChatwootApp.enterprise? diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index ea56f906a..7987d8e51 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -1212,6 +1212,30 @@ RSpec.describe 'Conversations API', type: :request do expect(conversation.reload.custom_attributes).not_to be_nil expect(conversation.reload.custom_attributes.count).to eq 3 end + + it 'merges custom attributes when merge is enabled' do + conversation.update!(custom_attributes: { existing_key: 'keep', user_id: 1 }) + + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/custom_attributes", + headers: agent.create_new_auth_token, + params: { custom_attributes: { user_id: 1001 }, merge: true }, + as: :json + + expect(response).to have_http_status(:success) + expect(conversation.reload.custom_attributes).to eq({ 'existing_key' => 'keep', 'user_id' => 1001 }) + end + + it 'replaces custom attributes by default' do + conversation.update!(custom_attributes: { existing_key: 'gone' }) + + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/custom_attributes", + headers: agent.create_new_auth_token, + params: { custom_attributes: { user_id: 1001 } }, + as: :json + + expect(response).to have_http_status(:success) + expect(conversation.reload.custom_attributes).to eq({ 'user_id' => 1001 }) + end end context 'when it is a bot' do @@ -1236,6 +1260,37 @@ RSpec.describe 'Conversations API', type: :request do end end + describe 'POST /api/v1/accounts/{account.id}/conversations/:id/destroy_custom_attributes' do + let(:conversation) { create(:conversation, account: account, custom_attributes: { test: 'test', test1: 'test1' }) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/destroy_custom_attributes" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + + before do + create(:inbox_member, user: agent, inbox: conversation.inbox) + end + + it 'deletes the given custom attribute' do + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/destroy_custom_attributes", + headers: agent.create_new_auth_token, + params: { custom_attributes: ['test'] }, + as: :json + + expect(response).to have_http_status(:ok) + expect(conversation.reload.custom_attributes).to eq({ 'test1' => 'test1' }) + expect(response.parsed_body['custom_attributes']).to eq({ 'test1' => 'test1' }) + end + end + end + describe 'GET /api/v1/accounts/{account.id}/conversations/:id/attachments' do let(:conversation) { create(:conversation, account: account) } diff --git a/swagger/paths/application/conversation/custom_attributes.yml b/swagger/paths/application/conversation/custom_attributes.yml index 6e5786742..97ff0f7c7 100644 --- a/swagger/paths/application/conversation/custom_attributes.yml +++ b/swagger/paths/application/conversation/custom_attributes.yml @@ -20,6 +20,13 @@ requestBody: example: order_id: '12345' previous_conversation: '67890' + merge: + type: boolean + default: false + description: >- + When true, only the keys sent in `custom_attributes` are updated and the remaining + keys are kept intact. When false (default), the whole custom attributes hash is + replaced. Use the destroy_custom_attributes endpoint to remove keys. responses: '200': description: Success diff --git a/swagger/paths/application/conversation/destroy_custom_attributes.yml b/swagger/paths/application/conversation/destroy_custom_attributes.yml new file mode 100644 index 000000000..0b1fada40 --- /dev/null +++ b/swagger/paths/application/conversation/destroy_custom_attributes.yml @@ -0,0 +1,47 @@ +tags: + - Conversations +operationId: destroy-custom-attributes-of-a-conversation +summary: Destroy Custom Attributes +description: Removes the given custom attribute keys from a conversation +security: + - userApiKey: [] +requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - custom_attributes + properties: + custom_attributes: + type: array + items: + type: string + description: The list of custom attribute keys to remove from the conversation + example: + - order_id + - previous_conversation +responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + custom_attributes: + type: object + description: The remaining custom attributes of the conversation + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/bad_request_error' + '404': + description: Conversation not found + content: + application/json: + schema: + $ref: '#/components/schemas/bad_request_error' diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml index e610c9662..7f80c3061 100644 --- a/swagger/paths/index.yml +++ b/swagger/paths/index.yml @@ -381,6 +381,13 @@ post: $ref: ./application/conversation/custom_attributes.yml +/api/v1/accounts/{account_id}/conversations/{conversation_id}/destroy_custom_attributes: + parameters: + - $ref: '#/components/parameters/account_id' + - $ref: '#/components/parameters/conversation_id' + post: + $ref: ./application/conversation/destroy_custom_attributes.yml + # Conversations Assignments /api/v1/accounts/{account_id}/conversations/{conversation_id}/assignments: diff --git a/swagger/swagger.json b/swagger/swagger.json index 61aaf55a8..0de86b309 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -5063,6 +5063,11 @@ "order_id": "12345", "previous_conversation": "67890" } + }, + "merge": { + "type": "boolean", + "default": false, + "description": "When true, only the keys sent in `custom_attributes` are updated and the remaining keys are kept intact. When false (default), the whole custom attributes hash is replaced. Use the destroy_custom_attributes endpoint to remove keys." } } } @@ -5109,6 +5114,93 @@ } } }, + "/api/v1/accounts/{account_id}/conversations/{conversation_id}/destroy_custom_attributes": { + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + }, + { + "$ref": "#/components/parameters/conversation_id" + } + ], + "post": { + "tags": [ + "Conversations" + ], + "operationId": "destroy-custom-attributes-of-a-conversation", + "summary": "Destroy Custom Attributes", + "description": "Removes the given custom attribute keys from a conversation", + "security": [ + { + "userApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "custom_attributes" + ], + "properties": { + "custom_attributes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The list of custom attribute keys to remove from the conversation", + "example": [ + "order_id", + "previous_conversation" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "custom_attributes": { + "type": "object", + "description": "The remaining custom attributes of the conversation" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, "/api/v1/accounts/{account_id}/conversations/{conversation_id}/assignments": { "parameters": [ { diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json index 1c72fe3e1..3173c28ac 100644 --- a/swagger/tag_groups/application_swagger.json +++ b/swagger/tag_groups/application_swagger.json @@ -3606,6 +3606,11 @@ "order_id": "12345", "previous_conversation": "67890" } + }, + "merge": { + "type": "boolean", + "default": false, + "description": "When true, only the keys sent in `custom_attributes` are updated and the remaining keys are kept intact. When false (default), the whole custom attributes hash is replaced. Use the destroy_custom_attributes endpoint to remove keys." } } } @@ -3652,6 +3657,93 @@ } } }, + "/api/v1/accounts/{account_id}/conversations/{conversation_id}/destroy_custom_attributes": { + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + }, + { + "$ref": "#/components/parameters/conversation_id" + } + ], + "post": { + "tags": [ + "Conversations" + ], + "operationId": "destroy-custom-attributes-of-a-conversation", + "summary": "Destroy Custom Attributes", + "description": "Removes the given custom attribute keys from a conversation", + "security": [ + { + "userApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "custom_attributes" + ], + "properties": { + "custom_attributes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The list of custom attribute keys to remove from the conversation", + "example": [ + "order_id", + "previous_conversation" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "custom_attributes": { + "type": "object", + "description": "The remaining custom attributes of the conversation" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, "/api/v1/accounts/{account_id}/conversations/{conversation_id}/assignments": { "parameters": [ {