mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 19:48:08 +00:00
feat: IndexedDB based caching for labels, inboxes and teams [CW-50] (#6710)
* feat: allow caching of labels in the account scope * feat: send cache keys in account json response * feat: kickstart web worker * feat: setup basic architecture for workers * feat: install idb * feat: add datamanger * fix: typos * refactor: rename method * feat: make init db a manual step * refactor: separate accountIdFromRoute * feat: cache enabled API client * feat: enable caching for inboxes and labels * feat: enable cache for team * feat: manage exceptions for team * feat: add team to data manager * feat: add a generic listener * refactor: send only cache keys * refactor: separate validate method * feat: add listeners * feat: add event for revalidate * feat: add cache keys endpoint * refactor: fetch cache keys instead of full account data * fix: key pattern * feat: don't fetch account for cache_keys * fix: cache key base class * refactor: cache keys helper * feat: add helper * fix: cache-key update logic * feat: delete indexeddb on logout * feat: remove worker.js * refactor: move data-manager * refactor: name of file * feat: add test for DataManager * refactor: add fake idb to jest setup * test: cache keys helper * test: cache keys helper * test: cache_keys in accounts controller * refactor: remove cache_keys context * feat: add policy for cache-keys
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
class Api::V1::AccountsController < Api::BaseController
|
||||
include AuthHelper
|
||||
include CacheKeysHelper
|
||||
|
||||
skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception,
|
||||
only: [:create], raise: false
|
||||
@@ -30,6 +31,10 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
end
|
||||
|
||||
def cache_keys
|
||||
render json: { cache_keys: get_cache_keys }, status: :ok
|
||||
end
|
||||
|
||||
def show
|
||||
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
|
||||
render 'api/v1/accounts/show', format: :json
|
||||
@@ -47,6 +52,14 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
|
||||
private
|
||||
|
||||
def get_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
|
||||
|
||||
def fetch_account
|
||||
@account = current_user.accounts.find(params[:id])
|
||||
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
|
||||
|
||||
15
app/helpers/cache_keys_helper.rb
Normal file
15
app/helpers/cache_keys_helper.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
module CacheKeysHelper
|
||||
def get_prefixed_cache_key(account_id, key)
|
||||
"idb-cache-key-account-#{account_id}-#{key}"
|
||||
end
|
||||
|
||||
def fetch_value_for_key(account_id, key)
|
||||
prefixed_cache_key = get_prefixed_cache_key(account_id, key)
|
||||
value_from_cache = Redis::Alfred.get(prefixed_cache_key)
|
||||
|
||||
return value_from_cache if value_from_cache.present?
|
||||
|
||||
# zero epoch time: 1970-01-01 00:00:00 UTC
|
||||
'0000000000'
|
||||
end
|
||||
end
|
||||
@@ -13,6 +13,19 @@ class ApiClient {
|
||||
return `${this.baseUrl()}/${this.resource}`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get accountIdFromRoute() {
|
||||
const isInsideAccountScopedURLs = window.location.pathname.includes(
|
||||
'/app/accounts'
|
||||
);
|
||||
|
||||
if (isInsideAccountScopedURLs) {
|
||||
return window.location.pathname.split('/')[3];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
baseUrl() {
|
||||
let url = this.apiVersion;
|
||||
|
||||
@@ -20,15 +33,8 @@ class ApiClient {
|
||||
url = `/enterprise${url}`;
|
||||
}
|
||||
|
||||
if (this.options.accountScoped) {
|
||||
const isInsideAccountScopedURLs = window.location.pathname.includes(
|
||||
'/app/accounts'
|
||||
);
|
||||
|
||||
if (isInsideAccountScopedURLs) {
|
||||
const accountId = window.location.pathname.split('/')[3];
|
||||
url = `${url}/accounts/${accountId}`;
|
||||
}
|
||||
if (this.options.accountScoped && this.accountIdFromRoute) {
|
||||
url = `${url}/accounts/${this.accountIdFromRoute}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
|
||||
82
app/javascript/dashboard/api/CacheEnabledApiClient.js
Normal file
82
app/javascript/dashboard/api/CacheEnabledApiClient.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/* global axios */
|
||||
import { DataManager } from '../helper/CacheHelper/DataManager';
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class CacheEnabledApiClient extends ApiClient {
|
||||
constructor(resource, options = {}) {
|
||||
super(resource, options);
|
||||
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();
|
||||
}
|
||||
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
extractDataFromResponse(response) {
|
||||
return response.data.payload;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
marshallData(dataToParse) {
|
||||
return { data: { payload: dataToParse } };
|
||||
}
|
||||
|
||||
async getFromCache() {
|
||||
await this.dataManager.initDb();
|
||||
|
||||
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);
|
||||
|
||||
let localData = [];
|
||||
if (isCacheValid) {
|
||||
localData = await this.dataManager.get({
|
||||
modelName: this.cacheModelName,
|
||||
});
|
||||
}
|
||||
|
||||
if (localData.length === 0) {
|
||||
return this.refetchAndCommit(cacheKeyFromApi);
|
||||
}
|
||||
|
||||
return this.marshallData(localData);
|
||||
}
|
||||
|
||||
async refetchAndCommit(newKey = null) {
|
||||
await this.dataManager.initDb();
|
||||
const response = await axios.get(this.url);
|
||||
this.dataManager.replace({
|
||||
modelName: this.cacheModelName,
|
||||
data: this.extractDataFromResponse(response),
|
||||
});
|
||||
|
||||
await this.dataManager.setCacheKeys({
|
||||
[this.cacheModelName]: newKey,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async validateCacheKey(cacheKeyFromApi) {
|
||||
if (!this.dataManager.db) {
|
||||
await this.dataManager.initDb();
|
||||
}
|
||||
|
||||
const cachekey = await this.dataManager.getCacheKey(this.cacheModelName);
|
||||
return cacheKeyFromApi === cachekey;
|
||||
}
|
||||
}
|
||||
|
||||
export default CacheEnabledApiClient;
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import Cookies from 'js-cookie';
|
||||
import endPoints from './endPoints';
|
||||
import { setAuthCredentials, clearCookiesOnLogout } from '../store/utils/api';
|
||||
import {
|
||||
setAuthCredentials,
|
||||
clearCookiesOnLogout,
|
||||
deleteIndexedDBOnLogout,
|
||||
} from '../store/utils/api';
|
||||
|
||||
export default {
|
||||
login(creds) {
|
||||
@@ -50,6 +54,7 @@ export default {
|
||||
axios
|
||||
.delete(urlData.url)
|
||||
.then(response => {
|
||||
deleteIndexedDBOnLogout();
|
||||
clearCookiesOnLogout();
|
||||
resolve(response);
|
||||
})
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class Inboxes extends ApiClient {
|
||||
class Inboxes extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('inboxes', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'inbox';
|
||||
}
|
||||
|
||||
getCampaigns(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/campaigns`);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class LabelsAPI extends ApiClient {
|
||||
class LabelsAPI extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('labels', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'label';
|
||||
}
|
||||
}
|
||||
|
||||
export default new LabelsAPI();
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
// import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
export class TeamsAPI extends ApiClient {
|
||||
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 };
|
||||
}
|
||||
|
||||
getAgents({ teamId }) {
|
||||
return axios.get(`${this.url}/${teamId}/team_members`);
|
||||
}
|
||||
|
||||
70
app/javascript/dashboard/helper/CacheHelper/DataManager.js
Normal file
70
app/javascript/dashboard/helper/CacheHelper/DataManager.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { openDB } from 'idb';
|
||||
import { DATA_VERSION } from './version';
|
||||
|
||||
export class DataManager {
|
||||
constructor(accountId) {
|
||||
this.modelsToSync = ['inbox', 'label', 'team'];
|
||||
this.accountId = accountId;
|
||||
this.db = null;
|
||||
}
|
||||
|
||||
async initDb() {
|
||||
if (this.db) return this.db;
|
||||
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' });
|
||||
},
|
||||
});
|
||||
|
||||
return this.db;
|
||||
}
|
||||
|
||||
validateModel(name) {
|
||||
if (!name) throw new Error('Model name is not defined');
|
||||
if (!this.modelsToSync.includes(name)) {
|
||||
throw new Error(`Model ${name} is not defined`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async replace({ modelName, data }) {
|
||||
this.validateModel(modelName);
|
||||
|
||||
this.db.clear(modelName);
|
||||
return this.push({ modelName, data });
|
||||
}
|
||||
|
||||
async push({ modelName, data }) {
|
||||
this.validateModel(modelName);
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
const tx = this.db.transaction(modelName, 'readwrite');
|
||||
data.forEach(item => {
|
||||
tx.store.add(item);
|
||||
});
|
||||
await tx.done;
|
||||
} else {
|
||||
await this.db.add(modelName, data);
|
||||
}
|
||||
}
|
||||
|
||||
async get({ modelName }) {
|
||||
this.validateModel(modelName);
|
||||
return this.db.getAll(modelName);
|
||||
}
|
||||
|
||||
async setCacheKeys(cacheKeys) {
|
||||
Object.keys(cacheKeys).forEach(async modelName => {
|
||||
this.db.put('cache-keys', cacheKeys[modelName], modelName);
|
||||
});
|
||||
}
|
||||
|
||||
async getCacheKey(modelName) {
|
||||
this.validateModel(modelName);
|
||||
|
||||
return this.db.get('cache-keys', modelName);
|
||||
}
|
||||
}
|
||||
3
app/javascript/dashboard/helper/CacheHelper/version.js
Normal file
3
app/javascript/dashboard/helper/CacheHelper/version.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Monday, 13 March 2023
|
||||
// Change this version if you want to invalidate old data
|
||||
export const DATA_VERSION = '1678706392';
|
||||
@@ -26,6 +26,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'first.reply.created': this.onFirstReplyCreated,
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,6 +157,13 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
onFirstReplyCreated = () => {
|
||||
bus.$emit('fetch_overview_reports');
|
||||
};
|
||||
|
||||
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 });
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { DataManager } from '../../CacheHelper/DataManager';
|
||||
|
||||
describe('DataManager', () => {
|
||||
const accountId = 'test-account';
|
||||
let dataManager;
|
||||
|
||||
beforeAll(async () => {
|
||||
dataManager = new DataManager(accountId);
|
||||
await dataManager.initDb();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const tx = dataManager.db.transaction(
|
||||
dataManager.modelsToSync,
|
||||
'readwrite'
|
||||
);
|
||||
dataManager.modelsToSync.forEach(modelName => {
|
||||
tx.objectStore(modelName).clear();
|
||||
});
|
||||
await tx.done;
|
||||
});
|
||||
|
||||
describe('initDb', () => {
|
||||
it('should initialize the database', async () => {
|
||||
expect(dataManager.db).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should return the same instance of the database', async () => {
|
||||
const db1 = await dataManager.initDb();
|
||||
const db2 = await dataManager.initDb();
|
||||
expect(db1).toBe(db2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateModel', () => {
|
||||
it('should throw an error for empty input', async () => {
|
||||
expect(() => {
|
||||
dataManager.validateModel();
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should throw an error for invalid model', async () => {
|
||||
expect(() => {
|
||||
dataManager.validateModel('invalid-model');
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should not throw an error for valid model', async () => {
|
||||
expect(dataManager.validateModel('label')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('replace', () => {
|
||||
it('should replace existing data in the specified model', async () => {
|
||||
const inboxData = [
|
||||
{ id: 1, name: 'inbox-1' },
|
||||
{ id: 2, name: 'inbox-2' },
|
||||
];
|
||||
const newData = [
|
||||
{ id: 3, name: 'inbox-3' },
|
||||
{ id: 4, name: 'inbox-4' },
|
||||
];
|
||||
|
||||
await dataManager.push({ modelName: 'inbox', data: inboxData });
|
||||
await dataManager.replace({ modelName: 'inbox', data: newData });
|
||||
const result = await dataManager.get({ modelName: 'inbox' });
|
||||
expect(result).toEqual(newData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('push', () => {
|
||||
it('should add data to the specified model', async () => {
|
||||
const inboxData = { id: 1, name: 'inbox-1' };
|
||||
|
||||
await dataManager.push({ modelName: 'inbox', data: inboxData });
|
||||
const result = await dataManager.get({ modelName: 'inbox' });
|
||||
expect(result).toEqual([inboxData]);
|
||||
});
|
||||
|
||||
it('should add multiple items to the specified model if an array of data is provided', async () => {
|
||||
const inboxData = [
|
||||
{ id: 1, name: 'inbox-1' },
|
||||
{ id: 2, name: 'inbox-2' },
|
||||
];
|
||||
|
||||
await dataManager.push({ modelName: 'inbox', data: inboxData });
|
||||
const result = await dataManager.get({ modelName: 'inbox' });
|
||||
expect(result).toEqual(inboxData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should return all data in the specified model', async () => {
|
||||
const inboxData = [
|
||||
{ id: 1, name: 'inbox-1' },
|
||||
{ id: 2, name: 'inbox-2' },
|
||||
];
|
||||
|
||||
await dataManager.push({ modelName: 'inbox', data: inboxData });
|
||||
const result = await dataManager.get({ modelName: 'inbox' });
|
||||
expect(result).toEqual(inboxData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCacheKeys', () => {
|
||||
it('should add cache keys for each model', async () => {
|
||||
const cacheKeys = { inbox: 'cache-key-1', label: 'cache-key-2' };
|
||||
|
||||
await dataManager.setCacheKeys(cacheKeys);
|
||||
const result = await dataManager.getCacheKey('inbox');
|
||||
expect(result).toEqual(cacheKeys.inbox);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -126,10 +126,21 @@ 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 {
|
||||
const response = await InboxesAPI.get();
|
||||
const response = await InboxesAPI.get(true);
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: false });
|
||||
commit(types.default.SET_INBOXES, response.data.payload);
|
||||
} catch (error) {
|
||||
|
||||
@@ -29,10 +29,22 @@ 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 {
|
||||
const response = await LabelsAPI.get();
|
||||
const response = await LabelsAPI.get(true);
|
||||
commit(types.SET_LABELS, response.data.payload);
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
|
||||
@@ -22,10 +22,21 @@ 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();
|
||||
const { data } = await TeamsAPI.get(true);
|
||||
commit(CLEAR_TEAMS);
|
||||
commit(SET_TEAMS, data);
|
||||
} catch (error) {
|
||||
|
||||
@@ -42,6 +42,13 @@ export const clearLocalStorageOnLogout = () => {
|
||||
LocalStorage.remove(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES);
|
||||
};
|
||||
|
||||
export const deleteIndexedDBOnLogout = async () => {
|
||||
const dbs = await window.indexedDB.databases();
|
||||
dbs.forEach(db => {
|
||||
window.indexedDB.deleteDatabase(db.name);
|
||||
});
|
||||
};
|
||||
|
||||
export const clearCookiesOnLogout = () => {
|
||||
window.bus.$emit(CHATWOOT_RESET);
|
||||
window.bus.$emit(ANALYTICS_RESET);
|
||||
|
||||
@@ -7,6 +7,15 @@ class ActionCableListener < BaseListener
|
||||
broadcast(account, tokens, NOTIFICATION_CREATED, { notification: notification.push_event_data, unread_count: unread_count, count: count })
|
||||
end
|
||||
|
||||
def account_cache_invalidated(event)
|
||||
account = event.data[:account]
|
||||
tokens = user_tokens(account, account.agents)
|
||||
|
||||
broadcast(account, tokens, ACCOUNT_CACHE_INVALIDATED, {
|
||||
cache_keys: event.data[:cache_keys]
|
||||
})
|
||||
end
|
||||
|
||||
def message_created(event)
|
||||
message, account = extract_message_and_account(event)
|
||||
conversation = message.conversation
|
||||
|
||||
@@ -26,6 +26,7 @@ class Account < ApplicationRecord
|
||||
include FlagShihTzu
|
||||
include Reportable
|
||||
include Featurable
|
||||
include CacheKeys
|
||||
|
||||
DEFAULT_QUERY_SETTING = {
|
||||
flag_query_mode: :bit_operator,
|
||||
|
||||
13
app/models/concerns/account_cache_revalidator.rb
Normal file
13
app/models/concerns/account_cache_revalidator.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module AccountCacheRevalidator
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_save :update_account_cache
|
||||
after_destroy :update_account_cache
|
||||
after_create :update_account_cache
|
||||
end
|
||||
|
||||
def update_account_cache
|
||||
account.update_cache_key(self.class.name.underscore)
|
||||
end
|
||||
end
|
||||
32
app/models/concerns/cache_keys.rb
Normal file
32
app/models/concerns/cache_keys.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
module CacheKeys
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include CacheKeysHelper
|
||||
include Events::Types
|
||||
|
||||
def cache_keys
|
||||
{
|
||||
label: fetch_value_for_key(id, Label.name.underscore),
|
||||
inbox: fetch_value_for_key(id, Inbox.name.underscore),
|
||||
team: fetch_value_for_key(id, Team.name.underscore)
|
||||
}
|
||||
end
|
||||
|
||||
def invalidate_cache_key_for(key)
|
||||
prefixed_cache_key = get_prefixed_cache_key(id, key)
|
||||
Redis::Alfred.del(prefixed_cache_key)
|
||||
dispatch_cache_udpate_event
|
||||
end
|
||||
|
||||
def update_cache_key(key)
|
||||
prefixed_cache_key = get_prefixed_cache_key(id, key)
|
||||
Redis::Alfred.set(prefixed_cache_key, Time.now.utc.to_i)
|
||||
dispatch_cache_udpate_event
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dispatch_cache_udpate_event
|
||||
Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, cache_keys: cache_keys, account: self)
|
||||
end
|
||||
end
|
||||
@@ -34,6 +34,7 @@ class Inbox < ApplicationRecord
|
||||
include Reportable
|
||||
include Avatarable
|
||||
include OutOfOffisable
|
||||
include AccountCacheRevalidator
|
||||
|
||||
# Not allowing characters:
|
||||
validates :name, presence: true
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
#
|
||||
class Label < ApplicationRecord
|
||||
include RegexHelper
|
||||
include AccountCacheRevalidator
|
||||
|
||||
belongs_to :account
|
||||
|
||||
validates :title,
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
# index_teams_on_name_and_account_id (name,account_id) UNIQUE
|
||||
#
|
||||
class Team < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
belongs_to :account
|
||||
has_many :team_members, dependent: :destroy_async
|
||||
has_many :members, through: :team_members, source: :user
|
||||
|
||||
@@ -3,6 +3,10 @@ class AccountPolicy < ApplicationPolicy
|
||||
@account_user.administrator? || @account_user.agent?
|
||||
end
|
||||
|
||||
def cache_keys?
|
||||
@account_user.administrator? || @account_user.agent?
|
||||
end
|
||||
|
||||
def update?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
@@ -14,3 +14,4 @@ json.locale @account.locale
|
||||
json.name @account.name
|
||||
json.support_email @account.support_email
|
||||
json.status @account.status
|
||||
json.cache_keys @account.cache_keys
|
||||
|
||||
@@ -35,6 +35,7 @@ Rails.application.routes.draw do
|
||||
resources :accounts, only: [:create, :show, :update] do
|
||||
member do
|
||||
post :update_active_at
|
||||
get :cache_keys
|
||||
end
|
||||
|
||||
scope module: :accounts do
|
||||
|
||||
@@ -31,9 +31,11 @@ environment.loaders.append('audio', {
|
||||
},
|
||||
});
|
||||
|
||||
const preserveNameFor = ['sdk', 'worker'];
|
||||
|
||||
environment.config.merge({ resolve });
|
||||
environment.config.set('output.filename', chunkData => {
|
||||
return chunkData.chunk.name === 'sdk'
|
||||
return preserveNameFor.includes(chunkData.chunk.name)
|
||||
? 'js/[name].js'
|
||||
: 'js/[name]-[hash].js';
|
||||
});
|
||||
|
||||
@@ -32,4 +32,5 @@ module.exports = {
|
||||
testURL: 'http://localhost/',
|
||||
globalSetup: './jest.setup.js',
|
||||
testEnvironment: 'jsdom',
|
||||
setupFiles: ['fake-indexeddb/auto'],
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ module Events::Types
|
||||
### Installation Events ###
|
||||
# account events
|
||||
ACCOUNT_CREATED = 'account.created'
|
||||
ACCOUNT_CACHE_INVALIDATED = 'account.cache_invalidated'
|
||||
|
||||
#### Account Events ###
|
||||
# campaign events
|
||||
|
||||
@@ -41,12 +41,13 @@
|
||||
"dompurify": "2.2.7",
|
||||
"foundation-sites": "~6.5.3",
|
||||
"highlight.js": "~10.4.1",
|
||||
"idb": "^7.1.1",
|
||||
"ionicons": "~2.0.1",
|
||||
"js-cookie": "^2.2.1",
|
||||
"markdown-it": "^13.0.1",
|
||||
"markdown-it-link-attributes": "^4.0.1",
|
||||
"logrocket": "^3.0.1",
|
||||
"logrocket-vuex": "^0.0.3",
|
||||
"markdown-it": "^13.0.1",
|
||||
"markdown-it-link-attributes": "^4.0.1",
|
||||
"md5": "^2.3.0",
|
||||
"ninja-keys": "^1.1.9",
|
||||
"opus-recorder": "^8.0.5",
|
||||
@@ -108,6 +109,7 @@
|
||||
"eslint-plugin-storybook": "^0.5.13",
|
||||
"eslint-plugin-vue": "^6.2.2",
|
||||
"expect-more-jest": "^2.4.2",
|
||||
"fake-indexeddb": "^4.0.1",
|
||||
"husky": "^7.0.0",
|
||||
"jest": "26.6.3",
|
||||
"jest-serializer-vue": "^2.0.2",
|
||||
|
||||
@@ -137,6 +137,22 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/cache_keys' do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns cache_keys as expected' do
|
||||
account.update(auto_resolve_duration: 30)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/cache_keys",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(JSON.parse(response.body)['cache_keys'].keys).to match_array(%w[label inbox team])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/accounts/{account.id}' do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
34
spec/helpers/cache_keys_helper_spec.rb
Normal file
34
spec/helpers/cache_keys_helper_spec.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CacheKeysHelper, type: :helper do
|
||||
let(:account_id) { 1 }
|
||||
let(:key) { 'example_key' }
|
||||
|
||||
describe '#get_prefixed_cache_key' do
|
||||
it 'returns a string with the correct prefix, account ID, and key' do
|
||||
expected_key = "idb-cache-key-account-#{account_id}-#{key}"
|
||||
result = helper.get_prefixed_cache_key(account_id, key)
|
||||
|
||||
expect(result).to eq(expected_key)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#fetch_value_for_key' do
|
||||
it 'returns the zero epoch time if no value is cached' do
|
||||
result = helper.fetch_value_for_key(account_id, 'another-key')
|
||||
|
||||
expect(result).to eq('0000000000')
|
||||
end
|
||||
|
||||
it 'returns a cached value if it exists' do
|
||||
value = Time.now.to_i
|
||||
prefixed_cache_key = helper.get_prefixed_cache_key(account_id, key)
|
||||
|
||||
Redis::Alfred.set(prefixed_cache_key, value)
|
||||
|
||||
result = helper.fetch_value_for_key(account_id, key)
|
||||
|
||||
expect(result).to eq(value.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
68
yarn.lock
68
yarn.lock
@@ -5365,6 +5365,11 @@ balanced-match@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||
|
||||
base64-arraybuffer-es6@^0.7.0:
|
||||
version "0.7.0"
|
||||
resolved "https://registry.yarnpkg.com/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.7.0.tgz#dbe1e6c87b1bf1ca2875904461a7de40f21abc86"
|
||||
integrity sha512-ESyU/U1CFZDJUdr+neHRhNozeCv72Y7Vm0m1DCbjX3KBjT6eYocvAJlSk6+8+HkVwXlT1FNxhGW6q3UKAlCvvw==
|
||||
|
||||
base64-js@^1.0.2:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
@@ -7260,6 +7265,13 @@ domelementtype@^2.0.1, domelementtype@^2.2.0:
|
||||
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57"
|
||||
integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==
|
||||
|
||||
domexception@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90"
|
||||
integrity sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==
|
||||
dependencies:
|
||||
webidl-conversions "^4.0.2"
|
||||
|
||||
domexception@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304"
|
||||
@@ -8137,6 +8149,13 @@ extsprintf@^1.2.0:
|
||||
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
|
||||
integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
|
||||
|
||||
fake-indexeddb@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-4.0.1.tgz#09bb2468e21d0832b2177e894765fb109edac8fb"
|
||||
integrity sha512-hFRyPmvEZILYgdcLBxVdHLik4Tj3gDTu/g7s9ZDOiU3sTNiGx+vEu1ri/AMsFJUZ/1sdRbAVrEcKndh3sViBcA==
|
||||
dependencies:
|
||||
realistic-structured-clone "^3.0.0"
|
||||
|
||||
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
||||
@@ -9342,6 +9361,11 @@ icss-utils@^4.0.0, icss-utils@^4.1.1:
|
||||
dependencies:
|
||||
postcss "^7.0.14"
|
||||
|
||||
idb@^7.1.1:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b"
|
||||
integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==
|
||||
|
||||
ieee754@^1.1.4:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
|
||||
@@ -14259,6 +14283,15 @@ readdirp@~3.5.0:
|
||||
dependencies:
|
||||
picomatch "^2.2.1"
|
||||
|
||||
realistic-structured-clone@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/realistic-structured-clone/-/realistic-structured-clone-3.0.0.tgz#7b518049ce2dad41ac32b421cd297075b00e3e35"
|
||||
integrity sha512-rOjh4nuWkAqf9PWu6JVpOWD4ndI+JHfgiZeMmujYcPi+fvILUu7g6l26TC1K5aBIp34nV+jE1cDO75EKOfHC5Q==
|
||||
dependencies:
|
||||
domexception "^1.0.1"
|
||||
typeson "^6.1.0"
|
||||
typeson-registry "^1.0.0-alpha.20"
|
||||
|
||||
realpath-native@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-2.0.0.tgz#7377ac429b6e1fd599dc38d08ed942d0d7beb866"
|
||||
@@ -16077,6 +16110,13 @@ tr46@^2.0.2:
|
||||
dependencies:
|
||||
punycode "^2.1.1"
|
||||
|
||||
tr46@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240"
|
||||
integrity sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==
|
||||
dependencies:
|
||||
punycode "^2.1.1"
|
||||
|
||||
tr46@~0.0.3:
|
||||
version "0.0.3"
|
||||
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
|
||||
@@ -16239,6 +16279,20 @@ typedarray@^0.0.6:
|
||||
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
|
||||
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
|
||||
|
||||
typeson-registry@^1.0.0-alpha.20:
|
||||
version "1.0.0-alpha.39"
|
||||
resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211"
|
||||
integrity sha512-NeGDEquhw+yfwNhguLPcZ9Oj0fzbADiX4R0WxvoY8nGhy98IbzQy1sezjoEFWOywOboj/DWehI+/aUlRVrJnnw==
|
||||
dependencies:
|
||||
base64-arraybuffer-es6 "^0.7.0"
|
||||
typeson "^6.0.0"
|
||||
whatwg-url "^8.4.0"
|
||||
|
||||
typeson@^6.0.0, typeson@^6.1.0:
|
||||
version "6.1.0"
|
||||
resolved "https://registry.yarnpkg.com/typeson/-/typeson-6.1.0.tgz#5b2a53705a5f58ff4d6f82f965917cabd0d7448b"
|
||||
integrity sha512-6FTtyGr8ldU0pfbvW/eOZrEtEkczHRUtduBnA90Jh9kMPCiFNnXIon3vF41N0S4tV1HHQt4Hk1j4srpESziCaA==
|
||||
|
||||
uc.micro@^1.0.1, uc.micro@^1.0.5:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"
|
||||
@@ -16990,6 +17044,11 @@ webidl-conversions@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
|
||||
integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=
|
||||
|
||||
webidl-conversions@^4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
|
||||
integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
|
||||
|
||||
webidl-conversions@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff"
|
||||
@@ -17232,6 +17291,15 @@ whatwg-url@^8.0.0, whatwg-url@^8.5.0:
|
||||
tr46 "^2.0.2"
|
||||
webidl-conversions "^6.1.0"
|
||||
|
||||
whatwg-url@^8.4.0:
|
||||
version "8.7.0"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77"
|
||||
integrity sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==
|
||||
dependencies:
|
||||
lodash "^4.7.0"
|
||||
tr46 "^2.1.0"
|
||||
webidl-conversions "^6.1.0"
|
||||
|
||||
which-boxed-primitive@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"
|
||||
|
||||
Reference in New Issue
Block a user