Compare commits

..
130 changed files with 1103 additions and 1190 deletions
+20 -31
View File
@@ -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
View File
@@ -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])
+14
View File
@@ -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
+1 -12
View File
@@ -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,
+10 -2
View File
@@ -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;
}
}
-7
View File
@@ -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();
+3 -3
View File
@@ -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 }) {
+7 -5
View File
@@ -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);
}
}
+11 -6
View File
@@ -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);
}
}
+5 -12
View File
@@ -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();
+5 -6
View File
@@ -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 -17
View File
@@ -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',
}
);
@@ -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')"
@@ -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 },
@@ -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'
);
});
});
+3
View File
@@ -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)
+6
View File
@@ -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 -4
View File
@@ -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)")
+2
View File
@@ -11,6 +11,8 @@
#
class CannedResponse < ApplicationRecord
include AccountCacheRevalidator
validates :content, presence: true
validates :short_code, presence: true
validates :account, presence: true
-6
View File
@@ -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?
-5
View File
@@ -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.
+6 -2
View File
@@ -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],
+7
View File
@@ -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
+6
View File
@@ -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')
+12
View File
@@ -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')
-4
View File
@@ -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
+2 -14
View File
@@ -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
+5 -11
View File
@@ -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']
+1 -5
View File
@@ -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
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.14.2'
version: '4.14.1'
development:
<<: *shared
+20 -20
View File
@@ -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 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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