Compare commits

...
50 changed files with 599 additions and 212 deletions
+14
View File
@@ -7,6 +7,7 @@ class RoomChannel < ApplicationCable::Channel
ensure_stream ensure_stream
update_subscription update_subscription
broadcast_presence broadcast_presence
transmit_cache_keys
end end
def update_presence def update_presence
@@ -24,6 +25,19 @@ class RoomChannel < ApplicationCable::Channel
ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data }) ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data })
end 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 def ensure_stream
stream_from pubsub_token stream_from pubsub_token
stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User) stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User)
@@ -50,7 +50,6 @@ class Api::V1::AccountsController < Api::BaseController
end end
def cache_keys def cache_keys
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
render json: { cache_keys: cache_keys_for_account }, status: :ok render json: { cache_keys: cache_keys_for_account }, status: :ok
end end
@@ -93,11 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def cache_keys_for_account def cache_keys_for_account
{ @account.cache_keys
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)
}
end end
def fetch_account def fetch_account
@@ -13,11 +13,17 @@ class Api::V1::ProfilesController < Api::BaseController
@user.assign_attributes(profile_params) @user.assign_attributes(profile_params)
@user.custom_attributes.merge!(custom_attributes_params) @user.custom_attributes.merge!(custom_attributes_params)
@user.save! @user.save!
# Profile updates can change cached agent fields, including avatar-backed thumbnails.
@user.invalidate_avatar_cache
end end
def avatar def avatar
@user.avatar.attachment.destroy! if @user.avatar.attached? @user.avatar.attachment.destroy! if @user.avatar.attached?
@user.reload @user.reload
# Agent thumbnails are cached separately, and avatar attachment deletes do not dirty user columns.
@user.invalidate_avatar_cache
end end
def auto_offline def auto_offline
+10 -2
View File
@@ -19,6 +19,7 @@ import {
verifyServiceWorkerExistence, verifyServiceWorkerExistence,
} from './helper/pushHelper'; } from './helper/pushHelper';
import ReconnectService from 'dashboard/helper/ReconnectService'; import ReconnectService from 'dashboard/helper/ReconnectService';
import paintStoresFromCache from 'dashboard/helper/CacheHelper/paintStoresFromCache';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
export default { export default {
@@ -108,14 +109,21 @@ export default {
this.$store.dispatch('setActiveAccount', { this.$store.dispatch('setActiveAccount', {
accountId: this.currentAccountId, 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 account = this.getAccount(this.currentAccountId);
const { locale, latest_chatwoot_version: latestChatwootVersion } = const { locale, latest_chatwoot_version: latestChatwootVersion } =
account; account;
const { pubsub_token: pubsubToken } = this.currentUser || {};
// If user locale is set, use it; otherwise use account locale // If user locale is set, use it; otherwise use account locale
this.setLocale(this.uiSettings?.locale || locale); this.setLocale(this.uiSettings?.locale || locale);
this.latestChatwootVersion = latestChatwootVersion; this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(this.store, pubsubToken);
this.reconnectService = new ReconnectService(this.store, this.router); this.reconnectService = new ReconnectService(this.store, this.router);
window.reconnectService = this.reconnectService; window.reconnectService = this.reconnectService;
@@ -5,14 +5,15 @@ import ApiClient from './ApiClient';
class CacheEnabledApiClient extends ApiClient { class CacheEnabledApiClient extends ApiClient {
constructor(resource, options = {}) { constructor(resource, options = {}) {
super(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); 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) { get(cache = false) {
if (cache) { if (cache) {
return this.getFromCache(); return this.getFromCache();
@@ -25,14 +26,14 @@ class CacheEnabledApiClient extends ApiClient {
return axios.get(this.url); return axios.get(this.url);
} }
// eslint-disable-next-line class-methods-use-this
extractDataFromResponse(response) { extractDataFromResponse(response) {
return response.data.payload; return this.payloadEnvelope ? response.data.payload : response.data;
} }
// eslint-disable-next-line class-methods-use-this
marshallData(dataToParse) { marshallData(dataToParse) {
return { data: { payload: dataToParse } }; return this.payloadEnvelope
? { data: { payload: dataToParse } }
: { data: dataToParse };
} }
async getFromCache() { async getFromCache() {
@@ -43,24 +44,23 @@ class CacheEnabledApiClient extends ApiClient {
return this.getFromNetwork(); return this.getFromNetwork();
} }
const { data } = await axios.get( // Trust the IDB cache. Freshness is maintained by the
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys` // account.cache_invalidated event alone: RoomChannel pushes the cache-key
); // map on every (re)subscribe — boot and reconnect included — and the
const cacheKeyFromApi = data.cache_keys[this.cacheModelName]; // server broadcasts it on every change. Skipping a per-call /cache_keys
const isCacheValid = await this.validateCacheKey(cacheKeyFromApi); // preflight eliminates N GET requests per cold settings-page load.
const localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
let localData = []; if (localData.length > 0) {
if (isCacheValid) { return this.marshallData(localData);
localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
} }
if (localData.length === 0) { // Empty IDB (first load or wiped): fetch data without a cache key. The
return this.refetchAndCommit(cacheKeyFromApi); // 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);
return this.marshallData(localData);
} }
async refetchAndCommit(newKey = null) { async refetchAndCommit(newKey = null) {
@@ -69,7 +69,9 @@ class CacheEnabledApiClient extends ApiClient {
try { try {
await this.dataManager.initDb(); 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, modelName: this.cacheModelName,
data: this.extractDataFromResponse(response), data: this.extractDataFromResponse(response),
}); });
@@ -89,8 +91,15 @@ class CacheEnabledApiClient extends ApiClient {
await this.dataManager.initDb(); await this.dataManager.initDb();
} }
const cachekey = await this.dataManager.getCacheKey(this.cacheModelName); const cacheKey = await this.dataManager.getCacheKey(this.cacheModelName);
return cacheKeyFromApi === cachekey; 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) { createAccount(data) {
return axios.post(`${this.apiVersion}/accounts`, 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(); export default new AccountAPI();
+3 -3
View File
@@ -1,10 +1,10 @@
/* global axios */ /* global axios */
import ApiClient from './ApiClient'; import CacheEnabledApiClient from './CacheEnabledApiClient';
class Agents extends ApiClient { class Agents extends CacheEnabledApiClient {
constructor() { constructor() {
super('agents', { accountScoped: true }); super('agents', { accountScoped: true, cacheModel: 'account_user' });
} }
bulkInvite({ emails }) { bulkInvite({ emails }) {
+7 -5
View File
@@ -1,13 +1,15 @@
/* global axios */ import CacheEnabledApiClient from './CacheEnabledApiClient';
import ApiClient from './ApiClient';
class AttributeAPI extends ApiClient { class AttributeAPI extends CacheEnabledApiClient {
constructor() { constructor() {
super('custom_attribute_definitions', { accountScoped: true }); super('custom_attribute_definitions', {
accountScoped: true,
cacheModel: 'custom_attribute_definition',
});
} }
getAttributesByModel() { getAttributesByModel() {
return axios.get(this.url); return super.get(true);
} }
} }
+11 -6
View File
@@ -1,15 +1,20 @@
/* global axios */ /* global axios */
import ApiClient from './ApiClient'; import CacheEnabledApiClient from './CacheEnabledApiClient';
class CannedResponse extends ApiClient { class CannedResponse extends CacheEnabledApiClient {
constructor() { constructor() {
super('canned_responses', { accountScoped: true }); super('canned_responses', {
accountScoped: true,
cacheModel: 'canned_response',
});
} }
get({ searchKey }) { get({ searchKey } = {}) {
const url = searchKey ? `${this.url}?search=${searchKey}` : this.url; if (searchKey) {
return axios.get(url); return axios.get(`${this.url}?search=${searchKey}`);
}
return super.get(true);
} }
} }
+5 -6
View File
@@ -3,12 +3,11 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
class Inboxes extends CacheEnabledApiClient { class Inboxes extends CacheEnabledApiClient {
constructor() { constructor() {
super('inboxes', { accountScoped: true }); super('inboxes', {
} accountScoped: true,
cacheModel: 'inbox',
// eslint-disable-next-line class-methods-use-this payloadEnvelope: true,
get cacheModelName() { });
return 'inbox';
} }
getCampaigns(inboxId) { getCampaigns(inboxId) {
+5 -6
View File
@@ -2,12 +2,11 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
class LabelsAPI extends CacheEnabledApiClient { class LabelsAPI extends CacheEnabledApiClient {
constructor() { constructor() {
super('labels', { accountScoped: true }); super('labels', {
} accountScoped: true,
cacheModel: 'label',
// eslint-disable-next-line class-methods-use-this payloadEnvelope: true,
get cacheModelName() { });
return 'label';
} }
} }
+1 -17
View File
@@ -1,25 +1,9 @@
/* global axios */ /* global axios */
// import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient'; import CacheEnabledApiClient from './CacheEnabledApiClient';
export class TeamsAPI extends CacheEnabledApiClient { export class TeamsAPI extends CacheEnabledApiClient {
constructor() { constructor() {
super('teams', { accountScoped: true }); super('teams', { accountScoped: true, cacheModel: 'team' });
}
// 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 };
} }
getAgents({ teamId }) { getAgents({ teamId }) {
@@ -1,9 +1,10 @@
import { openDB } from 'idb'; import { openDB } from 'idb';
import { DATA_VERSION } from './version'; import { DATA_VERSION } from './version';
import { cacheableModels, cacheableModelNames } from './cacheableModels';
export class DataManager { export class DataManager {
constructor(accountId) { constructor(accountId) {
this.modelsToSync = ['inbox', 'label', 'team']; this.modelsToSync = cacheableModelNames;
this.accountId = accountId; this.accountId = accountId;
this.db = null; this.db = null;
} }
@@ -11,12 +12,26 @@ export class DataManager {
async initDb() { async initDb() {
if (this.db) return this.db; if (this.db) return this.db;
const dbName = `cw-store-${this.accountId}`; const dbName = `cw-store-${this.accountId}`;
this.db = await openDB(`cw-store-${this.accountId}`, DATA_VERSION, { this.db = await openDB(dbName, DATA_VERSION, {
upgrade(db) { upgrade(db, oldVersion, _newVersion, tx) {
db.createObjectStore('cache-keys'); // Flush data carried over from a previous schema version so a
db.createObjectStore('inbox', { keyPath: 'id' }); // DATA_VERSION bump acts as a global cache reset. oldVersion === 0 on
db.createObjectStore('label', { keyPath: 'id' }); // first install, so fresh devices skip this. Clearing before creating
db.createObjectStore('team', { keyPath: 'id' }); // 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 }) { async replace({ modelName, data }) {
this.validateModel(modelName); this.validateModel(modelName);
this.db.clear(modelName); await this.db.clear(modelName);
return this.push({ modelName, data }); return this.push({ modelName, data });
} }
@@ -65,9 +80,11 @@ export class DataManager {
} }
async setCacheKeys(cacheKeys) { async setCacheKeys(cacheKeys) {
Object.keys(cacheKeys).forEach(async modelName => { await Promise.all(
this.db.put('cache-keys', cacheKeys[modelName], modelName); Object.entries(cacheKeys).map(([modelName, value]) =>
}); this.db.put('cache-keys', value, modelName)
)
);
} }
async getCacheKey(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 // Bump DATA_VERSION to (a) add new object stores to the IDB schema or (b)
// Change this version if you want to invalidate old data // flush bad/stale cache globally. The `upgrade()` callback in DataManager runs
export const DATA_VERSION = '1678706392'; // 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 }); 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 () => { handleRouteSpecificFetch = async () => {
const currentRoute = this.router.currentRoute.value.name; const currentRoute = this.router.currentRoute.value.name;
if (isAConversationRoute(currentRoute, true)) { if (isAConversationRoute(currentRoute, true)) {
@@ -138,9 +127,11 @@ class ReconnectService {
this.setConversationLastMessageId(); 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 () => { onReconnect = async () => {
await this.handleRouteSpecificFetch(); await this.handleRouteSpecificFetch();
await this.revalidateCaches();
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED); emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED);
}; };
} }
@@ -14,6 +14,7 @@ import {
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox'; import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants'; import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { dispatchCacheRevalidations } from './CacheHelper/dispatchCacheRevalidations';
const { isImpersonating } = useImpersonation(); const { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000; const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
@@ -269,10 +270,7 @@ class ActionCableConnector extends BaseActionCableConnector {
}; };
onCacheInvalidate = data => { onCacheInvalidate = data => {
const keys = data.cache_keys; dispatchCacheRevalidations(this.app.$store, 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 });
}; };
onVoiceCallIncoming = data => { onVoiceCallIncoming = data => {
@@ -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', () => { describe('handleRouteSpecificFetch', () => {
it('should fetch conversations and messages if current route is a conversation route', async () => { it('should fetch conversations and messages if current route is a conversation route', async () => {
isAConversationRoute.mockReturnValue(true); isAConversationRoute.mockReturnValue(true);
@@ -335,12 +314,10 @@ describe('ReconnectService', () => {
}); });
describe('onReconnect', () => { 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.handleRouteSpecificFetch = vi.fn();
reconnectService.revalidateCaches = vi.fn();
await reconnectService.onReconnect(); await reconnectService.onReconnect();
expect(reconnectService.handleRouteSpecificFetch).toHaveBeenCalled(); expect(reconnectService.handleRouteSpecificFetch).toHaveBeenCalled();
expect(reconnectService.revalidateCaches).toHaveBeenCalled();
expect(emitter.emit).toHaveBeenCalledWith( expect(emitter.emit).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED
); );
@@ -163,10 +163,6 @@ export const actions = {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: false }); commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: false });
} }
}, },
getCacheKeys: async () => {
return AccountAPI.getCacheKeys();
},
}; };
export const mutations = { export const mutations = {
@@ -44,7 +44,7 @@ export const actions = {
get: async ({ commit }) => { get: async ({ commit }) => {
commit(types.default.SET_AGENT_FETCHING_STATUS, true); commit(types.default.SET_AGENT_FETCHING_STATUS, true);
try { try {
const response = await AgentAPI.get(); const response = await AgentAPI.get(true);
commit(types.default.SET_AGENT_FETCHING_STATUS, false); commit(types.default.SET_AGENT_FETCHING_STATUS, false);
commit(types.default.SET_AGENTS, response.data); commit(types.default.SET_AGENTS, response.data);
} catch (error) { } catch (error) {
@@ -189,17 +189,6 @@ const sendAnalyticsEvent = channelType => {
}; };
export const actions = { 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 }) => { get: async ({ commit }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: true }); commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: true });
try { try {
@@ -32,18 +32,6 @@ export const getters = {
}; };
export const actions = { 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 }) { get: async function getLabels({ commit }) {
commit(types.SET_LABEL_UI_FLAG, { isFetching: true }); commit(types.SET_LABEL_UI_FLAG, { isFetching: true });
try { try {
@@ -1,6 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import { actions } from '../../agents'; import { actions } from '../../agents';
import * as types from '../../../mutation-types'; import * as types from '../../../mutation-types';
import AgentAPI from '../../../../api/agents';
import agentList from './fixtures'; import agentList from './fixtures';
const commit = vi.fn(); const commit = vi.fn();
@@ -8,6 +9,13 @@ const dispatch = vi.fn();
global.axios = axios; global.axios = axios;
vi.mock('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('#actions', () => {
describe('#get', () => { describe('#get', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
@@ -1,12 +1,20 @@
import axios from 'axios'; import axios from 'axios';
import { actions } from '../../attributes'; import { actions } from '../../attributes';
import * as types from '../../../mutation-types'; import * as types from '../../../mutation-types';
import AttributeAPI from '../../../../api/attributes';
import attributesList from './fixtures'; import attributesList from './fixtures';
const commit = vi.fn(); const commit = vi.fn();
global.axios = axios; global.axios = axios;
vi.mock('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('#actions', () => {
describe('#get', () => { describe('#get', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
@@ -1,12 +1,20 @@
import axios from 'axios'; import axios from 'axios';
import { actions } from '../../inboxes'; import { actions } from '../../inboxes';
import * as types from '../../../mutation-types'; import * as types from '../../../mutation-types';
import InboxesAPI from '../../../../api/inboxes';
import inboxList from './fixtures'; import inboxList from './fixtures';
const commit = vi.fn(); const commit = vi.fn();
global.axios = axios; global.axios = axios;
vi.mock('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('#actions', () => {
describe('#get', () => { describe('#get', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
@@ -1,12 +1,20 @@
import axios from 'axios'; import axios from 'axios';
import { actions } from '../../labels'; import { actions } from '../../labels';
import * as types from '../../../mutation-types'; import * as types from '../../../mutation-types';
import LabelsAPI from '../../../../api/labels';
import labelsList from './fixtures'; import labelsList from './fixtures';
const commit = vi.fn(); const commit = vi.fn();
global.axios = axios; global.axios = axios;
vi.mock('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('#actions', () => {
describe('#get', () => { describe('#get', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
@@ -2,18 +2,25 @@ import axios from 'axios';
import { actions } from '../../teams/actions'; import { actions } from '../../teams/actions';
import { import {
SET_TEAM_UI_FLAG, SET_TEAM_UI_FLAG,
CLEAR_TEAMS,
SET_TEAMS, SET_TEAMS,
SET_TEAM_ITEM, SET_TEAM_ITEM,
EDIT_TEAM, EDIT_TEAM,
DELETE_TEAM, DELETE_TEAM,
} from '../../teams/types'; } from '../../teams/types';
import TeamsAPI from '../../../../api/teams';
import teamsList from './fixtures'; import teamsList from './fixtures';
const commit = vi.fn(); const commit = vi.fn();
global.axios = axios; global.axios = axios;
vi.mock('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('#actions', () => {
describe('#get', () => { describe('#get', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
@@ -33,7 +40,6 @@ describe('#actions', () => {
await actions.get({ commit }); await actions.get({ commit });
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[SET_TEAM_UI_FLAG, { isFetching: true }], [SET_TEAM_UI_FLAG, { isFetching: true }],
[CLEAR_TEAMS],
[SET_TEAMS, teamsList[1]], [SET_TEAMS, teamsList[1]],
[SET_TEAM_UI_FLAG, { isFetching: false }], [SET_TEAM_UI_FLAG, { isFetching: false }],
]); ]);
@@ -1,5 +1,4 @@
import { import {
CLEAR_TEAMS,
SET_TEAMS, SET_TEAMS,
SET_TEAM_ITEM, SET_TEAM_ITEM,
EDIT_TEAM, EDIT_TEAM,
@@ -11,10 +10,15 @@ describe('#mutations', () => {
describe('#SET_teams', () => { describe('#SET_teams', () => {
it('set teams records', () => { it('set teams records', () => {
const state = { records: {} }; const state = { records: {} };
mutations[SET_TEAMS](state, [teams[1]]); mutations[SET_TEAMS](state, [teams[1], teams[2]]);
mutations[SET_TEAMS](state, [teams[2]]);
expect(state.records).toEqual(teams); 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', () => { describe('#ADD_TEAM', () => {
@@ -43,12 +47,4 @@ describe('#mutations', () => {
expect(state.records).toEqual({}); 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 { import {
SET_TEAM_UI_FLAG, SET_TEAM_UI_FLAG,
CLEAR_TEAMS,
SET_TEAMS, SET_TEAMS,
SET_TEAM_ITEM, SET_TEAM_ITEM,
EDIT_TEAM, EDIT_TEAM,
@@ -22,22 +21,10 @@ export const actions = {
commit(SET_TEAM_UI_FLAG, { isCreating: false }); 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 }) => { get: async ({ commit }) => {
commit(SET_TEAM_UI_FLAG, { isFetching: true }); commit(SET_TEAM_UI_FLAG, { isFetching: true });
try { try {
const { data } = await TeamsAPI.get(true); const { data } = await TeamsAPI.get(true);
commit(CLEAR_TEAMS);
commit(SET_TEAMS, data); commit(SET_TEAMS, data);
} catch (error) { } catch (error) {
throw new Error(error); throw new Error(error);
@@ -1,6 +1,5 @@
import { import {
SET_TEAM_UI_FLAG, SET_TEAM_UI_FLAG,
CLEAR_TEAMS,
SET_TEAMS, SET_TEAMS,
SET_TEAM_ITEM, SET_TEAM_ITEM,
EDIT_TEAM, EDIT_TEAM,
@@ -15,19 +14,14 @@ export const mutations = {
}; };
}, },
[CLEAR_TEAMS]: $state => { // Replaces (not merges) so rows deleted server-side never survive as
$state.records = {}; // phantoms — SET_TEAMS only ever receives the full list.
},
[SET_TEAMS]: ($state, data) => { [SET_TEAMS]: ($state, data) => {
const updatedRecords = { ...$state.records }; const records = {};
data.forEach(team => { data.forEach(team => {
updatedRecords[team.id] = { records[team.id] = team;
...(updatedRecords[team.id] || {}),
...team,
};
}); });
$state.records = updatedRecords; $state.records = records;
}, },
[SET_TEAM_ITEM]: ($state, data) => { [SET_TEAM_ITEM]: ($state, data) => {
@@ -1,5 +1,4 @@
export const SET_TEAM_UI_FLAG = 'SET_TEAM_UI_FLAG'; 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_TEAMS = 'SET_TEAMS';
export const SET_TEAM_ITEM = 'SET_TEAM_ITEM'; export const SET_TEAM_ITEM = 'SET_TEAM_ITEM';
export const EDIT_TEAM = 'EDIT_TEAM'; export const EDIT_TEAM = 'EDIT_TEAM';
+3
View File
@@ -52,6 +52,9 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
filename: avatar_file.original_filename, filename: avatar_file.original_filename,
content_type: avatar_file.content_type 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 end
def log_http_error(avatar_url, error) def log_http_error(avatar_url, error)
+6
View File
@@ -36,10 +36,16 @@ class AccountUser < ApplicationRecord
accepts_nested_attributes_for :account 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_create_commit :notify_creation, :create_notification_setting
after_destroy :notify_deletion, :remove_user_from_account after_destroy :notify_deletion, :remove_user_from_account
after_save :update_presence_in_redis, if: :saved_change_to_availability? 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 } validates :user_id, uniqueness: { scope: :account_id }
def create_notification_setting def create_notification_setting
+2
View File
@@ -11,6 +11,8 @@
# #
class CannedResponse < ApplicationRecord class CannedResponse < ApplicationRecord
include AccountCacheRevalidator
validates :content, presence: true validates :content, presence: true
validates :short_code, presence: true validates :short_code, presence: true
validates :account, presence: true validates :account, presence: true
+6 -2
View File
@@ -4,11 +4,15 @@ module CacheKeys
include CacheKeysHelper include CacheKeysHelper
include Events::Types 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 included do
class_attribute :cacheable_models class_attribute :cacheable_models
self.cacheable_models = [Label, Inbox, Team] self.cacheable_models = [Label, Inbox, Team, CannedResponse, AccountUser, CustomAttributeDefinition]
end end
def cache_keys def cache_keys
@@ -22,6 +22,8 @@
# index_custom_attribute_definitions_on_account_id (account_id) # index_custom_attribute_definitions_on_account_id (account_id)
# #
class CustomAttributeDefinition < ApplicationRecord class CustomAttributeDefinition < ApplicationRecord
include AccountCacheRevalidator
STANDARD_ATTRIBUTES = { STANDARD_ATTRIBUTES = {
:conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at :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], last_activity_at],
+7
View File
@@ -50,6 +50,13 @@ class Portal < ApplicationRecord
schema: PortalConfigSchema::CONFIG_PARAMS_SCHEMA, schema: PortalConfigSchema::CONFIG_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.config } 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) } scope :active, -> { where(archived: false) }
# TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config # 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 :user
belongs_to :team belongs_to :team
validates :user_id, uniqueness: { scope: :team_id } 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 end
TeamMember.include_mod_with('Audit::TeamMember') 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 has_many :macros, foreign_key: 'created_by_id', inverse_of: :created_by
# rubocop:enable Rails/HasManyOrHasOneDependent # 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 before_validation :set_password_and_uid, on: :create
after_destroy :remove_macros 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') } scope :order_by_full_name, -> { order('lower(name) ASC') }
@@ -212,11 +216,19 @@ class User < ApplicationRecord
super super
end end
def invalidate_avatar_cache
bump_account_user_cache_keys
end
private private
def remove_macros def remove_macros
macros.personal.destroy_all macros.personal.destroy_all
end end
def bump_account_user_cache_keys
accounts.each { |account| account.update_cache_key('account_user') }
end
end end
User.include_mod_with('Audit::User') User.include_mod_with('Audit::User')
+7
View File
@@ -39,4 +39,11 @@ class CustomRole < ApplicationRecord
validates :name, presence: true validates :name, presence: true
validates :permissions, inclusion: { in: PERMISSIONS } 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 end
+15
View File
@@ -21,4 +21,19 @@ RSpec.describe RoomChannel do
expect(subscription).to have_stream_for(user.pubsub_token) expect(subscription).to have_stream_for(user.pubsub_token)
expect(subscription).to have_stream_for("account_#{account.id}") expect(subscription).to have_stream_for("account_#{account.id}")
end end
it 'transmits the account cache keys to user subscribers' do
subscribe(user_id: user.id, pubsub_token: user.pubsub_token, account_id: account.id)
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 canned_response account_user custom_attribute_definition])
end
it 'does not transmit cache keys to contact subscribers' do
subscribe(pubsub_token: contact_inbox.pubsub_token)
cache_event = transmissions.find { |message| message['event'] == 'account.cache_invalidated' }
expect(cache_event).to be_nil
end
end end
@@ -213,17 +213,18 @@ RSpec.describe 'Accounts API', type: :request do
as: :json as: :json
expect(response).to have_http_status(:success) 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 end
it 'sets the appropriate cache headers' do it 'does not allow cached cache key responses' do
get "/api/v1/accounts/#{account.id}/cache_keys", get "/api/v1/accounts/#{account.id}/cache_keys",
headers: admin.create_new_auth_token, headers: admin.create_new_auth_token,
as: :json as: :json
expect(response.headers['Cache-Control']).to include('max-age=10') expect(response.headers['Cache-Control']).to include('max-age=0')
expect(response.headers['Cache-Control']).to include('private') expect(response.headers['Cache-Control']).to include('private')
expect(response.headers['Cache-Control']).to include('stale-while-revalidate=300') expect(response.headers['Cache-Control']).to include('must-revalidate')
expect(response.headers['Cache-Control']).not_to include('stale-while-revalidate')
end end
end end
@@ -45,7 +45,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
context 'when it is an authenticated user' do context 'when it is an authenticated user' do
it 'shows the list of accounts' 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) sign_in(super_admin, scope: :super_admin)
now_timestamp = Time.now.utc.to_i now_timestamp = Time.now.utc.to_i
@@ -9,4 +9,19 @@ RSpec.describe CustomRole, type: :model do
describe 'validations' do describe 'validations' do
it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_presence_of(:name) }
end 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 end
+18
View File
@@ -150,4 +150,22 @@ RSpec.describe Portal do
expect(portal.display_title).to eq('Help Center | Acme') expect(portal.display_title).to eq('Help Center | Acme')
end end
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 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(:team) }
it { is_expected.to belong_to(:user) } it { is_expected.to belong_to(:user) }
end 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 end