Compare commits

..
Author SHA1 Message Date
Shivam Mishra 94cc3228c8 perf: extend cache key expiry to 7 days 2026-06-10 13:03:07 +05:30
Shivam Mishra 3e65534e68 refactor: fold cache revalidation into the event dispatcher 2026-06-10 12:59:26 +05:30
Shivam Mishra 343b25842a refactor: replace teams merge mutation with full replace 2026-06-10 12:54:42 +05:30
Shivam Mishra 880c5e8bf4 refactor: configure cache api clients via constructor options 2026-06-10 12:46:10 +05:30
Shivam Mishra ac5b3a38c5 refactor: remove cold-path cache key preflight 2026-06-10 12:45:48 +05:30
Shivam Mishra 0b4d1c801c refactor: remove client-side cache key pulls 2026-06-10 12:37:11 +05:30
Shivam Mishra f452b88e8d refactor: simplify boot cache hydration to a paint-only step 2026-06-10 12:37:11 +05:30
Shivam Mishra 9fb75656d2 feat: transmit cache keys on room channel subscribe 2026-06-10 12:37:11 +05:30
Sivin VargheseandGitHub 1ccb0352d3 Merge branch 'develop' into feat/expand-idb-coverage 2026-06-09 20:55:11 +05:30
Shivam Mishra c13d59bb7f chore: update relevant columns for user 2026-06-09 15:18:16 +05:30
Shivam MishraandGitHub 08cb8e8638 Merge branch 'develop' into feat/expand-idb-coverage 2026-06-09 14:29:58 +05:30
Shivam Mishra b8629e5aa8 feat: remove http caching from cache keys 2026-06-08 19:01:24 +05:30
Shivam Mishra 91f2df8834 refactor: share cache revalidation dispatch 2026-06-08 18:47:42 +05:30
Shivam Mishra 625302ac3c refactor: deduplicate cache revalidation actions 2026-06-08 18:39:40 +05:30
Shivam Mishra 0deb026f9d fix: bump inbox cache when portal changes
The cached inbox payload embeds the portal name and slug as help_center,
but portal updates and deletes never bumped the inbox cache key, so warm
IDB caches kept showing stale help-center details. Bump the account inbox
cache key from the portal lifecycle; destroy is covered because nullify
detaches inboxes via update_all and skips their callbacks.
2026-06-08 18:28:09 +05:30
Shivam Mishra fe8c228715 fix: bump team cache when membership changes
The cached team payload embeds a per-user is_member flag, but TeamMember
create/destroy never bumped the team cache key, so warm IDB caches kept
showing a stale membership flag after a user was added to or removed from
a team. Bump the team cache key from the TeamMember lifecycle.
2026-06-08 18:27:58 +05:30
Shivam Mishra 68bd8290e5 fix: bump agent cache when custom role changes
Editing or deleting a custom role left warm IDB caches showing stale
embedded role details and permissions in the agent list, since the
account_user cache key only tracked custom_role_id on the account user.
Bump the account_user cache key from the custom role lifecycle so clients
refetch the agent list. The destroy bump is needed because dependent:
nullify updates account_users via update_all and skips their callbacks.
2026-06-08 18:17:14 +05:30
Shivam Mishra 304d5b85ef fix: revalidate cache after cable subscription 2026-06-08 18:05:14 +05:30
Shivam MishraandGitHub 81c01e7ff1 Merge branch 'develop' into feat/expand-idb-coverage 2026-06-08 17:58:14 +05:30
Shivam Mishra b8bca46ba8 fix: address cache revalidation feedback 2026-05-29 16:28:19 +05:30
Shivam MishraandGitHub 264987624d Merge branch 'develop' into feat/expand-idb-coverage 2026-05-27 16:50:30 +05:30
Shivam Mishra cf7056e379 test: update attributes 2026-05-27 15:25:36 +05:30
Shivam Mishra 60d6f7f104 feat: invalidate cache on avatar update 2026-05-27 13:13:33 +05:30
Shivam Mishra 24c1ef7807 test: update cacheable models 2026-05-27 12:52:23 +05:30
Shivam Mishra c929319045 feat: cache custom attributes in IDB 2026-05-27 12:45:30 +05:30
Shivam Mishra 5cb517612f feat: flush IndexedDB cache on DATA_VERSION bump
The idempotent upgrade only created missing object stores, so bumping
DATA_VERSION no longer reset bad or stale data — it just added stores. This
restores a bump as a true global cache flush: the upgrade now clears every
existing store before recreating any missing ones, so shipping a version
bump forces all clients to refetch fresh on next boot.

Gated on oldVersion > 0 so fresh installs skip the flush, and clearing
before creating means only pre-existing stores are touched. This is the
break-glass lever for the one non-self-healing failure mode (non-empty
store holding wrong-shaped data paired with a matching cache key), e.g.
after a serializer shape change shipped without a version bump.
2026-05-27 12:31:51 +05:30
Shivam MishraandGitHub f4c5f45327 Merge branch 'develop' into feat/expand-idb-coverage 2026-05-27 12:10:39 +05:30
Shivam MishraandGitHub be541db44f Merge branch 'develop' into feat/expand-idb-coverage 2026-05-25 15:23:53 +05:30
Shivam Mishra 4633b49c39 feat: hydrate Vuex stores from IndexedDB on boot
Cached fetches no longer issue a /cache_keys preflight per call. The client
trusts whatever it has in IDB and keeps it fresh through ActionCable
broadcasts and the existing reconnect-time batch revalidate.

On warm boots a new hydrateStoresFromCache helper seeds Vuex from IDB
before ActionCable connects, so inboxes, labels, teams, canned responses,
and agents render immediately from cache. The helper snapshots local cache
keys before fetching server keys — without that ordering the comparison
would always see "fresh" against the just-written key and stale data would
be served forever. Stale entries are revalidated in the background; cold
devices stay on the network-fetch path with no regression.

Also awaits replace inside refetchAndCommit so the data write completes
before the cache key is persisted, closing a window where a concurrent
reader could see a fresh key paired with stale data.
2026-05-21 17:22:04 +05:30
Shivam Mishra fe5c353106 feat: cache canned responses and agents in IndexedDB
Converts the canned-response and agent API clients to CacheEnabledApiClient
subclasses and wires revalidate actions into the corresponding Vuex modules,
so settings pages and pickers stop fetching the same lists on every visit.
The canned-response search box continues to bypass the cache and hit the
network directly, so server-side ordering stays authoritative.

Also fixes a pre-existing gap in teams/revalidate: SET_TEAMS merges into a
record dict by id and never removes missing entries, so a team deleted
server-side would survive a reconnect-driven revalidate. Now matches the
get action by emitting CLEAR_TEAMS first.

Each affected spec clears its IDB store before every case, since the
preflight-throws-on-axios-reject pattern they relied on is going away in the
next commit.
2026-05-21 17:21:53 +05:30
Shivam Mishra 1a61e8102e refactor: generalize ActionCable cache invalidation over the model registry
Replaces the three hardcoded dispatches in actionCable.onCacheInvalidate and
ReconnectService.revalidateCaches with a loop over cacheableModels. Adding a
new cached model no longer requires editing these two handlers — they pick
it up automatically from the registry.

Also guards against partial server payloads: dispatches are skipped for
models whose key is undefined, so a client running ahead of a server
deploy keeps working without spurious dispatches.
2026-05-21 17:21:42 +05:30
Shivam Mishra 0ae3a82339 feat: drive IndexedDB cache layer from a single registry
Introduces cacheableModels.js as the source of truth for which models the
client caches in IDB and how to dispatch their revalidate actions. DataManager
derives modelsToSync from the registry and creates object stores
idempotently, so adding new cached models needs only one place to change
plus a one-time DATA_VERSION bump.

Also fixes two latent bugs that the upcoming boot-hydration relies on:
setCacheKeys now awaits all per-key writes (was async-forEach with no await),
and replace awaits clear before push (avoids ConstraintError on rapid
refetches).
2026-05-21 17:21:33 +05:30
Shivam Mishra 991bc5a265 feat: expand workspace cache_keys to canned responses and agents
Adds CannedResponse and AccountUser to the per-account cacheable_models so
their cache keys are exposed via /cache_keys and broadcast over ActionCable
on mutation. The endpoint now delegates to Account#cache_keys instead of
listing the three models inline, so future additions only need to touch the
concern.

AccountUser uses bespoke field-gated callbacks rather than the blanket
AccountCacheRevalidator: active_at is bumped on every page navigation and
would otherwise invalidate the agent cache constantly. User adds a
cross-account callback so name/email/display_name/confirmed_at changes
propagate, since those fields appear in the agent serializer but live on
the User model.
2026-05-21 17:21:17 +05:30
149 changed files with 1085 additions and 1851 deletions
+21 -32
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)
@@ -582,7 +578,7 @@ GEM
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.6.4.1)
net-imap (0.4.24)
date
net-protocol
net-pop (0.1.2)
@@ -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)
@@ -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
@@ -1,36 +0,0 @@
class Api::V1::Profile::SessionsController < Api::BaseController
before_action :set_session, only: [:destroy]
def index
@sessions = current_user.user_sessions.where(client_id: active_token_client_ids).order(last_activity_at: :desc)
@current_client_id = request.headers['client']
end
def destroy
if @session.current?(request.headers['client'])
render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity
return
end
revoke_token!(@session.client_id)
@session.destroy!
head :ok
end
private
def set_session
@session = current_user.user_sessions.find(params[:id])
end
def revoke_token!(client_id)
tokens = current_user.tokens
tokens.delete(client_id)
current_user.update!(tokens: tokens)
end
def active_token_client_ids
now = Time.current.to_i
(current_user.tokens || {}).select { |_, v| v['expiry'].to_i > now }.keys
end
end
@@ -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
@@ -3,7 +3,6 @@ class ApplicationController < ActionController::Base
include RequestExceptionHandler
include Pundit::Authorization
include SwitchLocale
include TrackSessionActivity
skip_before_action :verify_authenticity_token
@@ -1,22 +0,0 @@
module TrackSessionActivity
extend ActiveSupport::Concern
included do
after_action :update_session_activity
end
private
def update_session_activity
return unless current_user
return if request.headers['client'].blank?
UserSessionTrackingService.new(
user: current_user,
request: request,
client_id: request.headers['client']
).update_activity!
rescue StandardError => e
Rails.logger.warn "Session activity update failed: #{e.message}"
end
end
@@ -20,7 +20,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def render_create_success
track_user_session
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
end
@@ -115,19 +114,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
def render_mfa_error(message_key, status = :bad_request)
render json: { error: I18n.t(message_key) }, status: status
end
def track_user_session
client_id = @token&.try(:client) || response.headers['client']
return unless client_id.present? && @resource.present?
UserSessionTrackingService.new(
user: @resource,
request: request,
client_id: client_id
).create_or_update!
rescue StandardError => e
Rails.logger.warn "Session tracking failed: #{e.message}"
end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
+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);
}
}
-6
View File
@@ -106,10 +106,4 @@ export default {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
getSessions() {
return axios.get('/api/v1/profile/sessions');
},
revokeSession(id) {
return axios.delete(`/api/v1/profile/sessions/${id}`);
},
};
+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'),
});
});
@@ -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 }
);
});
@@ -154,10 +154,6 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
SHARE_CLICKED: 'Year in Review: Share clicked',
});
export const SESSION_EVENTS = Object.freeze({
REVOKED_FROM_PROFILE: 'Revoked an active session',
});
export const ONBOARDING_EVENTS = Object.freeze({
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
@@ -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
);
@@ -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": {
@@ -86,17 +86,6 @@
"NOTE": "Manage additional security features for your account.",
"MFA_BUTTON": "Manage Two-Factor Authentication"
},
"SESSIONS_SECTION": {
"TITLE": "Active Sessions",
"NOTE": "These are the devices currently logged in to your account.",
"CURRENT": "Current session",
"REVOKE": "Revoke",
"REVOKE_SUCCESS": "Session revoked successfully",
"REVOKE_ERROR": "Unable to revoke session. Please try again.",
"FETCH_ERROR": "Unable to fetch sessions. Please try again.",
"LAST_ACTIVE": "Last active",
"UNKNOWN_DEVICE": "Unknown device"
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
@@ -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>
@@ -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')"
@@ -1,137 +0,0 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatDistanceToNow, parseISO } from 'date-fns';
import { useAlert } from 'dashboard/composables';
import authAPI from 'dashboard/api/auth';
import AnalyticsHelper from 'dashboard/helper/AnalyticsHelper';
import { SESSION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const sessions = ref([]);
const loading = ref(false);
const relativeTime = dateStr => {
if (!dateStr) return '';
return formatDistanceToNow(parseISO(dateStr), { addSuffix: true });
};
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
const deviceIcon = session => {
const name = (session.device_name || '').toLowerCase();
if (
name.includes('iphone') ||
name.includes('android') ||
name.includes('mobile')
) {
return 'i-lucide-smartphone';
}
if (name.includes('ipad') || name.includes('tablet')) {
return 'i-lucide-tablet';
}
return 'i-lucide-monitor';
};
const sessionLabel = session => {
const parts = [];
if (!isUnknown(session.browser_name)) {
parts.push(
session.browser_version
? `${session.browser_name} ${session.browser_version}`
: session.browser_name
);
}
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
return (
parts.join(' on ') ||
t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.UNKNOWN_DEVICE')
);
};
const locationLabel = session => {
const parts = [];
if (session.city) parts.push(session.city);
if (session.country) parts.push(session.country);
return parts.join(', ');
};
const fetchSessions = async () => {
loading.value = true;
try {
const { data } = await authAPI.getSessions();
sessions.value = data;
} catch {
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.FETCH_ERROR'));
} finally {
loading.value = false;
}
};
const revokeSession = async session => {
try {
await authAPI.revokeSession(session.id);
sessions.value = sessions.value.filter(s => s.id !== session.id);
AnalyticsHelper.track(SESSION_EVENTS.REVOKED_FROM_PROFILE);
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_SUCCESS'));
} catch {
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_ERROR'));
}
};
onMounted(fetchSessions);
</script>
<template>
<div class="flex flex-col gap-3">
<div
v-for="session in sessions"
:key="session.id"
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background p-4"
>
<div class="flex items-start gap-3">
<Icon
:icon="deviceIcon(session)"
class="size-5 mt-0.5 text-n-slate-10 flex-shrink-0"
/>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-heading-3 text-n-slate-12">
{{ sessionLabel(session) }}
</span>
<span
v-if="session.current"
class="rounded-full bg-n-teal-3 px-2 py-0.5 text-caption text-n-teal-11"
>
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.CURRENT') }}
</span>
</div>
<span
v-if="locationLabel(session)"
class="text-body-b3 text-n-slate-11"
>
{{ locationLabel(session) }}
</span>
<span
v-if="session.last_activity_at"
class="text-body-b3 text-n-slate-10"
>
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.LAST_ACTIVE') }}
{{ relativeTime(session.last_activity_at) }}
</span>
</div>
</div>
<Button
v-if="!session.current"
type="button"
faded
xs
:label="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE')"
color="ruby"
@click="revokeSession(session)"
/>
</div>
</div>
</template>
@@ -20,7 +20,6 @@ import SectionLayout from '../account/components/SectionLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AccessToken from './AccessToken.vue';
import MfaSettingsCard from './MfaSettingsCard.vue';
import ActiveSessions from './ActiveSessions.vue';
import Policy from 'dashboard/components/policy.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import {
@@ -43,7 +42,6 @@ export default {
AudioNotifications,
AccessToken,
MfaSettingsCard,
ActiveSessions,
BaseSettingsHeader,
},
setup() {
@@ -309,13 +307,6 @@ export default {
>
<MfaSettingsCard />
</SectionLayout>
<SectionLayout
with-border
:title="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.NOTE')"
>
<ActiveSessions />
</SectionLayout>
<Policy :permissions="audioNotificationPermissions">
<SectionLayout
with-border
@@ -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)
-18
View File
@@ -1,18 +0,0 @@
class UserSessionIpLookupJob < ApplicationJob
queue_as :low
def perform(session)
return if session.ip_address.blank?
result = IpLookupService.new.perform(session.ip_address)
return unless result
session.update_columns( # rubocop:disable Rails/SkipsModelValidations
city: result.city,
country: result.country,
country_code: result.country_code
)
rescue StandardError => e
Rails.logger.warn "UserSessionIpLookupJob failed: #{e.message}"
end
end
+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?
+7 -14
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.
@@ -69,10 +64,8 @@ class Channel::Whatsapp < ApplicationRecord
end
end
# Enables voice: turns calling on at Meta (idempotent), then re-registers webhooks
# with the in-memory calling_enabled flag so the `calls` field is subscribed. The
# flag is persisted only after registration succeeds, so a webhook failure can't
# leave the inbox reporting voice_enabled? while the WABA isn't subscribed to calls.
# Enables voice: turns calling on at Meta (idempotent), subscribes the `calls`
# webhook field, and sets calling_enabled. Raises on Meta failure.
# Saved with validate: false to skip validate_provider_config's remote credential
# re-check, which could spuriously fail and desync the flag from Meta.
def enable_voice_calling!
@@ -80,21 +73,21 @@ class Channel::Whatsapp < ApplicationRecord
raise 'WhatsApp calling requires the channel_voice feature' unless account.feature_enabled?('channel_voice')
provider_service.update_calling_status('ENABLED')
self.provider_config = provider_config.merge('calling_enabled' => true)
webhook_setup_service.register_callback
self.provider_config = provider_config.merge('calling_enabled' => true)
save!(validate: false)
end
# Disables voice: unsets calling_enabled (gates the call subsystem) and re-registers
# webhooks, which drops `calls` from the subscription (best-effort, so a Meta outage
# can't trap admins). Leaves Meta's WABA calling.status untouched.
# Disables voice: unsets calling_enabled (gates the call subsystem) and drops
# `calls` from the webhook subscription (best-effort, so a Meta outage can't
# trap admins). Leaves Meta's WABA calling.status untouched.
def disable_voice_calling!
raise 'WhatsApp calling requires a whatsapp_cloud inbox' unless voice_calling_supported?
self.provider_config = provider_config.merge('calling_enabled' => false)
save!(validate: false)
begin
webhook_setup_service.register_callback
webhook_setup_service.register_callback(subscribed_fields: %w[messages smb_message_echoes])
rescue StandardError => e
Rails.logger.warn "[WHATSAPP CALL] disable webhook re-subscribe failed: #{e.message}"
end
+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 -7
View File
@@ -101,7 +101,6 @@ class User < ApplicationRecord
has_many :messages, as: :sender, dependent: :nullify
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify
has_many :user_sessions, dependent: :destroy
has_many :custom_filters, dependent: :destroy_async
has_many :dashboard_apps, dependent: :nullify
has_many :mentions, dependent: :destroy_async
@@ -117,9 +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_save :sync_user_sessions, if: :saved_change_to_tokens?
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') }
@@ -214,16 +216,19 @@ class User < ApplicationRecord
super
end
private
def sync_user_sessions
active_client_ids = (tokens || {}).keys
user_sessions.where.not(client_id: active_client_ids).destroy_all
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')
-46
View File
@@ -1,46 +0,0 @@
# == Schema Information
#
# Table name: user_sessions
#
# id :bigint not null, primary key
# browser_name :string
# browser_version :string
# city :string
# country :string
# country_code :string
# device_name :string
# ip_address :string
# last_activity_at :datetime
# platform_name :string
# platform_version :string
# user_agent :string
# created_at :datetime not null
# updated_at :datetime not null
# client_id :string not null
# user_id :bigint not null
#
# Indexes
#
# index_user_sessions_on_user_id (user_id)
# index_user_sessions_on_user_id_and_client_id (user_id,client_id) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class UserSession < ApplicationRecord
ACTIVITY_THROTTLE = 5.minutes
belongs_to :user
validates :client_id, presence: true, uniqueness: { scope: :user_id }
def current?(active_client_id)
client_id == active_client_id
end
def should_update_activity?
last_activity_at.nil? || last_activity_at < ACTIVITY_THROTTLE.ago
end
end
-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
@@ -1,39 +0,0 @@
class UserSessionTrackingService
def initialize(user:, request:, client_id:)
@user = user
@request = request
@client_id = client_id
end
def create_or_update!
session = @user.user_sessions.find_or_initialize_by(client_id: @client_id)
session.assign_attributes(session_attributes)
session.last_activity_at = Time.current
session.save!
UserSessionIpLookupJob.perform_later(session) if session.ip_address.present?
session
end
def update_activity!
session = @user.user_sessions.find_by(client_id: @client_id)
return unless session&.should_update_activity?
session.update_columns(last_activity_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
end
private
def session_attributes
browser = Browser.new(@request.user_agent)
{
ip_address: @request.remote_ip,
user_agent: @request.user_agent,
browser_name: browser.name,
browser_version: browser.full_version,
device_name: browser.device.name,
platform_name: browser.platform.name,
platform_version: browser.platform.version
}
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(
+1 -1
View File
@@ -60,7 +60,7 @@ class Whatsapp::FacebookApiClient
data['code_verification_status'] == 'VERIFIED'
end
WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes].freeze
WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes calls].freeze
def subscribe_waba_webhook(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS)
# Step 1: Subscribe app to WABA first (required before override)
@@ -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
+9 -11
View File
@@ -17,9 +17,9 @@ class Whatsapp::WebhookSetupService
setup_webhook
end
def register_callback
def register_callback(subscribed_fields: nil)
validate_parameters!
setup_webhook
setup_webhook(subscribed_fields: subscribed_fields)
end
private
@@ -55,23 +55,21 @@ class Whatsapp::WebhookSetupService
@channel.save!
end
def setup_webhook
def setup_webhook(subscribed_fields: nil)
callback_url = build_callback_url
verify_token = @channel.provider_config['webhook_verify_token']
@api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token, subscribed_fields: subscribed_fields)
args = [@waba_id, callback_url, verify_token]
if subscribed_fields
@api_client.subscribe_waba_webhook(*args, subscribed_fields: subscribed_fields)
else
@api_client.subscribe_waba_webhook(*args)
end
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
raise "Webhook setup failed: #{e.message}"
end
# Subscribe to `calls` only when voice calling is enabled on the inbox
def subscribed_fields
fields = %w[messages smb_message_echoes]
fields << 'calls' if @channel.provider_config['calling_enabled']
fields
end
def build_callback_url
frontend_url = ENV.fetch('FRONTEND_URL', nil)
phone_number = @channel.phone_number
@@ -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,15 +0,0 @@
json.array! @sessions do |session|
json.id session.id
json.browser_name session.browser_name
json.browser_version session.browser_version
json.device_name session.device_name
json.platform_name session.platform_name
json.platform_version session.platform_version
json.ip_address session.ip_address
json.city session.city
json.country session.country
json.country_code session.country_code
json.last_activity_at session.last_activity_at
json.created_at session.created_at
json.current session.current?(@current_client_id)
end
+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
-4
View File
@@ -47,10 +47,6 @@ en:
saml_not_available: SAML authentication is not available in this installation.
inbox_deletetion_response: Your inbox deletion request will be processed in some time.
profile_settings:
sessions:
cannot_revoke_current: You cannot revoke the current session.
errors:
account:
reporting_timezone:
+1 -5
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
@@ -437,7 +434,6 @@ Rails.application.routes.draw do
post :verify
post :backup_codes
end
resources :sessions, only: [:index, :destroy]
end
end
@@ -1,5 +0,0 @@
class AddProviderConfigToChannelTwilioSms < ActiveRecord::Migration[7.1]
def change
add_column :channel_twilio_sms, :provider_config, :jsonb, default: {}
end
end
@@ -1,23 +0,0 @@
class CreateUserSessions < ActiveRecord::Migration[7.0]
def change
create_table :user_sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :client_id, null: false
t.string :ip_address
t.string :user_agent
t.string :browser_name
t.string :browser_version
t.string :device_name
t.string :platform_name
t.string :platform_version
t.string :city
t.string :country
t.string :country_code
t.datetime :last_activity_at
t.timestamps
end
add_index :user_sessions, [:user_id, :client_id], unique: true
end
end

Some files were not shown because too many files have changed in this diff Show More