Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f04c7c36e | ||
|
|
8218880424 | ||
|
|
dec5468cd5 | ||
|
|
d4dd5c64a7 | ||
|
|
4b2abcfbb2 | ||
|
|
261deaaeec |
+20
-31
@@ -136,8 +136,6 @@ 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)
|
||||
@@ -170,7 +168,7 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.3.1)
|
||||
bigdecimal (3.2.2)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
@@ -186,7 +184,6 @@ 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)
|
||||
@@ -315,7 +312,7 @@ GEM
|
||||
hashie
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.4)
|
||||
faraday-net_http (3.4.2)
|
||||
net-http (~> 0.5)
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
@@ -438,8 +435,7 @@ GEM
|
||||
hana (1.3.7)
|
||||
hash_diff (1.1.1)
|
||||
hashdiff (1.1.0)
|
||||
hashie (5.1.0)
|
||||
logger
|
||||
hashie (5.0.0)
|
||||
html2text (0.4.0)
|
||||
nokogiri (>= 1.0, < 2.0)
|
||||
http (5.1.1)
|
||||
@@ -474,7 +470,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.19.8)
|
||||
json (2.19.5)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -572,7 +568,7 @@ GEM
|
||||
ruby2_keywords
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.9.1)
|
||||
multi_xml (0.8.0)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
@@ -607,26 +603,19 @@ GEM
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
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)
|
||||
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)
|
||||
multi_xml (~> 0.5)
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
@@ -946,9 +935,9 @@ GEM
|
||||
gli
|
||||
hashie
|
||||
logger
|
||||
snaky_hash (2.0.5)
|
||||
hashie (>= 0.1.0, < 6)
|
||||
version_gem (>= 1.1.8, < 3)
|
||||
snaky_hash (2.0.1)
|
||||
hashie
|
||||
version_gem (~> 1.1, >= 1.1.1)
|
||||
sorbet-runtime (0.5.11934)
|
||||
spring (4.1.1)
|
||||
spring-watcher-listen (2.1.0)
|
||||
@@ -1006,7 +995,7 @@ GEM
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
mail (~> 2.5)
|
||||
version_gem (1.1.11)
|
||||
version_gem (1.1.4)
|
||||
vite_rails (3.10.0)
|
||||
railties (>= 5.1, < 9)
|
||||
vite_ruby (~> 3.0, >= 3.2.2)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.14.2
|
||||
4.14.1
|
||||
|
||||
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
|
||||
|
||||
def create_contact
|
||||
account.contacts.create!(
|
||||
name: contact_name,
|
||||
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
|
||||
phone_number: contact_attributes[:phone_number],
|
||||
email: contact_attributes[:email],
|
||||
identifier: contact_attributes[:identifier],
|
||||
@@ -59,11 +59,6 @@ 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])
|
||||
|
||||
@@ -7,6 +7,7 @@ class RoomChannel < ApplicationCable::Channel
|
||||
ensure_stream
|
||||
update_subscription
|
||||
broadcast_presence
|
||||
transmit_cache_keys
|
||||
end
|
||||
|
||||
def update_presence
|
||||
@@ -24,6 +25,19 @@ class RoomChannel < ApplicationCable::Channel
|
||||
ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data })
|
||||
end
|
||||
|
||||
# Push the authoritative cache-key map to this subscriber on every
|
||||
# (re)subscribe. Boot and reconnect cache freshness ride the same
|
||||
# account.cache_invalidated event the dashboard already handles for live
|
||||
# invalidations — the client never pulls /cache_keys itself.
|
||||
def transmit_cache_keys
|
||||
return if @current_account.blank? || !@current_user.is_a?(User)
|
||||
|
||||
transmit({
|
||||
event: Events::Types::ACCOUNT_CACHE_INVALIDATED,
|
||||
data: { account_id: @current_account.id, cache_keys: @current_account.cache_keys }
|
||||
})
|
||||
end
|
||||
|
||||
def ensure_stream
|
||||
stream_from pubsub_token
|
||||
stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User)
|
||||
|
||||
@@ -20,7 +20,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
||||
ActiveRecord::Base.transaction do
|
||||
authenticate_twilio
|
||||
build_inbox
|
||||
setup_webhooks
|
||||
setup_webhooks if @twilio_channel.sms?
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -16,10 +16,6 @@ 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?
|
||||
@@ -37,15 +33,4 @@ 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')
|
||||
|
||||
@@ -50,7 +50,6 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def cache_keys
|
||||
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
|
||||
render json: { cache_keys: cache_keys_for_account }, status: :ok
|
||||
end
|
||||
|
||||
@@ -93,11 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def cache_keys_for_account
|
||||
{
|
||||
label: fetch_value_for_key(params[:id], Label.name.underscore),
|
||||
inbox: fetch_value_for_key(params[:id], Inbox.name.underscore),
|
||||
team: fetch_value_for_key(params[:id], Team.name.underscore)
|
||||
}
|
||||
@account.cache_keys
|
||||
end
|
||||
|
||||
def fetch_account
|
||||
|
||||
@@ -13,11 +13,17 @@ class Api::V1::ProfilesController < Api::BaseController
|
||||
@user.assign_attributes(profile_params)
|
||||
@user.custom_attributes.merge!(custom_attributes_params)
|
||||
@user.save!
|
||||
|
||||
# Profile updates can change cached agent fields, including avatar-backed thumbnails.
|
||||
@user.invalidate_avatar_cache
|
||||
end
|
||||
|
||||
def avatar
|
||||
@user.avatar.attachment.destroy! if @user.avatar.attached?
|
||||
@user.reload
|
||||
|
||||
# Agent thumbnails are cached separately, and avatar attachment deletes do not dirty user columns.
|
||||
@user.invalidate_avatar_cache
|
||||
end
|
||||
|
||||
def auto_offline
|
||||
|
||||
@@ -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,17 +184,6 @@ 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,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
verifyServiceWorkerExistence,
|
||||
} from './helper/pushHelper';
|
||||
import ReconnectService from 'dashboard/helper/ReconnectService';
|
||||
import paintStoresFromCache from 'dashboard/helper/CacheHelper/paintStoresFromCache';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
export default {
|
||||
@@ -108,14 +109,21 @@ export default {
|
||||
this.$store.dispatch('setActiveAccount', {
|
||||
accountId: this.currentAccountId,
|
||||
});
|
||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||
vueActionCable.init(this.store, pubsubToken);
|
||||
|
||||
// Paint cached config from IndexedDB instantly while the cable
|
||||
// connects. Freshness needs no orchestration here: RoomChannel pushes
|
||||
// the cache-key map on every (re)subscribe and on every server-side
|
||||
// change, all through the same account.cache_invalidated event.
|
||||
await paintStoresFromCache(this.$store, this.currentAccountId);
|
||||
|
||||
const account = this.getAccount(this.currentAccountId);
|
||||
const { locale, latest_chatwoot_version: latestChatwootVersion } =
|
||||
account;
|
||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||
// If user locale is set, use it; otherwise use account locale
|
||||
this.setLocale(this.uiSettings?.locale || locale);
|
||||
this.latestChatwootVersion = latestChatwootVersion;
|
||||
vueActionCable.init(this.store, pubsubToken);
|
||||
this.reconnectService = new ReconnectService(this.store, this.router);
|
||||
window.reconnectService = this.reconnectService;
|
||||
|
||||
|
||||
@@ -5,14 +5,15 @@ import ApiClient from './ApiClient';
|
||||
class CacheEnabledApiClient extends ApiClient {
|
||||
constructor(resource, options = {}) {
|
||||
super(resource, options);
|
||||
// `cacheModel` is the Rails Model.name.underscore value — simultaneously
|
||||
// the server cache-key name and the IDB object-store name.
|
||||
this.cacheModelName = options.cacheModel;
|
||||
// inbox/label endpoints wrap collections in { payload }; the rest return
|
||||
// the bare array.
|
||||
this.payloadEnvelope = options.payloadEnvelope || false;
|
||||
this.dataManager = new DataManager(this.accountIdFromRoute);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
throw new Error('cacheModelName is not defined');
|
||||
}
|
||||
|
||||
get(cache = false) {
|
||||
if (cache) {
|
||||
return this.getFromCache();
|
||||
@@ -25,14 +26,14 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
extractDataFromResponse(response) {
|
||||
return response.data.payload;
|
||||
return this.payloadEnvelope ? response.data.payload : response.data;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
marshallData(dataToParse) {
|
||||
return { data: { payload: dataToParse } };
|
||||
return this.payloadEnvelope
|
||||
? { data: { payload: dataToParse } }
|
||||
: { data: dataToParse };
|
||||
}
|
||||
|
||||
async getFromCache() {
|
||||
@@ -43,24 +44,23 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
return this.getFromNetwork();
|
||||
}
|
||||
|
||||
const { data } = await axios.get(
|
||||
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
|
||||
);
|
||||
const cacheKeyFromApi = data.cache_keys[this.cacheModelName];
|
||||
const isCacheValid = await this.validateCacheKey(cacheKeyFromApi);
|
||||
// Trust the IDB cache. Freshness is maintained by the
|
||||
// account.cache_invalidated event alone: RoomChannel pushes the cache-key
|
||||
// map on every (re)subscribe — boot and reconnect included — and the
|
||||
// server broadcasts it on every change. Skipping a per-call /cache_keys
|
||||
// preflight eliminates N GET requests per cold settings-page load.
|
||||
const localData = await this.dataManager.get({
|
||||
modelName: this.cacheModelName,
|
||||
});
|
||||
|
||||
let localData = [];
|
||||
if (isCacheValid) {
|
||||
localData = await this.dataManager.get({
|
||||
modelName: this.cacheModelName,
|
||||
});
|
||||
if (localData.length > 0) {
|
||||
return this.marshallData(localData);
|
||||
}
|
||||
|
||||
if (localData.length === 0) {
|
||||
return this.refetchAndCommit(cacheKeyFromApi);
|
||||
}
|
||||
|
||||
return this.marshallData(localData);
|
||||
// Empty IDB (first load or wiped): fetch data without a cache key. The
|
||||
// next pushed key map won't match the missing key and will refetch once,
|
||||
// stamping the authoritative key — the client never pulls keys itself.
|
||||
return this.refetchAndCommit(null);
|
||||
}
|
||||
|
||||
async refetchAndCommit(newKey = null) {
|
||||
@@ -69,7 +69,9 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
try {
|
||||
await this.dataManager.initDb();
|
||||
|
||||
this.dataManager.replace({
|
||||
// Await replace so data is persisted before the cache key is — otherwise
|
||||
// a concurrent reader could see a fresh key paired with stale data.
|
||||
await this.dataManager.replace({
|
||||
modelName: this.cacheModelName,
|
||||
data: this.extractDataFromResponse(response),
|
||||
});
|
||||
@@ -89,8 +91,15 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
await this.dataManager.initDb();
|
||||
}
|
||||
|
||||
const cachekey = await this.dataManager.getCacheKey(this.cacheModelName);
|
||||
return cacheKeyFromApi === cachekey;
|
||||
const cacheKey = await this.dataManager.getCacheKey(this.cacheModelName);
|
||||
if (cacheKey === undefined) {
|
||||
const localData = await this.dataManager.get({
|
||||
modelName: this.cacheModelName,
|
||||
});
|
||||
return localData.length === 0;
|
||||
}
|
||||
|
||||
return cacheKeyFromApi === cacheKey;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,6 @@ class AccountAPI extends ApiClient {
|
||||
createAccount(data) {
|
||||
return axios.post(`${this.apiVersion}/accounts`, data);
|
||||
}
|
||||
|
||||
async getCacheKeys() {
|
||||
const response = await axios.get(
|
||||
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
|
||||
);
|
||||
return response.data.cache_keys;
|
||||
}
|
||||
}
|
||||
|
||||
export default new AccountAPI();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* global axios */
|
||||
|
||||
import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class Agents extends ApiClient {
|
||||
class Agents extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('agents', { accountScoped: true });
|
||||
super('agents', { accountScoped: true, cacheModel: 'account_user' });
|
||||
}
|
||||
|
||||
bulkInvite({ emails }) {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class AttributeAPI extends ApiClient {
|
||||
class AttributeAPI extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('custom_attribute_definitions', { accountScoped: true });
|
||||
super('custom_attribute_definitions', {
|
||||
accountScoped: true,
|
||||
cacheModel: 'custom_attribute_definition',
|
||||
});
|
||||
}
|
||||
|
||||
getAttributesByModel() {
|
||||
return axios.get(this.url);
|
||||
return super.get(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
/* global axios */
|
||||
|
||||
import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class CannedResponse extends ApiClient {
|
||||
class CannedResponse extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('canned_responses', { accountScoped: true });
|
||||
super('canned_responses', {
|
||||
accountScoped: true,
|
||||
cacheModel: 'canned_response',
|
||||
});
|
||||
}
|
||||
|
||||
get({ searchKey }) {
|
||||
const url = searchKey ? `${this.url}?search=${searchKey}` : this.url;
|
||||
return axios.get(url);
|
||||
get({ searchKey } = {}) {
|
||||
if (searchKey) {
|
||||
return axios.get(`${this.url}?search=${searchKey}`);
|
||||
}
|
||||
return super.get(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,11 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class Inboxes extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('inboxes', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'inbox';
|
||||
super('inboxes', {
|
||||
accountScoped: true,
|
||||
cacheModel: 'inbox',
|
||||
payloadEnvelope: true,
|
||||
});
|
||||
}
|
||||
|
||||
getCampaigns(inboxId) {
|
||||
@@ -60,12 +59,6 @@ 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();
|
||||
|
||||
@@ -2,12 +2,11 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class LabelsAPI extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('labels', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'label';
|
||||
super('labels', {
|
||||
accountScoped: true,
|
||||
cacheModel: 'label',
|
||||
payloadEnvelope: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,6 @@ 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();
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
/* global axios */
|
||||
// import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
export class TeamsAPI extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('teams', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'team';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
extractDataFromResponse(response) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
marshallData(dataToParse) {
|
||||
return { data: dataToParse };
|
||||
super('teams', { accountScoped: true, cacheModel: 'team' });
|
||||
}
|
||||
|
||||
getAgents({ teamId }) {
|
||||
|
||||
@@ -175,9 +175,6 @@ useEventListener(document, 'touchend', onResizeEnd);
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
||||
const allUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getAllUnreadCount'
|
||||
);
|
||||
const getInboxUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getInboxUnreadCount'
|
||||
);
|
||||
@@ -300,7 +297,6 @@ 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.isAWhatsAppCloudChannel) {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return AUDIO_FORMATS.OGG;
|
||||
}
|
||||
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
|
||||
if (this.isATelegramChannel) {
|
||||
return AUDIO_FORMATS.MP3;
|
||||
}
|
||||
if (this.isAPIInbox) {
|
||||
|
||||
@@ -70,8 +70,7 @@ describe('useFacebookPageConnect', () => {
|
||||
ACCOUNT_ID
|
||||
);
|
||||
expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), {
|
||||
scope:
|
||||
'pages_manage_metadata,business_management,pages_messaging,pages_show_list,pages_read_engagement',
|
||||
scope: expect.stringContaining('pages_show_list'),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -133,7 +133,6 @@ describe('useInbox', () => {
|
||||
});
|
||||
|
||||
expect(wrapper.vm.isATwilioChannel).toBe(true);
|
||||
expect(wrapper.vm.isATwilioSMSChannel).toBe(true);
|
||||
expect(wrapper.vm.isASmsInbox).toBe(true);
|
||||
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
|
||||
});
|
||||
@@ -269,7 +268,6 @@ describe('useInbox', () => {
|
||||
'isASmsInbox',
|
||||
'isATelegramChannel',
|
||||
'isATwilioChannel',
|
||||
'isATwilioSMSChannel',
|
||||
'isAWebWidgetInbox',
|
||||
'isAWhatsAppChannel',
|
||||
'isAMicrosoftInbox',
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { ref } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import ChannelApi from 'dashboard/api/channels';
|
||||
import { buildFacebookLoginScopes } from 'dashboard/helper/facebookScopes';
|
||||
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
|
||||
|
||||
// Page-management + messaging scopes required to list pages and create a
|
||||
// Channel::FacebookPage inbox (mirrors the standalone settings flow).
|
||||
const FB_PAGE_SCOPES =
|
||||
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages';
|
||||
|
||||
// Headless half of the Facebook Page connect flow: load the Meta SDK, run
|
||||
// FB.login for page scopes, and fetch the user's pages. The caller owns the
|
||||
// page-picker UI and the channel creation, because choosing a page is an
|
||||
@@ -47,7 +51,7 @@ export function useFacebookPageConnect() {
|
||||
: null
|
||||
);
|
||||
},
|
||||
{ scope: buildFacebookLoginScopes() }
|
||||
{ scope: FB_PAGE_SCOPES }
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -150,7 +150,6 @@ export const useInbox = (inboxId = null) => {
|
||||
isASmsInbox,
|
||||
isATelegramChannel,
|
||||
isATwilioChannel,
|
||||
isATwilioSMSChannel,
|
||||
isAWebWidgetInbox,
|
||||
isAWhatsAppChannel,
|
||||
isAMicrosoftInbox,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { openDB } from 'idb';
|
||||
import { DATA_VERSION } from './version';
|
||||
import { cacheableModels, cacheableModelNames } from './cacheableModels';
|
||||
|
||||
export class DataManager {
|
||||
constructor(accountId) {
|
||||
this.modelsToSync = ['inbox', 'label', 'team'];
|
||||
this.modelsToSync = cacheableModelNames;
|
||||
this.accountId = accountId;
|
||||
this.db = null;
|
||||
}
|
||||
@@ -11,12 +12,26 @@ export class DataManager {
|
||||
async initDb() {
|
||||
if (this.db) return this.db;
|
||||
const dbName = `cw-store-${this.accountId}`;
|
||||
this.db = await openDB(`cw-store-${this.accountId}`, DATA_VERSION, {
|
||||
upgrade(db) {
|
||||
db.createObjectStore('cache-keys');
|
||||
db.createObjectStore('inbox', { keyPath: 'id' });
|
||||
db.createObjectStore('label', { keyPath: 'id' });
|
||||
db.createObjectStore('team', { keyPath: 'id' });
|
||||
this.db = await openDB(dbName, DATA_VERSION, {
|
||||
upgrade(db, oldVersion, _newVersion, tx) {
|
||||
// Flush data carried over from a previous schema version so a
|
||||
// DATA_VERSION bump acts as a global cache reset. oldVersion === 0 on
|
||||
// first install, so fresh devices skip this. Clearing before creating
|
||||
// means we only ever clear stores that pre-existed this upgrade.
|
||||
if (oldVersion > 0) {
|
||||
for (let index = 0; index < db.objectStoreNames.length; index += 1) {
|
||||
tx.objectStore(db.objectStoreNames.item(index)).clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('cache-keys')) {
|
||||
db.createObjectStore('cache-keys');
|
||||
}
|
||||
cacheableModels.forEach(model => {
|
||||
if (!db.objectStoreNames.contains(model.name)) {
|
||||
db.createObjectStore(model.name, { keyPath: 'id' });
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,7 +56,7 @@ export class DataManager {
|
||||
async replace({ modelName, data }) {
|
||||
this.validateModel(modelName);
|
||||
|
||||
this.db.clear(modelName);
|
||||
await this.db.clear(modelName);
|
||||
return this.push({ modelName, data });
|
||||
}
|
||||
|
||||
@@ -65,9 +80,11 @@ export class DataManager {
|
||||
}
|
||||
|
||||
async setCacheKeys(cacheKeys) {
|
||||
Object.keys(cacheKeys).forEach(async modelName => {
|
||||
this.db.put('cache-keys', cacheKeys[modelName], modelName);
|
||||
});
|
||||
await Promise.all(
|
||||
Object.entries(cacheKeys).map(([modelName, value]) =>
|
||||
this.db.put('cache-keys', value, modelName)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async getCacheKey(modelName) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Single source of truth for IDB-cached workspace config.
|
||||
//
|
||||
// Each entry must keep `name` equal to the Rails `Model.name.underscore` value
|
||||
// so the server's `cache_keys` payload (and the IDB object store name) lines up
|
||||
// with what the client looks up.
|
||||
//
|
||||
// `setMutation` is the full commit path used to seed Vuex from IDB (boot
|
||||
// paint) and to swap in refetched rows (event-driven revalidation). Every
|
||||
// SET_* mutation must REPLACE its records (not merge) so rows deleted
|
||||
// server-side never survive as phantoms.
|
||||
export const cacheableModels = [
|
||||
{ name: 'inbox', setMutation: 'inboxes/SET_INBOXES' },
|
||||
{ name: 'label', setMutation: 'labels/SET_LABELS' },
|
||||
{ name: 'team', setMutation: 'teams/SET_TEAMS' },
|
||||
{ name: 'canned_response', setMutation: 'SET_CANNED' },
|
||||
{ name: 'account_user', setMutation: 'agents/SET_AGENTS' },
|
||||
{
|
||||
name: 'custom_attribute_definition',
|
||||
setMutation: 'attributes/SET_CUSTOM_ATTRIBUTE',
|
||||
},
|
||||
];
|
||||
|
||||
export const cacheableModelNames = cacheableModels.map(model => model.name);
|
||||
@@ -0,0 +1,42 @@
|
||||
import AgentAPI from 'dashboard/api/agents';
|
||||
import AttributeAPI from 'dashboard/api/attributes';
|
||||
import CannedResponseAPI from 'dashboard/api/cannedResponse';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import LabelsAPI from 'dashboard/api/labels';
|
||||
import TeamsAPI from 'dashboard/api/teams';
|
||||
import { cacheableModels } from './cacheableModels';
|
||||
|
||||
// model name → cache-enabled API client. Lives here rather than in
|
||||
// cacheableModels to keep that module import-cycle-free: the API clients
|
||||
// import DataManager, which imports cacheableModels.
|
||||
const apiByModel = {
|
||||
inbox: InboxesAPI,
|
||||
label: LabelsAPI,
|
||||
team: TeamsAPI,
|
||||
canned_response: CannedResponseAPI,
|
||||
account_user: AgentAPI,
|
||||
custom_attribute_definition: AttributeAPI,
|
||||
};
|
||||
|
||||
const revalidateModel = async (store, model, newKey) => {
|
||||
try {
|
||||
const api = apiByModel[model.name];
|
||||
if (await api.validateCacheKey(newKey)) return;
|
||||
|
||||
const response = await api.refetchAndCommit(newKey);
|
||||
store.commit(model.setMutation, api.extractDataFromResponse(response));
|
||||
} catch {
|
||||
// Ignore error — a failed refetch leaves the painted data in place; the
|
||||
// next pushed key map retries.
|
||||
}
|
||||
};
|
||||
|
||||
// The single freshness engine: given a pushed { model_name => key } map
|
||||
// (RoomChannel transmits one on every (re)subscribe, the server broadcasts
|
||||
// one on every change), diff each key against IDB and refetch mismatches.
|
||||
export const dispatchCacheRevalidations = (store, keys = {}) =>
|
||||
Promise.all(
|
||||
cacheableModels
|
||||
.filter(model => keys[model.name] !== undefined)
|
||||
.map(model => revalidateModel(store, model, keys[model.name]))
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
import { DataManager } from './DataManager';
|
||||
import { cacheableModels } from './cacheableModels';
|
||||
|
||||
// Seed Vuex from IndexedDB before the dashboard renders so warm boots paint
|
||||
// cached config instantly. This is purely local — zero network calls.
|
||||
//
|
||||
// Freshness is handled entirely by the account.cache_invalidated event:
|
||||
// RoomChannel transmits the current cache-key map on every (re)subscribe, and
|
||||
// the server broadcasts it on every change. dispatchCacheRevalidations diffs
|
||||
// those keys against IDB and refetches mismatches — the client never pulls
|
||||
// cache keys itself.
|
||||
export default async function paintStoresFromCache(store, accountId) {
|
||||
let dm;
|
||||
try {
|
||||
dm = new DataManager(accountId);
|
||||
await dm.initDb();
|
||||
} catch {
|
||||
// IDB unsupported (e.g. Firefox private mode) — silent no-op. Components
|
||||
// will fetch from the network normally via the cache-enabled API client.
|
||||
return;
|
||||
}
|
||||
|
||||
// Stale-while-revalidate paint: commit cached data into Vuex immediately.
|
||||
await Promise.all(
|
||||
cacheableModels.map(async model => {
|
||||
const localData = await dm.get({ modelName: model.name });
|
||||
if (localData.length === 0) return;
|
||||
store.commit(model.setMutation, localData);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
// Monday, 13 March 2023
|
||||
// Change this version if you want to invalidate old data
|
||||
export const DATA_VERSION = '1678706392';
|
||||
// Bump DATA_VERSION to (a) add new object stores to the IDB schema or (b)
|
||||
// flush bad/stale cache globally. The `upgrade()` callback in DataManager runs
|
||||
// only when the stored DB version is less than the requested version; on any
|
||||
// such bump it clears every existing store (a full cache reset) and then
|
||||
// idempotently creates any missing stores. So bump this whenever a cached
|
||||
// model's serializer shape changes, or to force all clients to refetch.
|
||||
//
|
||||
// Thursday, 28 May 2026 — bumped to add canned_response + account_user stores + custom_attribute_definition store
|
||||
export const DATA_VERSION = '1748390400';
|
||||
|
||||
@@ -98,17 +98,6 @@ class ReconnectService {
|
||||
await this.store.dispatch('notifications/index', { ...filter, page: 1 });
|
||||
};
|
||||
|
||||
revalidateCaches = async () => {
|
||||
const { label, inbox, team } = await this.store.dispatch(
|
||||
'accounts/getCacheKeys'
|
||||
);
|
||||
await Promise.all([
|
||||
this.store.dispatch('labels/revalidate', { newKey: label }),
|
||||
this.store.dispatch('inboxes/revalidate', { newKey: inbox }),
|
||||
this.store.dispatch('teams/revalidate', { newKey: team }),
|
||||
]);
|
||||
};
|
||||
|
||||
handleRouteSpecificFetch = async () => {
|
||||
const currentRoute = this.router.currentRoute.value.name;
|
||||
if (isAConversationRoute(currentRoute, true)) {
|
||||
@@ -138,9 +127,11 @@ class ReconnectService {
|
||||
this.setConversationLastMessageId();
|
||||
};
|
||||
|
||||
// Cached workspace config needs no explicit revalidation here: ActionCable
|
||||
// auto-resubscribes after a drop, and RoomChannel pushes the cache-key map
|
||||
// on every subscribe via the account.cache_invalidated event.
|
||||
onReconnect = async () => {
|
||||
await this.handleRouteSpecificFetch();
|
||||
await this.revalidateCaches();
|
||||
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { dispatchCacheRevalidations } from './CacheHelper/dispatchCacheRevalidations';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
|
||||
@@ -269,10 +270,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
};
|
||||
|
||||
onCacheInvalidate = data => {
|
||||
const keys = data.cache_keys;
|
||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
||||
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
||||
dispatchCacheRevalidations(this.app.$store, data.cache_keys);
|
||||
};
|
||||
|
||||
onVoiceCallIncoming = data => {
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
export const FACEBOOK_PAGE_SCOPES = [
|
||||
'pages_manage_metadata',
|
||||
'business_management',
|
||||
'pages_messaging',
|
||||
'pages_show_list',
|
||||
'pages_read_engagement',
|
||||
];
|
||||
|
||||
export const INSTAGRAM_SCOPES = [
|
||||
'instagram_basic',
|
||||
'instagram_manage_messages',
|
||||
];
|
||||
|
||||
export const buildFacebookLoginScopes = ({
|
||||
includeInstagramScopes = false,
|
||||
} = {}) => {
|
||||
const scopes = [...FACEBOOK_PAGE_SCOPES];
|
||||
if (includeInstagramScopes) {
|
||||
scopes.push(...INSTAGRAM_SCOPES);
|
||||
}
|
||||
return scopes.join(',');
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { dispatchCacheRevalidations } from '../../CacheHelper/dispatchCacheRevalidations';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import LabelsAPI from 'dashboard/api/labels';
|
||||
import CannedResponseAPI from 'dashboard/api/cannedResponse';
|
||||
import TeamsAPI from 'dashboard/api/teams';
|
||||
|
||||
vi.mock('dashboard/api/inboxes', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('dashboard/api/labels', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('dashboard/api/teams', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('dashboard/api/cannedResponse', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('dashboard/api/agents', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('dashboard/api/attributes', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('dispatchCacheRevalidations', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
store = { commit: vi.fn() };
|
||||
});
|
||||
|
||||
it('refetches stale models and commits via their setMutation', async () => {
|
||||
InboxesAPI.validateCacheKey.mockResolvedValue(false);
|
||||
InboxesAPI.refetchAndCommit.mockResolvedValue({ data: { payload: [] } });
|
||||
InboxesAPI.extractDataFromResponse.mockReturnValue([{ id: 1 }]);
|
||||
LabelsAPI.validateCacheKey.mockResolvedValue(true);
|
||||
|
||||
await dispatchCacheRevalidations(store, {
|
||||
inbox: 'inbox-key',
|
||||
label: 'label-key',
|
||||
});
|
||||
|
||||
expect(InboxesAPI.refetchAndCommit).toHaveBeenCalledWith('inbox-key');
|
||||
expect(store.commit).toHaveBeenCalledWith('inboxes/SET_INBOXES', [
|
||||
{ id: 1 },
|
||||
]);
|
||||
expect(LabelsAPI.refetchAndCommit).not.toHaveBeenCalled();
|
||||
expect(store.commit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips models absent from the key payload', async () => {
|
||||
InboxesAPI.validateCacheKey.mockResolvedValue(true);
|
||||
|
||||
await dispatchCacheRevalidations(store, { inbox: 'inbox-key' });
|
||||
|
||||
expect(TeamsAPI.validateCacheKey).not.toHaveBeenCalled();
|
||||
expect(store.commit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats missing keys as an empty payload', async () => {
|
||||
await dispatchCacheRevalidations(store);
|
||||
|
||||
expect(InboxesAPI.validateCacheKey).not.toHaveBeenCalled();
|
||||
expect(store.commit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows per-model errors so one failure does not block the rest', async () => {
|
||||
InboxesAPI.validateCacheKey.mockResolvedValue(false);
|
||||
InboxesAPI.refetchAndCommit.mockRejectedValue(new Error('network down'));
|
||||
CannedResponseAPI.validateCacheKey.mockResolvedValue(false);
|
||||
CannedResponseAPI.refetchAndCommit.mockResolvedValue({ data: [] });
|
||||
CannedResponseAPI.extractDataFromResponse.mockReturnValue([{ id: 7 }]);
|
||||
|
||||
await dispatchCacheRevalidations(store, {
|
||||
inbox: 'inbox-key',
|
||||
canned_response: 'canned-key',
|
||||
});
|
||||
|
||||
expect(store.commit).toHaveBeenCalledWith('SET_CANNED', [{ id: 7 }]);
|
||||
expect(store.commit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import paintStoresFromCache from '../../CacheHelper/paintStoresFromCache';
|
||||
import { DataManager } from '../../CacheHelper/DataManager';
|
||||
|
||||
describe('paintStoresFromCache', () => {
|
||||
const accountId = 'paint-test-account';
|
||||
const originalAxios = window.axios;
|
||||
let axiosMock;
|
||||
let dm;
|
||||
let storeMock;
|
||||
|
||||
beforeEach(async () => {
|
||||
axiosMock = {
|
||||
get: vi.fn(),
|
||||
};
|
||||
window.axios = axiosMock;
|
||||
|
||||
storeMock = {
|
||||
commit: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
};
|
||||
|
||||
dm = new DataManager(accountId);
|
||||
await dm.initDb();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const tx = dm.db.transaction(
|
||||
[...dm.modelsToSync, 'cache-keys'],
|
||||
'readwrite'
|
||||
);
|
||||
[...dm.modelsToSync, 'cache-keys'].forEach(name => {
|
||||
tx.objectStore(name).clear();
|
||||
});
|
||||
await tx.done;
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('does nothing when IDB is empty (first ever load)', async () => {
|
||||
await paintStoresFromCache(storeMock, accountId);
|
||||
|
||||
expect(storeMock.commit).not.toHaveBeenCalled();
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds Vuex from IDB without any network interaction', async () => {
|
||||
await dm.push({
|
||||
modelName: 'inbox',
|
||||
data: [{ id: 1, name: 'Support' }],
|
||||
});
|
||||
await dm.push({
|
||||
modelName: 'label',
|
||||
data: [{ id: 9, title: 'Bug' }],
|
||||
});
|
||||
|
||||
await paintStoresFromCache(storeMock, accountId);
|
||||
|
||||
expect(storeMock.commit).toHaveBeenCalledWith('inboxes/SET_INBOXES', [
|
||||
{ id: 1, name: 'Support' },
|
||||
]);
|
||||
expect(storeMock.commit).toHaveBeenCalledWith('labels/SET_LABELS', [
|
||||
{ id: 9, title: 'Bug' },
|
||||
]);
|
||||
expect(axiosMock.get).not.toHaveBeenCalled();
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds teams via SET_TEAMS', async () => {
|
||||
await dm.push({
|
||||
modelName: 'team',
|
||||
data: [{ id: 1, name: 'Sales' }],
|
||||
});
|
||||
|
||||
await paintStoresFromCache(storeMock, accountId);
|
||||
|
||||
expect(storeMock.commit).toHaveBeenCalledWith('teams/SET_TEAMS', [
|
||||
{ id: 1, name: 'Sales' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -253,27 +253,6 @@ describe('ReconnectService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('revalidateCaches', () => {
|
||||
it('should dispatch revalidate actions for labels, inboxes, and teams', async () => {
|
||||
storeMock.dispatch.mockResolvedValueOnce({
|
||||
label: 'labelKey',
|
||||
inbox: 'inboxKey',
|
||||
team: 'teamKey',
|
||||
});
|
||||
await reconnectService.revalidateCaches();
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('accounts/getCacheKeys');
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('labels/revalidate', {
|
||||
newKey: 'labelKey',
|
||||
});
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('inboxes/revalidate', {
|
||||
newKey: 'inboxKey',
|
||||
});
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('teams/revalidate', {
|
||||
newKey: 'teamKey',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleRouteSpecificFetch', () => {
|
||||
it('should fetch conversations and messages if current route is a conversation route', async () => {
|
||||
isAConversationRoute.mockReturnValue(true);
|
||||
@@ -335,12 +314,10 @@ describe('ReconnectService', () => {
|
||||
});
|
||||
|
||||
describe('onReconnect', () => {
|
||||
it('should handle route-specific fetch, revalidate caches, and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
|
||||
it('should handle route-specific fetch and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
|
||||
reconnectService.handleRouteSpecificFetch = vi.fn();
|
||||
reconnectService.revalidateCaches = vi.fn();
|
||||
await reconnectService.onReconnect();
|
||||
expect(reconnectService.handleRouteSpecificFetch).toHaveBeenCalled();
|
||||
expect(reconnectService.revalidateCaches).toHaveBeenCalled();
|
||||
expect(emitter.emit).toHaveBeenCalledWith(
|
||||
BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED
|
||||
);
|
||||
|
||||
@@ -134,6 +134,7 @@
|
||||
},
|
||||
"TWILIO": {
|
||||
"TITLE": "Twilio SMS/WhatsApp Channel",
|
||||
"DESC": "Integrate Twilio and start supporting your customers via SMS or WhatsApp.",
|
||||
"ACCOUNT_SID": {
|
||||
"LABEL": "Account SID",
|
||||
"PLACEHOLDER": "Please enter your Twilio Account SID",
|
||||
@@ -175,13 +176,13 @@
|
||||
"PLACEHOLDER": "Please enter the phone number from which message will be sent.",
|
||||
"ERROR": "Please provide a valid phone number that starts with a `+` sign and does not contain any spaces."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Twilio Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to authenticate Twilio credentials, please try again"
|
||||
},
|
||||
"API_CALLBACK": {
|
||||
"TITLE": "Callback URL",
|
||||
"SUBTITLE": "You have to configure the message callback URL in Twilio with the URL mentioned here."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Twilio Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to authenticate Twilio credentials, please try again"
|
||||
}
|
||||
},
|
||||
"SMS": {
|
||||
@@ -558,7 +559,6 @@
|
||||
"MORE_SETTINGS": "More settings",
|
||||
"WEBSITE_SUCCESS": "You have successfully finished creating a website channel. Copy the code shown below and paste it on your website. Next time a customer use the live chat, the conversation will automatically appear on your inbox.",
|
||||
"WHATSAPP_QR_INSTRUCTION": "Scan the QR code above to quickly test your WhatsApp inbox",
|
||||
"SMS_QR_INSTRUCTION": "Scan the QR code above to quickly test your SMS inbox",
|
||||
"MESSENGER_QR_INSTRUCTION": "Scan the QR code above to quickly test your Facebook Messenger inbox",
|
||||
"TELEGRAM_QR_INSTRUCTION": "Scan the QR code above to quickly test your Telegram inbox"
|
||||
},
|
||||
@@ -653,10 +653,6 @@
|
||||
},
|
||||
"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,18 +94,8 @@ export default {
|
||||
return this.getAccount(this.accountId) || {};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'currentAccount.id'(id) {
|
||||
if (id) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// Account already in the store (navigated in): seed immediately.
|
||||
if (this.currentAccount.id) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -16,7 +16,6 @@ const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const qrCodes = reactive({
|
||||
sms: '',
|
||||
whatsapp: '',
|
||||
messenger: '',
|
||||
telegram: '',
|
||||
@@ -29,13 +28,14 @@ const currentInbox = computed(() =>
|
||||
// Use useInbox composable with the inbox ID
|
||||
const {
|
||||
isAWhatsAppCloudChannel,
|
||||
isAWhatsAppChannel,
|
||||
isATwilioChannel,
|
||||
isASmsInbox,
|
||||
isALineChannel,
|
||||
isAnEmailChannel,
|
||||
isAWhatsAppChannel,
|
||||
isAFacebookInbox,
|
||||
isATelegramChannel,
|
||||
isATwilioSMSChannel,
|
||||
isATwilioWhatsAppChannel,
|
||||
} = useInbox(route.params.inbox_id);
|
||||
|
||||
const hasDuplicateInstagramInbox = computed(() => {
|
||||
@@ -55,28 +55,23 @@ const shouldShowWhatsAppWebhookDetails = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const whatsappPhoneNumber = computed(() => {
|
||||
return (currentInbox.value?.phone_number || '').replace('whatsapp:', '');
|
||||
});
|
||||
|
||||
const shouldShowWhatsAppQr = computed(() => {
|
||||
return isAWhatsAppChannel.value && Boolean(whatsappPhoneNumber.value);
|
||||
});
|
||||
|
||||
const shouldShowSmsQr = computed(() => {
|
||||
return isATwilioSMSChannel.value && Boolean(currentInbox.value?.phone_number);
|
||||
const isWhatsAppEmbeddedSignup = computed(() => {
|
||||
return (
|
||||
isAWhatsAppCloudChannel.value &&
|
||||
currentInbox.value.provider_config?.source === 'embedded_signup'
|
||||
);
|
||||
});
|
||||
|
||||
const message = computed(() => {
|
||||
if (shouldShowWhatsAppQr.value) {
|
||||
if (isATwilioChannel.value) {
|
||||
return `${t('INBOX_MGMT.FINISH.MESSAGE')}. ${t(
|
||||
'INBOX_MGMT.FINISH.WHATSAPP_QR_INSTRUCTION'
|
||||
'INBOX_MGMT.ADD.TWILIO.API_CALLBACK.SUBTITLE'
|
||||
)}`;
|
||||
}
|
||||
|
||||
if (shouldShowSmsQr.value) {
|
||||
if (isASmsInbox.value) {
|
||||
return `${t('INBOX_MGMT.FINISH.MESSAGE')}. ${t(
|
||||
'INBOX_MGMT.FINISH.SMS_QR_INSTRUCTION'
|
||||
'INBOX_MGMT.ADD.SMS.BANDWIDTH.API_CALLBACK.SUBTITLE'
|
||||
)}`;
|
||||
}
|
||||
|
||||
@@ -96,6 +91,12 @@ const message = computed(() => {
|
||||
return t('INBOX_MGMT.FINISH.WEBSITE_SUCCESS');
|
||||
}
|
||||
|
||||
if (isWhatsAppEmbeddedSignup.value) {
|
||||
return `${t('INBOX_MGMT.FINISH.MESSAGE')}. ${t(
|
||||
'INBOX_MGMT.FINISH.WHATSAPP_QR_INSTRUCTION'
|
||||
)}`;
|
||||
}
|
||||
|
||||
return t('INBOX_MGMT.FINISH.MESSAGE');
|
||||
});
|
||||
|
||||
@@ -108,7 +109,6 @@ async function generateQRCode(platform, identifier) {
|
||||
|
||||
try {
|
||||
const platformUrls = {
|
||||
sms: id => `sms:${id}`,
|
||||
whatsapp: id => `https://wa.me/${id}`,
|
||||
messenger: id => `https://m.me/${id}`,
|
||||
telegram: id => `https://t.me/${id}`,
|
||||
@@ -127,13 +127,15 @@ async function generateQRCode(platform, identifier) {
|
||||
async function generateQRCodes() {
|
||||
if (!currentInbox.value) return;
|
||||
|
||||
// WhatsApp
|
||||
if (shouldShowWhatsAppQr.value) {
|
||||
await generateQRCode('whatsapp', whatsappPhoneNumber.value);
|
||||
}
|
||||
|
||||
if (shouldShowSmsQr.value) {
|
||||
await generateQRCode('sms', currentInbox.value.phone_number);
|
||||
// WhatsApp (both Cloud and Twilio)
|
||||
if (currentInbox.value.phone_number && isAWhatsAppChannel.value) {
|
||||
// For Twilio WhatsApp, phone_number format is "whatsapp:+1234567890"
|
||||
// Extract just the phone number part for QR code generation
|
||||
const phoneNumber = currentInbox.value.phone_number.replace(
|
||||
'whatsapp:',
|
||||
''
|
||||
);
|
||||
await generateQRCode('whatsapp', phoneNumber);
|
||||
}
|
||||
|
||||
// Facebook Messenger
|
||||
@@ -181,6 +183,13 @@ onMounted(() => {
|
||||
:script="currentInbox.web_widget_script"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-[50%] max-w-[50%] ml-[25%]">
|
||||
<woot-code
|
||||
v-if="isATwilioWhatsAppChannel"
|
||||
lang="html"
|
||||
:script="currentInbox.callback_webhook_url"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="shouldShowWhatsAppWebhookDetails"
|
||||
class="w-[50%] max-w-[50%] ml-[25%]"
|
||||
@@ -208,32 +217,12 @@ onMounted(() => {
|
||||
:script="currentInbox.callback_webhook_url"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isASmsInbox && !isATwilioSMSChannel"
|
||||
class="w-[50%] max-w-[50%] ml-[25%]"
|
||||
>
|
||||
<p class="mt-8 font-medium text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.SMS.BANDWIDTH.API_CALLBACK.TITLE') }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-n-slate-9">
|
||||
{{ $t('INBOX_MGMT.ADD.SMS.BANDWIDTH.API_CALLBACK.SUBTITLE') }}
|
||||
</p>
|
||||
<woot-code lang="html" :script="currentInbox.callback_webhook_url" />
|
||||
</div>
|
||||
<div
|
||||
v-if="shouldShowSmsQr && qrCodes.sms"
|
||||
class="flex flex-col gap-3 items-center mt-8"
|
||||
>
|
||||
<p class="mt-2 text-sm text-n-slate-9">
|
||||
{{ $t('INBOX_MGMT.FINISH.SMS_QR_INSTRUCTION') }}
|
||||
</p>
|
||||
<div class="rounded-lg shadow outline-1 outline-n-strong outline">
|
||||
<img
|
||||
:src="qrCodes.sms"
|
||||
alt="SMS QR Code"
|
||||
class="rounded-lg size-48 dark:invert"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-[50%] max-w-[50%] ml-[25%]">
|
||||
<woot-code
|
||||
v-if="isASmsInbox"
|
||||
lang="html"
|
||||
:script="currentInbox.callback_webhook_url"
|
||||
/>
|
||||
</div>
|
||||
<EmailInboxFinish
|
||||
v-if="isAnEmailChannel && !currentInbox.provider"
|
||||
@@ -241,7 +230,7 @@ onMounted(() => {
|
||||
:inbox-id="$route.params.inbox_id"
|
||||
/>
|
||||
<div
|
||||
v-if="shouldShowWhatsAppQr && qrCodes.whatsapp"
|
||||
v-if="isAWhatsAppChannel && qrCodes.whatsapp"
|
||||
class="flex flex-col gap-3 items-center mt-8"
|
||||
>
|
||||
<p class="mt-2 text-sm text-n-slate-9">
|
||||
|
||||
@@ -4,7 +4,6 @@ import InboxReconnectionRequired from '../components/InboxReconnectionRequired.v
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import { loadScript } from 'dashboard/helper/DOMHelpers';
|
||||
import { buildFacebookLoginScopes } from 'dashboard/helper/facebookScopes';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
|
||||
export default {
|
||||
@@ -21,11 +20,6 @@ export default {
|
||||
inboxId() {
|
||||
return this.inbox.id;
|
||||
},
|
||||
facebookLoginScopes() {
|
||||
return buildFacebookLoginScopes({
|
||||
includeInstagramScopes: !!this.inbox.instagram_id,
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.fbAsyncInit = this.runFBInit;
|
||||
@@ -83,7 +77,8 @@ export default {
|
||||
}
|
||||
},
|
||||
{
|
||||
scope: this.facebookLoginScopes,
|
||||
scope:
|
||||
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages',
|
||||
auth_type: 'reauthorize',
|
||||
}
|
||||
);
|
||||
|
||||
-42
@@ -1,11 +1,9 @@
|
||||
<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: {
|
||||
@@ -13,7 +11,6 @@ export default {
|
||||
SettingsToggleSection,
|
||||
NextInput,
|
||||
NextButton,
|
||||
Spinner,
|
||||
},
|
||||
props: {
|
||||
inbox: {
|
||||
@@ -24,11 +21,9 @@ 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: {
|
||||
@@ -67,27 +62,8 @@ 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 {
|
||||
@@ -147,24 +123,6 @@ 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,13 +24,10 @@ 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: {
|
||||
@@ -47,27 +44,8 @@ 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;
|
||||
@@ -139,25 +117,6 @@ 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')"
|
||||
|
||||
@@ -163,10 +163,6 @@ export const actions = {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: false });
|
||||
}
|
||||
},
|
||||
|
||||
getCacheKeys: async () => {
|
||||
return AccountAPI.getCacheKeys();
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
|
||||
@@ -44,7 +44,7 @@ export const actions = {
|
||||
get: async ({ commit }) => {
|
||||
commit(types.default.SET_AGENT_FETCHING_STATUS, true);
|
||||
try {
|
||||
const response = await AgentAPI.get();
|
||||
const response = await AgentAPI.get(true);
|
||||
commit(types.default.SET_AGENT_FETCHING_STATUS, false);
|
||||
commit(types.default.SET_AGENTS, response.data);
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,21 +2,15 @@ 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 = normalizeCount(count);
|
||||
if (parsedCount > 0) {
|
||||
const parsedCount = Number(count);
|
||||
if (Number.isFinite(parsedCount) && parsedCount > 0) {
|
||||
result[String(id)] = parsedCount;
|
||||
}
|
||||
|
||||
@@ -25,9 +19,6 @@ const normalizeCounts = counts => {
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
getAllUnreadCount($state) {
|
||||
return $state.allCount;
|
||||
},
|
||||
getInboxUnreadCount: $state => inboxId => {
|
||||
return $state.inboxes[String(inboxId)] || 0;
|
||||
},
|
||||
@@ -64,7 +55,6 @@ 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);
|
||||
|
||||
@@ -189,17 +189,6 @@ const sendAnalyticsEvent = channelType => {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
revalidate: async ({ commit }, { newKey }) => {
|
||||
try {
|
||||
const isExistingKeyValid = await InboxesAPI.validateCacheKey(newKey);
|
||||
if (!isExistingKeyValid) {
|
||||
const response = await InboxesAPI.refetchAndCommit(newKey);
|
||||
commit(types.default.SET_INBOXES, response.data.payload);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
},
|
||||
get: async ({ commit }) => {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: true });
|
||||
try {
|
||||
|
||||
@@ -32,18 +32,6 @@ export const getters = {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
revalidate: async function revalidate({ commit }, { newKey }) {
|
||||
try {
|
||||
const isExistingKeyValid = await LabelsAPI.validateCacheKey(newKey);
|
||||
if (!isExistingKeyValid) {
|
||||
const response = await LabelsAPI.refetchAndCommit(newKey);
|
||||
commit(types.SET_LABELS, response.data.payload);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
},
|
||||
|
||||
get: async function getLabels({ commit }) {
|
||||
commit(types.SET_LABEL_UI_FLAG, { isFetching: true });
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { actions } from '../../agents';
|
||||
import * as types from '../../../mutation-types';
|
||||
import AgentAPI from '../../../../api/agents';
|
||||
import agentList from './fixtures';
|
||||
|
||||
const commit = vi.fn();
|
||||
@@ -8,6 +9,13 @@ const dispatch = vi.fn();
|
||||
global.axios = axios;
|
||||
vi.mock('axios');
|
||||
|
||||
// Clear the IDB-backed cache between tests so each case starts from a known
|
||||
// empty state and isn't affected by data persisted by a previous test.
|
||||
beforeEach(async () => {
|
||||
await AgentAPI.dataManager.initDb();
|
||||
await AgentAPI.dataManager.db.clear(AgentAPI.cacheModelName);
|
||||
});
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#get', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import axios from 'axios';
|
||||
import { actions } from '../../attributes';
|
||||
import * as types from '../../../mutation-types';
|
||||
import AttributeAPI from '../../../../api/attributes';
|
||||
import attributesList from './fixtures';
|
||||
|
||||
const commit = vi.fn();
|
||||
global.axios = axios;
|
||||
vi.mock('axios');
|
||||
|
||||
// Clear the IDB-backed cache between tests so each case starts from a known
|
||||
// empty state and isn't affected by data persisted by a previous test.
|
||||
beforeEach(async () => {
|
||||
await AttributeAPI.dataManager.initDb();
|
||||
await AttributeAPI.dataManager.db.clear(AttributeAPI.cacheModelName);
|
||||
});
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#get', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
|
||||
@@ -15,7 +15,6 @@ 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,7 +3,6 @@ import { getters } from '../../conversationUnreadCounts';
|
||||
describe('#getters', () => {
|
||||
it('returns inbox unread count by id', () => {
|
||||
const state = {
|
||||
allCount: 0,
|
||||
inboxes: { 1: 2 },
|
||||
labels: {},
|
||||
teams: {},
|
||||
@@ -16,7 +15,6 @@ describe('#getters', () => {
|
||||
|
||||
it('returns label unread count by id', () => {
|
||||
const state = {
|
||||
allCount: 0,
|
||||
inboxes: {},
|
||||
labels: { 3: 4 },
|
||||
teams: {},
|
||||
@@ -29,7 +27,6 @@ describe('#getters', () => {
|
||||
|
||||
it('returns team unread count by id', () => {
|
||||
const state = {
|
||||
allCount: 0,
|
||||
inboxes: {},
|
||||
labels: {},
|
||||
teams: { 5: 6 },
|
||||
@@ -40,20 +37,8 @@ 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 },
|
||||
|
||||
+1
-15
@@ -4,10 +4,9 @@ import { mutations } from '../../conversationUnreadCounts';
|
||||
describe('#mutations', () => {
|
||||
describe('#SET_CONVERSATION_UNREAD_COUNTS', () => {
|
||||
it('normalizes unread count payload', () => {
|
||||
const state = { allCount: 0, inboxes: {}, labels: {}, teams: {} };
|
||||
const state = { inboxes: {}, labels: {}, teams: {} };
|
||||
|
||||
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
|
||||
all_count: '3',
|
||||
inboxes: {
|
||||
1: '2',
|
||||
2: 0,
|
||||
@@ -24,7 +23,6 @@ describe('#mutations', () => {
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
allCount: 3,
|
||||
inboxes: { 1: 2 },
|
||||
labels: { 4: 5 },
|
||||
teams: { 6: 7 },
|
||||
@@ -33,7 +31,6 @@ describe('#mutations', () => {
|
||||
|
||||
it('clears counts when payload is empty', () => {
|
||||
const state = {
|
||||
allCount: 2,
|
||||
inboxes: { 1: 2 },
|
||||
labels: { 4: 5 },
|
||||
teams: { 6: 7 },
|
||||
@@ -42,21 +39,10 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import axios from 'axios';
|
||||
import { actions } from '../../inboxes';
|
||||
import * as types from '../../../mutation-types';
|
||||
import InboxesAPI from '../../../../api/inboxes';
|
||||
import inboxList from './fixtures';
|
||||
|
||||
const commit = vi.fn();
|
||||
global.axios = axios;
|
||||
vi.mock('axios');
|
||||
|
||||
// Clear the IDB-backed cache between tests so each case starts from a known
|
||||
// empty state and isn't affected by data persisted by a previous test.
|
||||
beforeEach(async () => {
|
||||
await InboxesAPI.dataManager.initDb();
|
||||
await InboxesAPI.dataManager.db.clear(InboxesAPI.cacheModelName);
|
||||
});
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#get', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import axios from 'axios';
|
||||
import { actions } from '../../labels';
|
||||
import * as types from '../../../mutation-types';
|
||||
import LabelsAPI from '../../../../api/labels';
|
||||
import labelsList from './fixtures';
|
||||
|
||||
const commit = vi.fn();
|
||||
global.axios = axios;
|
||||
vi.mock('axios');
|
||||
|
||||
// Clear the IDB-backed cache between tests so each case starts from a known
|
||||
// empty state and isn't affected by data persisted by a previous test.
|
||||
beforeEach(async () => {
|
||||
await LabelsAPI.dataManager.initDb();
|
||||
await LabelsAPI.dataManager.db.clear(LabelsAPI.cacheModelName);
|
||||
});
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#get', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
|
||||
@@ -2,18 +2,25 @@ import axios from 'axios';
|
||||
import { actions } from '../../teams/actions';
|
||||
import {
|
||||
SET_TEAM_UI_FLAG,
|
||||
CLEAR_TEAMS,
|
||||
SET_TEAMS,
|
||||
SET_TEAM_ITEM,
|
||||
EDIT_TEAM,
|
||||
DELETE_TEAM,
|
||||
} from '../../teams/types';
|
||||
import TeamsAPI from '../../../../api/teams';
|
||||
import teamsList from './fixtures';
|
||||
|
||||
const commit = vi.fn();
|
||||
global.axios = axios;
|
||||
vi.mock('axios');
|
||||
|
||||
// Clear the IDB-backed cache between tests so each case starts from a known
|
||||
// empty state and isn't affected by data persisted by a previous test.
|
||||
beforeEach(async () => {
|
||||
await TeamsAPI.dataManager.initDb();
|
||||
await TeamsAPI.dataManager.db.clear(TeamsAPI.cacheModelName);
|
||||
});
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#get', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
@@ -33,7 +40,6 @@ describe('#actions', () => {
|
||||
await actions.get({ commit });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[SET_TEAM_UI_FLAG, { isFetching: true }],
|
||||
[CLEAR_TEAMS],
|
||||
[SET_TEAMS, teamsList[1]],
|
||||
[SET_TEAM_UI_FLAG, { isFetching: false }],
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
CLEAR_TEAMS,
|
||||
SET_TEAMS,
|
||||
SET_TEAM_ITEM,
|
||||
EDIT_TEAM,
|
||||
@@ -11,10 +10,15 @@ describe('#mutations', () => {
|
||||
describe('#SET_teams', () => {
|
||||
it('set teams records', () => {
|
||||
const state = { records: {} };
|
||||
mutations[SET_TEAMS](state, [teams[1]]);
|
||||
mutations[SET_TEAMS](state, [teams[2]]);
|
||||
mutations[SET_TEAMS](state, [teams[1], teams[2]]);
|
||||
expect(state.records).toEqual(teams);
|
||||
});
|
||||
|
||||
it('drops records absent from the new list', () => {
|
||||
const state = { records: { ...teams } };
|
||||
mutations[SET_TEAMS](state, [teams[1]]);
|
||||
expect(state.records).toEqual({ 1: teams[1] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('#ADD_TEAM', () => {
|
||||
@@ -43,12 +47,4 @@ describe('#mutations', () => {
|
||||
expect(state.records).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#CLEAR_TEAMS', () => {
|
||||
it('delete teams record', () => {
|
||||
const state = { records: { 1: teams[1] } };
|
||||
mutations[CLEAR_TEAMS](state);
|
||||
expect(state.records).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
SET_TEAM_UI_FLAG,
|
||||
CLEAR_TEAMS,
|
||||
SET_TEAMS,
|
||||
SET_TEAM_ITEM,
|
||||
EDIT_TEAM,
|
||||
@@ -22,22 +21,10 @@ export const actions = {
|
||||
commit(SET_TEAM_UI_FLAG, { isCreating: false });
|
||||
}
|
||||
},
|
||||
revalidate: async ({ commit }, { newKey }) => {
|
||||
try {
|
||||
const isExistingKeyValid = await TeamsAPI.validateCacheKey(newKey);
|
||||
if (!isExistingKeyValid) {
|
||||
const response = await TeamsAPI.refetchAndCommit(newKey);
|
||||
commit(SET_TEAMS, response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
},
|
||||
get: async ({ commit }) => {
|
||||
commit(SET_TEAM_UI_FLAG, { isFetching: true });
|
||||
try {
|
||||
const { data } = await TeamsAPI.get(true);
|
||||
commit(CLEAR_TEAMS);
|
||||
commit(SET_TEAMS, data);
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
SET_TEAM_UI_FLAG,
|
||||
CLEAR_TEAMS,
|
||||
SET_TEAMS,
|
||||
SET_TEAM_ITEM,
|
||||
EDIT_TEAM,
|
||||
@@ -15,19 +14,14 @@ export const mutations = {
|
||||
};
|
||||
},
|
||||
|
||||
[CLEAR_TEAMS]: $state => {
|
||||
$state.records = {};
|
||||
},
|
||||
|
||||
// Replaces (not merges) so rows deleted server-side never survive as
|
||||
// phantoms — SET_TEAMS only ever receives the full list.
|
||||
[SET_TEAMS]: ($state, data) => {
|
||||
const updatedRecords = { ...$state.records };
|
||||
const records = {};
|
||||
data.forEach(team => {
|
||||
updatedRecords[team.id] = {
|
||||
...(updatedRecords[team.id] || {}),
|
||||
...team,
|
||||
};
|
||||
records[team.id] = team;
|
||||
});
|
||||
$state.records = updatedRecords;
|
||||
$state.records = records;
|
||||
},
|
||||
|
||||
[SET_TEAM_ITEM]: ($state, data) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export const SET_TEAM_UI_FLAG = 'SET_TEAM_UI_FLAG';
|
||||
export const CLEAR_TEAMS = 'CLEAR_TEAMS';
|
||||
export const SET_TEAMS = 'SET_TEAMS';
|
||||
export const SET_TEAM_ITEM = 'SET_TEAM_ITEM';
|
||||
export const EDIT_TEAM = 'EDIT_TEAM';
|
||||
|
||||
@@ -36,9 +36,6 @@ 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 = () => {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,9 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
|
||||
filename: avatar_file.original_filename,
|
||||
content_type: avatar_file.content_type
|
||||
)
|
||||
|
||||
# Agent thumbnails are cached separately, and avatar attachments do not dirty user columns.
|
||||
avatarable.invalidate_avatar_cache if avatarable.respond_to?(:invalidate_avatar_cache)
|
||||
end
|
||||
|
||||
def log_http_error(avatar_url, error)
|
||||
|
||||
@@ -36,10 +36,16 @@ class AccountUser < ApplicationRecord
|
||||
|
||||
accepts_nested_attributes_for :account
|
||||
|
||||
AGENT_CACHE_RELEVANT_COLUMNS = %w[role availability auto_offline custom_role_id].freeze
|
||||
|
||||
after_create_commit :notify_creation, :create_notification_setting
|
||||
after_destroy :notify_deletion, :remove_user_from_account
|
||||
after_save :update_presence_in_redis, if: :saved_change_to_availability?
|
||||
|
||||
after_commit -> { account.update_cache_key('account_user') }, on: [:create, :destroy]
|
||||
after_update_commit -> { account.update_cache_key('account_user') },
|
||||
if: -> { saved_changes.keys.intersect?(AGENT_CACHE_RELEVANT_COLUMNS) }
|
||||
|
||||
validates :user_id, uniqueness: { scope: :account_id }
|
||||
|
||||
def create_notification_setting
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
MAX_STRING_COLUMN_LENGTH = 255
|
||||
MAX_TEXT_COLUMN_LENGTH = 20_000
|
||||
|
||||
include Events::Types
|
||||
self.abstract_class = true
|
||||
|
||||
@@ -40,7 +37,7 @@ class ApplicationRecord < ActiveRecord::Base
|
||||
end
|
||||
|
||||
def validate_content_length(column)
|
||||
max_length = column.type == :text ? MAX_TEXT_COLUMN_LENGTH : MAX_STRING_COLUMN_LENGTH
|
||||
max_length = column.type == :text ? 20_000 : 255
|
||||
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)")
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
#
|
||||
|
||||
class CannedResponse < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
validates :content, presence: true
|
||||
validates :short_code, presence: true
|
||||
validates :account, presence: true
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
# 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
|
||||
@@ -55,11 +54,6 @@ 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,11 +49,6 @@ 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.
|
||||
|
||||
@@ -4,11 +4,15 @@ module CacheKeys
|
||||
include CacheKeysHelper
|
||||
include Events::Types
|
||||
|
||||
CACHE_KEYS_EXPIRY = 72.hours
|
||||
# Self-healing bound: if a write path ever changes cached data without
|
||||
# bumping its key, expiry forces the sentinel and every client refetches.
|
||||
# 7 days caps that staleness while sparing quiet models (labels, teams)
|
||||
# from a spurious full refetch after every idle weekend.
|
||||
CACHE_KEYS_EXPIRY = 7.days
|
||||
|
||||
included do
|
||||
class_attribute :cacheable_models
|
||||
self.cacheable_models = [Label, Inbox, Team]
|
||||
self.cacheable_models = [Label, Inbox, Team, CannedResponse, AccountUser, CustomAttributeDefinition]
|
||||
end
|
||||
|
||||
def cache_keys
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
# index_custom_attribute_definitions_on_account_id (account_id)
|
||||
#
|
||||
class CustomAttributeDefinition < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
STANDARD_ATTRIBUTES = {
|
||||
:conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at
|
||||
last_activity_at],
|
||||
|
||||
@@ -50,6 +50,13 @@ class Portal < ApplicationRecord
|
||||
schema: PortalConfigSchema::CONFIG_PARAMS_SCHEMA,
|
||||
attribute_resolver: ->(record) { record.config }
|
||||
|
||||
# Portal name/slug are embedded as help_center into the cached inbox payload
|
||||
# (api/v1/models/_inbox.json.jbuilder), so portal changes must bump the
|
||||
# account's inbox cache key. Destroy is covered too: dependent: :nullify
|
||||
# detaches inboxes via update_all and skips their callbacks.
|
||||
after_update_commit -> { account.update_cache_key('inbox') }
|
||||
after_destroy_commit -> { account.update_cache_key('inbox') }
|
||||
|
||||
scope :active, -> { where(archived: false) }
|
||||
|
||||
# TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config
|
||||
|
||||
@@ -18,6 +18,12 @@ class TeamMember < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :team
|
||||
validates :user_id, uniqueness: { scope: :team_id }
|
||||
|
||||
# is_member is embedded into the cached team payload (per current user) via
|
||||
# api/v1/models/_team.json.jbuilder, so membership changes must bump the team
|
||||
# cache key. team is safe-navigated because destroying a team cascades here
|
||||
# via destroy_async, by which point the team row is already gone.
|
||||
after_commit -> { team&.account&.update_cache_key('team') }, on: [:create, :destroy]
|
||||
end
|
||||
|
||||
TeamMember.include_mod_with('Audit::TeamMember')
|
||||
|
||||
@@ -116,8 +116,12 @@ class User < ApplicationRecord
|
||||
has_many :macros, foreign_key: 'created_by_id', inverse_of: :created_by
|
||||
# rubocop:enable Rails/HasManyOrHasOneDependent
|
||||
|
||||
AGENT_CACHE_RELEVANT_COLUMNS = %w[name email display_name confirmed_at custom_attributes].freeze
|
||||
|
||||
before_validation :set_password_and_uid, on: :create
|
||||
after_destroy :remove_macros
|
||||
after_update_commit :bump_account_user_cache_keys,
|
||||
if: -> { saved_changes.keys.intersect?(AGENT_CACHE_RELEVANT_COLUMNS) }
|
||||
|
||||
scope :order_by_full_name, -> { order('lower(name) ASC') }
|
||||
|
||||
@@ -212,11 +216,19 @@ class User < ApplicationRecord
|
||||
super
|
||||
end
|
||||
|
||||
def invalidate_avatar_cache
|
||||
bump_account_user_cache_keys
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remove_macros
|
||||
macros.personal.destroy_all
|
||||
end
|
||||
|
||||
def bump_account_user_cache_keys
|
||||
accounts.each { |account| account.update_cache_key('account_user') }
|
||||
end
|
||||
end
|
||||
|
||||
User.include_mod_with('Audit::User')
|
||||
|
||||
@@ -77,8 +77,4 @@ class InboxPolicy < ApplicationPolicy
|
||||
def disable_whatsapp_calling?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def set_inbound_calls?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,8 +23,6 @@ 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,11 +19,8 @@ class Conversations::UnreadCounts::Counter
|
||||
ensure_base_cache!
|
||||
ensure_assignment_cache! if assignment_mode?
|
||||
|
||||
inbox_counts = unread_inbox_counts
|
||||
|
||||
{
|
||||
all_count: inbox_counts.values.sum,
|
||||
inboxes: inbox_counts,
|
||||
inboxes: unread_inbox_counts,
|
||||
labels: unread_label_counts,
|
||||
teams: unread_team_counts
|
||||
}
|
||||
@@ -194,7 +191,7 @@ class Conversations::UnreadCounts::Counter
|
||||
end
|
||||
|
||||
def empty_counts
|
||||
{ all_count: 0, inboxes: {}, labels: {}, teams: {} }
|
||||
{ inboxes: {}, labels: {}, teams: {} }
|
||||
end
|
||||
|
||||
def store
|
||||
|
||||
@@ -33,16 +33,12 @@ class Twilio::WebhookSetupService
|
||||
end
|
||||
end
|
||||
|
||||
def twilio_phone_number
|
||||
channel.phone_number.delete_prefix('whatsapp:')
|
||||
end
|
||||
|
||||
def phonenumber_sid
|
||||
phone_numbers.first.sid
|
||||
end
|
||||
|
||||
def phone_numbers
|
||||
@phone_numbers ||= twilio_client.incoming_phone_numbers.list(phone_number: twilio_phone_number)
|
||||
@phone_numbers ||= twilio_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
|
||||
end
|
||||
|
||||
def channel
|
||||
@@ -50,14 +46,6 @@ class Twilio::WebhookSetupService
|
||||
end
|
||||
|
||||
def twilio_client
|
||||
@twilio_client ||= if channel.api_key_sid.present?
|
||||
::Twilio::REST::Client.new(
|
||||
channel.api_key_sid,
|
||||
channel.auth_token,
|
||||
channel.account_sid
|
||||
)
|
||||
else
|
||||
::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
|
||||
end
|
||||
@twilio_client ||= ::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,18 +37,12 @@ class WebsiteBrandingService
|
||||
private
|
||||
|
||||
def fetch_page
|
||||
body = nil
|
||||
SafeFetch.fetch(@url, validate_content_type: false) do |result|
|
||||
body = result.tempfile.read
|
||||
end
|
||||
@http_status = 200
|
||||
return nil if body.blank?
|
||||
response = HTTParty.get(@url, follow_redirects: true, timeout: 15)
|
||||
@http_status = response.code
|
||||
return nil unless response.success?
|
||||
|
||||
Nokogiri::HTML(body)
|
||||
rescue SafeFetch::HttpError => e
|
||||
@http_status = e.message.to_i
|
||||
nil
|
||||
rescue SafeFetch::Error => e
|
||||
Nokogiri::HTML(response.body)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WebsiteBranding] Failed to fetch #{@url}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
@@ -13,6 +13,7 @@ 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:
|
||||
@@ -41,6 +42,10 @@ 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(
|
||||
|
||||
@@ -13,17 +13,8 @@ class Whatsapp::IncomingMessageWhatsappCloudService < Whatsapp::IncomingMessageB
|
||||
inbox.channel.media_url(attachment_payload[:id]),
|
||||
headers: inbox.channel.api_headers
|
||||
)
|
||||
|
||||
# This url response will be failure if the access token has expired.
|
||||
inbox.channel.authorization_error! if url_response.unauthorized?
|
||||
|
||||
return unless url_response.success?
|
||||
|
||||
downloaded_file = Down.download(url_response.parsed_response['url'], headers: inbox.channel.api_headers)
|
||||
# WhatsApp Cloud sends the original filename in the payload; preserve it so accented
|
||||
# names keep their correct extension instead of relying on the mangled remote metadata.
|
||||
filename = attachment_payload[:filename]
|
||||
downloaded_file.define_singleton_method(:original_filename) { filename } if filename.present?
|
||||
downloaded_file
|
||||
Down.download(url_response.parsed_response['url'], headers: inbox.channel.api_headers) if url_response.success?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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
|
||||
@@ -14,9 +14,6 @@ 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,7 +140,6 @@ 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?
|
||||
@@ -150,7 +149,4 @@ if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
|
||||
end
|
||||
|
||||
## Voice attribute for WhatsApp Cloud (only embedded-signup channels surface true)
|
||||
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
|
||||
json.voice_enabled resource.channel.voice_enabled? if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.14.2'
|
||||
version: '4.14.1'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -19,7 +19,7 @@ class Rack::Attack
|
||||
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(redis: $velma, pool: false)
|
||||
|
||||
class Request < ::Rack::Request
|
||||
# You may need to specify a method to fetch the correct remote IP address
|
||||
# You many need to specify a method to fetch the correct remote IP address
|
||||
# if the web server is behind a load balancer.
|
||||
def remote_ip
|
||||
@remote_ip ||= (env['action_dispatch.remote_ip'] || ip).to_s
|
||||
@@ -31,9 +31,9 @@ class Rack::Attack
|
||||
(default_allowed_ips + env_allowed_ips).include?(remote_ip)
|
||||
end
|
||||
|
||||
# Rails would allow requests to paths with extensions, so lets compare against the path with extension stripped
|
||||
# Rails would allow requests to paths with extentions, so lets compare against the path with extention stripped
|
||||
# example /auth & /auth.json would both work
|
||||
def path_without_extensions
|
||||
def path_without_extentions
|
||||
path[/^[^.]+/]
|
||||
end
|
||||
end
|
||||
@@ -75,11 +75,11 @@ class Rack::Attack
|
||||
|
||||
### Prevent Brute-Force Super Admin Login Attacks ###
|
||||
throttle('super_admin_login/ip', limit: 5, period: 5.minutes) do |req|
|
||||
req.ip if req.path_without_extensions == '/super_admin/sign_in' && req.post?
|
||||
req.ip if req.path_without_extentions == '/super_admin/sign_in' && req.post?
|
||||
end
|
||||
|
||||
throttle('super_admin_login/email', limit: 5, period: 15.minutes) do |req|
|
||||
if req.path_without_extensions == '/super_admin/sign_in' && req.post?
|
||||
if req.path_without_extentions == '/super_admin/sign_in' && req.post?
|
||||
# NOTE: This line used to throw ArgumentError /rails/action_mailbox/sendgrid/inbound_emails : invalid byte sequence in UTF-8
|
||||
# Hence placed in the if block
|
||||
# ref: https://github.com/rack/rack-attack/issues/399
|
||||
@@ -91,7 +91,7 @@ class Rack::Attack
|
||||
# ### Prevent Brute-Force Login Attacks ###
|
||||
# Exclude MFA verification attempts from regular login throttling
|
||||
throttle('login/ip', limit: 5, period: 5.minutes) do |req|
|
||||
if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
|
||||
if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
|
||||
# Skip if this is an MFA verification request
|
||||
req.ip
|
||||
end
|
||||
@@ -99,7 +99,7 @@ class Rack::Attack
|
||||
|
||||
throttle('login/email', limit: 10, period: 15.minutes) do |req|
|
||||
# Skip if this is an MFA verification request
|
||||
if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
|
||||
if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
|
||||
# ref: https://github.com/rack/rack-attack/issues/399
|
||||
# NOTE: This line used to throw ArgumentError /rails/action_mailbox/sendgrid/inbound_emails : invalid byte sequence in UTF-8
|
||||
# Hence placed in the if block
|
||||
@@ -110,11 +110,11 @@ class Rack::Attack
|
||||
|
||||
## Reset password throttling
|
||||
throttle('reset_password/ip', limit: 5, period: 30.minutes) do |req|
|
||||
req.ip if req.path_without_extensions == '/auth/password' && req.post?
|
||||
req.ip if req.path_without_extentions == '/auth/password' && req.post?
|
||||
end
|
||||
|
||||
throttle('reset_password/email', limit: 5, period: 1.hour) do |req|
|
||||
if req.path_without_extensions == '/auth/password' && req.post?
|
||||
if req.path_without_extentions == '/auth/password' && req.post?
|
||||
email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence
|
||||
email.to_s.downcase.gsub(/\s+/, '')
|
||||
end
|
||||
@@ -122,11 +122,11 @@ class Rack::Attack
|
||||
|
||||
## Resend confirmation throttling (unauthenticated)
|
||||
throttle('resend_confirmation/ip', limit: 5, period: 30.minutes) do |req|
|
||||
req.ip if req.path_without_extensions == '/resend_confirmation' && req.post?
|
||||
req.ip if req.path_without_extentions == '/resend_confirmation' && req.post?
|
||||
end
|
||||
|
||||
throttle('resend_confirmation/email', limit: 5, period: 1.hour) do |req|
|
||||
if req.path_without_extensions == '/resend_confirmation' && req.post?
|
||||
if req.path_without_extentions == '/resend_confirmation' && req.post?
|
||||
email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence
|
||||
email.to_s.downcase.gsub(/\s+/, '')
|
||||
end
|
||||
@@ -134,25 +134,25 @@ class Rack::Attack
|
||||
|
||||
## Resend confirmation throttling (authenticated)
|
||||
throttle('resend_confirmation_auth/ip', limit: 5, period: 30.minutes) do |req|
|
||||
req.ip if req.path_without_extensions == '/api/v1/profile/resend_confirmation' && req.post?
|
||||
req.ip if req.path_without_extentions == '/api/v1/profile/resend_confirmation' && req.post?
|
||||
end
|
||||
|
||||
## MFA throttling - prevent brute force attacks
|
||||
throttle('mfa_verification/ip', limit: 5, period: 1.minute) do |req|
|
||||
if req.path_without_extensions == '/api/v1/profile/mfa'
|
||||
if req.path_without_extentions == '/api/v1/profile/mfa'
|
||||
req.ip if req.delete? # Throttle disable attempts
|
||||
elsif req.path_without_extensions.match?(%r{/api/v1/profile/mfa/(verify|backup_codes)})
|
||||
elsif req.path_without_extentions.match?(%r{/api/v1/profile/mfa/(verify|backup_codes)})
|
||||
req.ip if req.post? # Throttle verify and backup_codes attempts
|
||||
end
|
||||
end
|
||||
|
||||
# Separate rate limiting for MFA verification attempts
|
||||
throttle('mfa_login/ip', limit: 10, period: 1.minute) do |req|
|
||||
req.ip if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].present?
|
||||
req.ip if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].present?
|
||||
end
|
||||
|
||||
throttle('mfa_login/token', limit: 10, period: 1.minute) do |req|
|
||||
if req.path_without_extensions == '/auth/sign_in' && req.post?
|
||||
if req.path_without_extentions == '/auth/sign_in' && req.post?
|
||||
# Track by MFA token to prevent brute force on a specific token
|
||||
mfa_token = req.params['mfa_token'].presence
|
||||
(mfa_token.presence)
|
||||
@@ -161,7 +161,7 @@ class Rack::Attack
|
||||
|
||||
## Prevent Brute-Force Signup Attacks ###
|
||||
throttle('accounts/ip', limit: 5, period: 30.minutes) do |req|
|
||||
req.ip if req.path_without_extensions == '/api/v1/accounts' && req.post?
|
||||
req.ip if req.path_without_extentions == '/api/v1/accounts' && req.post?
|
||||
end
|
||||
|
||||
##-----------------------------------------------##
|
||||
@@ -176,17 +176,17 @@ class Rack::Attack
|
||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_API', true))
|
||||
## Prevent Conversation Bombing on Widget APIs ###
|
||||
throttle('api/v1/widget/conversations', limit: 6, period: 12.hours) do |req|
|
||||
req.ip if req.path_without_extensions == '/api/v1/widget/conversations' && req.post?
|
||||
req.ip if req.path_without_extentions == '/api/v1/widget/conversations' && req.post?
|
||||
end
|
||||
|
||||
## Prevent Contact update Bombing in Widget API ###
|
||||
throttle('api/v1/widget/contacts', limit: 60, period: 1.hour) do |req|
|
||||
req.ip if req.path_without_extensions == '/api/v1/widget/contacts' && (req.patch? || req.put?)
|
||||
req.ip if req.path_without_extentions == '/api/v1/widget/contacts' && (req.patch? || req.put?)
|
||||
end
|
||||
|
||||
## Prevent Conversation Bombing through multiple sessions
|
||||
throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}', limit: 5, period: 1.hour) do |req|
|
||||
req.ip if req.path_without_extensions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
|
||||
req.ip if req.path_without_extentions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
# 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.
|
||||
|
||||
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
|
||||
Rails.application.config.session_store :cookie_store, key: '_chatwoot_session', same_site: :lax
|
||||
|
||||
+1
-4
@@ -55,9 +55,7 @@ Rails.application.routes.draw do
|
||||
resource :contact_merge, only: [:create]
|
||||
end
|
||||
resource :bulk_actions, only: [:create]
|
||||
resource :onboarding, only: [:update] do
|
||||
get :help_center_generation
|
||||
end
|
||||
resource :onboarding, only: [:update]
|
||||
resources :agents, only: [:index, :create, :update, :destroy] do
|
||||
post :bulk_create, on: :collection
|
||||
end
|
||||
@@ -267,7 +265,6 @@ 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
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
class AddProviderConfigToChannelTwilioSms < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :channel_twilio_sms, :provider_config, :jsonb, default: {}
|
||||
end
|
||||
end
|
||||
+1
-2
@@ -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_06_04_000000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -557,7 +557,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) 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)
|
||||
body = execute_test_request(tool)
|
||||
render json: { status: 200, body: body.to_s.truncate(500) }
|
||||
result = execute_test_request(tool)
|
||||
render json: { status: result.code.to_i, body: result.body.to_s.truncate(500) }
|
||||
rescue StandardError => e
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
module Enterprise::Api::V1::Accounts::InboxesController
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def inbox_attributes
|
||||
super + ee_inbox_attributes
|
||||
end
|
||||
@@ -23,24 +21,6 @@ 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
|
||||
@@ -55,14 +35,6 @@ 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
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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,8 +24,6 @@ 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
|
||||
@@ -90,16 +88,6 @@ 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_generation, generation_id: generation_id, reason: reason)
|
||||
job.send(:skip_and_broadcast, user: User.find_by(id: user_id), 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_generation(generation_id: generation_id, reason: e.message)
|
||||
skip_and_broadcast(user: User.find_by(id: user_id), generation_id: generation_id, reason: e.message)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -89,12 +89,15 @@ 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
|
||||
account_id, portal_id, user_id, generation_id, { article: article }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def skip_generation(generation_id:, reason:)
|
||||
def skip_and_broadcast(user:, 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,27 +9,43 @@ class Onboarding::HelpCenterArticleWriterJob < ApplicationJob
|
||||
job.send(:on_writer_failure, error)
|
||||
end
|
||||
|
||||
def perform(account_id, portal_id, user_id, generation_id, article)
|
||||
Onboarding::HelpCenterArticleBuilder.new(
|
||||
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(
|
||||
account: Account.find(account_id),
|
||||
portal: Portal.find(portal_id),
|
||||
user: User.find(user_id),
|
||||
article: article
|
||||
user: user,
|
||||
article: payload[:article]
|
||||
).perform
|
||||
|
||||
finalize(generation_id: generation_id)
|
||||
finalize(user: user, generation_id: generation_id, article: article)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def on_writer_failure(error)
|
||||
generation_id = arguments[3]
|
||||
user, generation_id = failure_context
|
||||
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} failed: #{error.class} #{error.message}"
|
||||
finalize(generation_id: generation_id)
|
||||
finalize(user: user, generation_id: generation_id, article: nil)
|
||||
end
|
||||
|
||||
def finalize(generation_id:)
|
||||
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
|
||||
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')
|
||||
rescue Onboarding::HelpCenterGenerationState::Missing => e
|
||||
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} #{e.message}"
|
||||
end
|
||||
|
||||
@@ -39,4 +39,11 @@ class CustomRole < ApplicationRecord
|
||||
|
||||
validates :name, presence: true
|
||||
validates :permissions, inclusion: { in: PERMISSIONS }
|
||||
|
||||
# CustomRole details are embedded into the cached account_user payload via
|
||||
# api/v1/models/_account_user.json.jbuilder, so bump that cache key on any
|
||||
# change. `dependent: :nullify` updates account_users via update_all (which
|
||||
# skips their callbacks), so the deletion is bumped here directly.
|
||||
after_update_commit -> { account.update_cache_key('account_user') }
|
||||
after_destroy_commit -> { account.update_cache_key('account_user') }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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,7 +77,6 @@ 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')
|
||||
claim_conversation_and_set_call_status
|
||||
update_conversation_call_status(call.display_status)
|
||||
broadcast(:accepted, accepted_by_agent_id: agent.id)
|
||||
end
|
||||
call
|
||||
@@ -56,6 +56,7 @@ 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!
|
||||
@@ -63,13 +64,9 @@ class Whatsapp::CallService
|
||||
invoke_provider!(:accept_call, sdp_answer)
|
||||
end
|
||||
|
||||
# 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)
|
||||
# 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?
|
||||
end
|
||||
|
||||
# Raise on Meta failure (bool false or transport error) so callers bail before
|
||||
|
||||
@@ -72,12 +72,6 @@ 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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user