From c129ab00ba96a6e7bad089ffe36577b02f75b880 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:40:31 +0530 Subject: [PATCH 001/101] fix: normalize "in less than a minute" to "now" in chat list timestamp (#13874) --- app/javascript/shared/helpers/specs/timeHelper.spec.js | 1 + app/javascript/shared/helpers/timeHelper.js | 1 + 2 files changed, 2 insertions(+) diff --git a/app/javascript/shared/helpers/specs/timeHelper.spec.js b/app/javascript/shared/helpers/specs/timeHelper.spec.js index e7a4e025f..d12a42bc8 100644 --- a/app/javascript/shared/helpers/specs/timeHelper.spec.js +++ b/app/javascript/shared/helpers/specs/timeHelper.spec.js @@ -54,6 +54,7 @@ describe('#dateFormat', () => { describe('#shortTimestamp', () => { // Test cases when withAgo is false or not provided it('returns correct value without ago', () => { + expect(shortTimestamp('in less than a minute')).toEqual('now'); expect(shortTimestamp('less than a minute ago')).toEqual('now'); expect(shortTimestamp('1 minute ago')).toEqual('1m'); expect(shortTimestamp('12 minutes ago')).toEqual('12m'); diff --git a/app/javascript/shared/helpers/timeHelper.js b/app/javascript/shared/helpers/timeHelper.js index 5347d2410..07b302776 100644 --- a/app/javascript/shared/helpers/timeHelper.js +++ b/app/javascript/shared/helpers/timeHelper.js @@ -68,6 +68,7 @@ export const shortTimestamp = (time, withAgo = false) => { const suffix = withAgo ? ' ago' : ''; const timeMappings = { 'less than a minute ago': 'now', + 'in less than a minute': 'now', 'a minute ago': `1m${suffix}`, 'an hour ago': `1h${suffix}`, 'a day ago': `1d${suffix}`, From 6946859ba496ca5959a621e6df8c4c9321ea7f90 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:16:35 +0530 Subject: [PATCH 002/101] fix: normalize "in less than a minute" to "now" in chat list timestamp (#13874) # Pull Request Template ## Description This PR fixes the conversation list showing raw "**in less than a minute**" text instead of "**now**" for very recent conversations. Fixes https://linear.app/chatwoot/issue/CW-6666/issue-with-timestamps ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules From 14df7b3bc176c7899b61fe6a6821b4aa4b0b182d Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:58:11 +0530 Subject: [PATCH 003/101] fix: ai-assist 404 on CE (#13891) # Pull Request Template ## Description Relocate controller from enterprise/ to app/ and add Api::V1::Accounts::Captain::TasksController.prepend_mod_with for EE overrides. Fixes: Ai assist giving 404 on CE ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. before: image after: image ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules Co-authored-by: Shivam Mishra --- .../controllers/api/v1/accounts/captain/tasks_controller.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) rename {enterprise/app => app}/controllers/api/v1/accounts/captain/tasks_controller.rb (93%) diff --git a/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb b/app/controllers/api/v1/accounts/captain/tasks_controller.rb similarity index 93% rename from enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb rename to app/controllers/api/v1/accounts/captain/tasks_controller.rb index d7208d678..9ba197a3c 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb +++ b/app/controllers/api/v1/accounts/captain/tasks_controller.rb @@ -57,7 +57,7 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr if result.nil? render json: { message: nil } elsif result[:error] - render json: { error: result[:error] }, status: :unprocessable_entity + render json: { error: result[:error] }, status: :unprocessable_content else response_data = { message: result[:message] } response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context] @@ -69,3 +69,5 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr authorize(:'captain/tasks') end end + +Api::V1::Accounts::Captain::TasksController.prepend_mod_with('Api::V1::Accounts::Captain::TasksController') From 775b73d1f9d87f43d023bcef46c4bdc423868158 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Wed, 25 Mar 2026 06:07:07 +0530 Subject: [PATCH 004/101] fix: raise open file descriptor limit to prevent EMFILE errors (#13895) ## Summary - Adds `LimitNOFILE=65536` to both web and worker systemd service units - Fixes recurring `Errno::EMFILE` (Too many open files) errors during peak traffic after deploys ## Context Puma workers idle at 720-770 open FDs against the default soft limit of 1024, leaving ~250 FDs of headroom. During deploy-triggered instance refreshes at peak traffic, concurrent requests exhaust the remaining FDs, causing EMFILE across all web instances. 3 incidents in March 2026 with escalating event counts. The hard limit is already 524288, so this just raises the soft limit to a standard production value. Self-hosted instances pick this up automatically via `cwctl --upgrade`. Fixes https://linear.app/chatwoot/issue/CW-6685/errnoemfile-too-many-open-files-rb-sysopen --- deployment/chatwoot-web.1.service | 1 + deployment/chatwoot-worker.1.service | 1 + 2 files changed, 2 insertions(+) diff --git a/deployment/chatwoot-web.1.service b/deployment/chatwoot-web.1.service index 0d1d33804..79a531d55 100644 --- a/deployment/chatwoot-web.1.service +++ b/deployment/chatwoot-web.1.service @@ -15,6 +15,7 @@ TimeoutStopSec=30 KillMode=mixed StandardInput=null SyslogIdentifier=%p +LimitNOFILE=65536 Environment="PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4/bin:/home/chatwoot/.rvm/gems/ruby-3.4.4@global/bin:/home/chatwoot/.rvm/rubies/ruby-3.4.4/bin:/home/chatwoot/.rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/home/chatwoot/.rvm/bin:/home/chatwoot/.rvm/bin" Environment="PORT=3000" diff --git a/deployment/chatwoot-worker.1.service b/deployment/chatwoot-worker.1.service index da009a944..66fcbbb53 100644 --- a/deployment/chatwoot-worker.1.service +++ b/deployment/chatwoot-worker.1.service @@ -15,6 +15,7 @@ TimeoutStopSec=30 KillMode=mixed StandardInput=null SyslogIdentifier=%p +LimitNOFILE=65536 MemoryMax=1.2G MemoryHigh=infinity From 6ff643b045fe6965e7c02571edcb67abda1e698a Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 25 Mar 2026 04:24:18 -0700 Subject: [PATCH 005/101] fix(i18n): add zh_TW snooze parser locale (#13822) --- .../helper/snoozeDateParser/localization.js | 11 +- .../helper/snoozeDateParser/parser.js | 14 +++ .../helper/specs/snoozeDateParser.spec.js | 49 ++++++++ .../dashboard/i18n/locale/zh_TW/index.js | 2 + .../dashboard/i18n/locale/zh_TW/snooze.json | 106 +++++++++--------- 5 files changed, 127 insertions(+), 55 deletions(-) diff --git a/app/javascript/dashboard/helper/snoozeDateParser/localization.js b/app/javascript/dashboard/helper/snoozeDateParser/localization.js index 461fc96e9..8ffabe05a 100644 --- a/app/javascript/dashboard/helper/snoozeDateParser/localization.js +++ b/app/javascript/dashboard/helper/snoozeDateParser/localization.js @@ -166,6 +166,8 @@ const TOD_TO_MERIDIEM = { evening: 'pm', night: 'pm', }; +const CJK_CHAR_RE = + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; // ─── Translation Cache ────────────────────────────────────────────────────── @@ -278,8 +280,13 @@ const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const substituteLocalTokens = (text, pairs) => { let r = text; pairs.forEach(([local, en]) => { - const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g'); - r = r.replace(re, en); + if (CJK_CHAR_RE.test(local)) { + const re = new RegExp(escapeRegex(local), 'g'); + r = r.replace(re, ` ${en} `); + } else { + const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g'); + r = r.replace(re, en); + } }); return r; }; diff --git a/app/javascript/dashboard/helper/snoozeDateParser/parser.js b/app/javascript/dashboard/helper/snoozeDateParser/parser.js index 43d401cca..e076da712 100644 --- a/app/javascript/dashboard/helper/snoozeDateParser/parser.js +++ b/app/javascript/dashboard/helper/snoozeDateParser/parser.js @@ -82,6 +82,9 @@ const ORDINAL_RE = `(\\d{1,2}(?:st|nd|rd|th)?|${ORDINAL_WORDS})`; const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/; const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`); +const RELATIVE_DURATION_AFTER_RE = new RegExp( + `^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+after$` +); const DURATION_FROM_NOW_RE = new RegExp( `^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$` ); @@ -89,6 +92,9 @@ const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`); const RELATIVE_DAY_TOD_RE = new RegExp( `^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$` ); +const RELATIVE_DAY_MERIDIEM_RE = new RegExp( + `^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(am|pm)$` +); const RELATIVE_DAY_TOD_TIME_RE = new RegExp( `^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$` ); @@ -245,6 +251,7 @@ const matchDuration = (text, now) => { return ( parseDuration(text.match(DURATION_FROM_NOW_RE), now) || + parseDuration(text.match(RELATIVE_DURATION_AFTER_RE), now) || parseDuration(text.match(RELATIVE_DURATION_RE), now) ); }; @@ -303,6 +310,13 @@ const matchRelativeDay = (text, now) => { ); } + const dayMeridiemMatch = text.match(RELATIVE_DAY_MERIDIEM_RE); + if (dayMeridiemMatch) { + const [, dayKey, meridiem] = dayMeridiemMatch; + const hours = meridiem === 'am' ? 9 : 14; + return applyTimeWithRollover(RELATIVE_DAY_MAP[dayKey], hours, 0, now); + } + const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE); if (dayAtTimeMatch) { const [, dayKey, timeRaw] = dayAtTimeMatch; diff --git a/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js b/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js index 1ddde6fed..0c0bab385 100644 --- a/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js +++ b/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js @@ -1626,6 +1626,24 @@ describe('generateDateSuggestions — localized input regressions', () => { }, }; + const zhTWSnoozeTranslations = { + UNITS: { + HOUR: '小時', + HOURS: '小時', + DAY: '天', + DAYS: '天', + }, + HALF: '半', + RELATIVE: { + TOMORROW: '明天', + }, + MERIDIEM: { + AM: '上午', + PM: '下午', + }, + AFTER: '後', + }; + describe('P1: short non-English tokens must NOT produce spurious half-duration suggestions', () => { it('Arabic "غد" does not produce half-duration suggestions', () => { const results = generateDateSuggestions('غد', now, { @@ -1721,6 +1739,37 @@ describe('generateDateSuggestions — localized input regressions', () => { expect(results[0].date.getHours()).toBe(6); }); }); + + describe('zh_TW compact CJK inputs', () => { + const options = { + translations: zhTWSnoozeTranslations, + locale: 'zh-TW', + }; + + it('parses "2小時後" (2 hours from now) without spaces', () => { + const results = generateDateSuggestions('2小時後', now, options); + expect(results.length).toBeGreaterThan(0); + expect(results[0].date.getDate()).toBe(16); + expect(results[0].date.getHours()).toBe(12); + expect(results[0].date.getMinutes()).toBe(0); + }); + + it('parses "半天" (half day) without spaces', () => { + const results = generateDateSuggestions('半天', now, options); + expect(results.length).toBeGreaterThan(0); + expect(results[0].date.getDate()).toBe(16); + expect(results[0].date.getHours()).toBe(22); + expect(results[0].date.getMinutes()).toBe(0); + }); + + it('parses "明天 上午" (tomorrow AM) into tomorrow 9am', () => { + const results = generateDateSuggestions('明天 上午', now, options); + expect(results.length).toBeGreaterThan(0); + expect(results[0].date.getDate()).toBe(17); + expect(results[0].date.getHours()).toBe(9); + expect(results[0].date.getMinutes()).toBe(0); + }); + }); }); describe('no-space duration suggestions', () => { diff --git a/app/javascript/dashboard/i18n/locale/zh_TW/index.js b/app/javascript/dashboard/i18n/locale/zh_TW/index.js index 33e7851b0..1e5b0b2cd 100644 --- a/app/javascript/dashboard/i18n/locale/zh_TW/index.js +++ b/app/javascript/dashboard/i18n/locale/zh_TW/index.js @@ -34,6 +34,7 @@ import setNewPassword from './setNewPassword.json'; import settings from './settings.json'; import signup from './signup.json'; import sla from './sla.json'; +import snooze from './snooze.json'; import teamsSettings from './teamsSettings.json'; import whatsappTemplates from './whatsappTemplates.json'; @@ -74,6 +75,7 @@ export default { ...settings, ...signup, ...sla, + ...snooze, ...teamsSettings, ...whatsappTemplates, }; diff --git a/app/javascript/dashboard/i18n/locale/zh_TW/snooze.json b/app/javascript/dashboard/i18n/locale/zh_TW/snooze.json index 4b380fdef..8c631716a 100644 --- a/app/javascript/dashboard/i18n/locale/zh_TW/snooze.json +++ b/app/javascript/dashboard/i18n/locale/zh_TW/snooze.json @@ -1,72 +1,72 @@ { "SNOOZE_PARSER": { "UNITS": { - "MINUTE": "minute", - "MINUTES": "minutes", - "HOUR": "hour", + "MINUTE": "分鐘", + "MINUTES": "分鐘", + "HOUR": "小時", "HOURS": "小時", - "DAY": "day", - "DAYS": "days", - "WEEK": "week", - "WEEKS": "weeks", - "MONTH": "month", - "MONTHS": "months", - "YEAR": "month", - "YEARS": "years" + "DAY": "天", + "DAYS": "天", + "WEEK": "週", + "WEEKS": "週", + "MONTH": "月", + "MONTHS": "月", + "YEAR": "年", + "YEARS": "年" }, - "HALF": "half", - "NEXT": "next", - "THIS": "this", - "AT": "at", - "IN": "in", - "FROM_NOW": "from now", - "NEXT_YEAR": "next year", + "HALF": "半", + "NEXT": "下一個", + "THIS": "這個", + "AT": "在", + "IN": "在", + "FROM_NOW": "之後", + "NEXT_YEAR": "明年", "MERIDIEM": { - "AM": "am", - "PM": "pm" + "AM": "上午", + "PM": "下午" }, "RELATIVE": { "TOMORROW": "明天", - "DAY_AFTER_TOMORROW": "day after tomorrow", + "DAY_AFTER_TOMORROW": "後天", "NEXT_WEEK": "下週", - "NEXT_MONTH": "next month", - "THIS_WEEKEND": "this weekend", - "NEXT_WEEKEND": "next weekend" + "NEXT_MONTH": "下個月", + "THIS_WEEKEND": "這個週末", + "NEXT_WEEKEND": "下個週末" }, "TIME_OF_DAY": { - "MORNING": "morning", - "AFTERNOON": "afternoon", - "EVENING": "evening", - "NIGHT": "night", - "NOON": "noon", - "MIDNIGHT": "midnight" + "MORNING": "早上", + "AFTERNOON": "下午", + "EVENING": "晚上", + "NIGHT": "夜晚", + "NOON": "中午", + "MIDNIGHT": "午夜" }, "WORD_NUMBERS": { - "ONE": "one", - "TWO": "two", - "THREE": "three", - "FOUR": "four", - "FIVE": "five", - "SIX": "six", - "SEVEN": "seven", - "EIGHT": "eight", - "NINE": "nine", - "TEN": "ten", - "TWELVE": "twelve", - "FIFTEEN": "fifteen", - "TWENTY": "twenty", - "THIRTY": "thirty" + "ONE": "一", + "TWO": "二", + "THREE": "三", + "FOUR": "四", + "FIVE": "五", + "SIX": "六", + "SEVEN": "七", + "EIGHT": "八", + "NINE": "九", + "TEN": "十", + "TWELVE": "十二", + "FIFTEEN": "十五", + "TWENTY": "二十", + "THIRTY": "三十" }, "ORDINALS": { - "FIRST": "first", - "SECOND": "second", - "THIRD": "third", - "FOURTH": "fourth", - "FIFTH": "fifth" + "FIRST": "第一", + "SECOND": "第二", + "THIRD": "第三", + "FOURTH": "第四", + "FIFTH": "第五" }, - "OF": "of", - "AFTER": "after", - "WEEK": "week", - "DAY": "day" + "OF": "的", + "AFTER": "後", + "WEEK": "週", + "DAY": "天" } } From 608be1036b06f8b3026cd629215e1594cd977e0e Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 25 Mar 2026 16:56:22 +0400 Subject: [PATCH 006/101] fix: Send raw content in webhook payloads instead of channel-rendered markdown (#13896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Webhook payloads (`message_created`, `message_updated`) started sending channel-rendered HTML in the `content` field instead of the original raw message content after PR #12878. This broke downstream agent bots and integrations that expected plain text or markdown. Closes https://linear.app/chatwoot/issue/PLA-109/webhook-payloads-send-channel-rendered-html-instead-of-raw-content ## How to reproduce 1. Connect an agent bot to a WebWidget inbox 2. Send a message with markdown formatting (e.g. `**bold**`) from the widget 3. Observe the agent bot webhook payload — `content` field contains `

bold

` instead of `**bold**` ## What changed Split `MessageContentPresenter` into two public methods: - `outgoing_content` — renders markdown for the target channel (used by channel delivery services) - `webhook_content` — returns raw content with CSAT survey URL when applicable, no markdown rendering (used by `webhook_data`) Updated `Message#webhook_data` to use `webhook_content` instead of `outgoing_content`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- app/models/message.rb | 7 ++++- app/presenters/message_content_presenter.rb | 24 ++++++++++------ spec/models/message_spec.rb | 8 ++---- .../message_content_presenter_spec.rb | 28 +++++++++++++++++++ 4 files changed, 52 insertions(+), 15 deletions(-) diff --git a/app/models/message.rb b/app/models/message.rb index c33ab97d6..730d3e025 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -176,7 +176,7 @@ class Message < ApplicationRecord additional_attributes: additional_attributes, content_attributes: content_attributes, content_type: content_type, - content: outgoing_content, + content: webhook_content, conversation: conversation.webhook_data, created_at: created_at, id: id, @@ -195,6 +195,11 @@ class Message < ApplicationRecord MessageContentPresenter.new(self).outgoing_content end + # Raw content with survey URL (no markdown rendering) for webhook consumers + def webhook_content + MessageContentPresenter.new(self).webhook_content + end + def email_notifiable_message? return false if private? return false if %w[outgoing template].exclude?(message_type) diff --git a/app/presenters/message_content_presenter.rb b/app/presenters/message_content_presenter.rb index fa05d4ae9..3832e6a10 100644 --- a/app/presenters/message_content_presenter.rb +++ b/app/presenters/message_content_presenter.rb @@ -1,22 +1,28 @@ class MessageContentPresenter < SimpleDelegator def outgoing_content - content_to_send = if should_append_survey_link? - survey_link = survey_url(conversation.uuid) - custom_message = inbox.csat_config&.dig('message') - custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link) - else - content - end - Messages::MarkdownRendererService.new( - content_to_send, + content_with_survey_link, conversation.inbox.channel_type, conversation.inbox.channel ).render end + def webhook_content + content_with_survey_link + end + private + def content_with_survey_link + if should_append_survey_link? + survey_link = survey_url(conversation.uuid) + custom_message = inbox.csat_config&.dig('message') + custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link) + else + content + end + end + def should_append_survey_link? input_csat? && !inbox.web_widget? end diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index 4a59609b3..e331f68ac 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -401,12 +401,11 @@ RSpec.describe Message do expect(message.webhook_data.key?(:attachments)).to be false end - it 'uses outgoing_content for webhook content' do - message = create(:message, content: 'Test content') - expect(message).to receive(:outgoing_content).and_return('Outgoing test content') + it 'uses raw content without markdown rendering for webhook content' do + message = create(:message, content: 'Test **bold** content') webhook_data = message.webhook_data - expect(webhook_data[:content]).to eq('Outgoing test content') + expect(webhook_data[:content]).to eq('Test **bold** content') end it 'includes CSAT survey link in webhook content for input_csat messages' do @@ -414,7 +413,6 @@ RSpec.describe Message do conversation = create(:conversation, inbox: inbox) message = create(:message, conversation: conversation, content_type: 'input_csat', content: 'Rate your experience') - expect(message.outgoing_content).to include('survey/responses/') expect(message.webhook_data[:content]).to include('survey/responses/') end end diff --git a/spec/presenters/message_content_presenter_spec.rb b/spec/presenters/message_content_presenter_spec.rb index 902d45f13..d12e0d4d0 100644 --- a/spec/presenters/message_content_presenter_spec.rb +++ b/spec/presenters/message_content_presenter_spec.rb @@ -55,6 +55,34 @@ RSpec.describe MessageContentPresenter do end end + describe '#webhook_content' do + context 'when message is not input_csat' do + let(:content_type) { 'text' } + let(:content) { 'Regular **bold** message' } + + it 'returns raw content without markdown rendering' do + expect(presenter.webhook_content).to eq('Regular **bold** message') + end + end + + context 'when message is input_csat and inbox is not web widget' do + let(:content_type) { 'input_csat' } + let(:content) { 'Rate your experience' } + + before do + allow(message.inbox).to receive(:web_widget?).and_return(false) + end + + it 'includes CSAT survey URL without markdown rendering' do + with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do + expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}" + expect(presenter.webhook_content).to include(expected_url) + expect(presenter.webhook_content).not_to include('

') + end + end + end + end + describe 'delegation' do let(:content_type) { 'text' } let(:content) { 'Test message' } From 250650dd7af1e199b8f76c25ec26c0e72cb383a2 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 26 Mar 2026 02:58:08 +0400 Subject: [PATCH 007/101] feat(platform): Add email channel migration endpoint for bulk OAuth channel creation (#13902) Adds a Platform API endpoint that allows migrating existing Google and Microsoft email channels (with OAuth credentials) into Chatwoot without requiring end-users to re-authenticate. This enables customers who lack Rails console access to programmatically migrate email channels from legacy systems. ### How to test 1. Create a Platform App and grant it permissible access to a target account 2. `POST /platform/api/v1/accounts/:account_id/email_channel_migrations` with a payload like: ```json { "migrations": [ { "email": "support@example.com", "provider": "google", "provider_config": { "access_token": "...", "refresh_token": "...", "expires_on": "..." }, "inbox_name": "Migrated Support" } ] } ``` 3. Verify channels are created with correct provider, provider_config, and IMAP defaults 4. Verify partial failures (e.g. duplicate email) don't roll back other migrations in the batch 5. Verify unauthenticated and non-permissible requests return 401 --------- Co-authored-by: Sojan Jose --- .../v1/email_channel_migrations_controller.rb | 101 +++++++ config/routes.rb | 1 + ...mail_channel_migrations_controller_spec.rb | 266 ++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 app/controllers/platform/api/v1/email_channel_migrations_controller.rb create mode 100644 spec/controllers/platform/api/v1/email_channel_migrations_controller_spec.rb diff --git a/app/controllers/platform/api/v1/email_channel_migrations_controller.rb b/app/controllers/platform/api/v1/email_channel_migrations_controller.rb new file mode 100644 index 000000000..3e9e8defd --- /dev/null +++ b/app/controllers/platform/api/v1/email_channel_migrations_controller.rb @@ -0,0 +1,101 @@ +class Platform::Api::V1::EmailChannelMigrationsController < PlatformController + before_action :set_account + before_action :validate_account_permissible + before_action :validate_feature_flag + before_action :validate_params + + def create + results = migrate_email_channels + render json: { results: results }, status: :ok + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def validate_account_permissible + return if @platform_app.platform_app_permissibles.find_by(permissible: @account) + + render json: { error: 'Non permissible resource' }, status: :unauthorized + end + + def validate_feature_flag + return if ActiveModel::Type::Boolean.new.cast(ENV.fetch('EMAIL_CHANNEL_MIGRATION', false)) + + render json: { error: 'Email channel migration is not enabled' }, status: :forbidden + end + + def validate_params + return render json: { error: 'Missing migrations parameter' }, status: :unprocessable_entity if migration_params.blank? + + return unless migration_params.size > MAX_MIGRATIONS + + return render json: { error: "Too many migrations (max #{MAX_MIGRATIONS})" }, + status: :unprocessable_entity + end + + def migrate_email_channels + migration_params.map { |entry| migrate_single(entry) } + end + + MAX_MIGRATIONS = 25 + SUPPORTED_PROVIDERS = %w[google microsoft].freeze + + def migrate_single(entry) + validate_provider!(entry[:provider]) + + ActiveRecord::Base.transaction do + channel = create_channel(entry) + inbox = create_inbox(channel, entry) + + { email: entry[:email], inbox_id: inbox.id, channel_id: channel.id, status: 'success' } + end + rescue StandardError => e + { email: entry[:email], status: 'error', message: e.message } + end + + def create_channel(entry) + Channel::Email.create!( + account_id: @account.id, + email: entry[:email], + provider: entry[:provider], + provider_config: entry[:provider_config]&.to_h, + imap_enabled: entry.fetch(:imap_enabled, true), + imap_address: entry[:imap_address] || default_imap_address(entry[:provider]), + imap_port: entry[:imap_port] || 993, + imap_login: entry[:imap_login] || entry[:email], + imap_enable_ssl: entry.fetch(:imap_enable_ssl, true) + ) + end + + def create_inbox(channel, entry) + @account.inboxes.create!( + name: entry[:inbox_name] || "Migrated #{entry[:provider]&.capitalize}: #{entry[:email]}", + channel: channel + ) + end + + def validate_provider!(provider) + return if SUPPORTED_PROVIDERS.include?(provider) + + raise ArgumentError, "Unsupported provider '#{provider}'. Must be one of: #{SUPPORTED_PROVIDERS.join(', ')}" + end + + def default_imap_address(provider) + case provider + when 'google' then 'imap.gmail.com' + when 'microsoft' then 'outlook.office365.com' + else '' + end + end + + def migration_params + params.permit(migrations: [ + :email, :provider, :inbox_name, + :imap_enabled, :imap_address, :imap_port, :imap_login, :imap_enable_ssl, + { provider_config: {} } + ])[:migrations] + end +end diff --git a/config/routes.rb b/config/routes.rb index 5fe70bdca..9d442600e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -507,6 +507,7 @@ Rails.application.routes.draw do delete :destroy end end + resources :email_channel_migrations, only: [:create] end end end diff --git a/spec/controllers/platform/api/v1/email_channel_migrations_controller_spec.rb b/spec/controllers/platform/api/v1/email_channel_migrations_controller_spec.rb new file mode 100644 index 000000000..949c5da4e --- /dev/null +++ b/spec/controllers/platform/api/v1/email_channel_migrations_controller_spec.rb @@ -0,0 +1,266 @@ +require 'rails_helper' + +RSpec.describe 'Platform Email Channel Migrations API', type: :request do + let!(:account) { create(:account) } + let(:platform_app) { create(:platform_app) } + let(:base_url) { "/platform/api/v1/accounts/#{account.id}/email_channel_migrations" } + let(:headers) { { api_access_token: platform_app.access_token.token } } + + let(:google_provider_config) do + { access_token: 'ya29.test-access-token', refresh_token: '1//test-refresh-token', expires_on: 1.hour.from_now.to_s } + end + + let(:valid_migration_params) do + { + migrations: [ + { + email: 'support@example.com', + provider: 'google', + provider_config: google_provider_config, + inbox_name: 'Migrated Support' + } + ] + } + end + + before do + create(:platform_app_permissible, platform_app: platform_app, permissible: account) + end + + describe 'POST /platform/api/v1/accounts/:account_id/email_channel_migrations' do + context 'when unauthenticated' do + it 'returns unauthorized without token' do + with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do + post base_url, as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + it 'returns unauthorized with invalid token' do + with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do + post base_url, params: valid_migration_params, headers: { api_access_token: 'invalid' }, as: :json + expect(response).to have_http_status(:unauthorized) + end + end + end + + context 'when account is not permissible' do + let(:other_account) { create(:account) } + let(:other_url) { "/platform/api/v1/accounts/#{other_account.id}/email_channel_migrations" } + + it 'returns unauthorized' do + with_modified_env EMAIL_CHANNEL_MIGRATION: other_account.id.to_s do + post other_url, params: valid_migration_params, headers: headers, as: :json + expect(response).to have_http_status(:unauthorized) + end + end + end + + context 'when account is not in allowed list' do + it 'returns forbidden' do + with_modified_env EMAIL_CHANNEL_MIGRATION: '' do + post base_url, params: valid_migration_params, headers: headers, as: :json + expect(response).to have_http_status(:forbidden) + expect(response.parsed_body['error']).to eq('Email channel migration is not enabled') + end + end + end + + context 'when authenticated with permissible account' do + around do |example| + with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do + example.run + end + end + + it 'creates a google email channel and inbox' do + expect do + post base_url, params: valid_migration_params, headers: headers, as: :json + end.to change(Channel::Email, :count).by(1).and change(Inbox, :count).by(1) + + expect(response).to have_http_status(:ok) + result = response.parsed_body['results'].first + expect(result['status']).to eq('success') + expect(result['email']).to eq('support@example.com') + expect(result['inbox_id']).to be_present + expect(result['channel_id']).to be_present + end + + it 'sets correct google channel attributes' do + post base_url, params: valid_migration_params, headers: headers, as: :json + + channel = Channel::Email.find(response.parsed_body['results'].first['channel_id']) + expect(channel.provider).to eq('google') + expect(channel.imap_enabled).to be(true) + expect(channel.imap_address).to eq('imap.gmail.com') + expect(channel.imap_port).to eq(993) + expect(channel.imap_login).to eq('support@example.com') + expect(channel.provider_config['refresh_token']).to eq('1//test-refresh-token') + end + + it 'sets correct inbox attributes' do + post base_url, params: valid_migration_params, headers: headers, as: :json + + inbox = Inbox.find(response.parsed_body['results'].first['inbox_id']) + expect(inbox.name).to eq('Migrated Support') + expect(inbox.account_id).to eq(account.id) + end + + it 'creates a microsoft email channel with correct defaults' do + params = { + migrations: [ + { + email: 'support@outlook.com', + provider: 'microsoft', + provider_config: { access_token: 'test', refresh_token: 'test', expires_on: 1.hour.from_now.to_s } + } + ] + } + + post base_url, params: params, headers: headers, as: :json + + expect(response).to have_http_status(:ok) + result = response.parsed_body['results'].first + channel = Channel::Email.find(result['channel_id']) + + expect(channel.provider).to eq('microsoft') + expect(channel.imap_address).to eq('outlook.office365.com') + end + + it 'uses default inbox name when not provided' do + params = { migrations: [{ email: 'test@example.com', provider: 'google', provider_config: google_provider_config }] } + + post base_url, params: params, headers: headers, as: :json + + inbox = Inbox.find(response.parsed_body['results'].first['inbox_id']) + expect(inbox.name).to eq('Migrated Google: test@example.com') + end + + it 'defaults imap_login to email address' do + post base_url, params: valid_migration_params, headers: headers, as: :json + + channel = Channel::Email.find(response.parsed_body['results'].first['channel_id']) + expect(channel.imap_login).to eq('support@example.com') + end + + it 'allows overriding imap settings' do + params = { + migrations: [ + { + email: 'custom@example.com', + provider: 'google', + provider_config: google_provider_config, + imap_address: 'custom.imap.server.com', + imap_port: 143, + imap_login: 'custom-login@example.com', + imap_enable_ssl: false + } + ] + } + + post base_url, params: params, headers: headers, as: :json + + channel = Channel::Email.find(response.parsed_body['results'].first['channel_id']) + expect(channel.imap_address).to eq('custom.imap.server.com') + expect(channel.imap_port).to eq(143) + expect(channel.imap_login).to eq('custom-login@example.com') + expect(channel.imap_enable_ssl).to be(false) + end + end + + context 'when migrating multiple channels' do + around do |example| + with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do + example.run + end + end + + let(:bulk_params) do + { + migrations: [ + { email: 'first@example.com', provider: 'google', provider_config: google_provider_config }, + { email: 'second@example.com', provider: 'google', provider_config: google_provider_config }, + { email: 'third@example.com', provider: 'microsoft', + provider_config: { access_token: 'test', refresh_token: 'test', expires_on: 1.hour.from_now.to_s } } + ] + } + end + + it 'creates all channels and inboxes' do + expect do + post base_url, params: bulk_params, headers: headers, as: :json + end.to change(Channel::Email, :count).by(3).and change(Inbox, :count).by(3) + + results = response.parsed_body['results'] + expect(results.map { |r| r['status'] }).to all(eq('success')) + expect(results.map { |r| r['email'] }).to match_array(%w[first@example.com second@example.com third@example.com]) + end + + it 'continues processing when one migration fails' do + create(:channel_email, email: 'first@example.com', account: account) + + expect do + post base_url, params: bulk_params, headers: headers, as: :json + end.to change(Channel::Email, :count).by(2).and change(Inbox, :count).by(2) + + results = response.parsed_body['results'] + failed = results.find { |r| r['email'] == 'first@example.com' } + succeeded = results.reject { |r| r['email'] == 'first@example.com' } + + expect(failed['status']).to eq('error') + expect(failed['message']).to include('Email has already been taken') + expect(succeeded.map { |r| r['status'] }).to all(eq('success')) + end + end + + context 'when params are invalid' do + around do |example| + with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do + example.run + end + end + + it 'returns unprocessable entity when migrations param is missing' do + post base_url, params: {}, headers: headers, as: :json + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns unprocessable entity when migrations exceed max batch size' do + params = { + migrations: Array.new(26) { |i| { email: "user#{i}@example.com", provider: 'google', provider_config: google_provider_config } } + } + + post base_url, params: params, headers: headers, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to include('Too many migrations') + end + + it 'returns error for unsupported provider' do + params = { + migrations: [{ email: 'test@example.com', provider: 'Yahoo', provider_config: google_provider_config }] + } + + post base_url, params: params, headers: headers, as: :json + + result = response.parsed_body['results'].first + expect(result['status']).to eq('error') + expect(result['message']).to include("Unsupported provider 'Yahoo'") + end + + it 'returns error for duplicate email' do + create(:channel_email, email: 'existing@example.com', account: account) + + params = { + migrations: [{ email: 'existing@example.com', provider: 'google', provider_config: google_provider_config }] + } + + post base_url, params: params, headers: headers, as: :json + + result = response.parsed_body['results'].first + expect(result['status']).to eq('error') + expect(result['message']).to include('Email has already been taken') + end + end + end +end From 7144d55334794e66a7b033dbe98e539080fa3dc3 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 25 Mar 2026 16:20:58 -0700 Subject: [PATCH 008/101] Bump version to 4.12.1 --- VERSION_CW | 2 +- config/app.yml | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VERSION_CW b/VERSION_CW index 815588ef1..53cf85e17 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.12.0 +4.12.1 diff --git a/config/app.yml b/config/app.yml index 5013ad195..8926a53ad 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '4.12.0' + version: '4.12.1' development: <<: *shared diff --git a/package.json b/package.json index a30074a94..5bde17603 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "4.12.0", + "version": "4.12.1", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", From e0e321b8e2df9e5922d7a4f4b75a1154946ea553 Mon Sep 17 00:00:00 2001 From: Mazen Khalil Date: Thu, 26 Mar 2026 03:51:06 +0300 Subject: [PATCH 009/101] fix: Annotaterb model annotation incomplete migration (#13132) This pull request fixes the model annotation tooling due to previous incomplete migration from `annotate` to `annotaterb` gem (#12600). It also improves the handling of serialized values in the `InstallationConfig` model by ensuring a default value is set, simplifying the code, and removing a workaround for YAML deserialization. **Annotation tooling updates:** * Added `.annotaterb.yml` to configure the `annotate_rb` gem with project-specific options, centralizing annotation settings. * Replaced the custom `auto_annotate_models.rake` task with the standard rake task from `annotate_rb`, and added `lib/tasks/annotate_rb.rake` to load annotation tasks in development environments. [[1]](diffhunk://#diff-9450d2359e45f1db407b3871dde787a25d60bb721aed179a65ffd2692e95fb4bL1-L61) [[2]](diffhunk://#diff-578cdfc7ad56637e42472ea891ea286dff8803d9a1750afdbfeafec164d9b8b2R1-R8) **Model serialization improvements:** * Updated the `InstallationConfig` model to set a default value for the `serialized_value` attribute, ensuring it always has a hash with indifferent access and removing the need for a deserialization workaround in the `value` method. [[1]](diffhunk://#diff-b4bdde42c1ad0f584073818bd43dbd865b1b3b50d4701b131979f900d7c68297L22-R22) [[2]](diffhunk://#diff-b4bdde42c1ad0f584073818bd43dbd865b1b3b50d4701b131979f900d7c68297L36-L39) --------- Co-authored-by: Sojan Jose --- .annotaterb.yml | 65 +++++++++++++++++++++++++ app/models/csat_survey_response.rb | 34 +++++++------ app/models/installation_config.rb | 6 +-- app/models/reporting_event.rb | 15 +++--- app/models/reporting_events_rollup.rb | 22 ++++----- app/models/webhook.rb | 1 + lib/tasks/annotate_rb.rake | 8 +++ lib/tasks/auto_annotate_models.rake | 61 ----------------------- spec/models/installation_config_spec.rb | 12 +++++ 9 files changed, 125 insertions(+), 99 deletions(-) create mode 100644 .annotaterb.yml create mode 100644 lib/tasks/annotate_rb.rake delete mode 100644 lib/tasks/auto_annotate_models.rake diff --git a/.annotaterb.yml b/.annotaterb.yml new file mode 100644 index 000000000..07162a22d --- /dev/null +++ b/.annotaterb.yml @@ -0,0 +1,65 @@ +--- +:position: before +:position_in_additional_file_patterns: before +:position_in_class: before +:position_in_factory: before +:position_in_fixture: before +:position_in_routes: before +:position_in_serializer: before +:position_in_test: before +:classified_sort: true +:exclude_controllers: true +:exclude_factories: true +:exclude_fixtures: true +:exclude_helpers: true +:exclude_scaffolds: true +:exclude_serializers: true +:exclude_sti_subclasses: false +:exclude_tests: true +:force: false +:format_markdown: false +:format_rdoc: false +:format_yard: false +:frozen: false +:grouped_polymorphic: false +:ignore_model_sub_dir: false +:ignore_unknown_models: false +:include_version: false +:show_check_constraints: false +:show_complete_foreign_keys: false +:show_foreign_keys: true +:show_indexes: true +:show_indexes_include: false +:simple_indexes: false +:sort: false +:timestamp: false +:trace: false +:with_comment: true +:with_column_comments: true +:with_table_comments: true +:position_of_column_comment: :with_name +:active_admin: false +:command: +:debug: false +:hide_default_column_types: json,jsonb,hstore +:hide_limit_column_types: integer,bigint,boolean +:timestamp_columns: +- created_at +- updated_at +:ignore_columns: +:ignore_routes: +:models: true +:routes: false +:skip_on_db_migrate: false +:target_action: :do_annotations +:wrapper: +:wrapper_close: +:wrapper_open: +:classes_default_to_s: [] +:additional_file_patterns: [] +:model_dir: +- app/models +- enterprise/app/models +:require: [] +:root_dir: +- '' diff --git a/app/models/csat_survey_response.rb b/app/models/csat_survey_response.rb index 804dfd4b7..212530493 100644 --- a/app/models/csat_survey_response.rb +++ b/app/models/csat_survey_response.rb @@ -2,24 +2,28 @@ # # Table name: csat_survey_responses # -# id :bigint not null, primary key -# feedback_message :text -# rating :integer not null -# created_at :datetime not null -# updated_at :datetime not null -# account_id :bigint not null -# assigned_agent_id :bigint -# contact_id :bigint not null -# conversation_id :bigint not null -# message_id :bigint not null +# id :bigint not null, primary key +# csat_review_notes :text +# feedback_message :text +# rating :integer not null +# review_notes_updated_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# assigned_agent_id :bigint +# contact_id :bigint not null +# conversation_id :bigint not null +# message_id :bigint not null +# review_notes_updated_by_id :bigint # # Indexes # -# index_csat_survey_responses_on_account_id (account_id) -# index_csat_survey_responses_on_assigned_agent_id (assigned_agent_id) -# index_csat_survey_responses_on_contact_id (contact_id) -# index_csat_survey_responses_on_conversation_id (conversation_id) -# index_csat_survey_responses_on_message_id (message_id) UNIQUE +# index_csat_survey_responses_on_account_id (account_id) +# index_csat_survey_responses_on_assigned_agent_id (assigned_agent_id) +# index_csat_survey_responses_on_contact_id (contact_id) +# index_csat_survey_responses_on_conversation_id (conversation_id) +# index_csat_survey_responses_on_message_id (message_id) UNIQUE +# index_csat_survey_responses_on_review_notes_updated_by_id (review_notes_updated_by_id) # class CsatSurveyResponse < ApplicationRecord belongs_to :account diff --git a/app/models/installation_config.rb b/app/models/installation_config.rb index a7400460c..19252de50 100644 --- a/app/models/installation_config.rb +++ b/app/models/installation_config.rb @@ -19,7 +19,7 @@ class InstallationConfig < ApplicationRecord # https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017 # FIX ME : fixes breakage of installation config. we need to migrate. # Fix configuration in application.rb - serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess + serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess, default: {}.with_indifferent_access before_validation :set_lock validates :name, presence: true @@ -33,10 +33,6 @@ class InstallationConfig < ApplicationRecord after_commit :clear_cache def value - # This is an extra hack again cause of the YAML serialization, in case of new object initialization in super admin - # It was throwing error as the default value of column '{}' was failing in deserialization. - return {}.with_indifferent_access if new_record? && @attributes['serialized_value']&.value_before_type_cast == '{}' - serialized_value[:value] end diff --git a/app/models/reporting_event.rb b/app/models/reporting_event.rb index f083e32a1..6c2c10b01 100644 --- a/app/models/reporting_event.rb +++ b/app/models/reporting_event.rb @@ -17,13 +17,14 @@ # # Indexes # -# index_reporting_events_on_account_id (account_id) -# index_reporting_events_on_conversation_id (conversation_id) -# index_reporting_events_on_created_at (created_at) -# index_reporting_events_on_inbox_id (inbox_id) -# index_reporting_events_on_name (name) -# index_reporting_events_on_user_id (user_id) -# reporting_events__account_id__name__created_at (account_id,name,created_at) +# index_reporting_events_for_response_distribution (account_id,name,inbox_id,created_at) +# index_reporting_events_on_account_id (account_id) +# index_reporting_events_on_conversation_id (conversation_id) +# index_reporting_events_on_created_at (created_at) +# index_reporting_events_on_inbox_id (inbox_id) +# index_reporting_events_on_name (name) +# index_reporting_events_on_user_id (user_id) +# reporting_events__account_id__name__created_at (account_id,name,created_at) # class ReportingEvent < ApplicationRecord diff --git a/app/models/reporting_events_rollup.rb b/app/models/reporting_events_rollup.rb index a9e345cba..9ad1b5135 100644 --- a/app/models/reporting_events_rollup.rb +++ b/app/models/reporting_events_rollup.rb @@ -2,17 +2,17 @@ # # Table name: reporting_events_rollups # -# id :bigint not null, primary key -# count :bigint default(0), not null -# date :date not null -# dimension_id :bigint not null -# dimension_type :string not null -# metric :string not null -# sum_value :float default(0.0), not null -# sum_value_business_hours :float default(0.0), not null -# created_at :datetime not null -# updated_at :datetime not null -# account_id :integer not null +# id :bigint not null, primary key +# count :bigint default(0), not null +# date :date not null +# dimension_type :string not null +# metric :string not null +# sum_value :float default(0.0), not null +# sum_value_business_hours :float default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer not null +# dimension_id :bigint not null # # Indexes # diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 6b36c4bbd..9586e1053 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -4,6 +4,7 @@ # # id :bigint not null, primary key # name :string +# secret :string # subscriptions :jsonb # url :text # webhook_type :integer default("account_type") diff --git a/lib/tasks/annotate_rb.rake b/lib/tasks/annotate_rb.rake new file mode 100644 index 000000000..2c54aa32e --- /dev/null +++ b/lib/tasks/annotate_rb.rake @@ -0,0 +1,8 @@ +# This rake task was added by annotate_rb gem. + +# Can set `ANNOTATERB_SKIP_ON_DB_TASKS` to be anything to skip this +if Rails.env.development? && ENV['ANNOTATERB_SKIP_ON_DB_TASKS'].nil? + require 'annotate_rb' + + AnnotateRb::Core.load_rake_tasks +end diff --git a/lib/tasks/auto_annotate_models.rake b/lib/tasks/auto_annotate_models.rake deleted file mode 100644 index 9dcd13126..000000000 --- a/lib/tasks/auto_annotate_models.rake +++ /dev/null @@ -1,61 +0,0 @@ -# NOTE: only doing this in development as some production environments (Heroku) -# NOTE: are sensitive to local FS writes, and besides -- it's just not proper -# NOTE: to have a dev-mode tool do its thing in production. -if Rails.env.development? - require 'annotate_rb' - - AnnotateRb::Core.load_rake_tasks - - task :set_annotation_options do - # You can override any of these by setting an environment variable of the - # same name. - AnnotateRb::Options.set_defaults( - 'additional_file_patterns' => [], - 'routes' => 'false', - 'models' => 'true', - 'position_in_routes' => 'before', - 'position_in_class' => 'before', - 'position_in_test' => 'before', - 'position_in_fixture' => 'before', - 'position_in_factory' => 'before', - 'position_in_serializer' => 'before', - 'show_foreign_keys' => 'true', - 'show_complete_foreign_keys' => 'false', - 'show_indexes' => 'true', - 'simple_indexes' => 'false', - 'model_dir' => [ - 'app/models', - 'enterprise/app/models', - ], - 'root_dir' => '', - 'include_version' => 'false', - 'require' => '', - 'exclude_tests' => 'true', - 'exclude_fixtures' => 'true', - 'exclude_factories' => 'true', - 'exclude_serializers' => 'true', - 'exclude_scaffolds' => 'true', - 'exclude_controllers' => 'true', - 'exclude_helpers' => 'true', - 'exclude_sti_subclasses' => 'false', - 'ignore_model_sub_dir' => 'false', - 'ignore_columns' => nil, - 'ignore_routes' => nil, - 'ignore_unknown_models' => 'false', - 'hide_limit_column_types' => 'integer,bigint,boolean', - 'hide_default_column_types' => 'json,jsonb,hstore', - 'skip_on_db_migrate' => 'false', - 'format_bare' => 'true', - 'format_rdoc' => 'false', - 'format_markdown' => 'false', - 'sort' => 'false', - 'force' => 'false', - 'frozen' => 'false', - 'classified_sort' => 'true', - 'trace' => 'false', - 'wrapper_open' => nil, - 'wrapper_close' => nil, - 'with_comment' => 'true' - ) - end -end diff --git a/spec/models/installation_config_spec.rb b/spec/models/installation_config_spec.rb index 49270f2a8..49b58f79e 100644 --- a/spec/models/installation_config_spec.rb +++ b/spec/models/installation_config_spec.rb @@ -3,5 +3,17 @@ require 'rails_helper' RSpec.describe InstallationConfig do + subject(:installation_config) { described_class.new(name: 'INSTALLATION_NAME') } + it { is_expected.to validate_presence_of(:name) } + + describe 'new record defaults' do + it 'initializes serialized_value with indifferent access' do + expect(installation_config.serialized_value).to eq({}.with_indifferent_access) + end + + it 'returns nil for value before assignment' do + expect(installation_config.value).to be_nil + end + end end From d9e732c005baf95e9aa2c9ba63da1257c7bf8193 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:20:36 +0530 Subject: [PATCH 010/101] chore(v5): update priority icons (#13905) # Pull Request Template ## Description This PR updates the priority icons with a new set and makes them consistent across the app. ## How Has This Been Tested? **Screenshots** image image image image ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Pranav --- .../ConversationCard/CardPriorityIcon.vue | 246 ++++-------------- .../widgets/conversation/ConversationCard.vue | 9 +- .../widgets/conversation/PriorityMark.vue | 53 ---- .../conversation/ConversationAction.vue | 10 +- .../dashboard/settings/macros/MacroEditor.vue | 2 +- .../components/ui/MultiselectDropdown.vue | 12 +- .../ui/MultiselectDropdownItems.vue | 9 +- .../assets/images/dashboard/priority/high.svg | 6 - .../assets/images/dashboard/priority/low.svg | 4 - .../images/dashboard/priority/medium.svg | 5 - .../assets/images/dashboard/priority/none.svg | 4 - .../images/dashboard/priority/urgent.svg | 9 - theme/icons.js | 28 ++ 13 files changed, 110 insertions(+), 287 deletions(-) delete mode 100644 app/javascript/dashboard/components/widgets/conversation/PriorityMark.vue delete mode 100644 public/assets/images/dashboard/priority/high.svg delete mode 100644 public/assets/images/dashboard/priority/low.svg delete mode 100644 public/assets/images/dashboard/priority/medium.svg delete mode 100644 public/assets/images/dashboard/priority/none.svg delete mode 100644 public/assets/images/dashboard/priority/urgent.svg diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue index d51764ef5..c7453219f 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue @@ -1,207 +1,63 @@ - diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index f12b0c9c1..f50485723 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -10,7 +10,7 @@ import InboxName from '../InboxName.vue'; import ConversationContextMenu from './contextMenu/Index.vue'; import TimeAgo from 'dashboard/components/ui/TimeAgo.vue'; import CardLabels from './conversationCardComponents/CardLabels.vue'; -import PriorityMark from './PriorityMark.vue'; +import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue'; import SLACardLabel from './components/SLACardLabel.vue'; import ContextMenu from 'dashboard/components/ui/ContextMenu.vue'; import VoiceCallStatus from './VoiceCallStatus.vue'; @@ -305,7 +305,7 @@ const deleteConversation = () => { >

{ {{ assignee.name }} - +

-import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages'; - -export default { - name: 'PriorityMark', - props: { - priority: { - type: String, - default: '', - validate: value => - [...Object.values(CONVERSATION_PRIORITY), ''].includes(value), - }, - }, - data() { - return { - CONVERSATION_PRIORITY, - }; - }, - computed: { - tooltipText() { - return this.$t( - `CONVERSATION.PRIORITY.OPTIONS.${this.priority.toUpperCase()}` - ); - }, - isUrgent() { - return this.priority === CONVERSATION_PRIORITY.URGENT; - }, - }, -}; - - - - diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue b/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue index 4c14ba2ab..718a990bb 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue @@ -36,27 +36,27 @@ export default { { id: null, name: this.$t('CONVERSATION.PRIORITY.OPTIONS.NONE'), - thumbnail: `/assets/images/dashboard/priority/none.svg`, + icon: 'i-woot-priority-empty', }, { id: CONVERSATION_PRIORITY.URGENT, name: this.$t('CONVERSATION.PRIORITY.OPTIONS.URGENT'), - thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.URGENT}.svg`, + icon: 'i-woot-priority-urgent', }, { id: CONVERSATION_PRIORITY.HIGH, name: this.$t('CONVERSATION.PRIORITY.OPTIONS.HIGH'), - thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.HIGH}.svg`, + icon: 'i-woot-priority-high', }, { id: CONVERSATION_PRIORITY.MEDIUM, name: this.$t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'), - thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.MEDIUM}.svg`, + icon: 'i-woot-priority-medium', }, { id: CONVERSATION_PRIORITY.LOW, name: this.$t('CONVERSATION.PRIORITY.OPTIONS.LOW'), - thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.LOW}.svg`, + icon: 'i-woot-priority-low', }, ], }; diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue index 4e576e5ad..0b916a4ab 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue @@ -128,7 +128,7 @@ const saveMacro = async macroData => {