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": [ {