diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 507e00e64..af48ccdf3 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -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) diff --git a/app/helpers/cache_keys_helper.rb b/app/helpers/cache_keys_helper.rb new file mode 100644 index 000000000..aab33e44c --- /dev/null +++ b/app/helpers/cache_keys_helper.rb @@ -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 diff --git a/app/javascript/dashboard/api/ApiClient.js b/app/javascript/dashboard/api/ApiClient.js index 25083dc99..fed0746ca 100644 --- a/app/javascript/dashboard/api/ApiClient.js +++ b/app/javascript/dashboard/api/ApiClient.js @@ -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; diff --git a/app/javascript/dashboard/api/CacheEnabledApiClient.js b/app/javascript/dashboard/api/CacheEnabledApiClient.js new file mode 100644 index 000000000..2c00389e5 --- /dev/null +++ b/app/javascript/dashboard/api/CacheEnabledApiClient.js @@ -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; diff --git a/app/javascript/dashboard/api/auth.js b/app/javascript/dashboard/api/auth.js index 19ba40a42..040c27313 100644 --- a/app/javascript/dashboard/api/auth.js +++ b/app/javascript/dashboard/api/auth.js @@ -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); }) diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index 657a6d0e6..8c09791c8 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -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`); } diff --git a/app/javascript/dashboard/api/labels.js b/app/javascript/dashboard/api/labels.js index 8a088e840..2b521b058 100644 --- a/app/javascript/dashboard/api/labels.js +++ b/app/javascript/dashboard/api/labels.js @@ -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(); diff --git a/app/javascript/dashboard/api/teams.js b/app/javascript/dashboard/api/teams.js index ba202dff2..5413af96b 100644 --- a/app/javascript/dashboard/api/teams.js +++ b/app/javascript/dashboard/api/teams.js @@ -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`); } diff --git a/app/javascript/dashboard/helper/CacheHelper/DataManager.js b/app/javascript/dashboard/helper/CacheHelper/DataManager.js new file mode 100644 index 000000000..df7d560ac --- /dev/null +++ b/app/javascript/dashboard/helper/CacheHelper/DataManager.js @@ -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); + } +} diff --git a/app/javascript/dashboard/helper/CacheHelper/version.js b/app/javascript/dashboard/helper/CacheHelper/version.js new file mode 100644 index 000000000..07bd897f5 --- /dev/null +++ b/app/javascript/dashboard/helper/CacheHelper/version.js @@ -0,0 +1,3 @@ +// Monday, 13 March 2023 +// Change this version if you want to invalidate old data +export const DATA_VERSION = '1678706392'; diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index c1b82bc1c..b5ea85f0a 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -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 { diff --git a/app/javascript/dashboard/helper/specs/CacheHelper/DataManger.spec.js b/app/javascript/dashboard/helper/specs/CacheHelper/DataManger.spec.js new file mode 100644 index 000000000..d96b643b4 --- /dev/null +++ b/app/javascript/dashboard/helper/specs/CacheHelper/DataManger.spec.js @@ -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); + }); + }); +}); diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index a9e002471..dcc656cb2 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -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) { diff --git a/app/javascript/dashboard/store/modules/labels.js b/app/javascript/dashboard/store/modules/labels.js index 53c4d7b5f..e281caedb 100644 --- a/app/javascript/dashboard/store/modules/labels.js +++ b/app/javascript/dashboard/store/modules/labels.js @@ -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 diff --git a/app/javascript/dashboard/store/modules/teams/actions.js b/app/javascript/dashboard/store/modules/teams/actions.js index be6b842e2..a8eab88b5 100644 --- a/app/javascript/dashboard/store/modules/teams/actions.js +++ b/app/javascript/dashboard/store/modules/teams/actions.js @@ -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) { diff --git a/app/javascript/dashboard/store/utils/api.js b/app/javascript/dashboard/store/utils/api.js index 93791b760..889a7a85f 100644 --- a/app/javascript/dashboard/store/utils/api.js +++ b/app/javascript/dashboard/store/utils/api.js @@ -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); diff --git a/app/listeners/action_cable_listener.rb b/app/listeners/action_cable_listener.rb index e7c025600..e612da583 100644 --- a/app/listeners/action_cable_listener.rb +++ b/app/listeners/action_cable_listener.rb @@ -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 diff --git a/app/models/account.rb b/app/models/account.rb index eadd145bf..b3e12b7b3 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -26,6 +26,7 @@ class Account < ApplicationRecord include FlagShihTzu include Reportable include Featurable + include CacheKeys DEFAULT_QUERY_SETTING = { flag_query_mode: :bit_operator, diff --git a/app/models/concerns/account_cache_revalidator.rb b/app/models/concerns/account_cache_revalidator.rb new file mode 100644 index 000000000..53ffda28a --- /dev/null +++ b/app/models/concerns/account_cache_revalidator.rb @@ -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 diff --git a/app/models/concerns/cache_keys.rb b/app/models/concerns/cache_keys.rb new file mode 100644 index 000000000..4c27e6bbb --- /dev/null +++ b/app/models/concerns/cache_keys.rb @@ -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 diff --git a/app/models/inbox.rb b/app/models/inbox.rb index bc086ae87..b74a79894 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -34,6 +34,7 @@ class Inbox < ApplicationRecord include Reportable include Avatarable include OutOfOffisable + include AccountCacheRevalidator # Not allowing characters: validates :name, presence: true diff --git a/app/models/label.rb b/app/models/label.rb index 6f0bd9d07..9311c1bd3 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -18,6 +18,8 @@ # class Label < ApplicationRecord include RegexHelper + include AccountCacheRevalidator + belongs_to :account validates :title, diff --git a/app/models/team.rb b/app/models/team.rb index b4811e232..57bd30451 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -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 diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index f97418339..dcce5028a 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -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 diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder index 3d258265a..c70c1165b 100644 --- a/app/views/api/v1/models/_account.json.jbuilder +++ b/app/views/api/v1/models/_account.json.jbuilder @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 9e4687762..218b859be 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/config/webpack/environment.js b/config/webpack/environment.js index 1301fc36e..cf0f0fd13 100644 --- a/config/webpack/environment.js +++ b/config/webpack/environment.js @@ -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'; }); diff --git a/jest.config.js b/jest.config.js index 604b5f694..3f7749840 100644 --- a/jest.config.js +++ b/jest.config.js @@ -32,4 +32,5 @@ module.exports = { testURL: 'http://localhost/', globalSetup: './jest.setup.js', testEnvironment: 'jsdom', + setupFiles: ['fake-indexeddb/auto'], }; diff --git a/lib/events/types.rb b/lib/events/types.rb index 85a77a6d3..2c2213e48 100644 --- a/lib/events/types.rb +++ b/lib/events/types.rb @@ -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 diff --git a/package.json b/package.json index 360751991..39afb1529 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb index cebc36872..2e402e54e 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts_controller_spec.rb @@ -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) } diff --git a/spec/helpers/cache_keys_helper_spec.rb b/spec/helpers/cache_keys_helper_spec.rb new file mode 100644 index 000000000..fa13f50ef --- /dev/null +++ b/spec/helpers/cache_keys_helper_spec.rb @@ -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 diff --git a/yarn.lock b/yarn.lock index ef0e418db..795f52851 100644 --- a/yarn.lock +++ b/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"