Merge branch 'feat/billing-brl-pix-new-users' into feat/billing-switch-currency
This commit is contained in:
+31
-20
@@ -136,6 +136,8 @@ GEM
|
||||
audited (5.4.1)
|
||||
activerecord (>= 5.0, < 7.7)
|
||||
activesupport (>= 5.0, < 7.7)
|
||||
auth-sanitizer (0.2.1)
|
||||
version_gem (~> 1.1, >= 1.1.10)
|
||||
aws-actionmailbox-ses (0.1.0)
|
||||
actionmailbox (>= 7.1.0)
|
||||
aws-sdk-s3 (~> 1, >= 1.123.0)
|
||||
@@ -168,7 +170,7 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.2.2)
|
||||
bigdecimal (3.3.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
@@ -184,6 +186,7 @@ GEM
|
||||
bundler (>= 1.2.0, < 3)
|
||||
thor (~> 1.0)
|
||||
byebug (11.1.3)
|
||||
cgi (0.5.1)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
cld3 (3.7.0)
|
||||
@@ -312,7 +315,7 @@ GEM
|
||||
hashie
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.2)
|
||||
faraday-net_http (3.4.4)
|
||||
net-http (~> 0.5)
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
@@ -435,7 +438,8 @@ GEM
|
||||
hana (1.3.7)
|
||||
hash_diff (1.1.1)
|
||||
hashdiff (1.1.0)
|
||||
hashie (5.0.0)
|
||||
hashie (5.1.0)
|
||||
logger
|
||||
html2text (0.4.0)
|
||||
nokogiri (>= 1.0, < 2.0)
|
||||
http (5.1.1)
|
||||
@@ -470,7 +474,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.19.5)
|
||||
json (2.19.8)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -568,7 +572,7 @@ GEM
|
||||
ruby2_keywords
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.8.0)
|
||||
multi_xml (0.9.1)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
@@ -603,19 +607,26 @@ GEM
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
oauth-tty (1.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.1)
|
||||
oauth2 (2.0.9)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
jwt (>= 1.0, < 3.0)
|
||||
oauth (1.1.6)
|
||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
||||
base64 (~> 0.1)
|
||||
cgi
|
||||
oauth-tty (~> 1.0, >= 1.0.8)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
oauth-tty (1.0.8)
|
||||
auth-sanitizer (~> 0.1, >= 0.1.3)
|
||||
cgi
|
||||
version_gem (~> 1.1, >= 1.1.9)
|
||||
oauth2 (2.0.22)
|
||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
||||
faraday (>= 0.17.3, < 4.0)
|
||||
jwt (>= 1.0, < 4.0)
|
||||
logger (~> 1.2)
|
||||
multi_xml (~> 0.5)
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
@@ -935,9 +946,9 @@ GEM
|
||||
gli
|
||||
hashie
|
||||
logger
|
||||
snaky_hash (2.0.1)
|
||||
hashie
|
||||
version_gem (~> 1.1, >= 1.1.1)
|
||||
snaky_hash (2.0.5)
|
||||
hashie (>= 0.1.0, < 6)
|
||||
version_gem (>= 1.1.8, < 3)
|
||||
sorbet-runtime (0.5.11934)
|
||||
spring (4.1.1)
|
||||
spring-watcher-listen (2.1.0)
|
||||
@@ -995,7 +1006,7 @@ GEM
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
mail (~> 2.5)
|
||||
version_gem (1.1.4)
|
||||
version_gem (1.1.11)
|
||||
vite_rails (3.10.0)
|
||||
railties (>= 5.1, < 9)
|
||||
vite_ruby (~> 3.0, >= 3.2.2)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.14.1
|
||||
4.14.2
|
||||
|
||||
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
|
||||
|
||||
def create_contact
|
||||
account.contacts.create!(
|
||||
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
|
||||
name: contact_name,
|
||||
phone_number: contact_attributes[:phone_number],
|
||||
email: contact_attributes[:email],
|
||||
identifier: contact_attributes[:identifier],
|
||||
@@ -59,6 +59,11 @@ class ContactInboxWithContactBuilder
|
||||
)
|
||||
end
|
||||
|
||||
def contact_name
|
||||
name = contact_attributes[:name] || ::Haikunator.haikunate(1000)
|
||||
name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '')
|
||||
end
|
||||
|
||||
def find_contact
|
||||
contact = find_contact_by_identifier(contact_attributes[:identifier])
|
||||
contact ||= find_contact_by_email(contact_attributes[:email])
|
||||
|
||||
@@ -16,6 +16,10 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
|
||||
render 'api/v1/accounts/update', format: :json
|
||||
end
|
||||
|
||||
def help_center_generation
|
||||
render json: help_center_generation_status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def finalizing_account_details?
|
||||
@@ -33,4 +37,15 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
|
||||
def custom_attributes_params
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
|
||||
end
|
||||
|
||||
def help_center_generation_status
|
||||
{
|
||||
generation_id: nil,
|
||||
state: nil,
|
||||
articles_count: 0,
|
||||
categories_count: 0
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::OnboardingsController.prepend_mod_with('Api::V1::Accounts::OnboardingsController')
|
||||
|
||||
@@ -40,7 +40,7 @@ class ConversationFinder
|
||||
def perform
|
||||
set_up
|
||||
|
||||
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
|
||||
mine_count, unassigned_count, all_count = set_count_for_all_conversations
|
||||
assigned_count = all_count - unassigned_count
|
||||
|
||||
filter_by_assignee_type
|
||||
@@ -184,6 +184,17 @@ class ConversationFinder
|
||||
end
|
||||
|
||||
def set_count_for_all_conversations
|
||||
return legacy_count_for_all_conversations if @conversations.limit_value || @conversations.offset_value || @conversations.eager_loading?
|
||||
|
||||
counts = @conversations.unscope(:order).pick(
|
||||
Arel.sql("COUNT(*) FILTER (WHERE assignee_id = #{current_user.id})"),
|
||||
Arel.sql('COUNT(*) FILTER (WHERE assignee_id IS NULL)'),
|
||||
Arel.sql('COUNT(*)')
|
||||
)
|
||||
counts || [0, 0, 0]
|
||||
end
|
||||
|
||||
def legacy_count_for_all_conversations
|
||||
[
|
||||
@conversations.assigned_to(current_user).count,
|
||||
@conversations.unassigned.count,
|
||||
|
||||
@@ -60,6 +60,12 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
disableWhatsappCalling(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
|
||||
}
|
||||
|
||||
setInboundCalls(inboxId, enabled) {
|
||||
return axios.post(`${this.url}/${inboxId}/set_inbound_calls`, {
|
||||
inbound_calls_enabled: enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -9,6 +9,10 @@ class OnboardingAPI extends ApiClient {
|
||||
update(data) {
|
||||
return axios.patch(this.url, data);
|
||||
}
|
||||
|
||||
getHelpCenterGeneration() {
|
||||
return axios.get(`${this.url}/help_center_generation`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new OnboardingAPI();
|
||||
|
||||
@@ -175,6 +175,9 @@ useEventListener(document, 'touchend', onResizeEnd);
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
||||
const allUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getAllUnreadCount'
|
||||
);
|
||||
const getInboxUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getInboxUnreadCount'
|
||||
);
|
||||
@@ -297,6 +300,7 @@ const menuItems = computed(() => {
|
||||
{
|
||||
name: 'All',
|
||||
label: t('SIDEBAR.ALL_CONVERSATIONS'),
|
||||
badgeCount: allUnreadCount.value,
|
||||
activeOn: ['inbox_conversation'],
|
||||
to: accountScopedRoute('home'),
|
||||
},
|
||||
|
||||
@@ -375,10 +375,10 @@ export default {
|
||||
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
},
|
||||
audioRecordFormat() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
if (this.isAWhatsAppCloudChannel) {
|
||||
return AUDIO_FORMATS.OGG;
|
||||
}
|
||||
if (this.isATelegramChannel) {
|
||||
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
|
||||
return AUDIO_FORMATS.MP3;
|
||||
}
|
||||
if (this.isAPIInbox) {
|
||||
|
||||
@@ -653,6 +653,10 @@
|
||||
},
|
||||
"CREDENTIALS": {
|
||||
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
|
||||
},
|
||||
"INBOUND": {
|
||||
"LABEL": "Allow incoming calls",
|
||||
"DESCRIPTION": "Let customers call this number. When turned off, incoming calls are declined automatically — agents aren't notified and no conversation is created. Agents can still place outgoing calls."
|
||||
}
|
||||
},
|
||||
"WHATSAPP_CALLING": {
|
||||
|
||||
@@ -94,8 +94,18 @@ export default {
|
||||
return this.getAccount(this.accountId) || {};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'currentAccount.id'(id) {
|
||||
if (id) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initializeAccount();
|
||||
// Account already in the store (navigated in): seed immediately.
|
||||
if (this.currentAccount.id) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initializeAccount() {
|
||||
|
||||
@@ -8,7 +8,7 @@ import SectionLayout from './SectionLayout.vue';
|
||||
const { t } = useI18n();
|
||||
const { currentAccount } = useAccount();
|
||||
|
||||
const getAccountId = computed(() => currentAccount.value.id.toString());
|
||||
const getAccountId = computed(() => currentAccount.value?.id?.toString());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+42
@@ -1,9 +1,11 @@
|
||||
<script>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import NextInput from 'dashboard/components-next/input/Input.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -11,6 +13,7 @@ export default {
|
||||
SettingsToggleSection,
|
||||
NextInput,
|
||||
NextButton,
|
||||
Spinner,
|
||||
},
|
||||
props: {
|
||||
inbox: {
|
||||
@@ -21,9 +24,11 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
voiceEnabled: this.inbox.voice_enabled || false,
|
||||
inboundCallsEnabled: this.inbox.inbound_calls_enabled !== false,
|
||||
apiKeySid: this.inbox.api_key_sid || '',
|
||||
apiKeySecret: '',
|
||||
isUpdating: false,
|
||||
isTogglingInbound: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -62,8 +67,27 @@ export default {
|
||||
'inbox.api_key_sid'(val) {
|
||||
this.apiKeySid = val || '';
|
||||
},
|
||||
'inbox.inbound_calls_enabled'(val) {
|
||||
this.inboundCallsEnabled = val !== false;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async handleInboundToggle(newValue) {
|
||||
if (this.isTogglingInbound) return;
|
||||
const previousValue = this.inboundCallsEnabled;
|
||||
this.inboundCallsEnabled = newValue;
|
||||
this.isTogglingInbound = true;
|
||||
try {
|
||||
await InboxesAPI.setInboundCalls(this.inbox.id, newValue);
|
||||
await this.$store.dispatch('inboxes/get', this.inbox.id);
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (_) {
|
||||
this.inboundCallsEnabled = previousValue;
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
this.isTogglingInbound = false;
|
||||
}
|
||||
},
|
||||
async updateVoiceSettings() {
|
||||
this.isUpdating = true;
|
||||
try {
|
||||
@@ -123,6 +147,24 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="inbox.voice_enabled"
|
||||
class="relative"
|
||||
:class="{ 'pointer-events-none opacity-60': isTogglingInbound }"
|
||||
>
|
||||
<SettingsToggleSection
|
||||
:model-value="inboundCallsEnabled"
|
||||
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.LABEL')"
|
||||
:description="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.DESCRIPTION')"
|
||||
:hide-toggle="isTogglingInbound"
|
||||
@update:model-value="handleInboundToggle"
|
||||
>
|
||||
<template v-if="isTogglingInbound" #hiddenToggle>
|
||||
<Spinner class="size-4 text-n-slate-11" />
|
||||
</template>
|
||||
</SettingsToggleSection>
|
||||
</div>
|
||||
|
||||
<div v-if="inbox.voice_enabled && inbox.voice_call_webhook_url">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
|
||||
|
||||
+41
@@ -24,10 +24,13 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
callingEnabled: this.inbox.provider_config?.calling_enabled || false,
|
||||
inboundCallsEnabled:
|
||||
this.inbox.provider_config?.inbound_calls_enabled !== false,
|
||||
permissionRequestBody:
|
||||
this.inbox.provider_config?.call_permission_request_body || '',
|
||||
isUpdating: false,
|
||||
isTogglingCalling: false,
|
||||
isTogglingInbound: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -44,8 +47,27 @@ export default {
|
||||
'inbox.provider_config.call_permission_request_body'(val) {
|
||||
this.permissionRequestBody = val || '';
|
||||
},
|
||||
'inbox.provider_config.inbound_calls_enabled'(val) {
|
||||
this.inboundCallsEnabled = val !== false;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async handleInboundToggle(newValue) {
|
||||
if (this.isTogglingInbound) return;
|
||||
const previousValue = this.inboundCallsEnabled;
|
||||
this.inboundCallsEnabled = newValue;
|
||||
this.isTogglingInbound = true;
|
||||
try {
|
||||
await InboxesAPI.setInboundCalls(this.inbox.id, newValue);
|
||||
await this.$store.dispatch('inboxes/get', this.inbox.id);
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (_) {
|
||||
this.inboundCallsEnabled = previousValue;
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
this.isTogglingInbound = false;
|
||||
}
|
||||
},
|
||||
async handleCallingToggle(newValue) {
|
||||
if (this.isTogglingCalling) return;
|
||||
const previousValue = this.callingEnabled;
|
||||
@@ -117,6 +139,25 @@ export default {
|
||||
</div>
|
||||
|
||||
<template v-if="callingEnabled">
|
||||
<div
|
||||
class="relative"
|
||||
:class="{ 'pointer-events-none opacity-60': isTogglingInbound }"
|
||||
>
|
||||
<SettingsToggleSection
|
||||
:model-value="inboundCallsEnabled"
|
||||
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.LABEL')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.DESCRIPTION')
|
||||
"
|
||||
:hide-toggle="isTogglingInbound"
|
||||
@update:model-value="handleInboundToggle"
|
||||
>
|
||||
<template v-if="isTogglingInbound" #hiddenToggle>
|
||||
<Spinner class="size-4 text-n-slate-11" />
|
||||
</template>
|
||||
</SettingsToggleSection>
|
||||
</div>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="phoneNumber"
|
||||
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.LABEL')"
|
||||
|
||||
@@ -2,15 +2,21 @@ import ConversationAPI from '../../api/conversations';
|
||||
import types from '../mutation-types';
|
||||
|
||||
export const state = {
|
||||
allCount: 0,
|
||||
inboxes: {},
|
||||
labels: {},
|
||||
teams: {},
|
||||
};
|
||||
|
||||
const normalizeCount = count => {
|
||||
const parsedCount = Number(count);
|
||||
return Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 0;
|
||||
};
|
||||
|
||||
const normalizeCounts = counts => {
|
||||
return Object.entries(counts || {}).reduce((result, [id, count]) => {
|
||||
const parsedCount = Number(count);
|
||||
if (Number.isFinite(parsedCount) && parsedCount > 0) {
|
||||
const parsedCount = normalizeCount(count);
|
||||
if (parsedCount > 0) {
|
||||
result[String(id)] = parsedCount;
|
||||
}
|
||||
|
||||
@@ -19,6 +25,9 @@ const normalizeCounts = counts => {
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
getAllUnreadCount($state) {
|
||||
return $state.allCount;
|
||||
},
|
||||
getInboxUnreadCount: $state => inboxId => {
|
||||
return $state.inboxes[String(inboxId)] || 0;
|
||||
},
|
||||
@@ -55,6 +64,7 @@ export const actions = {
|
||||
|
||||
export const mutations = {
|
||||
[types.SET_CONVERSATION_UNREAD_COUNTS]($state, payload = {}) {
|
||||
$state.allCount = normalizeCount(payload.all_count);
|
||||
$state.inboxes = normalizeCounts(payload.inboxes);
|
||||
$state.labels = normalizeCounts(payload.labels);
|
||||
$state.teams = normalizeCounts(payload.teams);
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('#actions', () => {
|
||||
describe('#get', () => {
|
||||
it('commits unread counts when API is successful', async () => {
|
||||
const payload = {
|
||||
all_count: 2,
|
||||
inboxes: { 1: '2' },
|
||||
labels: { 3: 4 },
|
||||
teams: { 5: 6 },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getters } from '../../conversationUnreadCounts';
|
||||
describe('#getters', () => {
|
||||
it('returns inbox unread count by id', () => {
|
||||
const state = {
|
||||
allCount: 0,
|
||||
inboxes: { 1: 2 },
|
||||
labels: {},
|
||||
teams: {},
|
||||
@@ -15,6 +16,7 @@ describe('#getters', () => {
|
||||
|
||||
it('returns label unread count by id', () => {
|
||||
const state = {
|
||||
allCount: 0,
|
||||
inboxes: {},
|
||||
labels: { 3: 4 },
|
||||
teams: {},
|
||||
@@ -27,6 +29,7 @@ describe('#getters', () => {
|
||||
|
||||
it('returns team unread count by id', () => {
|
||||
const state = {
|
||||
allCount: 0,
|
||||
inboxes: {},
|
||||
labels: {},
|
||||
teams: { 5: 6 },
|
||||
@@ -37,8 +40,20 @@ describe('#getters', () => {
|
||||
expect(getters.getTeamUnreadCount(state)(6)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns all unread count', () => {
|
||||
const state = {
|
||||
allCount: 7,
|
||||
inboxes: {},
|
||||
labels: {},
|
||||
teams: {},
|
||||
};
|
||||
|
||||
expect(getters.getAllUnreadCount(state)).toBe(7);
|
||||
});
|
||||
|
||||
it('returns unread count maps', () => {
|
||||
const state = {
|
||||
allCount: 0,
|
||||
inboxes: { 1: 2 },
|
||||
labels: { 3: 4 },
|
||||
teams: { 5: 6 },
|
||||
|
||||
+15
-1
@@ -4,9 +4,10 @@ import { mutations } from '../../conversationUnreadCounts';
|
||||
describe('#mutations', () => {
|
||||
describe('#SET_CONVERSATION_UNREAD_COUNTS', () => {
|
||||
it('normalizes unread count payload', () => {
|
||||
const state = { inboxes: {}, labels: {}, teams: {} };
|
||||
const state = { allCount: 0, inboxes: {}, labels: {}, teams: {} };
|
||||
|
||||
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
|
||||
all_count: '3',
|
||||
inboxes: {
|
||||
1: '2',
|
||||
2: 0,
|
||||
@@ -23,6 +24,7 @@ describe('#mutations', () => {
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
allCount: 3,
|
||||
inboxes: { 1: 2 },
|
||||
labels: { 4: 5 },
|
||||
teams: { 6: 7 },
|
||||
@@ -31,6 +33,7 @@ describe('#mutations', () => {
|
||||
|
||||
it('clears counts when payload is empty', () => {
|
||||
const state = {
|
||||
allCount: 2,
|
||||
inboxes: { 1: 2 },
|
||||
labels: { 4: 5 },
|
||||
teams: { 6: 7 },
|
||||
@@ -39,10 +42,21 @@ describe('#mutations', () => {
|
||||
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
|
||||
|
||||
expect(state).toEqual({
|
||||
allCount: 0,
|
||||
inboxes: {},
|
||||
labels: {},
|
||||
teams: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes invalid aggregate counts to zero', () => {
|
||||
const state = { allCount: 2, inboxes: {}, labels: {}, teams: {} };
|
||||
|
||||
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
|
||||
all_count: 'invalid',
|
||||
});
|
||||
|
||||
expect(state.allCount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,13 @@ const createMarkdownInstance = (linkify = true) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Help center article tables persist column widths as an internal
|
||||
// `<!--cw-colwidths:...-->` comment before the table. It exists only for the
|
||||
// editor's markdown round-trip and must never surface as text — markdown-it runs
|
||||
// with `html: false`, which would otherwise escape it into a visible comment in
|
||||
// rendered/plain output (e.g. dashboard search snippets). Strip it on the way in.
|
||||
const COLWIDTHS_MARKER_REGEX = /<!--cw-colwidths:[\d,]+-->\r?\n?/g;
|
||||
|
||||
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
|
||||
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
|
||||
const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g;
|
||||
@@ -75,7 +82,7 @@ class MessageFormatter {
|
||||
isAPrivateNote = false,
|
||||
linkify = true
|
||||
) {
|
||||
this.message = message || '';
|
||||
this.message = (message || '').replace(COLWIDTHS_MARKER_REGEX, '');
|
||||
this.isAPrivateNote = isAPrivateNote;
|
||||
this.isATweet = isATweet;
|
||||
this.linkify = linkify;
|
||||
|
||||
@@ -126,6 +126,16 @@ describe('#MessageFormatter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('help center table colwidth marker', () => {
|
||||
it('strips the internal colwidths marker from rendered output', () => {
|
||||
const message =
|
||||
'<!--cw-colwidths:120,200-->\n| A | B |\n| --- | --- |\n| 1 | 2 |';
|
||||
const formatter = new MessageFormatter(message);
|
||||
expect(formatter.formattedMessage).not.toContain('cw-colwidths');
|
||||
expect(formatter.plainText).not.toContain('cw-colwidths');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#sanitize', () => {
|
||||
it('sanitizes markup and removes all unnecessary elements', () => {
|
||||
const message =
|
||||
|
||||
@@ -36,6 +36,9 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
|
||||
onReconnect = () => {
|
||||
this.syncLatestMessages();
|
||||
// Re-fetch conversation attributes so a status change (e.g. auto-resolve)
|
||||
// that happened while disconnected is reflected, keeping the reply box state correct.
|
||||
this.app.$store.dispatch('conversationAttributes/getAttributes');
|
||||
};
|
||||
|
||||
setLastMessageId = () => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
import ActionCableConnector from '../actionCable';
|
||||
|
||||
vi.mock('@rails/actioncable', () => ({
|
||||
createConsumer: () => ({
|
||||
subscriptions: { create: () => ({}) },
|
||||
disconnect: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Widget ActionCableConnector', () => {
|
||||
let app;
|
||||
let mockDispatch;
|
||||
let connector;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockDispatch = vi.fn();
|
||||
app = {
|
||||
$store: {
|
||||
dispatch: mockDispatch,
|
||||
getters: {
|
||||
getCurrentAccountId: 1,
|
||||
getCurrentUserID: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
connector = new ActionCableConnector(app, 'test-token');
|
||||
mockDispatch.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('registers the conversation.status_changed event handler', () => {
|
||||
expect(connector.events['conversation.status_changed']).toBe(
|
||||
connector.onStatusChange
|
||||
);
|
||||
});
|
||||
|
||||
it('re-fetches conversation attributes on reconnect so a status change missed while disconnected is reflected', () => {
|
||||
connector.onReconnect();
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
'conversation/syncLatestMessages'
|
||||
);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
'conversationAttributes/getAttributes'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
MAX_STRING_COLUMN_LENGTH = 255
|
||||
MAX_TEXT_COLUMN_LENGTH = 20_000
|
||||
|
||||
include Events::Types
|
||||
self.abstract_class = true
|
||||
|
||||
@@ -37,7 +40,7 @@ class ApplicationRecord < ActiveRecord::Base
|
||||
end
|
||||
|
||||
def validate_content_length(column)
|
||||
max_length = column.type == :text ? 20_000 : 255
|
||||
max_length = column.type == :text ? MAX_TEXT_COLUMN_LENGTH : MAX_STRING_COLUMN_LENGTH
|
||||
return if self[column.name].nil? || self[column.name].length <= max_length
|
||||
|
||||
errors.add(column.name.to_sym, "is too long (maximum is #{max_length} characters)")
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
# medium :integer default("sms")
|
||||
# messaging_service_sid :string
|
||||
# phone_number :string
|
||||
# provider_config :jsonb
|
||||
# twiml_app_sid :string
|
||||
# voice_enabled :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
@@ -54,6 +55,11 @@ class Channel::TwilioSms < ApplicationRecord
|
||||
medium == 'sms' ? 'Twilio SMS' : 'Whatsapp'
|
||||
end
|
||||
|
||||
# Mutes only the incoming side of calling; default on, so only an explicit false disables inbound.
|
||||
def inbound_calls_enabled?
|
||||
provider_config['inbound_calls_enabled'] != false
|
||||
end
|
||||
|
||||
def send_message(to:, body:, media_url: nil)
|
||||
params = send_message_from.merge(to: to, body: body)
|
||||
params[:media_url] = media_url if media_url.present?
|
||||
|
||||
@@ -49,6 +49,11 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
account.feature_enabled?('channel_voice')
|
||||
end
|
||||
|
||||
# Mutes only the incoming side of calling; default on, so only an explicit false disables inbound.
|
||||
def inbound_calls_enabled?
|
||||
provider_config['inbound_calls_enabled'] != false
|
||||
end
|
||||
|
||||
# Whether this inbox can do WhatsApp calling at all. Meta's Calling API is
|
||||
# reachable by any whatsapp_cloud inbox, so 360dialog inboxes can't be toggled
|
||||
# on even though calling_enabled would persist.
|
||||
|
||||
@@ -77,4 +77,8 @@ class InboxPolicy < ApplicationPolicy
|
||||
def disable_whatsapp_calling?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def set_inbound_calls?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,6 +23,8 @@ class BaseRefreshOauthTokenService
|
||||
# Refresh the access tokens using the refresh token
|
||||
# Refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp/tree/b4a6869fe4a438cde42b161196484a929f1bee46
|
||||
def refresh_tokens
|
||||
raise 'A refresh_token is not available' if provider_config[:refresh_token].blank?
|
||||
|
||||
oauth_strategy = build_oauth_strategy
|
||||
token_service = build_token_service(oauth_strategy)
|
||||
|
||||
|
||||
@@ -19,8 +19,11 @@ class Conversations::UnreadCounts::Counter
|
||||
ensure_base_cache!
|
||||
ensure_assignment_cache! if assignment_mode?
|
||||
|
||||
inbox_counts = unread_inbox_counts
|
||||
|
||||
{
|
||||
inboxes: unread_inbox_counts,
|
||||
all_count: inbox_counts.values.sum,
|
||||
inboxes: inbox_counts,
|
||||
labels: unread_label_counts,
|
||||
teams: unread_team_counts
|
||||
}
|
||||
@@ -191,7 +194,7 @@ class Conversations::UnreadCounts::Counter
|
||||
end
|
||||
|
||||
def empty_counts
|
||||
{ inboxes: {}, labels: {}, teams: {} }
|
||||
{ all_count: 0, inboxes: {}, labels: {}, teams: {} }
|
||||
end
|
||||
|
||||
def store
|
||||
|
||||
@@ -37,12 +37,18 @@ class WebsiteBrandingService
|
||||
private
|
||||
|
||||
def fetch_page
|
||||
response = HTTParty.get(@url, follow_redirects: true, timeout: 15)
|
||||
@http_status = response.code
|
||||
return nil unless response.success?
|
||||
body = nil
|
||||
SafeFetch.fetch(@url, validate_content_type: false) do |result|
|
||||
body = result.tempfile.read
|
||||
end
|
||||
@http_status = 200
|
||||
return nil if body.blank?
|
||||
|
||||
Nokogiri::HTML(response.body)
|
||||
rescue StandardError => e
|
||||
Nokogiri::HTML(body)
|
||||
rescue SafeFetch::HttpError => e
|
||||
@http_status = e.message.to_i
|
||||
nil
|
||||
rescue SafeFetch::Error => e
|
||||
Rails.logger.error "[WebsiteBranding] Failed to fetch #{@url}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
@@ -13,7 +13,6 @@ class Whatsapp::EmbeddedSignupService
|
||||
|
||||
access_token = exchange_code_for_token
|
||||
phone_info = fetch_phone_info(access_token)
|
||||
validate_token_access(access_token)
|
||||
|
||||
channel = create_or_reauthorize_channel(access_token, phone_info)
|
||||
# NOTE: We call setup_webhooks explicitly here instead of relying on after_commit callback because:
|
||||
@@ -42,10 +41,6 @@ class Whatsapp::EmbeddedSignupService
|
||||
Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
|
||||
end
|
||||
|
||||
def validate_token_access(access_token)
|
||||
Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
|
||||
end
|
||||
|
||||
def create_or_reauthorize_channel(access_token, phone_info)
|
||||
if @inbox_id.present?
|
||||
Whatsapp::ReauthorizationService.new(
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
class Whatsapp::TokenValidationService
|
||||
def initialize(access_token, waba_id)
|
||||
@access_token = access_token
|
||||
@waba_id = waba_id
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
validate_token_waba_access
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
end
|
||||
|
||||
def validate_token_waba_access
|
||||
token_debug_data = @api_client.debug_token(@access_token)
|
||||
waba_scope = extract_waba_scope(token_debug_data)
|
||||
verify_waba_authorization(waba_scope)
|
||||
end
|
||||
|
||||
def extract_waba_scope(token_data)
|
||||
granular_scopes = token_data.dig('data', 'granular_scopes')
|
||||
waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
|
||||
|
||||
raise 'No WABA scope found in token' unless waba_scope
|
||||
|
||||
waba_scope
|
||||
end
|
||||
|
||||
def verify_waba_authorization(waba_scope)
|
||||
authorized_waba_ids = waba_scope['target_ids'] || []
|
||||
|
||||
return if authorized_waba_ids.include?(@waba_id)
|
||||
|
||||
raise "Token does not have access to WABA #{@waba_id}. Authorized WABAs: #{authorized_waba_ids}"
|
||||
end
|
||||
end
|
||||
@@ -15,6 +15,9 @@ if resource.custom_attributes.present?
|
||||
json.referral_source resource.custom_attributes['referral_source'] if resource.custom_attributes['referral_source'].present?
|
||||
json.brand_info resource.custom_attributes['brand_info'] if resource.custom_attributes['brand_info'].present?
|
||||
json.onboarding_step resource.onboarding_step if resource.onboarding_step.present?
|
||||
if resource.custom_attributes['help_center_generation_id'].present?
|
||||
json.help_center_generation_id resource.custom_attributes['help_center_generation_id']
|
||||
end
|
||||
json.marked_for_deletion_at resource.custom_attributes['marked_for_deletion_at'] if resource.custom_attributes['marked_for_deletion_at'].present?
|
||||
if resource.custom_attributes['marked_for_deletion_reason'].present?
|
||||
json.marked_for_deletion_reason resource.custom_attributes['marked_for_deletion_reason']
|
||||
|
||||
@@ -140,6 +140,7 @@ end
|
||||
## Voice attributes for TwilioSms
|
||||
if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
|
||||
json.voice_enabled resource.channel.voice_enabled?
|
||||
json.inbound_calls_enabled resource.channel.inbound_calls_enabled?
|
||||
json.voice_configured resource.channel.try(:twiml_app_sid).present?
|
||||
json.has_api_key_secret resource.channel.try(:api_key_secret).present?
|
||||
if resource.channel.try(:twiml_app_sid).present?
|
||||
@@ -149,4 +150,7 @@ if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
|
||||
end
|
||||
|
||||
## Voice attribute for WhatsApp Cloud (only embedded-signup channels surface true)
|
||||
json.voice_enabled resource.channel.voice_enabled? if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
|
||||
if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
|
||||
json.voice_enabled resource.channel.voice_enabled?
|
||||
json.inbound_calls_enabled resource.channel.inbound_calls_enabled?
|
||||
end
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.14.1'
|
||||
version: '4.14.2'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
# Be sure to restart your server when you modify this file.
|
||||
# Sessions are used only for the super_admin dashboard (flash/CSRF), not for API auth.
|
||||
|
||||
Rails.application.config.session_store :cookie_store, key: '_chatwoot_session', same_site: :lax
|
||||
secure_cookies = ActiveModel::Type::Boolean.new.cast(ENV.fetch('FORCE_SSL', false))
|
||||
|
||||
Rails.application.config.session_store :cookie_store,
|
||||
key: '_chatwoot_session',
|
||||
same_site: :lax,
|
||||
secure: secure_cookies,
|
||||
httponly: true
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
description: 'Config to store stripe plans for cloud'
|
||||
- name: CHATWOOT_CLOUD_TOPUP_OPTIONS
|
||||
display_title: 'Cloud Topup Options'
|
||||
value:
|
||||
value: {}
|
||||
description: 'Currency-keyed AI credit top-up packages, e.g. {"usd":[{"credits":1000,"amount":20.0}],"brl":[{"credits":1000,"amount":100.0}]}'
|
||||
type: code
|
||||
- name: CHATWOOT_CLOUD_PLAN_FEATURES
|
||||
|
||||
+4
-1
@@ -55,7 +55,9 @@ Rails.application.routes.draw do
|
||||
resource :contact_merge, only: [:create]
|
||||
end
|
||||
resource :bulk_actions, only: [:create]
|
||||
resource :onboarding, only: [:update]
|
||||
resource :onboarding, only: [:update] do
|
||||
get :help_center_generation
|
||||
end
|
||||
resources :agents, only: [:index, :create, :update, :destroy] do
|
||||
post :bulk_create, on: :collection
|
||||
end
|
||||
@@ -265,6 +267,7 @@ Rails.application.routes.draw do
|
||||
end
|
||||
post :enable_whatsapp_calling, on: :member
|
||||
post :disable_whatsapp_calling, on: :member
|
||||
post :set_inbound_calls, on: :member
|
||||
end
|
||||
|
||||
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' do
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddProviderConfigToChannelTwilioSms < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :channel_twilio_sms, :provider_config, :jsonb, default: {}
|
||||
end
|
||||
end
|
||||
+2
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -557,6 +557,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
|
||||
t.boolean "voice_enabled", default: false, null: false
|
||||
t.string "twiml_app_sid"
|
||||
t.string "api_key_secret"
|
||||
t.jsonb "provider_config", default: {}
|
||||
t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true
|
||||
t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true
|
||||
t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true
|
||||
|
||||
@@ -27,8 +27,8 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
|
||||
|
||||
def test
|
||||
tool = account_custom_tools.new(custom_tool_params)
|
||||
result = execute_test_request(tool)
|
||||
render json: { status: result.code.to_i, body: result.body.to_s.truncate(500) }
|
||||
body = execute_test_request(tool)
|
||||
render json: { status: 200, body: body.to_s.truncate(500) }
|
||||
rescue StandardError => e
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
module Enterprise::Api::V1::Accounts::InboxesController
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def inbox_attributes
|
||||
super + ee_inbox_attributes
|
||||
end
|
||||
@@ -21,6 +23,24 @@ module Enterprise::Api::V1::Accounts::InboxesController
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
# Toggles only the inbound-calls flag in provider_config. Saved with validate: false
|
||||
# so WhatsApp's remote credential re-check (validate_provider_config) can't reject a
|
||||
# simple toggle, mirroring enable_voice_calling!. Voice support (WhatsApp calling or
|
||||
# Twilio voice) is guarded inline by ensure_inbound_calls_supported.
|
||||
def set_inbound_calls
|
||||
return unless ensure_inbound_calls_supported
|
||||
|
||||
channel = @inbox.channel
|
||||
channel.provider_config = (channel.provider_config || {}).merge(
|
||||
'inbound_calls_enabled' => ActiveModel::Type::Boolean.new.cast(params[:inbound_calls_enabled])
|
||||
)
|
||||
channel.save!(validate: false)
|
||||
@inbox.update_account_cache # bump inbox cache key so the cached inbox list refetches the new flag
|
||||
head :ok
|
||||
rescue StandardError => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def ee_inbox_attributes
|
||||
[auto_assignment_config: [:max_assignment_limit]]
|
||||
end
|
||||
@@ -35,6 +55,14 @@ module Enterprise::Api::V1::Accounts::InboxesController
|
||||
false
|
||||
end
|
||||
|
||||
# Inbound calls can be toggled on any voice-enabled inbox (WhatsApp calling or Twilio voice).
|
||||
def ensure_inbound_calls_supported
|
||||
return true if @inbox.channel.try(:voice_enabled?)
|
||||
|
||||
render_could_not_create_error('Inbox does not support calling')
|
||||
false
|
||||
end
|
||||
|
||||
def allowed_channel_types
|
||||
super + ['voice']
|
||||
end
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
module Enterprise::Api::V1::Accounts::OnboardingsController
|
||||
def help_center_generation
|
||||
@account = Current.account
|
||||
render json: help_center_generation_status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def help_center_generation_status
|
||||
generation_id = help_center_generation_id
|
||||
return super if generation_id.blank?
|
||||
|
||||
state = Onboarding::HelpCenterGenerationState.current(generation_id)
|
||||
|
||||
{
|
||||
generation_id: generation_id,
|
||||
state: state,
|
||||
articles_count: articles_count,
|
||||
categories_count: categories_count
|
||||
}
|
||||
end
|
||||
|
||||
def help_center_generation_id
|
||||
@account.custom_attributes['help_center_generation_id']
|
||||
end
|
||||
|
||||
def articles_count
|
||||
onboarding_portal&.articles&.count || 0
|
||||
end
|
||||
|
||||
def categories_count
|
||||
onboarding_portal&.categories&.count || 0
|
||||
end
|
||||
|
||||
def onboarding_portal
|
||||
@onboarding_portal ||= @account.portals.first
|
||||
end
|
||||
end
|
||||
@@ -24,6 +24,8 @@ class Twilio::VoiceController < ApplicationController
|
||||
"TWILIO_VOICE_TWIML account=#{current_account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
|
||||
)
|
||||
|
||||
return render xml: reject_twiml if reject_inbound?
|
||||
|
||||
call = resolve_call
|
||||
render xml: conference_twiml(call)
|
||||
end
|
||||
@@ -88,6 +90,16 @@ class Twilio::VoiceController < ApplicationController
|
||||
from_number.start_with?('client:')
|
||||
end
|
||||
|
||||
# A fresh contact-initiated leg on an inbox with inbound calls turned off.
|
||||
# Reject it so no conference, conversation, or Call row is created.
|
||||
def reject_inbound?
|
||||
twilio_direction == 'inbound' && !agent_leg?(twilio_from) && !inbox.channel.inbound_calls_enabled?
|
||||
end
|
||||
|
||||
def reject_twiml
|
||||
Twilio::TwiML::VoiceResponse.new(&:reject).to_s
|
||||
end
|
||||
|
||||
def resolve_call
|
||||
return find_call_for_agent if agent_leg?(twilio_from)
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error|
|
||||
_account_id, _portal_id, user_id, generation_id = job.arguments
|
||||
_account_id, _portal_id, _user_id, generation_id = job.arguments
|
||||
reason = "firecrawl exhausted: #{error.message}"
|
||||
Rails.logger.warn "[HelpCenterGenerationJob] gen=#{generation_id} #{reason}"
|
||||
job.send(:skip_and_broadcast, user: User.find_by(id: user_id), generation_id: generation_id, reason: reason)
|
||||
job.send(:skip_generation, generation_id: generation_id, reason: reason)
|
||||
end
|
||||
|
||||
def perform(account_id, portal_id, user_id, generation_id)
|
||||
@@ -19,7 +19,7 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
|
||||
)
|
||||
rescue Onboarding::HelpCenterErrors::CurationSkipped => e
|
||||
Rails.logger.info "[HelpCenterGenerationJob] gen=#{generation_id} skipped: #{e.message}"
|
||||
skip_and_broadcast(user: User.find_by(id: user_id), generation_id: generation_id, reason: e.message)
|
||||
skip_generation(generation_id: generation_id, reason: e.message)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -89,15 +89,12 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
|
||||
def enqueue_writer_jobs(account_id:, portal_id:, user_id:, generation_id:, articles:)
|
||||
articles.each do |article|
|
||||
Onboarding::HelpCenterArticleWriterJob.perform_later(
|
||||
account_id, portal_id, user_id, generation_id, { article: article }
|
||||
account_id, portal_id, user_id, generation_id, article
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def skip_and_broadcast(user:, generation_id:, reason:)
|
||||
def skip_generation(generation_id:, reason:)
|
||||
Onboarding::HelpCenterGenerationState.skip(generation_id, reason: reason)
|
||||
Onboarding::HelpCenterBroadcaster.completed(
|
||||
user: user, generation_id: generation_id, status: 'skipped', skip_reason: reason
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,43 +9,27 @@ class Onboarding::HelpCenterArticleWriterJob < ApplicationJob
|
||||
job.send(:on_writer_failure, error)
|
||||
end
|
||||
|
||||
def perform(account_id, portal_id, user_id, generation_id, article_payload)
|
||||
user = User.find(user_id)
|
||||
payload = article_payload.with_indifferent_access
|
||||
article = Onboarding::HelpCenterArticleBuilder.new(
|
||||
def perform(account_id, portal_id, user_id, generation_id, article)
|
||||
Onboarding::HelpCenterArticleBuilder.new(
|
||||
account: Account.find(account_id),
|
||||
portal: Portal.find(portal_id),
|
||||
user: user,
|
||||
article: payload[:article]
|
||||
user: User.find(user_id),
|
||||
article: article
|
||||
).perform
|
||||
|
||||
finalize(user: user, generation_id: generation_id, article: article)
|
||||
finalize(generation_id: generation_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def on_writer_failure(error)
|
||||
user, generation_id = failure_context
|
||||
generation_id = arguments[3]
|
||||
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} failed: #{error.class} #{error.message}"
|
||||
finalize(user: user, generation_id: generation_id, article: nil)
|
||||
finalize(generation_id: generation_id)
|
||||
end
|
||||
|
||||
def failure_context
|
||||
_account_id, _portal_id, user_id, generation_id = arguments
|
||||
[User.find_by(id: user_id), generation_id]
|
||||
end
|
||||
|
||||
def finalize(user:, generation_id:, article:)
|
||||
result = Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
|
||||
|
||||
if article
|
||||
Onboarding::HelpCenterBroadcaster.article_generated(
|
||||
user: user, generation_id: generation_id, article: article, articles_finished: result[:finished]
|
||||
)
|
||||
end
|
||||
return unless result[:completed]
|
||||
|
||||
Onboarding::HelpCenterBroadcaster.completed(user: user, generation_id: generation_id, status: 'completed')
|
||||
def finalize(generation_id:)
|
||||
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
|
||||
rescue Onboarding::HelpCenterGenerationState::Missing => e
|
||||
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} #{e.message}"
|
||||
end
|
||||
|
||||
@@ -23,20 +23,22 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
|
||||
def prepare_customer_id
|
||||
customer_id = account.custom_attributes['stripe_customer_id']
|
||||
if customer_id.blank?
|
||||
customer = Stripe::Customer.create(
|
||||
{
|
||||
name: account.name,
|
||||
email: billing_email,
|
||||
address: { country: Enterprise::Billing::Currencies.country_for(account.billing_currency) },
|
||||
preferred_locales: [Enterprise::Billing::Currencies.preferred_locale_for(account.billing_currency)]
|
||||
}
|
||||
)
|
||||
customer_id = customer.id
|
||||
end
|
||||
customer_id = Stripe::Customer.create(customer_params).id if customer_id.blank?
|
||||
customer_id
|
||||
end
|
||||
|
||||
# Only currencies that need a country override (e.g. BRL/PIX) set address/locale; usd keeps Stripe defaults.
|
||||
def customer_params
|
||||
params = { name: account.name, email: billing_email }
|
||||
country = Enterprise::Billing::Currencies.country_for(account.billing_currency)
|
||||
return params if country.blank?
|
||||
|
||||
params.merge(
|
||||
address: { country: country },
|
||||
preferred_locales: [Enterprise::Billing::Currencies.preferred_locale_for(account.billing_currency)]
|
||||
)
|
||||
end
|
||||
|
||||
def default_quantity
|
||||
default_plan['default_quantity'] || DEFAULT_QUANTITY
|
||||
end
|
||||
|
||||
@@ -9,13 +9,13 @@ module Enterprise::Billing::Currencies
|
||||
'pt_BR' => 'brl'
|
||||
}.freeze
|
||||
|
||||
# Billing country override per currency; absent currencies (e.g. usd) keep Stripe's default.
|
||||
COUNTRY_BY_CURRENCY = {
|
||||
'usd' => 'US',
|
||||
'brl' => 'BR'
|
||||
}.freeze
|
||||
|
||||
# Preferred Stripe/checkout locale per currency; absent currencies keep Stripe's default.
|
||||
PREFERRED_LOCALE_BY_CURRENCY = {
|
||||
'usd' => 'en',
|
||||
'brl' => 'pt-BR'
|
||||
}.freeze
|
||||
|
||||
@@ -29,8 +29,8 @@ module Enterprise::Billing::Currencies
|
||||
SUPPORTED.include?(normalize(code))
|
||||
end
|
||||
|
||||
# Coerce arbitrary input to a usable supported code, else DEFAULT.
|
||||
def coerce(code)
|
||||
# Map arbitrary input to a supported code, else DEFAULT.
|
||||
def to_supported(code)
|
||||
supported?(code) ? normalize(code) : DEFAULT
|
||||
end
|
||||
|
||||
@@ -44,10 +44,10 @@ module Enterprise::Billing::Currencies
|
||||
end
|
||||
|
||||
def country_for(code)
|
||||
COUNTRY_BY_CURRENCY[coerce(code)]
|
||||
COUNTRY_BY_CURRENCY[to_supported(code)]
|
||||
end
|
||||
|
||||
def preferred_locale_for(code)
|
||||
PREFERRED_LOCALE_BY_CURRENCY[coerce(code)]
|
||||
PREFERRED_LOCALE_BY_CURRENCY[to_supported(code)]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -159,7 +159,7 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
|
||||
end
|
||||
|
||||
def find_plan(plan_id)
|
||||
Enterprise::Billing::PlanConfiguration.find_plan_by_product_id(plan_id)
|
||||
def find_plan(product_id)
|
||||
Enterprise::Billing::PlanConfiguration.find_plan_by_product_id(product_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,7 +25,7 @@ module Enterprise::Billing::PlanConfiguration
|
||||
# Price id for `plan` in `currency`, falling back to usd then any configured price.
|
||||
def price_id_for(plan, currency)
|
||||
by_currency = price_ids_by_currency(plan)
|
||||
code = Enterprise::Billing::Currencies.coerce(currency)
|
||||
code = Enterprise::Billing::Currencies.to_supported(currency)
|
||||
|
||||
(by_currency[code].presence ||
|
||||
by_currency[Enterprise::Billing::Currencies::DEFAULT].presence ||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
module Onboarding::HelpCenterBroadcaster
|
||||
ARTICLE_GENERATED = 'help_center.article_generated'.freeze
|
||||
GENERATION_COMPLETED = 'help_center.generation_completed'.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def article_generated(user:, generation_id:, article:, articles_finished:)
|
||||
broadcast(user, ARTICLE_GENERATED, {
|
||||
generation_id: generation_id,
|
||||
article_id: article.id,
|
||||
articles_finished: articles_finished
|
||||
})
|
||||
end
|
||||
|
||||
def completed(user:, generation_id:, status:, skip_reason: nil)
|
||||
broadcast(user, GENERATION_COMPLETED, {
|
||||
generation_id: generation_id,
|
||||
status: status,
|
||||
skip_reason: skip_reason
|
||||
})
|
||||
end
|
||||
|
||||
def broadcast(user, event, payload)
|
||||
token = user&.pubsub_token
|
||||
return if token.blank?
|
||||
|
||||
ActionCableBroadcastJob.perform_later([token], event, payload)
|
||||
end
|
||||
end
|
||||
@@ -77,6 +77,7 @@ class Onboarding::HelpCenterCreationService
|
||||
|
||||
generation_id = SecureRandom.uuid
|
||||
Onboarding::HelpCenterArticleGenerationJob.perform_later(@account.id, portal.id, @user.id, generation_id)
|
||||
@account.update!(custom_attributes: @account.custom_attributes.merge('help_center_generation_id' => generation_id))
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[HelpCenterCreation] Failed to enqueue article generation for account #{@account.id}: #{e.class} - #{e.message}"
|
||||
end
|
||||
|
||||
@@ -9,7 +9,7 @@ class Whatsapp::CallService
|
||||
call.with_lock do
|
||||
transition_to_in_progress!
|
||||
update_message_status('in_progress')
|
||||
update_conversation_call_status(call.display_status)
|
||||
claim_conversation_and_set_call_status
|
||||
broadcast(:accepted, accepted_by_agent_id: agent.id)
|
||||
end
|
||||
call
|
||||
@@ -56,7 +56,6 @@ class Whatsapp::CallService
|
||||
forward_answer_to_meta!
|
||||
call.update!(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: Time.current,
|
||||
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
|
||||
claim_conversation_for_agent
|
||||
end
|
||||
|
||||
def forward_answer_to_meta!
|
||||
@@ -64,9 +63,13 @@ class Whatsapp::CallService
|
||||
invoke_provider!(:accept_call, sdp_answer)
|
||||
end
|
||||
|
||||
# Take ownership of the conversation if no one holds it; leave assignee alone otherwise (transfer via UI).
|
||||
def claim_conversation_for_agent
|
||||
call.conversation.update!(assignee: agent) if call.conversation.assignee_id.blank?
|
||||
# Claim an unheld conversation and set call_status in one save so previous_changes carries both the
|
||||
# assignee change (activity message + ASSIGNEE_CHANGED) and the call_status change (conversation.updated webhook).
|
||||
def claim_conversation_and_set_call_status
|
||||
conversation = call.conversation
|
||||
attrs = { additional_attributes: (conversation.additional_attributes || {}).merge('call_status' => call.display_status) }
|
||||
attrs[:assignee] = agent if conversation.assignee_id.blank?
|
||||
conversation.update!(attrs)
|
||||
end
|
||||
|
||||
# Raise on Meta failure (bool false or transport error) so callers bail before
|
||||
|
||||
@@ -72,6 +72,12 @@ class Whatsapp::IncomingCallService
|
||||
end
|
||||
|
||||
def create_inbound_call(payload)
|
||||
unless inbox.channel.inbound_calls_enabled?
|
||||
Rails.logger.info "[WHATSAPP CALL] Inbound calls disabled for inbox #{inbox.id}; rejecting call #{payload[:id]}"
|
||||
inbox.channel.provider_service.reject_call(payload[:id])
|
||||
return
|
||||
end
|
||||
|
||||
sdp_offer = payload.dig(:session, :sdp)
|
||||
extra_meta = { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
|
||||
name = caller_profile_name(payload)
|
||||
|
||||
@@ -15,8 +15,8 @@ class Captain::Tools::HttpTool < Agents::Tool
|
||||
url = @custom_tool.build_request_url(params)
|
||||
body = @custom_tool.build_request_body(params)
|
||||
|
||||
response = execute_http_request(url, body, tool_context)
|
||||
@custom_tool.format_response(response.body)
|
||||
response_body = execute_http_request(url, body, tool_context)
|
||||
@custom_tool.format_response(response_body)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}")
|
||||
'An error occurred while executing the request'
|
||||
@@ -24,89 +24,32 @@ class Captain::Tools::HttpTool < Agents::Tool
|
||||
|
||||
private
|
||||
|
||||
PRIVATE_IP_RANGES = [
|
||||
IPAddr.new('127.0.0.0/8'), # IPv4 Loopback
|
||||
IPAddr.new('10.0.0.0/8'), # IPv4 Private network
|
||||
IPAddr.new('172.16.0.0/12'), # IPv4 Private network
|
||||
IPAddr.new('192.168.0.0/16'), # IPv4 Private network
|
||||
IPAddr.new('169.254.0.0/16'), # IPv4 Link-local
|
||||
IPAddr.new('::1'), # IPv6 Loopback
|
||||
IPAddr.new('fc00::/7'), # IPv6 Unique local addresses
|
||||
IPAddr.new('fe80::/10') # IPv6 Link-local
|
||||
].freeze
|
||||
|
||||
# Limit response size to prevent memory exhaustion and match LLM token limits
|
||||
# 1MB of text ≈ 250K tokens, which exceeds most LLM context windows
|
||||
MAX_RESPONSE_SIZE = 1.megabyte
|
||||
|
||||
# Route through SafeFetch so custom tool requests share the app's centralized HTTP
|
||||
# fetching (resolution, timeouts, response size limits, and redirect handling).
|
||||
def execute_http_request(url, body, tool_context)
|
||||
uri = URI.parse(url)
|
||||
json_body = body if @custom_tool.http_method == 'POST'
|
||||
|
||||
# Check if resolved IP is private
|
||||
check_private_ip!(uri.host)
|
||||
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = uri.scheme == 'https'
|
||||
http.read_timeout = 30
|
||||
http.open_timeout = 10
|
||||
http.max_retries = 0 # Disable redirects
|
||||
|
||||
request = build_http_request(uri, body)
|
||||
apply_authentication(request)
|
||||
apply_metadata_headers(request, tool_context)
|
||||
|
||||
response = http.request(request)
|
||||
|
||||
raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
validate_response!(response)
|
||||
|
||||
response
|
||||
response_body = +''
|
||||
SafeFetch.fetch(
|
||||
url,
|
||||
method: @custom_tool.http_method == 'POST' ? :post : :get,
|
||||
body: json_body,
|
||||
headers: request_headers(tool_context, json_body),
|
||||
http_basic_authentication: @custom_tool.build_basic_auth_credentials,
|
||||
max_bytes: MAX_RESPONSE_SIZE,
|
||||
validate_content_type: false
|
||||
) { |result| response_body = result.tempfile.read }
|
||||
response_body
|
||||
end
|
||||
|
||||
def check_private_ip!(hostname)
|
||||
ip_address = IPAddr.new(Resolv.getaddress(hostname))
|
||||
|
||||
raise 'Request blocked: hostname resolves to private IP address' if PRIVATE_IP_RANGES.any? { |range| range.include?(ip_address) }
|
||||
rescue Resolv::ResolvError, SocketError => e
|
||||
raise "DNS resolution failed: #{e.message}"
|
||||
end
|
||||
|
||||
def validate_response!(response)
|
||||
content_length = response['content-length']&.to_i
|
||||
if content_length && content_length > MAX_RESPONSE_SIZE
|
||||
raise "Response size #{content_length} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
|
||||
end
|
||||
|
||||
return unless response.body && response.body.bytesize > MAX_RESPONSE_SIZE
|
||||
|
||||
raise "Response body size #{response.body.bytesize} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
|
||||
end
|
||||
|
||||
def build_http_request(uri, body)
|
||||
if @custom_tool.http_method == 'POST'
|
||||
request = Net::HTTP::Post.new(uri.request_uri)
|
||||
if body
|
||||
request.body = body
|
||||
request['Content-Type'] = 'application/json'
|
||||
end
|
||||
else
|
||||
request = Net::HTTP::Get.new(uri.request_uri)
|
||||
end
|
||||
request
|
||||
end
|
||||
|
||||
def apply_authentication(request)
|
||||
def request_headers(tool_context, json_body)
|
||||
headers = @custom_tool.build_auth_headers
|
||||
headers.each { |key, value| request[key] = value }
|
||||
|
||||
credentials = @custom_tool.build_basic_auth_credentials
|
||||
request.basic_auth(*credentials) if credentials
|
||||
end
|
||||
|
||||
def apply_metadata_headers(request, tool_context)
|
||||
state = tool_context&.state || {}
|
||||
metadata_headers = @custom_tool.build_metadata_headers(state)
|
||||
metadata_headers.each { |key, value| request[key] = value }
|
||||
headers.merge!(@custom_tool.build_metadata_headers(tool_context&.state || {}))
|
||||
headers['Content-Type'] = 'application/json' if json_body.present?
|
||||
headers
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,9 +9,32 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
@embed_regexes ||= config.transform_values { |embed_config| Regexp.new(embed_config['regex']) }
|
||||
end
|
||||
|
||||
# Matches columnResizing({ cellMinWidth: 50 }) in @chatwoot/prosemirror-schema
|
||||
# so cells without an explicit colwidth render the same minimum here as in the editor.
|
||||
TABLE_CELL_MIN_WIDTH_PX = 50
|
||||
COLWIDTHS_COMMENT = /<!--cw-colwidths:([\d,]+)-->/
|
||||
|
||||
# The article editor serializes column widths as a `<!--cw-colwidths:...-->` HTML
|
||||
# comment immediately before each resized table. Capture it (emitting nothing) so the
|
||||
# next `table` can size itself; any other raw HTML keeps its default rendering.
|
||||
def html(node)
|
||||
match = node.string_content.match(COLWIDTHS_COMMENT)
|
||||
return super unless match
|
||||
|
||||
@pending_colwidths = match[1].split(',').map(&:to_i)
|
||||
end
|
||||
|
||||
def table(node)
|
||||
out('<div class="tableWrapper">')
|
||||
super
|
||||
widths = @pending_colwidths
|
||||
@pending_colwidths = nil
|
||||
|
||||
if sized_widths?(widths)
|
||||
out(table_wrapper_open(widths))
|
||||
out(inject_table_sizing(capture_html { super(node) }, widths))
|
||||
else
|
||||
out('<div class="tableWrapper">')
|
||||
super
|
||||
end
|
||||
out('</div>')
|
||||
end
|
||||
|
||||
@@ -47,6 +70,61 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
|
||||
private
|
||||
|
||||
def sized_widths?(widths)
|
||||
widths.is_a?(Array) && widths.any? { |w| w.to_i.positive? }
|
||||
end
|
||||
|
||||
def fully_sized?(widths)
|
||||
widths.all? { |w| w.to_i.positive? }
|
||||
end
|
||||
|
||||
# Fully-sized tables hug their exact width so the card doesn't trail empty space;
|
||||
# partial tables stay a plain full-width card so flexible columns can expand.
|
||||
def table_wrapper_open(widths)
|
||||
return '<div class="tableWrapper">' unless fully_sized?(widths)
|
||||
|
||||
%(<div class="tableWrapper" style="width: #{total_width(widths)}px; max-width: 100%;">)
|
||||
end
|
||||
|
||||
# Let the gem render the whole table, then splice a <colgroup> and sizing style
|
||||
# into the opening <table> tag. Delegating the row/cell/tbody/alignment markup to
|
||||
# super keeps this working across commonmarker upgrades.
|
||||
# `!important` overrides the portal's `[&_table]:!min-w-full` Tailwind rule.
|
||||
def inject_table_sizing(html, widths)
|
||||
opening = %(<table style="#{table_sizing_style(widths)}">\n#{colgroup_html(widths)})
|
||||
html.sub(/<table[^>]*>\n?/, opening)
|
||||
end
|
||||
|
||||
# Capture everything `super` writes by swapping the renderer's output buffer.
|
||||
def capture_html
|
||||
original = @stream
|
||||
@stream = StringIO.new(+'')
|
||||
yield
|
||||
@stream.string
|
||||
ensure
|
||||
@stream = original
|
||||
end
|
||||
|
||||
# Total table width: each column's saved width, or the cell min for unsized ones.
|
||||
def total_width(widths)
|
||||
widths.sum { |w| w.to_i.positive? ? w.to_i : TABLE_CELL_MIN_WIDTH_PX }
|
||||
end
|
||||
|
||||
# Fully sized → lock to the exact total (min-width too, so a narrow saved width
|
||||
# beats the portal's `[&_table]:!min-w-full`). Partial → `max(100%, total)` fills
|
||||
# the container (flexible columns) yet scrolls when the sized columns exceed it.
|
||||
def table_sizing_style(widths)
|
||||
total = total_width(widths)
|
||||
return "table-layout: fixed; min-width: max(100%, #{total}px) !important;" unless fully_sized?(widths)
|
||||
|
||||
"table-layout: fixed; width: #{total}px !important; min-width: #{total}px !important;"
|
||||
end
|
||||
|
||||
def colgroup_html(widths)
|
||||
cols = widths.map { |w| w.to_i.positive? ? %(<col style="width: #{w.to_i}px;">) : '<col>' }
|
||||
"<colgroup>#{cols.join}</colgroup>\n"
|
||||
end
|
||||
|
||||
def extract_image_width(src)
|
||||
query = URI.parse(src).query
|
||||
raw = query && CGI.parse(query)['cw_image_width']&.first
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.14.1",
|
||||
"version": "4.14.2",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
@@ -34,7 +34,7 @@
|
||||
"@amplitude/analytics-browser": "^2.11.10",
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.3.17",
|
||||
"@chatwoot/prosemirror-schema": "1.3.22",
|
||||
"@chatwoot/utils": "^0.0.55",
|
||||
"@formkit/core": "^1.7.2",
|
||||
"@formkit/vue": "^1.7.2",
|
||||
|
||||
Generated
+5
-5
@@ -25,8 +25,8 @@ importers:
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3
|
||||
'@chatwoot/prosemirror-schema':
|
||||
specifier: 1.3.17
|
||||
version: 1.3.17
|
||||
specifier: 1.3.22
|
||||
version: 1.3.22
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.55
|
||||
version: 0.0.55
|
||||
@@ -458,8 +458,8 @@ packages:
|
||||
'@chatwoot/ninja-keys@1.2.3':
|
||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.17':
|
||||
resolution: {integrity: sha512-n78ZfMIzSrylImIN5cjCeEdTJ8ub0JtCybwUlqFyOyLy3ZzAZpOHvCSo+w/KmV4dCgOH2mBmYlxBQ9Rww+e0Rw==}
|
||||
'@chatwoot/prosemirror-schema@1.3.22':
|
||||
resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==}
|
||||
|
||||
'@chatwoot/utils@0.0.55':
|
||||
resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==}
|
||||
@@ -5128,7 +5128,7 @@ snapshots:
|
||||
hotkeys-js: 3.8.7
|
||||
lit: 2.2.6
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.17':
|
||||
'@chatwoot/prosemirror-schema@1.3.22':
|
||||
dependencies:
|
||||
markdown-it-sup: 2.0.0
|
||||
prosemirror-commands: 1.7.1
|
||||
|
||||
@@ -39,6 +39,21 @@ describe ContactInboxWithContactBuilder do
|
||||
expect(contact_inbox.inbox_id).to eq(inbox.id)
|
||||
end
|
||||
|
||||
it 'truncates long contact names before creating the contact' do
|
||||
long_name = 'a' * 300
|
||||
|
||||
contact_inbox = described_class.new(
|
||||
source_id: '123456',
|
||||
inbox: inbox,
|
||||
contact_attributes: {
|
||||
name: long_name,
|
||||
email: 'testemail@example.com'
|
||||
}
|
||||
).perform
|
||||
|
||||
expect(contact_inbox.contact.name).to eq(long_name.first(ApplicationRecord::MAX_STRING_COLUMN_LENGTH))
|
||||
end
|
||||
|
||||
it 'doesnot create contact if it already exist with identifier' do
|
||||
contact_inbox = described_class.new(
|
||||
source_id: '123456',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
require 'rails_helper'
|
||||
|
||||
# rubocop:disable RSpec/DescribeClass
|
||||
describe 'Session Store Configuration' do
|
||||
# rubocop:enable RSpec/DescribeClass
|
||||
|
||||
let(:session_options) { Rails.application.config.session_options }
|
||||
|
||||
it 'uses cookie_store as the session store' do
|
||||
expect(Rails.application.config.session_store).to eq(ActionDispatch::Session::CookieStore)
|
||||
end
|
||||
|
||||
it 'sets the session key' do
|
||||
expect(session_options[:key]).to eq('_chatwoot_session')
|
||||
end
|
||||
|
||||
it 'sets same_site to lax' do
|
||||
expect(session_options[:same_site]).to eq(:lax)
|
||||
end
|
||||
|
||||
it 'sets httponly to true' do
|
||||
expect(session_options[:httponly]).to be(true)
|
||||
end
|
||||
|
||||
it 'sets secure flag based on FORCE_SSL' do
|
||||
expected_secure = ActiveModel::Type::Boolean.new.cast(ENV.fetch('FORCE_SSL', false))
|
||||
expect(session_options[:secure]).to eq(expected_secure)
|
||||
end
|
||||
end
|
||||
@@ -141,6 +141,7 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['payload']).to eq(
|
||||
'all_count' => 1,
|
||||
'inboxes' => { visible_inbox.id.to_s => 1 },
|
||||
'labels' => { label.id.to_s => 1 },
|
||||
'teams' => {}
|
||||
|
||||
@@ -111,4 +111,40 @@ RSpec.describe 'Onboarding API', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation", as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as an agent (non-admin)' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no help center generation has started' do
|
||||
it 'returns not_started with zero counts' do
|
||||
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body).to include(
|
||||
'generation_id' => nil,
|
||||
'state' => nil,
|
||||
'articles_count' => 0,
|
||||
'categories_count' => 0
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -122,8 +122,8 @@ RSpec.describe SamlUserBuilder do
|
||||
it 'does not add the user to the target account' do
|
||||
expect do
|
||||
builder.perform
|
||||
rescue SamlUserBuilder::AuthenticationFailed
|
||||
nil
|
||||
rescue StandardError => e
|
||||
raise unless e.class.name == 'SamlUserBuilder::AuthenticationFailed' # rubocop:disable Style/ClassEqualityComparison
|
||||
end.not_to change(AccountUser, :count)
|
||||
expect(existing_user.reload.accounts).not_to include(account)
|
||||
end
|
||||
@@ -131,8 +131,8 @@ RSpec.describe SamlUserBuilder do
|
||||
it 'does not convert the user provider to saml' do
|
||||
expect do
|
||||
builder.perform
|
||||
rescue SamlUserBuilder::AuthenticationFailed
|
||||
nil
|
||||
rescue StandardError => e
|
||||
raise unless e.class.name == 'SamlUserBuilder::AuthenticationFailed' # rubocop:disable Style/ClassEqualityComparison
|
||||
end.not_to(change { existing_user.reload.provider })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Onboarding API', type: :request do
|
||||
let(:account) { create(:account, domain: 'example.com') }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do
|
||||
context 'when help center generation is in progress' do
|
||||
let(:generation_id) { 'generation-123' }
|
||||
let!(:portal) { create(:portal, account_id: account.id) }
|
||||
let!(:category) { create(:category, portal: portal, account_id: account.id) }
|
||||
|
||||
before do
|
||||
account.update!(custom_attributes: { 'help_center_generation_id' => generation_id })
|
||||
create(:article, portal: portal, category: category, account_id: account.id, author_id: admin.id)
|
||||
Onboarding::HelpCenterGenerationState.start(generation_id, total: 3)
|
||||
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
|
||||
end
|
||||
|
||||
after do
|
||||
Redis::Alfred.delete(Onboarding::HelpCenterGenerationState.key(generation_id))
|
||||
end
|
||||
|
||||
it 'returns Redis state and help center counts' do
|
||||
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body).to include(
|
||||
'generation_id' => generation_id,
|
||||
'articles_count' => 1,
|
||||
'categories_count' => 1
|
||||
)
|
||||
expect(response.parsed_body['state']).to include(
|
||||
'status' => 'generating',
|
||||
'finished' => '1',
|
||||
'total' => '3'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -49,6 +49,59 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/inboxes/:id/set_inbound_calls' do
|
||||
before do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
|
||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
|
||||
end
|
||||
|
||||
context 'when administrator' do
|
||||
it 'disables inbound calls on a Twilio voice inbox' do
|
||||
channel = create(:channel_twilio_sms, :with_voice, account: account)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{channel.inbox.id}/set_inbound_calls",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { inbound_calls_enabled: false },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(channel.reload.inbound_calls_enabled?).to be false
|
||||
end
|
||||
|
||||
it 'enables inbound calls on a WhatsApp inbox without re-validating provider config' do
|
||||
account.enable_features('channel_voice')
|
||||
account.save!
|
||||
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
channel.update!(provider_config: channel.provider_config.merge('calling_enabled' => true, 'inbound_calls_enabled' => false))
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{channel.inbox.id}/set_inbound_calls",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { inbound_calls_enabled: true },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(channel.reload.inbound_calls_enabled?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'is forbidden' do
|
||||
channel = create(:channel_twilio_sms, :with_voice, account: account)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{channel.inbox.id}/set_inbound_calls",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { inbound_calls_enabled: false },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(channel.reload.inbound_calls_enabled?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/inboxes/:id' do
|
||||
let(:inbox) { create(:inbox, account: account, auto_assignment_config: { max_assignment_limit: 5 }) }
|
||||
|
||||
|
||||
@@ -112,6 +112,23 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
|
||||
}
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'rejects the inbound contact leg without building a call when inbound calls are disabled' do
|
||||
channel.update!(provider_config: { 'inbound_calls_enabled' => false })
|
||||
expect(Voice::InboundCallBuilder).not_to receive(:perform!)
|
||||
|
||||
expect do
|
||||
post "/twilio/voice/call/#{digits}", params: {
|
||||
'CallSid' => call_sid,
|
||||
'From' => from_number,
|
||||
'To' => to_number,
|
||||
'Direction' => 'inbound'
|
||||
}
|
||||
end.not_to change(Call, :count)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include('<Reject')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /twilio/voice/status/:phone' do
|
||||
|
||||
@@ -53,11 +53,9 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
|
||||
admin.id,
|
||||
generation_id,
|
||||
hash_including(
|
||||
'article' => hash_including(
|
||||
'title' => 'Hello',
|
||||
'urls' => ['https://x.test/a'],
|
||||
'category_id' => portal.categories.first.id
|
||||
)
|
||||
'title' => 'Hello',
|
||||
'urls' => ['https://x.test/a'],
|
||||
'category_id' => portal.categories.first.id
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -83,7 +81,7 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
|
||||
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
|
||||
expect(writer_jobs.size).to eq(1)
|
||||
expect(writer_jobs.first['arguments']).to include(
|
||||
hash_including('article' => hash_including('title' => 'Valid'))
|
||||
hash_including('title' => 'Valid')
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -106,7 +104,7 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
|
||||
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
|
||||
expect(writer_jobs.size).to eq(1)
|
||||
expect(writer_jobs.first['arguments']).to include(
|
||||
hash_including('article' => hash_including('title' => 'Approved', 'urls' => ['https://x.test/a']))
|
||||
hash_including('title' => 'Approved', 'urls' => ['https://x.test/a'])
|
||||
)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('total' => '1')
|
||||
end
|
||||
@@ -170,19 +168,4 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
|
||||
expect(state['skip_reason']).to include('firecrawl exhausted')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'broadcasts' do
|
||||
it 'broadcasts generation_completed with status: skipped on CurationSkipped' do
|
||||
curator = instance_double(Onboarding::HelpCenterCurator)
|
||||
allow(curator).to receive(:perform).and_raise(
|
||||
Onboarding::HelpCenterErrors::CurationSkipped, 'no website url'
|
||||
)
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
|
||||
|
||||
payload = hash_including(generation_id: generation_id, status: 'skipped', skip_reason: 'no website url')
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,8 +6,7 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:generation_id) { 'generation-123' }
|
||||
let(:article_spec) { { 'urls' => ['https://x.test/a'], 'title' => 'A', 'category_id' => nil } }
|
||||
let(:article_payload) { { 'article' => article_spec } }
|
||||
let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_payload] }
|
||||
let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_spec] }
|
||||
let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
|
||||
|
||||
before do
|
||||
@@ -68,16 +67,14 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
|
||||
end
|
||||
|
||||
it 'broadcasts completion when the final writer fails with ArticleBuildFailed' do
|
||||
it 'marks generation completed when the final writer fails with ArticleBuildFailed' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
|
||||
)
|
||||
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
|
||||
payload = hash_including(generation_id: generation_id, status: 'completed')
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'completed', 'finished' => '2'
|
||||
)
|
||||
@@ -105,7 +102,7 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'broadcasts' do
|
||||
describe 'missing state' do
|
||||
let(:built_article) { instance_double(Article, id: 9876) }
|
||||
|
||||
before do
|
||||
@@ -113,47 +110,10 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
|
||||
end
|
||||
|
||||
it 'broadcasts help_center.article_generated on success' do
|
||||
payload = hash_including(generation_id: generation_id, article_id: 9876, articles_finished: 1)
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.article_generated', payload)
|
||||
end
|
||||
|
||||
it 'broadcasts help_center.generation_completed when the last writer finishes' do
|
||||
described_class.perform_now(*job_args)
|
||||
payload = hash_including(generation_id: generation_id, status: 'completed')
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
end
|
||||
|
||||
it 'does not broadcast article_generated on builder failure' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
|
||||
)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.not_to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with(anything, 'help_center.article_generated', anything)
|
||||
end
|
||||
|
||||
it 'broadcasts generation_completed on late retries past total' do
|
||||
described_class.perform_now(*job_args)
|
||||
described_class.perform_now(*job_args)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', hash_including(generation_id: generation_id))
|
||||
end
|
||||
|
||||
it 'skips progress broadcasts when state is missing' do
|
||||
it 'does not raise when state is missing' do
|
||||
Redis::Alfred.delete(state_key)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.not_to have_enqueued_job(ActionCableBroadcastJob)
|
||||
expect { described_class.perform_now(*job_args) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,6 +38,19 @@ RSpec.describe Channel::TwilioSms do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#inbound_calls_enabled?' do
|
||||
it 'returns true by default when nothing has been toggled' do
|
||||
channel = create(:channel_twilio_sms, :with_voice, account: account)
|
||||
expect(channel.inbound_calls_enabled?).to be true
|
||||
end
|
||||
|
||||
it 'returns false only when explicitly disabled in provider_config' do
|
||||
channel = create(:channel_twilio_sms, :with_voice, account: account,
|
||||
provider_config: { 'inbound_calls_enabled' => false })
|
||||
expect(channel.inbound_calls_enabled?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#voice_call_webhook_url' do
|
||||
it 'returns the webhook URL based on phone number' do
|
||||
channel = create(:channel_twilio_sms, :with_voice)
|
||||
|
||||
@@ -26,6 +26,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
|
||||
result = described_class.new(account: account, user: agent).perform
|
||||
|
||||
expect(result[:all_count]).to eq(2)
|
||||
expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
|
||||
expect(result[:labels]).to eq(label.id.to_s => 2)
|
||||
expect(result[:teams]).to eq(team.id.to_s => 2)
|
||||
@@ -40,6 +41,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
|
||||
result = described_class.new(account: account, user: agent).perform
|
||||
|
||||
expect(result[:all_count]).to eq(2)
|
||||
expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
|
||||
expect(result[:labels]).to eq(label.id.to_s => 2)
|
||||
expect(result[:teams]).to eq(team.id.to_s => 2)
|
||||
@@ -53,6 +55,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
|
||||
result = described_class.new(account: account, user: agent).perform
|
||||
|
||||
expect(result[:all_count]).to eq(1)
|
||||
expect(result[:inboxes]).to eq(inbox.id.to_s => 1)
|
||||
expect(result[:labels]).to eq(label.id.to_s => 1)
|
||||
expect(result[:teams]).to eq(team.id.to_s => 1)
|
||||
@@ -65,7 +68,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
|
||||
result = described_class.new(account: account, user: agent).perform
|
||||
|
||||
expect(result).to eq(inboxes: {}, labels: {}, teams: {})
|
||||
expect(result).to eq(all_count: 0, inboxes: {}, labels: {}, teams: {})
|
||||
expect(store.base_ready?(account.id)).to be(false)
|
||||
expect(store.assignment_ready?(account.id)).to be(false)
|
||||
end
|
||||
|
||||
@@ -97,7 +97,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
expect(Stripe::Customer).to have_received(:create).with(
|
||||
{ name: account.name, email: admin1.email, address: { country: 'US' }, preferred_locales: ['en'] }
|
||||
{ name: account.name, email: admin1.email }
|
||||
)
|
||||
expect(Stripe::Subscription)
|
||||
.to have_received(:create)
|
||||
@@ -116,6 +116,20 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
}.with_indifferent_access
|
||||
)
|
||||
end
|
||||
|
||||
it 'sets the billing country override when the account currency requires it' do
|
||||
account.update!(custom_attributes: { billing_currency: 'brl' })
|
||||
customer = double
|
||||
allow(Stripe::Customer).to receive(:create).and_return(customer)
|
||||
allow(customer).to receive(:id).and_return('cus_random_number')
|
||||
allow(Stripe::Subscription).to receive(:create).and_return(created_subscription)
|
||||
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
expect(Stripe::Customer).to have_received(:create).with(
|
||||
{ name: account.name, email: admin1.email, address: { country: 'BR' }, preferred_locales: ['pt-BR'] }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when checking for existing subscriptions' do
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Enterprise::Billing::Currencies do
|
||||
describe 'Brazilian Real (brl)' do
|
||||
it 'is a supported currency' do
|
||||
expect(described_class.supported?('brl')).to be(true)
|
||||
end
|
||||
|
||||
it 'recognizes brl regardless of casing or surrounding whitespace' do
|
||||
expect(described_class.supported?(' BRL ')).to be(true)
|
||||
expect(described_class.normalize(' BRL ')).to eq('brl')
|
||||
end
|
||||
|
||||
it 'keeps brl when coercing to a supported code' do
|
||||
expect(described_class.to_supported('BRL')).to eq('brl')
|
||||
end
|
||||
|
||||
it 'defaults the pt_BR account locale to brl' do
|
||||
expect(described_class.for_locale('pt_BR')).to eq('brl')
|
||||
end
|
||||
|
||||
it 'maps brl to Brazil and the pt-BR checkout locale' do
|
||||
expect(described_class.country_for('brl')).to eq('BR')
|
||||
expect(described_class.preferred_locale_for('brl')).to eq('pt-BR')
|
||||
end
|
||||
|
||||
it 'falls back to the usd default for unsupported input' do
|
||||
expect(described_class.to_supported('eur')).to eq('usd')
|
||||
end
|
||||
|
||||
it 'does not set a country override for usd customers' do
|
||||
expect(described_class.country_for('usd')).to be_nil
|
||||
expect(described_class.preferred_locale_for('usd')).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -30,6 +30,20 @@ describe Whatsapp::IncomingCallService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbound calls are disabled on the channel' do
|
||||
it 'rejects the call with Meta without creating a Call or Conversation' do
|
||||
channel.provider_config = channel.provider_config.merge('inbound_calls_enabled' => false)
|
||||
channel.save!
|
||||
provider_service = instance_double(Whatsapp::Providers::WhatsappCloudService, reject_call: true)
|
||||
allow(inbox.channel).to receive(:provider_service).and_return(provider_service)
|
||||
|
||||
params = call_payload(event: 'connect', session: { sdp: "v=0\r\n...sdp...", sdp_type: 'offer' })
|
||||
expect { described_class.new(inbox: inbox, params: params).perform }
|
||||
.to not_change(Call, :count).and not_change(Conversation, :count)
|
||||
expect(provider_service).to have_received(:reject_call).with(provider_call_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'inbound connect' do
|
||||
let(:sdp_offer) { "v=0\r\n...sdp..." }
|
||||
let!(:agent) { create(:user, account: account) }
|
||||
|
||||
@@ -58,7 +58,7 @@ RSpec.describe MutexApplicationJob do
|
||||
|
||||
describe '.retry_on_lock_conflict' do
|
||||
let(:job_class) do
|
||||
Class.new(described_class) do
|
||||
Class.new(MutexApplicationJob) do
|
||||
retry_on_lock_conflict wait: 1.second, attempts: 1, on_exhaustion: :process_without_lock
|
||||
|
||||
attr_reader :fallback_args
|
||||
@@ -90,7 +90,7 @@ RSpec.describe MutexApplicationJob do
|
||||
|
||||
context 'without an exhaustion handler' do
|
||||
let(:job_class) do
|
||||
Class.new(described_class) do
|
||||
Class.new(MutexApplicationJob) do
|
||||
retry_on_lock_conflict wait: 1.second, attempts: 1
|
||||
|
||||
def perform(lock_key)
|
||||
|
||||
@@ -258,6 +258,59 @@ describe CustomMarkdownRenderer do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#table' do
|
||||
def render_table(markdown)
|
||||
doc = CommonMarker.render_doc(markdown, :DEFAULT, [:table])
|
||||
described_class.new.render(doc)
|
||||
end
|
||||
|
||||
let(:plain_table) { "| A | B |\n| --- | --- |\n| 1 | 2 |\n" }
|
||||
|
||||
it 'renders a table without column widths when no marker is present' do
|
||||
output = render_table(plain_table)
|
||||
expect(output).to include('<div class="tableWrapper"><table>')
|
||||
expect(output).not_to include('colgroup')
|
||||
expect(output).not_to include('cw-colwidths')
|
||||
end
|
||||
|
||||
context 'when every column has a saved width' do
|
||||
it 'lays the table out at the total width with a sized colgroup' do
|
||||
output = render_table("<!--cw-colwidths:120,200-->\n#{plain_table}")
|
||||
# Wrapper hugs the table; min-width is set alongside width so a narrow saved width beats min-w-full.
|
||||
expect(output).to include('<div class="tableWrapper" style="width: 320px; max-width: 100%;">')
|
||||
expect(output).to include('<table style="table-layout: fixed; width: 320px !important; min-width: 320px !important;">')
|
||||
expect(output).to include('<colgroup><col style="width: 120px;"><col style="width: 200px;"></colgroup>')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when only some columns have a saved width' do
|
||||
it 'fills the container so unsized columns stay flexible, floored at the sized total' do
|
||||
output = render_table("<!--cw-colwidths:150,0-->\n#{plain_table}")
|
||||
# max(100%, 200px): fills the container (flexible) but scrolls if the sized columns exceed it.
|
||||
expect(output).to include('table-layout: fixed; min-width: max(100%, 200px) !important;')
|
||||
expect(output).to include('<colgroup><col style="width: 150px;"><col></colgroup>')
|
||||
# No exact-width lock on the wrapper or table — the table must be free to expand.
|
||||
expect(output).to include('<div class="tableWrapper"><table')
|
||||
expect(output).not_to include('width: 200px !important')
|
||||
end
|
||||
end
|
||||
|
||||
it 'associates each marker with the table that follows it' do
|
||||
markdown = "#{plain_table}\n<!--cw-colwidths:150,250-->\n| P | Q |\n| --- | --- |\n| a | b |\n"
|
||||
output = render_table(markdown)
|
||||
|
||||
# First table has no marker and stays unsized; the marker applies to the second table.
|
||||
expect(output).to include('<div class="tableWrapper"><table>')
|
||||
expect(output).to include('width: 400px !important;')
|
||||
expect(output).to include('<col style="width: 250px;">')
|
||||
expect(output.scan('colgroup').length).to eq(2)
|
||||
end
|
||||
|
||||
it 'does not emit the marker comment into the rendered html' do
|
||||
expect(render_table("<!--cw-colwidths:120,200-->\n#{plain_table}")).not_to include('cw-colwidths')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#image' do
|
||||
it 'renders width in px with responsive cap and auto height' do
|
||||
markdown = ''
|
||||
|
||||
@@ -248,4 +248,22 @@ RSpec.describe Channel::Whatsapp do
|
||||
expect(channel.voice_enabled?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#inbound_calls_enabled?' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'returns true by default when nothing has been toggled' do
|
||||
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
expect(channel.inbound_calls_enabled?).to be true
|
||||
end
|
||||
|
||||
it 'returns false only when explicitly disabled in provider_config' do
|
||||
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
channel.update!(provider_config: channel.provider_config.merge('inbound_calls_enabled' => false))
|
||||
|
||||
expect(channel.inbound_calls_enabled?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -62,6 +62,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
result = described_class.new(account: account, user: agent).perform
|
||||
|
||||
expect(result).to eq(
|
||||
all_count: 1,
|
||||
inboxes: { visible_inbox.id.to_s => 1 },
|
||||
labels: { label.id.to_s => 1 },
|
||||
teams: { visible_team.id.to_s => 1 }
|
||||
@@ -75,6 +76,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
result = described_class.new(account: account, user: admin).perform
|
||||
|
||||
expect(result).to eq(
|
||||
all_count: 2,
|
||||
inboxes: { visible_inbox.id.to_s => 1, hidden_inbox.id.to_s => 1 },
|
||||
labels: { label.id.to_s => 2 },
|
||||
teams: { visible_team.id.to_s => 2 }
|
||||
@@ -87,6 +89,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
result = described_class.new(account: account, user: agent).perform
|
||||
|
||||
expect(result).to eq(
|
||||
all_count: 1,
|
||||
inboxes: { visible_inbox.id.to_s => 1 },
|
||||
labels: {},
|
||||
teams: { visible_team.id.to_s => 1 }
|
||||
|
||||
@@ -36,11 +36,6 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
.with(params[:waba_id], params[:phone_number_id], access_token).and_return(phone_service)
|
||||
allow(phone_service).to receive(:perform).and_return(phone_info)
|
||||
|
||||
validation_service = instance_double(Whatsapp::TokenValidationService)
|
||||
allow(Whatsapp::TokenValidationService).to receive(:new)
|
||||
.with(access_token, params[:waba_id]).and_return(validation_service)
|
||||
allow(validation_service).to receive(:perform)
|
||||
|
||||
channel_creation = instance_double(Whatsapp::ChannelCreationService)
|
||||
allow(Whatsapp::ChannelCreationService).to receive(:new)
|
||||
.with(account, { waba_id: params[:waba_id], business_name: 'Test Business' }, phone_info, access_token)
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::TokenValidationService do
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:waba_id) { 'test_waba_id' }
|
||||
let(:service) { described_class.new(access_token, waba_id) }
|
||||
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
|
||||
|
||||
before do
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).with(access_token).and_return(api_client)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when token has access to WABA' do
|
||||
let(:debug_response) do
|
||||
{
|
||||
'data' => {
|
||||
'granular_scopes' => [
|
||||
{
|
||||
'scope' => 'whatsapp_business_management',
|
||||
'target_ids' => [waba_id, 'another_waba_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
|
||||
end
|
||||
|
||||
it 'validates successfully' do
|
||||
expect { service.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token does not have access to WABA' do
|
||||
let(:debug_response) do
|
||||
{
|
||||
'data' => {
|
||||
'granular_scopes' => [
|
||||
{
|
||||
'scope' => 'whatsapp_business_management',
|
||||
'target_ids' => ['different_waba_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/Token does not have access to WABA/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no WABA scope is found' do
|
||||
let(:debug_response) do
|
||||
{
|
||||
'data' => {
|
||||
'granular_scopes' => [
|
||||
{
|
||||
'scope' => 'some_other_scope',
|
||||
'target_ids' => ['some_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error('No WABA scope found in token')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when access_token is blank' do
|
||||
let(:access_token) { '' }
|
||||
|
||||
it 'raises ArgumentError' do
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when waba_id is blank' do
|
||||
let(:waba_id) { '' }
|
||||
|
||||
it 'raises ArgumentError' do
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user