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
50 changed files with 599 additions and 212 deletions
+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)
@@ -50,7 +50,6 @@ class Api::V1::AccountsController < Api::BaseController
end
def cache_keys
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
render json: { cache_keys: cache_keys_for_account }, status: :ok
end
@@ -93,11 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def cache_keys_for_account
{
label: fetch_value_for_key(params[:id], Label.name.underscore),
inbox: fetch_value_for_key(params[:id], Inbox.name.underscore),
team: fetch_value_for_key(params[:id], Team.name.underscore)
}
@account.cache_keys
end
def fetch_account
@@ -13,11 +13,17 @@ class Api::V1::ProfilesController < Api::BaseController
@user.assign_attributes(profile_params)
@user.custom_attributes.merge!(custom_attributes_params)
@user.save!
# Profile updates can change cached agent fields, including avatar-backed thumbnails.
@user.invalidate_avatar_cache
end
def avatar
@user.avatar.attachment.destroy! if @user.avatar.attached?
@user.reload
# Agent thumbnails are cached separately, and avatar attachment deletes do not dirty user columns.
@user.invalidate_avatar_cache
end
def auto_offline
+10 -2
View File
@@ -19,6 +19,7 @@ import {
verifyServiceWorkerExistence,
} from './helper/pushHelper';
import ReconnectService from 'dashboard/helper/ReconnectService';
import paintStoresFromCache from 'dashboard/helper/CacheHelper/paintStoresFromCache';
import { useUISettings } from 'dashboard/composables/useUISettings';
export default {
@@ -108,14 +109,21 @@ export default {
this.$store.dispatch('setActiveAccount', {
accountId: this.currentAccountId,
});
const { pubsub_token: pubsubToken } = this.currentUser || {};
vueActionCable.init(this.store, pubsubToken);
// Paint cached config from IndexedDB instantly while the cable
// connects. Freshness needs no orchestration here: RoomChannel pushes
// the cache-key map on every (re)subscribe and on every server-side
// change, all through the same account.cache_invalidated event.
await paintStoresFromCache(this.$store, this.currentAccountId);
const account = this.getAccount(this.currentAccountId);
const { locale, latest_chatwoot_version: latestChatwootVersion } =
account;
const { pubsub_token: pubsubToken } = this.currentUser || {};
// If user locale is set, use it; otherwise use account locale
this.setLocale(this.uiSettings?.locale || locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(this.store, pubsubToken);
this.reconnectService = new ReconnectService(this.store, this.router);
window.reconnectService = this.reconnectService;
@@ -5,14 +5,15 @@ import ApiClient from './ApiClient';
class CacheEnabledApiClient extends ApiClient {
constructor(resource, options = {}) {
super(resource, options);
// `cacheModel` is the Rails Model.name.underscore value — simultaneously
// the server cache-key name and the IDB object-store name.
this.cacheModelName = options.cacheModel;
// inbox/label endpoints wrap collections in { payload }; the rest return
// the bare array.
this.payloadEnvelope = options.payloadEnvelope || false;
this.dataManager = new DataManager(this.accountIdFromRoute);
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
throw new Error('cacheModelName is not defined');
}
get(cache = false) {
if (cache) {
return this.getFromCache();
@@ -25,14 +26,14 @@ class CacheEnabledApiClient extends ApiClient {
return axios.get(this.url);
}
// eslint-disable-next-line class-methods-use-this
extractDataFromResponse(response) {
return response.data.payload;
return this.payloadEnvelope ? response.data.payload : response.data;
}
// eslint-disable-next-line class-methods-use-this
marshallData(dataToParse) {
return { data: { payload: dataToParse } };
return this.payloadEnvelope
? { data: { payload: dataToParse } }
: { data: dataToParse };
}
async getFromCache() {
@@ -43,24 +44,23 @@ class CacheEnabledApiClient extends ApiClient {
return this.getFromNetwork();
}
const { data } = await axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
);
const cacheKeyFromApi = data.cache_keys[this.cacheModelName];
const isCacheValid = await this.validateCacheKey(cacheKeyFromApi);
// Trust the IDB cache. Freshness is maintained by the
// account.cache_invalidated event alone: RoomChannel pushes the cache-key
// map on every (re)subscribe — boot and reconnect included — and the
// server broadcasts it on every change. Skipping a per-call /cache_keys
// preflight eliminates N GET requests per cold settings-page load.
const localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
let localData = [];
if (isCacheValid) {
localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
if (localData.length > 0) {
return this.marshallData(localData);
}
if (localData.length === 0) {
return this.refetchAndCommit(cacheKeyFromApi);
}
return this.marshallData(localData);
// Empty IDB (first load or wiped): fetch data without a cache key. The
// next pushed key map won't match the missing key and will refetch once,
// stamping the authoritative key — the client never pulls keys itself.
return this.refetchAndCommit(null);
}
async refetchAndCommit(newKey = null) {
@@ -69,7 +69,9 @@ class CacheEnabledApiClient extends ApiClient {
try {
await this.dataManager.initDb();
this.dataManager.replace({
// Await replace so data is persisted before the cache key is — otherwise
// a concurrent reader could see a fresh key paired with stale data.
await this.dataManager.replace({
modelName: this.cacheModelName,
data: this.extractDataFromResponse(response),
});
@@ -89,8 +91,15 @@ class CacheEnabledApiClient extends ApiClient {
await this.dataManager.initDb();
}
const cachekey = await this.dataManager.getCacheKey(this.cacheModelName);
return cacheKeyFromApi === cachekey;
const cacheKey = await this.dataManager.getCacheKey(this.cacheModelName);
if (cacheKey === undefined) {
const localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
return localData.length === 0;
}
return cacheKeyFromApi === cacheKey;
}
}
-7
View File
@@ -9,13 +9,6 @@ class AccountAPI extends ApiClient {
createAccount(data) {
return axios.post(`${this.apiVersion}/accounts`, data);
}
async getCacheKeys() {
const response = await axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
);
return response.data.cache_keys;
}
}
export default new AccountAPI();
+3 -3
View File
@@ -1,10 +1,10 @@
/* global axios */
import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
class Agents extends ApiClient {
class Agents extends CacheEnabledApiClient {
constructor() {
super('agents', { accountScoped: true });
super('agents', { accountScoped: true, cacheModel: 'account_user' });
}
bulkInvite({ emails }) {
+7 -5
View File
@@ -1,13 +1,15 @@
/* global axios */
import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
class AttributeAPI extends ApiClient {
class AttributeAPI extends CacheEnabledApiClient {
constructor() {
super('custom_attribute_definitions', { accountScoped: true });
super('custom_attribute_definitions', {
accountScoped: true,
cacheModel: 'custom_attribute_definition',
});
}
getAttributesByModel() {
return axios.get(this.url);
return super.get(true);
}
}
+11 -6
View File
@@ -1,15 +1,20 @@
/* global axios */
import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
class CannedResponse extends ApiClient {
class CannedResponse extends CacheEnabledApiClient {
constructor() {
super('canned_responses', { accountScoped: true });
super('canned_responses', {
accountScoped: true,
cacheModel: 'canned_response',
});
}
get({ searchKey }) {
const url = searchKey ? `${this.url}?search=${searchKey}` : this.url;
return axios.get(url);
get({ searchKey } = {}) {
if (searchKey) {
return axios.get(`${this.url}?search=${searchKey}`);
}
return super.get(true);
}
}
+5 -6
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) {
+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,
});
}
}
+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 }) {
@@ -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 => {
@@ -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
);
@@ -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) {
@@ -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 () => {
@@ -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';
+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
+6 -2
View File
@@ -4,11 +4,15 @@ module CacheKeys
include CacheKeysHelper
include Events::Types
CACHE_KEYS_EXPIRY = 72.hours
# Self-healing bound: if a write path ever changes cached data without
# bumping its key, expiry forces the sentinel and every client refetches.
# 7 days caps that staleness while sparing quiet models (labels, teams)
# from a spurious full refetch after every idle weekend.
CACHE_KEYS_EXPIRY = 7.days
included do
class_attribute :cacheable_models
self.cacheable_models = [Label, Inbox, Team]
self.cacheable_models = [Label, Inbox, Team, CannedResponse, AccountUser, CustomAttributeDefinition]
end
def cache_keys
@@ -22,6 +22,8 @@
# index_custom_attribute_definitions_on_account_id (account_id)
#
class CustomAttributeDefinition < ApplicationRecord
include AccountCacheRevalidator
STANDARD_ATTRIBUTES = {
:conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at
last_activity_at],
+7
View File
@@ -50,6 +50,13 @@ class Portal < ApplicationRecord
schema: PortalConfigSchema::CONFIG_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.config }
# Portal name/slug are embedded as help_center into the cached inbox payload
# (api/v1/models/_inbox.json.jbuilder), so portal changes must bump the
# account's inbox cache key. Destroy is covered too: dependent: :nullify
# detaches inboxes via update_all and skips their callbacks.
after_update_commit -> { account.update_cache_key('inbox') }
after_destroy_commit -> { account.update_cache_key('inbox') }
scope :active, -> { where(archived: false) }
# TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config
+6
View File
@@ -18,6 +18,12 @@ class TeamMember < ApplicationRecord
belongs_to :user
belongs_to :team
validates :user_id, uniqueness: { scope: :team_id }
# is_member is embedded into the cached team payload (per current user) via
# api/v1/models/_team.json.jbuilder, so membership changes must bump the team
# cache key. team is safe-navigated because destroying a team cascades here
# via destroy_async, by which point the team row is already gone.
after_commit -> { team&.account&.update_cache_key('team') }, on: [:create, :destroy]
end
TeamMember.include_mod_with('Audit::TeamMember')
+12
View File
@@ -116,8 +116,12 @@ class User < ApplicationRecord
has_many :macros, foreign_key: 'created_by_id', inverse_of: :created_by
# rubocop:enable Rails/HasManyOrHasOneDependent
AGENT_CACHE_RELEVANT_COLUMNS = %w[name email display_name confirmed_at custom_attributes].freeze
before_validation :set_password_and_uid, on: :create
after_destroy :remove_macros
after_update_commit :bump_account_user_cache_keys,
if: -> { saved_changes.keys.intersect?(AGENT_CACHE_RELEVANT_COLUMNS) }
scope :order_by_full_name, -> { order('lower(name) ASC') }
@@ -212,11 +216,19 @@ class User < ApplicationRecord
super
end
def invalidate_avatar_cache
bump_account_user_cache_keys
end
private
def remove_macros
macros.personal.destroy_all
end
def bump_account_user_cache_keys
accounts.each { |account| account.update_cache_key('account_user') }
end
end
User.include_mod_with('Audit::User')
+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
+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("account_#{account.id}")
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
@@ -213,17 +213,18 @@ 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 'sets the appropriate cache headers' do
it 'does not allow cached cache key responses' do
get "/api/v1/accounts/#{account.id}/cache_keys",
headers: admin.create_new_auth_token,
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('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
@@ -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