Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
899fce1c92 | ||
|
|
7144d55334 | ||
|
|
250650dd7a | ||
|
|
608be1036b | ||
|
|
6ff643b045 | ||
|
|
775b73d1f9 | ||
|
|
14df7b3bc1 | ||
|
|
6946859ba4 | ||
|
|
c129ab00ba | ||
|
|
7edae93ee8 | ||
|
|
b6b856260f | ||
|
|
79b18e7009 |
+1
-1
@@ -1 +1 @@
|
||||
4.12.0
|
||||
4.12.1
|
||||
|
||||
+3
-1
@@ -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')
|
||||
@@ -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
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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": "天"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -8,7 +8,7 @@ class AgentBots::WebhookJob < WebhookJob
|
||||
def perform(url, payload, webhook_type = :agent_bot_webhook)
|
||||
super(url, payload, webhook_type)
|
||||
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
|
||||
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name}")
|
||||
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name} payload=#{payload.to_json}")
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.12.0'
|
||||
version: '4.12.1'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -507,6 +507,7 @@ Rails.application.routes.draw do
|
||||
delete :destroy
|
||||
end
|
||||
end
|
||||
resources :email_channel_migrations, only: [:create]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -15,6 +15,7 @@ TimeoutStopSec=30
|
||||
KillMode=mixed
|
||||
StandardInput=null
|
||||
SyslogIdentifier=%p
|
||||
LimitNOFILE=65536
|
||||
|
||||
MemoryMax=1.2G
|
||||
MemoryHigh=infinity
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.12.0",
|
||||
"version": "4.12.1",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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('<p>')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'delegation' do
|
||||
let(:content_type) { 'text' }
|
||||
let(:content) { 'Test message' }
|
||||
|
||||
Reference in New Issue
Block a user