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 <iamwishnu@gmail.com>
This commit is contained in:
enzogtrujillo
2026-07-28 04:13:56 -03:00
committed by GitHub
parent 3479b1026e
commit acae0a2acf
10 changed files with 318 additions and 5 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
json.custom_attributes @conversation.custom_attributes

View File

@@ -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?

View File

@@ -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) }

View File

@@ -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

View File

@@ -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'

View File

@@ -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:

View File

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

View File

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