diff --git a/.circleci/config.yml b/.circleci/config.yml index 804c63857..24119bc75 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -218,6 +218,49 @@ jobs: source ~/.rvm/scripts/rvm bundle install + # Install and configure OpenSearch + - run: + name: Install OpenSearch + command: | + # Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients) + wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz + tar -xzf opensearch-2.11.0-linux-x64.tar.gz + sudo mv opensearch-2.11.0 /opt/opensearch + + - run: + name: Configure and Start OpenSearch + command: | + # Configure OpenSearch for single-node testing + cat > /opt/opensearch/config/opensearch.yml \<< EOF + cluster.name: chatwoot-test + node.name: node-1 + network.host: 0.0.0.0 + http.port: 9200 + discovery.type: single-node + plugins.security.disabled: true + EOF + + # Set ownership and permissions + sudo chown -R $USER:$USER /opt/opensearch + + # Start OpenSearch in background + /opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid + + - run: + name: Wait for OpenSearch to be ready + command: | + echo "Waiting for OpenSearch to start..." + for i in {1..30}; do + if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then + echo "OpenSearch is ready!" + exit 0 + fi + echo "Waiting... ($i/30)" + sleep 2 + done + echo "OpenSearch failed to start" + exit 1 + # Configure environment and database - run: name: Database Setup and Configure Environment Variables @@ -234,6 +277,7 @@ jobs: sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env echo -en "\nINSTALLATION_ENV=circleci" >> ".env" + echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env" # Database setup - run: diff --git a/Gemfile.lock b/Gemfile.lock index 0a669b606..1cdfabee0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -441,7 +441,8 @@ GEM http-cookie (1.0.5) domain_name (~> 0.5) http-form_data (2.3.0) - httparty (0.21.0) + httparty (0.24.0) + csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) httpclient (2.8.3) @@ -555,7 +556,8 @@ GEM ruby2_keywords msgpack (1.8.0) multi_json (1.15.0) - multi_xml (0.6.0) + multi_xml (0.8.0) + bigdecimal (>= 3.1, < 5) multipart-post (2.3.0) mutex_m (0.3.0) neighbor (0.2.3) diff --git a/README.md b/README.md index 21316b422..d8b8ae7a2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ ___ The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.

- Maintainability CircleCI Badge Docker Pull Badge Docker Build Badge @@ -137,4 +136,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri -*Chatwoot* © 2017-2025, Chatwoot Inc - Released under the MIT License. +*Chatwoot* © 2017-2026, Chatwoot Inc - Released under the MIT License. diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb index 13e3a6a6c..7ee25e02e 100644 --- a/app/controllers/api/v1/accounts/search_controller.rb +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -28,5 +28,7 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController search_type: search_type, params: params ).perform + rescue ArgumentError => e + render json: { error: e.message }, status: :unprocessable_entity end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index abf42517c..d57ad0e53 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -16,7 +16,7 @@ class DashboardController < ActionController::Base CHATWOOT_INBOX_TOKEN API_CHANNEL_NAME API_CHANNEL_THUMBNAIL - ANALYTICS_TOKEN + CLOUD_ANALYTICS_TOKEN DIRECT_UPLOADS_ENABLED MAXIMUM_FILE_UPLOAD_SIZE HCAPTCHA_SITE_KEY diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index 361b9472f..83ba3e9ba 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -32,6 +32,16 @@ class Inboxes extends CacheEnabledApiClient { syncTemplates(inboxId) { return axios.post(`${this.url}/${inboxId}/sync_templates`); } + + createCSATTemplate(inboxId, template) { + return axios.post(`${this.url}/${inboxId}/csat_template`, { + template, + }); + } + + getCSATTemplateStatus(inboxId) { + return axios.get(`${this.url}/${inboxId}/csat_template`); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/api/search.js b/app/javascript/dashboard/api/search.js index d533c2f28..10214f3f5 100644 --- a/app/javascript/dashboard/api/search.js +++ b/app/javascript/dashboard/api/search.js @@ -14,38 +14,48 @@ class SearchAPI extends ApiClient { }); } - contacts({ q, page = 1 }) { + contacts({ q, page = 1, since, until }) { return axios.get(`${this.url}/contacts`, { params: { q, page: page, + since, + until, }, }); } - conversations({ q, page = 1 }) { + conversations({ q, page = 1, since, until }) { return axios.get(`${this.url}/conversations`, { params: { q, page: page, + since, + until, }, }); } - messages({ q, page = 1 }) { + messages({ q, page = 1, since, until, from, inboxId }) { return axios.get(`${this.url}/messages`, { params: { q, page: page, + since, + until, + from, + inbox_id: inboxId, }, }); } - articles({ q, page = 1 }) { + articles({ q, page = 1, since, until }) { return axios.get(`${this.url}/articles`, { params: { q, page: page, + since, + until, }, }); } diff --git a/app/javascript/dashboard/api/specs/search.spec.js b/app/javascript/dashboard/api/specs/search.spec.js new file mode 100644 index 000000000..251ea760e --- /dev/null +++ b/app/javascript/dashboard/api/specs/search.spec.js @@ -0,0 +1,134 @@ +import searchAPI from '../search'; +import ApiClient from '../ApiClient'; + +describe('#SearchAPI', () => { + it('creates correct instance', () => { + expect(searchAPI).toBeInstanceOf(ApiClient); + expect(searchAPI).toHaveProperty('get'); + expect(searchAPI).toHaveProperty('contacts'); + expect(searchAPI).toHaveProperty('conversations'); + expect(searchAPI).toHaveProperty('messages'); + expect(searchAPI).toHaveProperty('articles'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + get: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + vi.clearAllMocks(); + }); + + it('#get', () => { + searchAPI.get({ q: 'test query' }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search', { + params: { q: 'test query' }, + }); + }); + + it('#contacts', () => { + searchAPI.contacts({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', { + params: { q: 'test', page: 1, since: undefined, until: undefined }, + }); + }); + + it('#contacts with date filters', () => { + searchAPI.contacts({ + q: 'test', + page: 2, + since: 1700000000, + until: 1732000000, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', { + params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 }, + }); + }); + + it('#conversations', () => { + searchAPI.conversations({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/search/conversations', + { + params: { q: 'test', page: 1, since: undefined, until: undefined }, + } + ); + }); + + it('#conversations with date filters', () => { + searchAPI.conversations({ + q: 'test', + page: 1, + since: 1700000000, + until: 1732000000, + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/search/conversations', + { + params: { q: 'test', page: 1, since: 1700000000, until: 1732000000 }, + } + ); + }); + + it('#messages', () => { + searchAPI.messages({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', { + params: { + q: 'test', + page: 1, + since: undefined, + until: undefined, + from: undefined, + inbox_id: undefined, + }, + }); + }); + + it('#messages with all filters', () => { + searchAPI.messages({ + q: 'test', + page: 1, + since: 1700000000, + until: 1732000000, + from: 'contact:42', + inboxId: 10, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', { + params: { + q: 'test', + page: 1, + since: 1700000000, + until: 1732000000, + from: 'contact:42', + inbox_id: 10, + }, + }); + }); + + it('#articles', () => { + searchAPI.articles({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', { + params: { q: 'test', page: 1, since: undefined, until: undefined }, + }); + }); + + it('#articles with date filters', () => { + searchAPI.articles({ + q: 'test', + page: 2, + since: 1700000000, + until: 1732000000, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', { + params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 }, + }); + }); + }); +}); diff --git a/app/javascript/dashboard/components-next/CardLayout.vue b/app/javascript/dashboard/components-next/CardLayout.vue index 462402167..166f5ea4c 100644 --- a/app/javascript/dashboard/components-next/CardLayout.vue +++ b/app/javascript/dashboard/components-next/CardLayout.vue @@ -19,7 +19,7 @@ const handleClick = () => { diff --git a/app/javascript/dashboard/components-next/message/MessageList.vue b/app/javascript/dashboard/components-next/message/MessageList.vue index b73b44d5a..fd725f808 100644 --- a/app/javascript/dashboard/components-next/message/MessageList.vue +++ b/app/javascript/dashboard/components-next/message/MessageList.vue @@ -42,7 +42,10 @@ const props = defineProps({ const emit = defineEmits(['retry']); const allMessages = computed(() => { - return useCamelCase(props.messages, { deep: true }); + return useCamelCase(props.messages, { + deep: true, + stopPaths: ['content_attributes.translations'], + }); }); const currentChat = useMapGetter('getSelectedChat'); diff --git a/app/javascript/dashboard/components-next/message/bubbles/Template/CSAT.vue b/app/javascript/dashboard/components-next/message/bubbles/Template/CSAT.vue new file mode 100644 index 000000000..d1c9ebb37 --- /dev/null +++ b/app/javascript/dashboard/components-next/message/bubbles/Template/CSAT.vue @@ -0,0 +1,28 @@ + + + diff --git a/app/javascript/dashboard/components-next/message/chips/Audio.vue b/app/javascript/dashboard/components-next/message/chips/Audio.vue index 667d2d7b6..9c7a44b23 100644 --- a/app/javascript/dashboard/components-next/message/chips/Audio.vue +++ b/app/javascript/dashboard/components-next/message/chips/Audio.vue @@ -17,6 +17,10 @@ const { attachment } = defineProps({ type: Object, required: true, }, + showTranscribedText: { + type: Boolean, + default: true, + }, }); defineOptions({ @@ -182,7 +186,7 @@ const downloadAudio = async () => {

{{ attachment.transcribedText }} diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index a37cde89f..95baafcfd 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -1,12 +1,11 @@ diff --git a/app/javascript/dashboard/components-next/tabbar/TabBar.vue b/app/javascript/dashboard/components-next/tabbar/TabBar.vue index 1b08b838c..fbe105825 100644 --- a/app/javascript/dashboard/components-next/tabbar/TabBar.vue +++ b/app/javascript/dashboard/components-next/tabbar/TabBar.vue @@ -1,5 +1,5 @@ diff --git a/app/javascript/dashboard/modules/search/components/ReadMore.vue b/app/javascript/dashboard/modules/search/components/ReadMore.vue deleted file mode 100644 index 7b4296c89..000000000 --- a/app/javascript/dashboard/modules/search/components/ReadMore.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/app/javascript/dashboard/modules/search/components/RecentSearches.vue b/app/javascript/dashboard/modules/search/components/RecentSearches.vue new file mode 100644 index 000000000..0c52ea3ac --- /dev/null +++ b/app/javascript/dashboard/modules/search/components/RecentSearches.vue @@ -0,0 +1,116 @@ + + +