Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71fc0e0ab1 | ||
|
|
e3133e2103 | ||
|
|
c6d6664962 | ||
|
|
f5f6e527a8 | ||
|
|
1f55d98960 | ||
|
|
c5b13c8d45 | ||
|
|
63a14f7d06 | ||
|
|
a52223e5ea | ||
|
|
f0c4563ab8 | ||
|
|
f926f5e500 | ||
|
|
c9f6fb202c | ||
|
|
3dfb5061e1 | ||
|
|
72a59e4795 | ||
|
|
cabe9bc733 | ||
|
|
33f7550525 | ||
|
|
7830fec604 | ||
|
|
ba04e0b678 | ||
|
|
8625a00918 | ||
|
|
16e638aeb7 |
+31
-20
@@ -136,6 +136,8 @@ GEM
|
||||
audited (5.4.1)
|
||||
activerecord (>= 5.0, < 7.7)
|
||||
activesupport (>= 5.0, < 7.7)
|
||||
auth-sanitizer (0.2.1)
|
||||
version_gem (~> 1.1, >= 1.1.10)
|
||||
aws-actionmailbox-ses (0.1.0)
|
||||
actionmailbox (>= 7.1.0)
|
||||
aws-sdk-s3 (~> 1, >= 1.123.0)
|
||||
@@ -168,7 +170,7 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.2.2)
|
||||
bigdecimal (3.3.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
@@ -184,6 +186,7 @@ GEM
|
||||
bundler (>= 1.2.0, < 3)
|
||||
thor (~> 1.0)
|
||||
byebug (11.1.3)
|
||||
cgi (0.5.1)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
cld3 (3.7.0)
|
||||
@@ -312,7 +315,7 @@ GEM
|
||||
hashie
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.2)
|
||||
faraday-net_http (3.4.4)
|
||||
net-http (~> 0.5)
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
@@ -435,7 +438,8 @@ GEM
|
||||
hana (1.3.7)
|
||||
hash_diff (1.1.1)
|
||||
hashdiff (1.1.0)
|
||||
hashie (5.0.0)
|
||||
hashie (5.1.0)
|
||||
logger
|
||||
html2text (0.4.0)
|
||||
nokogiri (>= 1.0, < 2.0)
|
||||
http (5.1.1)
|
||||
@@ -470,7 +474,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.19.5)
|
||||
json (2.19.8)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -568,7 +572,7 @@ GEM
|
||||
ruby2_keywords
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.8.0)
|
||||
multi_xml (0.9.1)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
@@ -603,19 +607,26 @@ GEM
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
oauth-tty (1.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.1)
|
||||
oauth2 (2.0.9)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
jwt (>= 1.0, < 3.0)
|
||||
oauth (1.1.6)
|
||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
||||
base64 (~> 0.1)
|
||||
cgi
|
||||
oauth-tty (~> 1.0, >= 1.0.8)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
oauth-tty (1.0.8)
|
||||
auth-sanitizer (~> 0.1, >= 0.1.3)
|
||||
cgi
|
||||
version_gem (~> 1.1, >= 1.1.9)
|
||||
oauth2 (2.0.22)
|
||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
||||
faraday (>= 0.17.3, < 4.0)
|
||||
jwt (>= 1.0, < 4.0)
|
||||
logger (~> 1.2)
|
||||
multi_xml (~> 0.5)
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
@@ -935,9 +946,9 @@ GEM
|
||||
gli
|
||||
hashie
|
||||
logger
|
||||
snaky_hash (2.0.1)
|
||||
hashie
|
||||
version_gem (~> 1.1, >= 1.1.1)
|
||||
snaky_hash (2.0.5)
|
||||
hashie (>= 0.1.0, < 6)
|
||||
version_gem (>= 1.1.8, < 3)
|
||||
sorbet-runtime (0.5.11934)
|
||||
spring (4.1.1)
|
||||
spring-watcher-listen (2.1.0)
|
||||
@@ -995,7 +1006,7 @@ GEM
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
mail (~> 2.5)
|
||||
version_gem (1.1.4)
|
||||
version_gem (1.1.11)
|
||||
vite_rails (3.10.0)
|
||||
railties (>= 5.1, < 9)
|
||||
vite_ruby (~> 3.0, >= 3.2.2)
|
||||
|
||||
@@ -7,7 +7,6 @@ class RoomChannel < ApplicationCable::Channel
|
||||
ensure_stream
|
||||
update_subscription
|
||||
broadcast_presence
|
||||
transmit_cache_keys
|
||||
end
|
||||
|
||||
def update_presence
|
||||
@@ -25,19 +24,6 @@ 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,6 +50,7 @@ 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
|
||||
|
||||
@@ -92,7 +93,11 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def cache_keys_for_account
|
||||
@account.cache_keys
|
||||
{
|
||||
label: fetch_value_for_key(params[:id], Label.name.underscore),
|
||||
inbox: fetch_value_for_key(params[:id], Inbox.name.underscore),
|
||||
team: fetch_value_for_key(params[:id], Team.name.underscore)
|
||||
}
|
||||
end
|
||||
|
||||
def fetch_account
|
||||
|
||||
@@ -13,17 +13,11 @@ 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
|
||||
|
||||
@@ -19,7 +19,6 @@ 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 {
|
||||
@@ -109,21 +108,14 @@ 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,15 +5,14 @@ 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();
|
||||
@@ -26,14 +25,14 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
extractDataFromResponse(response) {
|
||||
return this.payloadEnvelope ? response.data.payload : response.data;
|
||||
return response.data.payload;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
marshallData(dataToParse) {
|
||||
return this.payloadEnvelope
|
||||
? { data: { payload: dataToParse } }
|
||||
: { data: dataToParse };
|
||||
return { data: { payload: dataToParse } };
|
||||
}
|
||||
|
||||
async getFromCache() {
|
||||
@@ -44,23 +43,24 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
return this.getFromNetwork();
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
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);
|
||||
|
||||
if (localData.length > 0) {
|
||||
return this.marshallData(localData);
|
||||
let localData = [];
|
||||
if (isCacheValid) {
|
||||
localData = await this.dataManager.get({
|
||||
modelName: this.cacheModelName,
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (localData.length === 0) {
|
||||
return this.refetchAndCommit(cacheKeyFromApi);
|
||||
}
|
||||
|
||||
return this.marshallData(localData);
|
||||
}
|
||||
|
||||
async refetchAndCommit(newKey = null) {
|
||||
@@ -69,9 +69,7 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
try {
|
||||
await this.dataManager.initDb();
|
||||
|
||||
// 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({
|
||||
this.dataManager.replace({
|
||||
modelName: this.cacheModelName,
|
||||
data: this.extractDataFromResponse(response),
|
||||
});
|
||||
@@ -91,15 +89,8 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
await this.dataManager.initDb();
|
||||
}
|
||||
|
||||
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;
|
||||
const cachekey = await this.dataManager.getCacheKey(this.cacheModelName);
|
||||
return cacheKeyFromApi === cachekey;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ 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();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* global axios */
|
||||
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class Agents extends CacheEnabledApiClient {
|
||||
class Agents extends ApiClient {
|
||||
constructor() {
|
||||
super('agents', { accountScoped: true, cacheModel: 'account_user' });
|
||||
super('agents', { accountScoped: true });
|
||||
}
|
||||
|
||||
bulkInvite({ emails }) {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class AttributeAPI extends CacheEnabledApiClient {
|
||||
class AttributeAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('custom_attribute_definitions', {
|
||||
accountScoped: true,
|
||||
cacheModel: 'custom_attribute_definition',
|
||||
});
|
||||
super('custom_attribute_definitions', { accountScoped: true });
|
||||
}
|
||||
|
||||
getAttributesByModel() {
|
||||
return super.get(true);
|
||||
return axios.get(this.url);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
/* global axios */
|
||||
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class CannedResponse extends CacheEnabledApiClient {
|
||||
class CannedResponse extends ApiClient {
|
||||
constructor() {
|
||||
super('canned_responses', {
|
||||
accountScoped: true,
|
||||
cacheModel: 'canned_response',
|
||||
});
|
||||
super('canned_responses', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ searchKey } = {}) {
|
||||
if (searchKey) {
|
||||
return axios.get(`${this.url}?search=${searchKey}`);
|
||||
}
|
||||
return super.get(true);
|
||||
get({ searchKey }) {
|
||||
const url = searchKey ? `${this.url}?search=${searchKey}` : this.url;
|
||||
return axios.get(url);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class Inboxes extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('inboxes', {
|
||||
accountScoped: true,
|
||||
cacheModel: 'inbox',
|
||||
payloadEnvelope: true,
|
||||
});
|
||||
super('inboxes', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'inbox';
|
||||
}
|
||||
|
||||
getCampaigns(inboxId) {
|
||||
|
||||
@@ -2,11 +2,12 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class LabelsAPI extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('labels', {
|
||||
accountScoped: true,
|
||||
cacheModel: 'label',
|
||||
payloadEnvelope: true,
|
||||
});
|
||||
super('labels', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'label';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
/* global axios */
|
||||
// import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
export class TeamsAPI extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('teams', { accountScoped: true, cacheModel: 'team' });
|
||||
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 }) {
|
||||
|
||||
@@ -375,10 +375,10 @@ export default {
|
||||
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
},
|
||||
audioRecordFormat() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
if (this.isAWhatsAppCloudChannel) {
|
||||
return AUDIO_FORMATS.OGG;
|
||||
}
|
||||
if (this.isATelegramChannel) {
|
||||
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
|
||||
return AUDIO_FORMATS.MP3;
|
||||
}
|
||||
if (this.isAPIInbox) {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { openDB } from 'idb';
|
||||
import { DATA_VERSION } from './version';
|
||||
import { cacheableModels, cacheableModelNames } from './cacheableModels';
|
||||
|
||||
export class DataManager {
|
||||
constructor(accountId) {
|
||||
this.modelsToSync = cacheableModelNames;
|
||||
this.modelsToSync = ['inbox', 'label', 'team'];
|
||||
this.accountId = accountId;
|
||||
this.db = null;
|
||||
}
|
||||
@@ -12,26 +11,12 @@ export class DataManager {
|
||||
async initDb() {
|
||||
if (this.db) return this.db;
|
||||
const dbName = `cw-store-${this.accountId}`;
|
||||
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' });
|
||||
}
|
||||
});
|
||||
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' });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,7 +41,7 @@ export class DataManager {
|
||||
async replace({ modelName, data }) {
|
||||
this.validateModel(modelName);
|
||||
|
||||
await this.db.clear(modelName);
|
||||
this.db.clear(modelName);
|
||||
return this.push({ modelName, data });
|
||||
}
|
||||
|
||||
@@ -80,11 +65,9 @@ export class DataManager {
|
||||
}
|
||||
|
||||
async setCacheKeys(cacheKeys) {
|
||||
await Promise.all(
|
||||
Object.entries(cacheKeys).map(([modelName, value]) =>
|
||||
this.db.put('cache-keys', value, modelName)
|
||||
)
|
||||
);
|
||||
Object.keys(cacheKeys).forEach(async modelName => {
|
||||
this.db.put('cache-keys', cacheKeys[modelName], modelName);
|
||||
});
|
||||
}
|
||||
|
||||
async getCacheKey(modelName) {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// 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);
|
||||
@@ -1,42 +0,0 @@
|
||||
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]))
|
||||
);
|
||||
@@ -1,31 +0,0 @@
|
||||
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,9 +1,3 @@
|
||||
// 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';
|
||||
// Monday, 13 March 2023
|
||||
// Change this version if you want to invalidate old data
|
||||
export const DATA_VERSION = '1678706392';
|
||||
|
||||
@@ -98,6 +98,17 @@ 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)) {
|
||||
@@ -127,11 +138,9 @@ 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,7 +14,6 @@ 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;
|
||||
@@ -270,7 +269,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
};
|
||||
|
||||
onCacheInvalidate = data => {
|
||||
dispatchCacheRevalidations(this.app.$store, data.cache_keys);
|
||||
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 });
|
||||
};
|
||||
|
||||
onVoiceCallIncoming = data => {
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
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,6 +253,27 @@ 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);
|
||||
@@ -314,10 +335,12 @@ describe('ReconnectService', () => {
|
||||
});
|
||||
|
||||
describe('onReconnect', () => {
|
||||
it('should handle route-specific fetch and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
|
||||
it('should handle route-specific fetch, revalidate caches, 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
|
||||
);
|
||||
|
||||
@@ -94,8 +94,18 @@ export default {
|
||||
return this.getAccount(this.accountId) || {};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'currentAccount.id'(id) {
|
||||
if (id) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initializeAccount();
|
||||
// Account already in the store (navigated in): seed immediately.
|
||||
if (this.currentAccount.id) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initializeAccount() {
|
||||
|
||||
@@ -8,7 +8,7 @@ import SectionLayout from './SectionLayout.vue';
|
||||
const { t } = useI18n();
|
||||
const { currentAccount } = useAccount();
|
||||
|
||||
const getAccountId = computed(() => currentAccount.value.id.toString());
|
||||
const getAccountId = computed(() => currentAccount.value?.id?.toString());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -163,6 +163,10 @@ 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(true);
|
||||
const response = await AgentAPI.get();
|
||||
commit(types.default.SET_AGENT_FETCHING_STATUS, false);
|
||||
commit(types.default.SET_AGENTS, response.data);
|
||||
} catch (error) {
|
||||
|
||||
@@ -189,6 +189,17 @@ 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,6 +32,18 @@ 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,7 +1,6 @@
|
||||
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();
|
||||
@@ -9,13 +8,6 @@ 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,20 +1,12 @@
|
||||
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,20 +1,12 @@
|
||||
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,20 +1,12 @@
|
||||
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,25 +2,18 @@ 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 () => {
|
||||
@@ -40,6 +33,7 @@ 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,4 +1,5 @@
|
||||
import {
|
||||
CLEAR_TEAMS,
|
||||
SET_TEAMS,
|
||||
SET_TEAM_ITEM,
|
||||
EDIT_TEAM,
|
||||
@@ -10,14 +11,9 @@ describe('#mutations', () => {
|
||||
describe('#SET_teams', () => {
|
||||
it('set teams records', () => {
|
||||
const state = { records: {} };
|
||||
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] });
|
||||
mutations[SET_TEAMS](state, [teams[2]]);
|
||||
expect(state.records).toEqual(teams);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,4 +43,12 @@ 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,5 +1,6 @@
|
||||
import {
|
||||
SET_TEAM_UI_FLAG,
|
||||
CLEAR_TEAMS,
|
||||
SET_TEAMS,
|
||||
SET_TEAM_ITEM,
|
||||
EDIT_TEAM,
|
||||
@@ -21,10 +22,22 @@ 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,5 +1,6 @@
|
||||
import {
|
||||
SET_TEAM_UI_FLAG,
|
||||
CLEAR_TEAMS,
|
||||
SET_TEAMS,
|
||||
SET_TEAM_ITEM,
|
||||
EDIT_TEAM,
|
||||
@@ -14,14 +15,19 @@ export const mutations = {
|
||||
};
|
||||
},
|
||||
|
||||
// Replaces (not merges) so rows deleted server-side never survive as
|
||||
// phantoms — SET_TEAMS only ever receives the full list.
|
||||
[CLEAR_TEAMS]: $state => {
|
||||
$state.records = {};
|
||||
},
|
||||
|
||||
[SET_TEAMS]: ($state, data) => {
|
||||
const records = {};
|
||||
const updatedRecords = { ...$state.records };
|
||||
data.forEach(team => {
|
||||
records[team.id] = team;
|
||||
updatedRecords[team.id] = {
|
||||
...(updatedRecords[team.id] || {}),
|
||||
...team,
|
||||
};
|
||||
});
|
||||
$state.records = records;
|
||||
$state.records = updatedRecords;
|
||||
},
|
||||
|
||||
[SET_TEAM_ITEM]: ($state, data) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
|
||||
@@ -109,6 +109,15 @@ const runSDK = ({ baseUrl, websiteToken }) => {
|
||||
});
|
||||
},
|
||||
|
||||
openArticle(slug) {
|
||||
if (!slug) {
|
||||
throw new Error('Article slug is required');
|
||||
}
|
||||
|
||||
IFrameHelper.events.toggleBubble('open');
|
||||
IFrameHelper.sendMessage('open-article', { slug });
|
||||
},
|
||||
|
||||
setUser(identifier, user) {
|
||||
if (typeof identifier !== 'string' && typeof identifier !== 'number') {
|
||||
throw new Error('Identifier should be a string or a number');
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
setBubbleText,
|
||||
addUnreadClass,
|
||||
removeUnreadClass,
|
||||
addArticleViewClass,
|
||||
removeArticleViewClass,
|
||||
} from './bubbleHelpers';
|
||||
import { isWidgetColorLighter } from 'shared/helpers/colorHelper';
|
||||
import { dispatchWindowEvent } from 'shared/helpers/CustomEventHelper';
|
||||
@@ -268,6 +270,10 @@ export const IFrameHelper = {
|
||||
},
|
||||
|
||||
resetUnreadMode: () => removeUnreadClass(),
|
||||
|
||||
expandWidget: () => addArticleViewClass(),
|
||||
collapseWidget: () => removeArticleViewClass(),
|
||||
|
||||
handleNotificationDot: event => {
|
||||
if (window.$chatwoot.hideMessageBubble) {
|
||||
return;
|
||||
|
||||
@@ -110,3 +110,13 @@ export const removeUnreadClass = () => {
|
||||
const holderEl = document.querySelector('.woot-widget-holder');
|
||||
removeClasses(holderEl, 'has-unread-view');
|
||||
};
|
||||
|
||||
export const addArticleViewClass = () => {
|
||||
const holderEl = document.querySelector('.woot-widget-holder');
|
||||
addClasses(holderEl, 'has-article-view');
|
||||
};
|
||||
|
||||
export const removeArticleViewClass = () => {
|
||||
const holderEl = document.querySelector('.woot-widget-holder');
|
||||
removeClasses(holderEl, 'has-article-view');
|
||||
};
|
||||
|
||||
@@ -7,11 +7,14 @@ export const SDK_CSS = `
|
||||
.woot-widget-holder {
|
||||
box-shadow: 0 5px 40px rgba(0, 0, 0, .16);
|
||||
opacity: 1;
|
||||
will-change: transform, opacity;
|
||||
will-change: transform, opacity, width, height;
|
||||
transform: translateY(0);
|
||||
overflow: hidden !important;
|
||||
position: fixed !important;
|
||||
transition: opacity 0.2s linear, transform 0.25s linear;
|
||||
transition: opacity 0.2s linear, transform 0.25s linear,
|
||||
width 0.18s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
height 0.18s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
max-height 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 2147483000 !important;
|
||||
}
|
||||
|
||||
@@ -287,6 +290,12 @@ export const SDK_CSS = `
|
||||
min-height: 250px !important;
|
||||
width: 400px !important;
|
||||
}
|
||||
|
||||
.woot-widget-holder.has-article-view {
|
||||
width: min(640px, max(0px, -20px + 100dvw)) !important;
|
||||
height: calc(100% - 125px) !important;
|
||||
max-height: calc(100% - 125px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.woot-hidden {
|
||||
|
||||
@@ -38,3 +38,24 @@ export const getMatchingLocale = (selectedLocale = '', allowedLocales = []) => {
|
||||
// Return the first match that exists in the allowed list, or null
|
||||
return priorityMatches.find(l => l && allowedLocales.includes(l)) ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the link consumed by the in-widget article viewer, appending the query
|
||||
* params it expects (plain layout, theme and locale).
|
||||
*
|
||||
* @export
|
||||
* @param {Object} options
|
||||
* @param {string} options.link Relative article/portal path (e.g. `hc/slug/articles/foo`).
|
||||
* @param {(string|null)} [options.locale] Resolved portal locale.
|
||||
* @param {boolean} [options.prefersDarkMode] Whether the widget is in dark mode.
|
||||
* @returns {string} The link with the article viewer query params appended.
|
||||
*/
|
||||
export const buildArticleViewerLink = ({ link, locale, prefersDarkMode }) => {
|
||||
const params = new URLSearchParams({
|
||||
show_plain_layout: 'true',
|
||||
theme: prefersDarkMode ? 'dark' : 'light',
|
||||
...(locale && { locale }),
|
||||
});
|
||||
|
||||
return `${link}?${params.toString()}`;
|
||||
};
|
||||
|
||||
@@ -16,12 +16,22 @@ import {
|
||||
ON_AGENT_MESSAGE_RECEIVED,
|
||||
ON_CAMPAIGN_MESSAGE_CLICK,
|
||||
ON_UNREAD_MESSAGE_CLICK,
|
||||
ON_ARTICLE_VIEW_RESIZING,
|
||||
} from './constants/widgetBusEvents';
|
||||
|
||||
// Keep in sync with the widget holder width/height transition in sdk.js. The
|
||||
// article view is masked for this long so the iframe can reflow off-screen.
|
||||
const ARTICLE_VIEW_RESIZE_DURATION = 180;
|
||||
import { useDarkMode } from 'widget/composables/useDarkMode';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAvailability } from 'widget/composables/useAvailability';
|
||||
import { useArticleView } from 'widget/composables/useArticleView';
|
||||
import { SDK_SET_BUBBLE_VISIBILITY } from '../shared/constants/sharedFrameEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import {
|
||||
getMatchingLocale,
|
||||
buildArticleViewerLink,
|
||||
} from 'shared/helpers/portalHelper';
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
@@ -33,8 +43,17 @@ export default {
|
||||
const { prefersDarkMode } = useDarkMode();
|
||||
const router = useRouter();
|
||||
const { isInWorkingHours } = useAvailability();
|
||||
const { isArticleView, isWidgetExpanded, setArticleView } =
|
||||
useArticleView();
|
||||
|
||||
return { prefersDarkMode, router, isInWorkingHours };
|
||||
return {
|
||||
prefersDarkMode,
|
||||
router,
|
||||
isInWorkingHours,
|
||||
isArticleView,
|
||||
isWidgetExpanded,
|
||||
setArticleView,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -66,6 +85,11 @@ export default {
|
||||
? getLanguageDirection(this.$root.$i18n.locale)
|
||||
: false;
|
||||
},
|
||||
shouldExpandArticleView() {
|
||||
// The widget only widens on article pages, and only when the user has
|
||||
// opted in via the header toggle (persisted, collapsed by default).
|
||||
return this.isArticleView && this.isWidgetExpanded;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
activeCampaign() {
|
||||
@@ -77,6 +101,26 @@ export default {
|
||||
document.documentElement.dir = value ? 'rtl' : 'ltr';
|
||||
},
|
||||
},
|
||||
'$route.name'(routeName, previousRouteName) {
|
||||
// Leaving the article view tears down the iframe, so reset the flag; the
|
||||
// watcher below collapses the widget if it was expanded.
|
||||
if (previousRouteName === 'article-viewer') {
|
||||
this.isArticleView = false;
|
||||
}
|
||||
},
|
||||
shouldExpandArticleView(shouldExpand) {
|
||||
if (!this.isIFrame) return;
|
||||
// Resize the host widget and mask the iframe while it reflows off-screen,
|
||||
// revealing it once the size transition settles.
|
||||
IFrameHelper.sendMessage({
|
||||
event: shouldExpand ? 'expandWidget' : 'collapseWidget',
|
||||
});
|
||||
emitter.emit(ON_ARTICLE_VIEW_RESIZING, true);
|
||||
setTimeout(
|
||||
() => emitter.emit(ON_ARTICLE_VIEW_RESIZING, false),
|
||||
ARTICLE_VIEW_RESIZE_DURATION
|
||||
);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const { websiteToken, locale, widgetColor } = window.chatwootWebChannel;
|
||||
@@ -160,6 +204,26 @@ export default {
|
||||
this.$root.$i18n.locale = localeWithoutVariation;
|
||||
}
|
||||
},
|
||||
openArticle(slug) {
|
||||
const { portal } = window.chatwootWebChannel;
|
||||
if (!portal || !slug) return;
|
||||
|
||||
const locale = getMatchingLocale(
|
||||
this.$root.$i18n.locale,
|
||||
portal.config?.allowed_locales
|
||||
);
|
||||
const link = buildArticleViewerLink({
|
||||
link: `hc/${portal.slug}/articles/${slug}`,
|
||||
locale,
|
||||
prefersDarkMode: this.prefersDarkMode,
|
||||
});
|
||||
// Add a timestamp so the route always changes, even when the same article
|
||||
// is requested again or the iframe was browsed to another page.
|
||||
this.router.push({
|
||||
name: 'article-viewer',
|
||||
query: { link, v: Date.now() },
|
||||
});
|
||||
},
|
||||
registerUnreadEvents() {
|
||||
emitter.on(ON_AGENT_MESSAGE_RECEIVED, () => {
|
||||
const { name: routeName } = this.$route;
|
||||
@@ -316,6 +380,15 @@ export default {
|
||||
this.setBubbleLabel();
|
||||
} else if (message.event === 'set-color-scheme') {
|
||||
this.setColorScheme(message.darkMode);
|
||||
} else if (message.event === 'open-article') {
|
||||
this.openArticle(message.slug);
|
||||
} else if (message.event === 'portalPageLoaded') {
|
||||
// portalPageLoaded is delivered asynchronously and can arrive after
|
||||
// we've left the article view; ignore it unless we're still there so
|
||||
// the expanded width can't leak onto the listing or other views.
|
||||
if (this.$route.name === 'article-viewer') {
|
||||
this.setArticleView(message.isArticle);
|
||||
}
|
||||
} else if (message.event === 'toggle-open') {
|
||||
this.$store.dispatch('appConfig/toggleWidgetOpen', message.isOpen);
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup>
|
||||
import { toRef } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import HeaderActions from './HeaderActions.vue';
|
||||
import AvailabilityContainer from 'widget/components/Availability/AvailabilityContainer.vue';
|
||||
import { useAvailability } from 'widget/composables/useAvailability';
|
||||
import { useArticleView } from 'widget/composables/useArticleView';
|
||||
|
||||
const props = defineProps({
|
||||
avatarUrl: { type: String, default: '' },
|
||||
@@ -17,7 +19,10 @@ const props = defineProps({
|
||||
const availableAgents = toRef(props, 'availableAgents');
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { isOnline } = useAvailability(availableAgents);
|
||||
const { isArticleView, isWidgetExpanded, toggleWidgetExpanded } =
|
||||
useArticleView();
|
||||
|
||||
const onBackButtonClick = () => {
|
||||
router.replace({ name: 'home' });
|
||||
@@ -58,6 +63,25 @@ const onBackButtonClick = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HeaderActions :show-popout-button="showPopoutButton" />
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
v-if="isArticleView"
|
||||
class="button transparent compact"
|
||||
:title="
|
||||
isWidgetExpanded
|
||||
? t('PORTAL.COLLAPSE_ARTICLE')
|
||||
: t('PORTAL.EXPAND_ARTICLE')
|
||||
"
|
||||
@click="toggleWidgetExpanded"
|
||||
>
|
||||
<span
|
||||
class="size-4 text-n-slate-12"
|
||||
:class="
|
||||
isWidgetExpanded ? 'i-lucide-minimize-2' : 'i-lucide-maximize-2'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
<HeaderActions :show-popout-button="showPopoutButton" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,10 @@ import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useDarkMode } from 'widget/composables/useDarkMode';
|
||||
import { getMatchingLocale } from 'shared/helpers/portalHelper';
|
||||
import {
|
||||
getMatchingLocale,
|
||||
buildArticleViewerLink,
|
||||
} from 'shared/helpers/portalHelper';
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
@@ -38,14 +41,11 @@ const fetchArticles = () => {
|
||||
};
|
||||
|
||||
const openArticleInArticleViewer = link => {
|
||||
const params = new URLSearchParams({
|
||||
show_plain_layout: 'true',
|
||||
theme: prefersDarkMode.value ? 'dark' : 'light',
|
||||
...(locale.value && { locale: locale.value }),
|
||||
const linkToOpen = buildArticleViewerLink({
|
||||
link,
|
||||
locale: locale.value,
|
||||
prefersDarkMode: prefersDarkMode.value,
|
||||
});
|
||||
|
||||
// Combine link with query parameters
|
||||
const linkToOpen = `${link}?${params.toString()}`;
|
||||
router.push({ name: 'article-viewer', query: { link: linkToOpen } });
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ref } from 'vue';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
|
||||
const EXPANDED_STORAGE_KEY = 'chatwoot:widget:articleViewExpanded';
|
||||
|
||||
// Module-level singletons so the header toggle and the resize logic in App.vue
|
||||
// share a single source of truth.
|
||||
//
|
||||
// `isArticleView` - whether the iframe is currently showing an article page.
|
||||
// `isWidgetExpanded`- the user's persisted expand/collapse preference. Defaults
|
||||
// to collapsed and only ever applies on article pages.
|
||||
const isArticleView = ref(false);
|
||||
const isWidgetExpanded = ref(LocalStorage.get(EXPANDED_STORAGE_KEY) === true);
|
||||
|
||||
export function useArticleView() {
|
||||
const setArticleView = value => {
|
||||
isArticleView.value = value;
|
||||
};
|
||||
|
||||
const toggleWidgetExpanded = () => {
|
||||
isWidgetExpanded.value = !isWidgetExpanded.value;
|
||||
LocalStorage.set(EXPANDED_STORAGE_KEY, isWidgetExpanded.value);
|
||||
};
|
||||
|
||||
return {
|
||||
isArticleView,
|
||||
isWidgetExpanded,
|
||||
setArticleView,
|
||||
toggleWidgetExpanded,
|
||||
};
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export const ON_AGENT_MESSAGE_RECEIVED = 'ON_AGENT_MESSAGE_RECEIVED';
|
||||
export const ON_UNREAD_MESSAGE_CLICK = 'ON_UNREAD_MESSAGE_CLICK';
|
||||
export const ON_CAMPAIGN_MESSAGE_CLICK = 'ON_CAMPAIGN_MESSAGE_CLICK';
|
||||
export const ON_CONVERSATION_CREATED = 'ON_CONVERSATION_CREATED';
|
||||
export const ON_ARTICLE_VIEW_RESIZING = 'ON_ARTICLE_VIEW_RESIZING';
|
||||
|
||||
@@ -125,7 +125,9 @@
|
||||
"PORTAL": {
|
||||
"POPULAR_ARTICLES": "Popular Articles",
|
||||
"VIEW_ALL_ARTICLES": "View all articles",
|
||||
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
|
||||
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again.",
|
||||
"EXPAND_ARTICLE": "Expand",
|
||||
"COLLAPSE_ARTICLE": "Collapse"
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
"image": {
|
||||
|
||||
@@ -1,16 +1,41 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { ON_ARTICLE_VIEW_RESIZING } from 'widget/constants/widgetBusEvents';
|
||||
import IframeLoader from 'shared/components/IframeLoader.vue';
|
||||
|
||||
export default {
|
||||
name: 'ArticleViewer',
|
||||
components: {
|
||||
IframeLoader,
|
||||
},
|
||||
const route = useRoute();
|
||||
|
||||
// Masks the article while the widget resizes (see App.vue#setArticleView) so the
|
||||
// iframe's text reflow happens off-screen instead of shifting in front of the user.
|
||||
const isResizing = ref(false);
|
||||
const setResizing = value => {
|
||||
isResizing.value = value;
|
||||
};
|
||||
|
||||
onMounted(() => emitter.on(ON_ARTICLE_VIEW_RESIZING, setResizing));
|
||||
onBeforeUnmount(() => emitter.off(ON_ARTICLE_VIEW_RESIZING, setResizing));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white h-full">
|
||||
<IframeLoader :url="$route.query.link" />
|
||||
<div class="bg-white dark:bg-slate-900 h-full relative">
|
||||
<!--
|
||||
Key by fullPath (not just the link) so the iframe remounts on every
|
||||
navigation here, including re-opening the same article via the SDK after
|
||||
the iframe was browsed to another help-center page. See App.vue#openArticle.
|
||||
-->
|
||||
<IframeLoader :key="route.fullPath" :url="route.query.link" />
|
||||
<!--
|
||||
Cover the article instantly while the widget resizes, then fade it out once
|
||||
the size transition settles. The asymmetric class (no transition on the way
|
||||
in, transition on the way out) keeps the cover from revealing the reflow.
|
||||
-->
|
||||
<div
|
||||
class="absolute inset-0 bg-white dark:bg-slate-900 pointer-events-none"
|
||||
:class="
|
||||
isResizing ? 'opacity-100' : 'opacity-0 transition-opacity duration-100'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -52,9 +52,6 @@ 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)
|
||||
|
||||
@@ -36,16 +36,10 @@ 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
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
#
|
||||
|
||||
class CannedResponse < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
validates :content, presence: true
|
||||
validates :short_code, presence: true
|
||||
validates :account, presence: true
|
||||
|
||||
@@ -4,15 +4,11 @@ module CacheKeys
|
||||
include CacheKeysHelper
|
||||
include Events::Types
|
||||
|
||||
# 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
|
||||
CACHE_KEYS_EXPIRY = 72.hours
|
||||
|
||||
included do
|
||||
class_attribute :cacheable_models
|
||||
self.cacheable_models = [Label, Inbox, Team, CannedResponse, AccountUser, CustomAttributeDefinition]
|
||||
self.cacheable_models = [Label, Inbox, Team]
|
||||
end
|
||||
|
||||
def cache_keys
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
# 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],
|
||||
|
||||
@@ -50,13 +50,6 @@ 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
|
||||
|
||||
@@ -18,12 +18,6 @@ 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')
|
||||
|
||||
@@ -116,12 +116,8 @@ 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') }
|
||||
|
||||
@@ -216,19 +212,11 @@ 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')
|
||||
|
||||
@@ -23,6 +23,8 @@ class BaseRefreshOauthTokenService
|
||||
# Refresh the access tokens using the refresh token
|
||||
# Refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp/tree/b4a6869fe4a438cde42b161196484a929f1bee46
|
||||
def refresh_tokens
|
||||
raise 'A refresh_token is not available' if provider_config[:refresh_token].blank?
|
||||
|
||||
oauth_strategy = build_oauth_strategy
|
||||
token_service = build_token_service(oauth_strategy)
|
||||
|
||||
|
||||
@@ -88,6 +88,19 @@ html.light {
|
||||
tocHeader: '<%= I18n.t('public_portal.toc_header') %>'
|
||||
};
|
||||
</script>
|
||||
<% if @is_plain_layout_enabled %>
|
||||
<script>
|
||||
// When rendered inside the chat widget, tell it whether this is an article
|
||||
// page so the widget widens only for articles and collapses on every other page.
|
||||
window.parent.postMessage(
|
||||
'chatwoot-widget:' + JSON.stringify({
|
||||
event: 'portalPageLoaded',
|
||||
isArticle: <%= @article.present? %>,
|
||||
}),
|
||||
'*'
|
||||
);
|
||||
</script>
|
||||
<% end %>
|
||||
<% if @portal.channel_web_widget.present? && !@is_plain_layout_enabled %>
|
||||
<script>
|
||||
window.chatwootSettings = window.chatwootSettings || {};
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<% end %>
|
||||
<section id="portal-bg" class="w-full bg-white dark:bg-slate-900 shadow-inner">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6 min-h-[240px] md:min-h-[260px]">
|
||||
<div class="mx-auto max-w-5xl px-4 md:px-8 flex flex-col items-start">
|
||||
<div class="mx-auto max-w-5xl px-5 md:px-8 flex flex-col items-start">
|
||||
<span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block lg:hidden' : 'hidden' %>"><%= @portal.localized_value('name', @locale) %></span>
|
||||
<h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
|
||||
<%= portal.localized_value('header_text', @locale) %>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="flex flex-row items-center gap-px mb-6">
|
||||
<a
|
||||
class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold"
|
||||
href="<%= generate_home_link(@portal.slug, @article.category&.locale, @theme_from_params, @is_plain_layout_enabled) %>"
|
||||
href="<%= generate_home_link(@portal.slug, @article.category&.locale, @theme_from_params, @is_plain_layout_enabled) %>"
|
||||
>
|
||||
<%= I18n.t('public_portal.common.home') %>
|
||||
</a>
|
||||
@@ -28,7 +28,7 @@
|
||||
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
|
||||
<%= article.title %>
|
||||
</h1>
|
||||
<div class="flex flex-col items-start justify-between w-full pt-6 md:flex-row md:items-center">
|
||||
<div class="flex flex-col items-start justify-between w-full md:flex-row md:items-center">
|
||||
<div class="flex items-start space-x-1">
|
||||
<span class="flex items-center text-base font-medium text-slate-600 dark:text-slate-400">
|
||||
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %>
|
||||
|
||||
@@ -24,19 +24,19 @@
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
|
||||
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
|
||||
<div class="max-w-5xl px-5 md:px-8 mx-auto flex flex-col">
|
||||
<%= render "public/api/v1/portals/articles/article_header", article: @article %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="max-w-5xl mx-auto space-y-4 w-full px-4 md:px-8 <%= @is_plain_layout_enabled ? 'py-4' : 'py-8' %>">
|
||||
<div class="max-w-5xl mx-auto space-y-4 w-full px-5 md:px-8 <%= @is_plain_layout_enabled ? 'py-4' : 'py-8' %>">
|
||||
<%= render "public/api/v1/portals/articles/article_header", article: @article %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex max-w-5xl w-full px-4 md:px-8 mx-auto">
|
||||
<article id="cw-article-content" class="article-content flex-grow flex-2 mx-auto text-slate-800 dark:text-slate-50 text-lg max-w-3xl prose-h1:text-2xl prose-h2:text-xl prose-h2:mt-0 prose-h3:text-lg prose-code:[&>p]:p-1 prose-code:[&>p]:rounded-sm prose-code:[&>p]:bg-black-100 dark:prose-code:[&>p]:bg-black-600 prose-code:after:content-none prose-code:before:content-none prose dark:prose-invert break-words w-full [&_table]:!border-slate-200 dark:[&_table]:!border-slate-800 [&_th]:!border-slate-200 dark:[&_th]:!border-slate-800 [&_td]:!border-slate-200 dark:[&_td]:!border-slate-800 [&_th]:!bg-slate-50 dark:[&_th]:!bg-slate-800/50 <%= @is_plain_layout_enabled ? 'py-4' : 'pt-8 pb-12' %>">
|
||||
<div class="flex max-w-5xl w-full px-5 md:px-8 mx-auto">
|
||||
<article id="cw-article-content" class="article-content flex-grow flex-2 mx-auto text-slate-800 dark:text-slate-50 text-base max-w-3xl prose-h1:text-xl prose-h2:text-lg prose-h2:mt-8 prose-h3:text-base prose-h3:mt-6 prose-headings:mb-3 [&>:first-child]:!mt-0 prose-code:[&>p]:p-1 prose-code:[&>p]:rounded-sm prose-code:[&>p]:bg-black-100 dark:prose-code:[&>p]:bg-black-600 prose-code:after:content-none prose-code:before:content-none prose dark:prose-invert break-words w-full [&_table]:!border-slate-200 dark:[&_table]:!border-slate-800 [&_th]:!border-slate-200 dark:[&_th]:!border-slate-800 [&_td]:!border-slate-200 dark:[&_td]:!border-slate-800 [&_th]:!bg-slate-50 dark:[&_th]:!bg-slate-800/50 <%= @is_plain_layout_enabled ? 'py-4' : 'pt-8 pb-12' %>">
|
||||
<%= @parsed_content %>
|
||||
</article>
|
||||
<div class="flex-1" id="cw-hc-toc"></div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="flex flex-col px-4 md:px-8 max-w-5xl w-full mx-auto gap-6 <%= @is_plain_layout_enabled && 'py-4' %>">
|
||||
<div class="flex flex-col px-5 md:px-8 max-w-5xl w-full mx-auto gap-6 <%= @is_plain_layout_enabled && 'py-4' %>">
|
||||
<div class="flex items-center flex-row">
|
||||
<a
|
||||
class="text-slate-500 dark:text-slate-200 text-sm gap-1 <%= @is_plain_layout_enabled && 'hover:underline' %> hover:cursor-pointer leading-8 font-semibold"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<% else %>
|
||||
<%= render 'public/api/v1/portals/categories/category-hero', category: @category, portal: @portal %>
|
||||
<% end %>
|
||||
<section class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
|
||||
<section class="max-w-5xl w-full mx-auto px-5 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
|
||||
<div class="w-full flex flex-col gap-6 flex-grow">
|
||||
<% if @category.articles.published.size == 0 %>
|
||||
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
|
||||
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
|
||||
<div class="max-w-5xl px-5 md:px-8 mx-auto flex flex-col">
|
||||
<div class="flex flex-row items-center gap-px mb-6">
|
||||
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
|
||||
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
|
||||
@@ -30,7 +30,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col py-4">
|
||||
<div class="max-w-5xl px-5 md:px-8 mx-auto flex flex-col py-4">
|
||||
<div class="flex flex-row items-center gap-px mb-6">
|
||||
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
|
||||
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
<%= render 'public/api/v1/portals/search/search_handler' %>
|
||||
|
||||
<section class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
|
||||
<section class="max-w-5xl w-full mx-auto px-5 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
|
||||
<div class="w-full flex flex-col gap-6 flex-grow">
|
||||
<% if @articles.empty? %>
|
||||
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<%= render "public/api/v1/portals/hero", portal: @portal %>
|
||||
<div class="max-w-5xl w-full flex flex-col flex-grow mx-auto py-8 px-4 md:px-8 gap-6">
|
||||
<div class="max-w-5xl w-full flex flex-col flex-grow mx-auto py-8 px-5 md:px-8 gap-6">
|
||||
<%# Featured Articles %>
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<div><%= render "public/api/v1/portals/featured_articles", articles: @portal.articles, categories: @portal.categories.where(locale: @locale), portal: @portal %></div>
|
||||
|
||||
@@ -27,8 +27,8 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
|
||||
|
||||
def test
|
||||
tool = account_custom_tools.new(custom_tool_params)
|
||||
result = execute_test_request(tool)
|
||||
render json: { status: result.code.to_i, body: result.body.to_s.truncate(500) }
|
||||
body = execute_test_request(tool)
|
||||
render json: { status: 200, body: body.to_s.truncate(500) }
|
||||
rescue StandardError => e
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
@@ -39,11 +39,4 @@ 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,8 +15,8 @@ class Captain::Tools::HttpTool < Agents::Tool
|
||||
url = @custom_tool.build_request_url(params)
|
||||
body = @custom_tool.build_request_body(params)
|
||||
|
||||
response = execute_http_request(url, body, tool_context)
|
||||
@custom_tool.format_response(response.body)
|
||||
response_body = execute_http_request(url, body, tool_context)
|
||||
@custom_tool.format_response(response_body)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}")
|
||||
'An error occurred while executing the request'
|
||||
@@ -24,89 +24,32 @@ class Captain::Tools::HttpTool < Agents::Tool
|
||||
|
||||
private
|
||||
|
||||
PRIVATE_IP_RANGES = [
|
||||
IPAddr.new('127.0.0.0/8'), # IPv4 Loopback
|
||||
IPAddr.new('10.0.0.0/8'), # IPv4 Private network
|
||||
IPAddr.new('172.16.0.0/12'), # IPv4 Private network
|
||||
IPAddr.new('192.168.0.0/16'), # IPv4 Private network
|
||||
IPAddr.new('169.254.0.0/16'), # IPv4 Link-local
|
||||
IPAddr.new('::1'), # IPv6 Loopback
|
||||
IPAddr.new('fc00::/7'), # IPv6 Unique local addresses
|
||||
IPAddr.new('fe80::/10') # IPv6 Link-local
|
||||
].freeze
|
||||
|
||||
# Limit response size to prevent memory exhaustion and match LLM token limits
|
||||
# 1MB of text ≈ 250K tokens, which exceeds most LLM context windows
|
||||
MAX_RESPONSE_SIZE = 1.megabyte
|
||||
|
||||
# Route through SafeFetch so custom tool requests share the app's centralized HTTP
|
||||
# fetching (resolution, timeouts, response size limits, and redirect handling).
|
||||
def execute_http_request(url, body, tool_context)
|
||||
uri = URI.parse(url)
|
||||
json_body = body if @custom_tool.http_method == 'POST'
|
||||
|
||||
# Check if resolved IP is private
|
||||
check_private_ip!(uri.host)
|
||||
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = uri.scheme == 'https'
|
||||
http.read_timeout = 30
|
||||
http.open_timeout = 10
|
||||
http.max_retries = 0 # Disable redirects
|
||||
|
||||
request = build_http_request(uri, body)
|
||||
apply_authentication(request)
|
||||
apply_metadata_headers(request, tool_context)
|
||||
|
||||
response = http.request(request)
|
||||
|
||||
raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
validate_response!(response)
|
||||
|
||||
response
|
||||
response_body = +''
|
||||
SafeFetch.fetch(
|
||||
url,
|
||||
method: @custom_tool.http_method == 'POST' ? :post : :get,
|
||||
body: json_body,
|
||||
headers: request_headers(tool_context, json_body),
|
||||
http_basic_authentication: @custom_tool.build_basic_auth_credentials,
|
||||
max_bytes: MAX_RESPONSE_SIZE,
|
||||
validate_content_type: false
|
||||
) { |result| response_body = result.tempfile.read }
|
||||
response_body
|
||||
end
|
||||
|
||||
def check_private_ip!(hostname)
|
||||
ip_address = IPAddr.new(Resolv.getaddress(hostname))
|
||||
|
||||
raise 'Request blocked: hostname resolves to private IP address' if PRIVATE_IP_RANGES.any? { |range| range.include?(ip_address) }
|
||||
rescue Resolv::ResolvError, SocketError => e
|
||||
raise "DNS resolution failed: #{e.message}"
|
||||
end
|
||||
|
||||
def validate_response!(response)
|
||||
content_length = response['content-length']&.to_i
|
||||
if content_length && content_length > MAX_RESPONSE_SIZE
|
||||
raise "Response size #{content_length} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
|
||||
end
|
||||
|
||||
return unless response.body && response.body.bytesize > MAX_RESPONSE_SIZE
|
||||
|
||||
raise "Response body size #{response.body.bytesize} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
|
||||
end
|
||||
|
||||
def build_http_request(uri, body)
|
||||
if @custom_tool.http_method == 'POST'
|
||||
request = Net::HTTP::Post.new(uri.request_uri)
|
||||
if body
|
||||
request.body = body
|
||||
request['Content-Type'] = 'application/json'
|
||||
end
|
||||
else
|
||||
request = Net::HTTP::Get.new(uri.request_uri)
|
||||
end
|
||||
request
|
||||
end
|
||||
|
||||
def apply_authentication(request)
|
||||
def request_headers(tool_context, json_body)
|
||||
headers = @custom_tool.build_auth_headers
|
||||
headers.each { |key, value| request[key] = value }
|
||||
|
||||
credentials = @custom_tool.build_basic_auth_credentials
|
||||
request.basic_auth(*credentials) if credentials
|
||||
end
|
||||
|
||||
def apply_metadata_headers(request, tool_context)
|
||||
state = tool_context&.state || {}
|
||||
metadata_headers = @custom_tool.build_metadata_headers(state)
|
||||
metadata_headers.each { |key, value| request[key] = value }
|
||||
headers.merge!(@custom_tool.build_metadata_headers(tool_context&.state || {}))
|
||||
headers['Content-Type'] = 'application/json' if json_body.present?
|
||||
headers
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,19 +21,4 @@ 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,18 +213,17 @@ 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[account_user canned_response custom_attribute_definition inbox label team])
|
||||
expect(response.parsed_body['cache_keys'].keys).to match_array(%w[label inbox team])
|
||||
end
|
||||
|
||||
it 'does not allow cached cache key responses' do
|
||||
it 'sets the appropriate cache headers' 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=0')
|
||||
expect(response.headers['Cache-Control']).to include('max-age=10')
|
||||
expect(response.headers['Cache-Control']).to include('private')
|
||||
expect(response.headers['Cache-Control']).to include('must-revalidate')
|
||||
expect(response.headers['Cache-Control']).not_to include('stale-while-revalidate')
|
||||
expect(response.headers['Cache-Control']).to include('stale-while-revalidate=300')
|
||||
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, :canned_response, :account_user, :custom_attribute_definition)
|
||||
expect(account.cache_keys.keys).to contain_exactly(:inbox, :label, :team)
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
|
||||
now_timestamp = Time.now.utc.to_i
|
||||
|
||||
@@ -9,19 +9,4 @@ 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
|
||||
|
||||
@@ -150,22 +150,4 @@ 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
|
||||
|
||||
@@ -5,20 +5,4 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user