Compare commits

...
26 changed files with 190 additions and 27 deletions
@@ -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 -3
View File
@@ -1,10 +1,10 @@
/* global axios */
import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
class Agents extends ApiClient {
class Agents extends CacheEnabledApiClient {
constructor() {
super('agents', { accountScoped: true });
super('agents', { accountScoped: true, cacheModel: 'account_user' });
}
bulkInvite({ emails }) {
+7 -5
View File
@@ -1,13 +1,15 @@
/* global axios */
import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
class AttributeAPI extends ApiClient {
class AttributeAPI extends CacheEnabledApiClient {
constructor() {
super('custom_attribute_definitions', { accountScoped: true });
super('custom_attribute_definitions', {
accountScoped: true,
cacheModel: 'custom_attribute_definition',
});
}
getAttributesByModel() {
return axios.get(this.url);
return super.get(true);
}
}
+11 -6
View File
@@ -1,15 +1,20 @@
/* global axios */
import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
class CannedResponse extends ApiClient {
class CannedResponse extends CacheEnabledApiClient {
constructor() {
super('canned_responses', { accountScoped: true });
super('canned_responses', {
accountScoped: true,
cacheModel: 'canned_response',
});
}
get({ searchKey }) {
const url = searchKey ? `${this.url}?search=${searchKey}` : this.url;
return axios.get(url);
get({ searchKey } = {}) {
if (searchKey) {
return axios.get(`${this.url}?search=${searchKey}`);
}
return super.get(true);
}
}
@@ -12,6 +12,12 @@ 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);
@@ -1,3 +1,6 @@
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';
@@ -10,6 +13,9 @@ const apiByModel = {
inbox: InboxesAPI,
label: LabelsAPI,
team: TeamsAPI,
canned_response: CannedResponseAPI,
account_user: AgentAPI,
custom_attribute_definition: AttributeAPI,
};
const revalidateModel = async (store, model, newKey) => {
@@ -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';
@@ -1,6 +1,7 @@
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', () => ({
@@ -24,6 +25,27 @@ vi.mock('dashboard/api/teams', () => ({
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;
@@ -71,16 +93,16 @@ describe('dispatchCacheRevalidations', () => {
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'));
TeamsAPI.validateCacheKey.mockResolvedValue(false);
TeamsAPI.refetchAndCommit.mockResolvedValue({ data: [] });
TeamsAPI.extractDataFromResponse.mockReturnValue([{ id: 7 }]);
CannedResponseAPI.validateCacheKey.mockResolvedValue(false);
CannedResponseAPI.refetchAndCommit.mockResolvedValue({ data: [] });
CannedResponseAPI.extractDataFromResponse.mockReturnValue([{ id: 7 }]);
await dispatchCacheRevalidations(store, {
inbox: 'inbox-key',
team: 'team-key',
canned_response: 'canned-key',
});
expect(store.commit).toHaveBeenCalledWith('teams/SET_TEAMS', [{ id: 7 }]);
expect(store.commit).toHaveBeenCalledWith('SET_CANNED', [{ id: 7 }]);
expect(store.commit).toHaveBeenCalledTimes(1);
});
});
@@ -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) {
@@ -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 () => {
+3
View File
@@ -52,6 +52,9 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
filename: avatar_file.original_filename,
content_type: avatar_file.content_type
)
# Agent thumbnails are cached separately, and avatar attachments do not dirty user columns.
avatarable.invalidate_avatar_cache if avatarable.respond_to?(:invalidate_avatar_cache)
end
def log_http_error(avatar_url, error)
+6
View File
@@ -36,10 +36,16 @@ class AccountUser < ApplicationRecord
accepts_nested_attributes_for :account
AGENT_CACHE_RELEVANT_COLUMNS = %w[role availability auto_offline custom_role_id].freeze
after_create_commit :notify_creation, :create_notification_setting
after_destroy :notify_deletion, :remove_user_from_account
after_save :update_presence_in_redis, if: :saved_change_to_availability?
after_commit -> { account.update_cache_key('account_user') }, on: [:create, :destroy]
after_update_commit -> { account.update_cache_key('account_user') },
if: -> { saved_changes.keys.intersect?(AGENT_CACHE_RELEVANT_COLUMNS) }
validates :user_id, uniqueness: { scope: :account_id }
def create_notification_setting
+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
+1 -1
View File
@@ -12,7 +12,7 @@ module CacheKeys
included do
class_attribute :cacheable_models
self.cacheable_models = [Label, Inbox, Team]
self.cacheable_models = [Label, Inbox, Team, CannedResponse, AccountUser, CustomAttributeDefinition]
end
def cache_keys
@@ -22,6 +22,8 @@
# index_custom_attribute_definitions_on_account_id (account_id)
#
class CustomAttributeDefinition < ApplicationRecord
include AccountCacheRevalidator
STANDARD_ATTRIBUTES = {
:conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at
last_activity_at],
+7
View File
@@ -50,6 +50,13 @@ class Portal < ApplicationRecord
schema: PortalConfigSchema::CONFIG_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.config }
# Portal name/slug are embedded as help_center into the cached inbox payload
# (api/v1/models/_inbox.json.jbuilder), so portal changes must bump the
# account's inbox cache key. Destroy is covered too: dependent: :nullify
# detaches inboxes via update_all and skips their callbacks.
after_update_commit -> { account.update_cache_key('inbox') }
after_destroy_commit -> { account.update_cache_key('inbox') }
scope :active, -> { where(archived: false) }
# TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config
+6
View File
@@ -18,6 +18,12 @@ class TeamMember < ApplicationRecord
belongs_to :user
belongs_to :team
validates :user_id, uniqueness: { scope: :team_id }
# is_member is embedded into the cached team payload (per current user) via
# api/v1/models/_team.json.jbuilder, so membership changes must bump the team
# cache key. team is safe-navigated because destroying a team cascades here
# via destroy_async, by which point the team row is already gone.
after_commit -> { team&.account&.update_cache_key('team') }, on: [:create, :destroy]
end
TeamMember.include_mod_with('Audit::TeamMember')
+12
View File
@@ -116,8 +116,12 @@ class User < ApplicationRecord
has_many :macros, foreign_key: 'created_by_id', inverse_of: :created_by
# rubocop:enable Rails/HasManyOrHasOneDependent
AGENT_CACHE_RELEVANT_COLUMNS = %w[name email display_name confirmed_at custom_attributes].freeze
before_validation :set_password_and_uid, on: :create
after_destroy :remove_macros
after_update_commit :bump_account_user_cache_keys,
if: -> { saved_changes.keys.intersect?(AGENT_CACHE_RELEVANT_COLUMNS) }
scope :order_by_full_name, -> { order('lower(name) ASC') }
@@ -212,11 +216,19 @@ class User < ApplicationRecord
super
end
def invalidate_avatar_cache
bump_account_user_cache_keys
end
private
def remove_macros
macros.personal.destroy_all
end
def bump_account_user_cache_keys
accounts.each { |account| account.update_cache_key('account_user') }
end
end
User.include_mod_with('Audit::User')
+7
View File
@@ -39,4 +39,11 @@ class CustomRole < ApplicationRecord
validates :name, presence: true
validates :permissions, inclusion: { in: PERMISSIONS }
# CustomRole details are embedded into the cached account_user payload via
# api/v1/models/_account_user.json.jbuilder, so bump that cache key on any
# change. `dependent: :nullify` updates account_users via update_all (which
# skips their callbacks), so the deletion is bumped here directly.
after_update_commit -> { account.update_cache_key('account_user') }
after_destroy_commit -> { account.update_cache_key('account_user') }
end
+1 -1
View File
@@ -27,7 +27,7 @@ RSpec.describe RoomChannel do
cache_event = transmissions.find { |message| message['event'] == 'account.cache_invalidated' }
expect(cache_event['data']['account_id']).to eq(account.id)
expect(cache_event['data']['cache_keys'].keys).to match_array(%w[label inbox team])
expect(cache_event['data']['cache_keys'].keys).to match_array(%w[label inbox team canned_response account_user custom_attribute_definition])
end
it 'does not transmit cache keys to contact subscribers' do
@@ -213,7 +213,7 @@ RSpec.describe 'Accounts API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['cache_keys'].keys).to match_array(%w[label inbox team])
expect(response.parsed_body['cache_keys'].keys).to match_array(%w[account_user canned_response custom_attribute_definition inbox label team])
end
it 'does not allow cached cache key responses' do
@@ -45,7 +45,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
context 'when it is an authenticated user' do
it 'shows the list of accounts' do
expect(account.cache_keys.keys).to contain_exactly(:inbox, :label, :team)
expect(account.cache_keys.keys).to contain_exactly(:inbox, :label, :team, :canned_response, :account_user, :custom_attribute_definition)
sign_in(super_admin, scope: :super_admin)
now_timestamp = Time.now.utc.to_i
@@ -9,4 +9,19 @@ RSpec.describe CustomRole, type: :model do
describe 'validations' do
it { is_expected.to validate_presence_of(:name) }
end
describe 'account_user cache invalidation' do
let(:custom_role) { create(:custom_role) }
it 'bumps the account_user cache key after update' do
expect(custom_role.account).to receive(:update_cache_key).with('account_user')
custom_role.update(name: 'New Name')
end
it 'bumps the account_user cache key after destroy' do
custom_role
expect(custom_role.account).to receive(:update_cache_key).with('account_user')
custom_role.destroy
end
end
end
+18
View File
@@ -150,4 +150,22 @@ RSpec.describe Portal do
expect(portal.display_title).to eq('Help Center | Acme')
end
end
describe 'inbox cache invalidation' do
# 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.
let(:account) { create(:account) }
let!(:portal) { create(:portal, account: account) }
it 'bumps the inbox cache key after update' do
expect(account).to receive(:update_cache_key).with('inbox')
portal.update!(name: 'Renamed Portal')
end
it 'bumps the inbox cache key after destroy' do
expect(account).to receive(:update_cache_key).with('inbox')
portal.destroy!
end
end
end
+16
View File
@@ -5,4 +5,20 @@ RSpec.describe TeamMember do
it { is_expected.to belong_to(:team) }
it { is_expected.to belong_to(:user) }
end
describe 'team cache invalidation' do
let(:team) { create(:team) }
let(:user) { create(:user) }
it 'bumps the team cache key after create' do
expect(team.account).to receive(:update_cache_key).with('team')
create(:team_member, team: team, user: user)
end
it 'bumps the team cache key after destroy' do
team_member = create(:team_member, team: team, user: user)
expect(team.account).to receive(:update_cache_key).with('team')
team_member.destroy
end
end
end