Compare commits

..
Author SHA1 Message Date
Shivam Mishra 94cc3228c8 perf: extend cache key expiry to 7 days 2026-06-10 13:03:07 +05:30
Shivam Mishra 3e65534e68 refactor: fold cache revalidation into the event dispatcher 2026-06-10 12:59:26 +05:30
Shivam Mishra 343b25842a refactor: replace teams merge mutation with full replace 2026-06-10 12:54:42 +05:30
Shivam Mishra 880c5e8bf4 refactor: configure cache api clients via constructor options 2026-06-10 12:46:10 +05:30
Shivam Mishra ac5b3a38c5 refactor: remove cold-path cache key preflight 2026-06-10 12:45:48 +05:30
Shivam Mishra 0b4d1c801c refactor: remove client-side cache key pulls 2026-06-10 12:37:11 +05:30
Shivam Mishra f452b88e8d refactor: simplify boot cache hydration to a paint-only step 2026-06-10 12:37:11 +05:30
Shivam Mishra 9fb75656d2 feat: transmit cache keys on room channel subscribe 2026-06-10 12:37:11 +05:30
Sivin VargheseandGitHub 1ccb0352d3 Merge branch 'develop' into feat/expand-idb-coverage 2026-06-09 20:55:11 +05:30
Shivam Mishra c13d59bb7f chore: update relevant columns for user 2026-06-09 15:18:16 +05:30
Shivam MishraandGitHub 08cb8e8638 Merge branch 'develop' into feat/expand-idb-coverage 2026-06-09 14:29:58 +05:30
Shivam Mishra b8629e5aa8 feat: remove http caching from cache keys 2026-06-08 19:01:24 +05:30
Shivam Mishra 91f2df8834 refactor: share cache revalidation dispatch 2026-06-08 18:47:42 +05:30
Shivam Mishra 625302ac3c refactor: deduplicate cache revalidation actions 2026-06-08 18:39:40 +05:30
Shivam Mishra 0deb026f9d fix: bump inbox cache when portal changes
The cached inbox payload embeds the portal name and slug as help_center,
but portal updates and deletes never bumped the inbox cache key, so warm
IDB caches kept showing stale help-center details. Bump the account inbox
cache key from the portal lifecycle; destroy is covered because nullify
detaches inboxes via update_all and skips their callbacks.
2026-06-08 18:28:09 +05:30
Shivam Mishra fe8c228715 fix: bump team cache when membership changes
The cached team payload embeds a per-user is_member flag, but TeamMember
create/destroy never bumped the team cache key, so warm IDB caches kept
showing a stale membership flag after a user was added to or removed from
a team. Bump the team cache key from the TeamMember lifecycle.
2026-06-08 18:27:58 +05:30
Shivam Mishra 68bd8290e5 fix: bump agent cache when custom role changes
Editing or deleting a custom role left warm IDB caches showing stale
embedded role details and permissions in the agent list, since the
account_user cache key only tracked custom_role_id on the account user.
Bump the account_user cache key from the custom role lifecycle so clients
refetch the agent list. The destroy bump is needed because dependent:
nullify updates account_users via update_all and skips their callbacks.
2026-06-08 18:17:14 +05:30
Shivam Mishra 304d5b85ef fix: revalidate cache after cable subscription 2026-06-08 18:05:14 +05:30
Shivam MishraandGitHub 81c01e7ff1 Merge branch 'develop' into feat/expand-idb-coverage 2026-06-08 17:58:14 +05:30
Shivam Mishra b8bca46ba8 fix: address cache revalidation feedback 2026-05-29 16:28:19 +05:30
Shivam MishraandGitHub 264987624d Merge branch 'develop' into feat/expand-idb-coverage 2026-05-27 16:50:30 +05:30
Shivam Mishra cf7056e379 test: update attributes 2026-05-27 15:25:36 +05:30
Shivam Mishra 60d6f7f104 feat: invalidate cache on avatar update 2026-05-27 13:13:33 +05:30
Shivam Mishra 24c1ef7807 test: update cacheable models 2026-05-27 12:52:23 +05:30
Shivam Mishra c929319045 feat: cache custom attributes in IDB 2026-05-27 12:45:30 +05:30
Shivam Mishra 5cb517612f feat: flush IndexedDB cache on DATA_VERSION bump
The idempotent upgrade only created missing object stores, so bumping
DATA_VERSION no longer reset bad or stale data — it just added stores. This
restores a bump as a true global cache flush: the upgrade now clears every
existing store before recreating any missing ones, so shipping a version
bump forces all clients to refetch fresh on next boot.

Gated on oldVersion > 0 so fresh installs skip the flush, and clearing
before creating means only pre-existing stores are touched. This is the
break-glass lever for the one non-self-healing failure mode (non-empty
store holding wrong-shaped data paired with a matching cache key), e.g.
after a serializer shape change shipped without a version bump.
2026-05-27 12:31:51 +05:30
Shivam MishraandGitHub f4c5f45327 Merge branch 'develop' into feat/expand-idb-coverage 2026-05-27 12:10:39 +05:30
Shivam MishraandGitHub be541db44f Merge branch 'develop' into feat/expand-idb-coverage 2026-05-25 15:23:53 +05:30
Shivam Mishra 4633b49c39 feat: hydrate Vuex stores from IndexedDB on boot
Cached fetches no longer issue a /cache_keys preflight per call. The client
trusts whatever it has in IDB and keeps it fresh through ActionCable
broadcasts and the existing reconnect-time batch revalidate.

On warm boots a new hydrateStoresFromCache helper seeds Vuex from IDB
before ActionCable connects, so inboxes, labels, teams, canned responses,
and agents render immediately from cache. The helper snapshots local cache
keys before fetching server keys — without that ordering the comparison
would always see "fresh" against the just-written key and stale data would
be served forever. Stale entries are revalidated in the background; cold
devices stay on the network-fetch path with no regression.

Also awaits replace inside refetchAndCommit so the data write completes
before the cache key is persisted, closing a window where a concurrent
reader could see a fresh key paired with stale data.
2026-05-21 17:22:04 +05:30
Shivam Mishra fe5c353106 feat: cache canned responses and agents in IndexedDB
Converts the canned-response and agent API clients to CacheEnabledApiClient
subclasses and wires revalidate actions into the corresponding Vuex modules,
so settings pages and pickers stop fetching the same lists on every visit.
The canned-response search box continues to bypass the cache and hit the
network directly, so server-side ordering stays authoritative.

Also fixes a pre-existing gap in teams/revalidate: SET_TEAMS merges into a
record dict by id and never removes missing entries, so a team deleted
server-side would survive a reconnect-driven revalidate. Now matches the
get action by emitting CLEAR_TEAMS first.

Each affected spec clears its IDB store before every case, since the
preflight-throws-on-axios-reject pattern they relied on is going away in the
next commit.
2026-05-21 17:21:53 +05:30
Shivam Mishra 1a61e8102e refactor: generalize ActionCable cache invalidation over the model registry
Replaces the three hardcoded dispatches in actionCable.onCacheInvalidate and
ReconnectService.revalidateCaches with a loop over cacheableModels. Adding a
new cached model no longer requires editing these two handlers — they pick
it up automatically from the registry.

Also guards against partial server payloads: dispatches are skipped for
models whose key is undefined, so a client running ahead of a server
deploy keeps working without spurious dispatches.
2026-05-21 17:21:42 +05:30
Shivam Mishra 0ae3a82339 feat: drive IndexedDB cache layer from a single registry
Introduces cacheableModels.js as the source of truth for which models the
client caches in IDB and how to dispatch their revalidate actions. DataManager
derives modelsToSync from the registry and creates object stores
idempotently, so adding new cached models needs only one place to change
plus a one-time DATA_VERSION bump.

Also fixes two latent bugs that the upcoming boot-hydration relies on:
setCacheKeys now awaits all per-key writes (was async-forEach with no await),
and replace awaits clear before push (avoids ConstraintError on rapid
refetches).
2026-05-21 17:21:33 +05:30
Shivam Mishra 991bc5a265 feat: expand workspace cache_keys to canned responses and agents
Adds CannedResponse and AccountUser to the per-account cacheable_models so
their cache keys are exposed via /cache_keys and broadcast over ActionCable
on mutation. The endpoint now delegates to Account#cache_keys instead of
listing the three models inline, so future additions only need to touch the
concern.

AccountUser uses bespoke field-gated callbacks rather than the blanket
AccountCacheRevalidator: active_at is bumped on every page navigation and
would otherwise invalidate the agent cache constantly. User adds a
cross-account callback so name/email/display_name/confirmed_at changes
propagate, since those fields appear in the agent serializer but live on
the User model.
2026-05-21 17:21:17 +05:30
780 changed files with 9838 additions and 32915 deletions
+1 -1
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 18
parallelism: 20
steps:
- checkout
- node/install:
+3 -3
View File
@@ -272,9 +272,9 @@ AZURE_APP_SECRET=
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
# AI powered features (Captain)
# The OpenAI API key and endpoint for Captain are not configured via .env.
# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT).
# AI powered features
## OpenAI key
# OPENAI_API_KEY=
# Housekeeping/Performance related configurations
# Set to true if you want to remove stale contact inboxes
+3 -8
View File
@@ -43,18 +43,13 @@
## General Guidelines
- Prefer the smallest production-ready change that solves the current problem.
- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary.
- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior.
- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks.
- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully.
- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing.
- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read.
- MVP focus: Least code change, happy-path only
- No unnecessary defensive programming
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity.
- Remove dead/unreachable/unused code
- Dont write multiple versions or backups for the same logic — pick the best approach and implement it
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
+1 -1
View File
@@ -195,7 +195,7 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.12.0'
gem 'ai-agents', '>= 0.10.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.14.1'
+36 -47
View File
@@ -126,7 +126,7 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.12.0)
ai-agents (0.10.0)
ruby_llm (~> 1.14)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
@@ -136,8 +136,6 @@ 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)
@@ -170,7 +168,7 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.4.1)
bigdecimal (4.1.2)
bigdecimal (3.2.2)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -186,19 +184,18 @@ 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)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.7)
concurrent-ruby (1.3.5)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.7)
crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
@@ -274,8 +271,8 @@ GEM
dry-logic (~> 1.5)
dry-types (~> 1.8)
zeitwerk (~> 2.6)
dry-types (1.9.1)
bigdecimal (>= 3.0)
dry-types (1.8.3)
bigdecimal (~> 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
@@ -304,7 +301,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.3)
faraday (2.14.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -315,7 +312,7 @@ GEM
hashie
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.4)
faraday-net_http (3.4.2)
net-http (~> 0.5)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
@@ -438,8 +435,7 @@ GEM
hana (1.3.7)
hash_diff (1.1.1)
hashdiff (1.1.0)
hashie (5.1.0)
logger
hashie (5.0.0)
html2text (0.4.0)
nokogiri (>= 1.0, < 2.0)
http (5.1.1)
@@ -474,7 +470,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.9)
json (2.19.5)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -570,9 +566,9 @@ GEM
minitest (5.25.5)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.8.3)
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.9.1)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
mutex_m (0.3.0)
@@ -582,7 +578,7 @@ GEM
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.6.4.1)
net-imap (0.4.24)
date
net-protocol
net-pop (0.1.2)
@@ -598,36 +594,29 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.5)
nokogiri (1.19.4)
nokogiri (1.19.3)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.4-arm64-darwin)
nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-darwin)
nokogiri (1.19.3-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
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)
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)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
oj (3.17.3)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.4)
@@ -674,7 +663,7 @@ GEM
opentelemetry-api (~> 1.0)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.3)
ostruct (0.6.1)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
@@ -946,9 +935,9 @@ GEM
gli
hashie
logger
snaky_hash (2.0.5)
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
sorbet-runtime (0.5.11934)
spring (4.1.1)
spring-watcher-listen (2.1.0)
@@ -1006,7 +995,7 @@ GEM
valid_email2 (5.2.6)
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.11)
version_gem (1.1.4)
vite_rails (3.10.0)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
@@ -1030,7 +1019,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.8.2)
websocket-driver (0.7.7)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -1058,7 +1047,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.12.0)
ai-agents (>= 0.10.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
+1 -1
View File
@@ -1 +1 @@
4.15.1
4.14.1
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
def create_contact
account.contacts.create!(
name: contact_name,
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
@@ -59,11 +59,6 @@ class ContactInboxWithContactBuilder
)
end
def contact_name
name = contact_attributes[:name] || ::Haikunator.haikunate(1000)
name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '')
end
def find_contact
contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email])
@@ -91,17 +91,15 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment)
{
fallback_title: attachment['title'] || attachment.dig('payload', 'title'),
fallback_title: attachment['title'],
external_url: attachment['url'] || attachment.dig('payload', 'url')
}
end
# Facebook shared posts point to page URLs, not downloadable media URLs.
# Both `share` and `post` attachment types carry a page URL rather than a media file,
# so map them to `fallback` (which keeps the title/link without attempting a download).
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
def normalize_file_type(type)
return :fallback if [:share, :post].include?(type.to_sym)
return :fallback if type.to_sym == :share
super
end
@@ -6,11 +6,6 @@ class Messages::Messenger::MessageBuilder
return if unsupported_file_type?(attachment['type'])
params = attachment_params(attachment)
# During Meta's sticker webhook transition, a sticker message carries both an `image`
# and a `sticker` attachment pointing to the same URL. Skip the redundant sticker so it
# isn't attached twice, while still storing legitimate duplicate attachments of other types.
return if duplicate_sticker?(attachment, params[:external_url])
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
attachment_obj.save!
if facebook_reel?(attachment)
@@ -18,14 +13,10 @@ class Messages::Messenger::MessageBuilder
elsif params[:remote_file_url]
attach_file(attachment_obj, params[:remote_file_url])
end
fetch_attachment_links(attachment_obj)
update_attachment_file_type(attachment_obj)
end
def fetch_attachment_links(attachment_obj)
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
update_attachment_file_type(attachment_obj)
end
def attach_file(attachment, file_url)
@@ -120,20 +111,13 @@ class Messages::Messenger::MessageBuilder
# Facebook may send attachment types that don't directly match our file_type enum.
# Map known aliases to their canonical enum values.
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
def normalize_file_type(type)
sym = type.to_sym
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
end
def duplicate_sticker?(attachment, url)
return false unless attachment['type'].to_sym == :sticker
return false if url.blank?
@message.attachments.any? { |existing| existing.external_url == url }
end
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
# direct video URLs. Downloading these yields HTML, not video content.
def facebook_reel?(attachment)
@@ -1,213 +0,0 @@
class V2::Reports::DrilldownBuilder
include DateRangeHelper
include TimezoneHelper
DEFAULT_GROUP_BY = 'day'.freeze
DEFAULT_PAGE = 1
DEFAULT_PER_PAGE = 25
MAX_PER_PAGE = 100
SUPPORTED_GROUP_BY = %w[hour day week month year].freeze
SUPPORTED_DIMENSION_TYPES = %w[account inbox agent label team].freeze
MESSAGE_METRICS = {
'incoming_messages_count' => :incoming,
'outgoing_messages_count' => :outgoing
}.freeze
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
pattr_initialize :account, :params
def self.supported_dimension_type?(type) = SUPPORTED_DIMENSION_TYPES.include?((type.presence || 'account').to_s)
def build
records = paginated_records.to_a
{ meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } }
end
private
def meta
{
metric: metric,
record_type: record_type,
bucket: {
since: bucket_range.begin.to_i,
until: bucket_range.end.to_i
},
current_page: current_page,
per_page: per_page,
total_count: paginated_records.total_count,
conversation_count: conversation_count
}
end
def conversation_count
return paginated_records.total_count if conversation_metric?
drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
end
def paginated_records
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
end
def drilldown_scope
if message_metric?
message_scope
elsif conversation_metric?
conversation_scope
else
reporting_event_scope
end
end
def message_scope
scope.messages
.where(account_id: account.id, created_at: bucket_range)
.public_send(MESSAGE_METRICS.fetch(metric))
.includes(:sender, conversation: [:assignee, :contact, :inbox])
.reorder(created_at: :desc)
end
def conversation_scope
scope.conversations
.where(account_id: account.id, created_at: bucket_range)
.includes(:assignee, :contact, :inbox)
.order(created_at: :desc)
end
def reporting_event_scope
events = scope.reporting_events
.where(account_id: account.id, name: raw_event_name, created_at: bucket_range)
.includes(:user, :inbox, conversation: [:assignee, :contact, :inbox])
.order(created_at: :desc)
if raw_count_strategy == :exclude_bot_handoffs
events = events.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
elsif raw_count_strategy == :distinct_conversation
events = events.where(id: distinct_conversation_event_ids(events))
end
events
end
def bot_handoff_conversation_ids_subquery
scope.reporting_events
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
.where.not(conversation_id: nil)
.select(:conversation_id)
end
def distinct_conversation_event_ids(events)
events.reorder(nil)
.where.not(conversation_id: nil)
.select('MAX(reporting_events.id)')
.group(:conversation_id)
end
def record_serializer(records)
@record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(
account,
metric,
use_business_hours?,
records
)
end
def bucket_range
@bucket_range ||= begin
bucket_start = Time.zone.at(params[:bucket_timestamp].to_i).in_time_zone(timezone)
bucket_end = bucket_end_for(bucket_start)
requested_start = Time.zone.at(params[:since].to_i)
requested_end = Time.zone.at(params[:until].to_i)
[bucket_start, requested_start].max...[bucket_end, requested_end].min
end
end
def bucket_end_for(bucket_start)
{
'hour' => bucket_start + 1.hour,
'day' => bucket_start + 1.day,
'week' => bucket_start + 1.week,
'month' => bucket_start + 1.month,
'year' => bucket_start + 1.year
}.fetch(group_by)
end
def scope
case dimension_type
when 'account' then account
when 'inbox' then inbox
when 'agent' then user
when 'label' then label
when 'team' then team
else
raise ArgumentError, "Unsupported drilldown dimension type: #{dimension_type}"
end
end
def inbox = @inbox ||= account.inboxes.find(params[:id])
def user = @user ||= account.users.find(params[:id])
def label = @label ||= account.labels.find(params[:id])
def team = @team ||= account.teams.find(params[:id])
def metric
params[:metric].to_s
end
def report_metric
@report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
end
def raw_event_name
report_metric&.raw_event_name
end
def raw_count_strategy
report_metric&.raw_count_strategy
end
def record_type
return 'message' if message_metric? || MESSAGE_EVENT_METRICS.include?(metric)
'conversation'
end
def message_metric?
MESSAGE_METRICS.key?(metric)
end
def conversation_metric?
metric == 'conversations_count'
end
def dimension_type
(params[:type].presence || 'account').to_s
end
def group_by
@group_by ||= SUPPORTED_GROUP_BY.include?(params[:group_by].to_s) ? params[:group_by].to_s : DEFAULT_GROUP_BY
end
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
end
def current_page
[params[:page].to_i, DEFAULT_PAGE].max
end
def per_page
requested_per_page = params[:per_page].to_i
requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0
[requested_per_page, MAX_PER_PAGE].min
end
def use_business_hours?
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
end
end
@@ -1,199 +0,0 @@
class V2::Reports::DrilldownRecordSerializer
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
attr_reader :account, :metric, :use_business_hours, :records
def initialize(account, metric, use_business_hours, records = [])
@account = account
@metric = metric
@use_business_hours = use_business_hours
@records = records
end
def serialize(record)
return serialize_message(record) if record.is_a?(Message)
return serialize_conversation_event(record) if record.is_a?(ReportingEvent)
serialize_conversation(record)
end
private
def serialize_message(message, metric_value: nil, occurred_at: nil)
{
record_type: 'message',
conversation: conversation_attributes(message.conversation),
message: message_attributes(message),
metric_value: metric_value,
occurred_at: (occurred_at || message.created_at).to_i
}
end
def serialize_conversation_event(event)
inferred_message = inferred_message_for(event)
if inferred_message.present?
return serialize_message(
inferred_message,
metric_value: event_metric_value(event),
occurred_at: event_timestamp(event)
)
end
serialize_conversation(
event.conversation,
metric_value: event_metric_value(event),
occurred_at: event_timestamp(event),
event_name: event.name
)
end
def serialize_conversation(conversation, metric_value: nil, occurred_at: nil, event_name: nil)
serialized_record = {
record_type: 'conversation',
conversation: conversation_attributes(conversation),
message: nil,
metric_value: metric_value,
occurred_at: (occurred_at || conversation&.created_at)&.to_i
}
serialized_record[:event_name] = event_name if event_name.present?
serialized_record
end
def conversation_attributes(conversation)
return {} if conversation.blank?
{
id: conversation.id,
display_id: conversation.display_id,
contact_id: conversation.contact_id,
contact_name: conversation.contact&.name,
inbox_id: conversation.inbox_id,
inbox_name: conversation.inbox&.name,
assignee_id: conversation.assignee_id,
assignee_name: conversation.assignee&.name,
status: conversation.status,
created_at: conversation.created_at.to_i,
last_activity_at: conversation.last_activity_at.to_i,
last_message: last_message_attributes(conversation)
}
end
def message_attributes(message)
{
id: message.id,
content: message.content,
message_type: message.message_type,
sender_name: message.sender&.try(:name),
created_at: message.created_at.to_i
}
end
def last_message_attributes(conversation)
message = latest_messages_by_conversation_id[conversation.id]
return if message.blank?
message_attributes(message)
end
def inferred_message_for(event)
return unless MESSAGE_EVENT_METRICS.include?(metric)
return if event.conversation.blank? || event.event_end_time.blank?
inferred_messages_by_event_id[event.id]
end
def first_response_event_with_user?(event)
metric == 'avg_first_response_time' && event.user_id.present?
end
def message_inference_range(event)
(event.event_end_time - 1.second)..(event.event_end_time + 1.second)
end
def event_metric_value(event)
use_business_hours ? event.value_in_business_hours : event.value
end
def event_timestamp(event)
event.event_end_time || event.created_at
end
def latest_messages_by_conversation_id
@latest_messages_by_conversation_id ||= if conversation_ids.blank?
{}
else
latest_messages.index_by(&:conversation_id)
end
end
def latest_messages
Message
.where(account_id: account.id, conversation_id: conversation_ids)
.where.not(message_type: :activity)
.select('DISTINCT ON (messages.conversation_id) messages.*')
.reorder(Arel.sql('messages.conversation_id, messages.created_at DESC, messages.id DESC'))
.includes(:sender)
end
def inferred_messages_by_event_id
@inferred_messages_by_event_id ||= inference_events.each_with_object({}) do |event, messages_by_event_id|
messages_by_event_id[event.id] = inferred_message_candidates.find do |message|
message_matches_event?(message, event)
end
end
end
def inferred_message_candidates
@inferred_message_candidates ||= if inference_events.blank?
[]
else
inferred_messages.to_a
end
end
def inferred_messages
Message
.where(account_id: account.id, conversation_id: inference_events.map(&:conversation_id).uniq)
.where(created_at: inference_time_range)
.where(message_type: %i[outgoing template])
.includes(:sender)
.reorder(created_at: :desc, id: :desc)
end
def message_matches_event?(message, event)
message.conversation_id == event.conversation_id &&
message.created_at.between?(
message_inference_range(event).begin,
message_inference_range(event).end
) &&
message_sender_matches_event?(message, event)
end
def message_sender_matches_event?(message, event)
return true unless first_response_event_with_user?(event)
message.sender_id == event.user_id && message.sender_type == 'User'
end
def inference_time_range
event_end_times = inference_events.map(&:event_end_time)
(event_end_times.min - 1.second)..(event_end_times.max + 1.second)
end
def inference_events
@inference_events ||= records.select do |record|
record.is_a?(ReportingEvent) && record.conversation_id.present? && record.event_end_time.present?
end
end
def conversation_ids
@conversation_ids ||= records.filter_map { |record| conversation_id_for(record) }.uniq
end
def conversation_id_for(record)
return record.conversation_id if record.is_a?(Message) || record.is_a?(ReportingEvent)
record.id
end
end
+14
View File
@@ -7,6 +7,7 @@ class RoomChannel < ApplicationCable::Channel
ensure_stream
update_subscription
broadcast_presence
transmit_cache_keys
end
def update_presence
@@ -24,6 +25,19 @@ class RoomChannel < ApplicationCable::Channel
ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data })
end
# Push the authoritative cache-key map to this subscriber on every
# (re)subscribe. Boot and reconnect cache freshness ride the same
# account.cache_invalidated event the dashboard already handles for live
# invalidations — the client never pulls /cache_keys itself.
def transmit_cache_keys
return if @current_account.blank? || !@current_user.is_a?(User)
transmit({
event: Events::Types::ACCOUNT_CACHE_INVALIDATED,
data: { account_id: @current_account.id, cache_keys: @current_account.cache_keys }
})
end
def ensure_stream
stream_from pubsub_token
stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User)
@@ -40,8 +40,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def reorder
positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
render json: { positions: positions }
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
end
private
@@ -2,8 +2,6 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
before_action :fetch_inboxes
def index
# TODO: Remove this opt-in once mobile clients support AgentBot assignees in this payload.
@include_agent_bots = params[:include_agent_bots].present?
agent_ids = @inboxes.map do |inbox|
authorize inbox, :show?
member_ids = inbox.members.pluck(:user_id)
@@ -12,7 +10,6 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
agent_ids = agent_ids.inject(:&)
agents = Current.account.users.where(id: agent_ids)
@assignable_agents = (agents + Current.account.administrators).uniq
@agent_bots = @include_agent_bots ? AgentBot.accessible_to(Current.account) : []
end
private
@@ -30,8 +30,7 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled,
:exclude_older_than_hours
:fair_distribution_limit, :fair_distribution_window, :enabled
)
end
end
@@ -6,7 +6,6 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token = params[:page_access_token]
page_id = params[:page_id]
inbox_name = params[:inbox_name]
ActiveRecord::Base.transaction do
facebook_channel = Current.account.facebook_pages.create!(
page_id: page_id, user_access_token: user_access_token,
@@ -16,8 +15,6 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id)
end
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Error in register_facebook_page: #{e.message}"
@@ -8,8 +8,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def update
params_to_update = captain_params
@current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models)
@current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features)
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
@current_account.save!
render json: preferences_payload
@@ -38,7 +38,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def merged_captain_models
existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models).compact_blank.presence
existing_models.merge(permitted_captain_models)
end
def merged_captain_features
@@ -47,38 +47,30 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
end
def permitted_captain_models
params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys
params.require(:captain_models).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def permitted_captain_features
params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys
end
def captain_feature_keys
Llm::Models.feature_keys.map(&:to_sym)
params.require(:captain_features).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
account_models = preferences[:models] || {}
Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
default: default_model_for(feature_key),
enabled: account_features[feature_key] == true,
model: route[:model],
selected: route[:model],
provider: route[:provider],
source: route[:source]
selected: account_models[feature_key] || config[:default]
)
end
end
def default_model_for(feature_key)
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2')
Llm::Models.default_model_for(feature_key)
end
end
@@ -53,7 +53,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
def category_params
params.require(:category).permit(
:name, :description, :position, :slug, :locale, :icon, :icon_color, :parent_category_id, :associated_category_id
:name, :description, :position, :slug, :locale, :icon, :parent_category_id, :associated_category_id
)
end
@@ -6,8 +6,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def create
process_create
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -214,5 +214,3 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
render json: error, status: error_status
end
end
Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController')
@@ -52,9 +52,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
render json: { content: translated_content }
rescue Google::Cloud::Error => e
# `details` carries the clean human message; `message` includes gRPC debug noise
render_could_not_create_error(e.details.presence || e.message)
end
private
@@ -1,40 +1,27 @@
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
include Events::Types
def show
@participants = @conversation.conversation_participants
end
def create
participant_ids_to_add = participants_to_be_added_ids
ActiveRecord::Base.transaction do
@participants = participant_ids_to_add.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
@participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
end
notify_unread_count_change if participant_ids_to_add.any?
end
def update
participant_ids_to_add = participants_to_be_added_ids
participant_ids_to_remove = participants_to_be_removed_ids
changed_participant_ids = participant_ids_to_add + participant_ids_to_remove
ActiveRecord::Base.transaction do
participant_ids_to_add.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participant_ids_to_remove.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
notify_unread_count_change if changed_participant_ids.any?
@participants = @conversation.conversation_participants
render action: 'show'
end
def destroy
participant_ids_to_remove = current_participant_ids & params[:user_ids]
ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
notify_unread_count_change if participant_ids_to_remove.any?
head :ok
end
@@ -51,11 +38,4 @@ class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accoun
def current_participant_ids
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
end
def notify_unread_count_change
return unless Current.account.feature_enabled?('conversation_unread_counts')
return unless Current.account.feature_enabled?('unread_count_for_filters')
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: @conversation)
end
end
@@ -2,28 +2,12 @@ class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accoun
before_action :ensure_unread_counts_enabled
def index
counts = if filtered_unread_counts_enabled?
instrumentation.summarize_request(account_id: Current.account.id) { unread_counts }
else
unread_counts
end
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
render json: { payload: counts }
end
private
def unread_counts
::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
end
def filtered_unread_counts_enabled?
Current.account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG)
end
def instrumentation
::Conversations::UnreadCounts::FilteredCountInstrumentation
end
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')
@@ -140,7 +140,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def destroy
authorize @conversation, :destroy?
::Conversations::DeleteService.new(conversation: @conversation, user: Current.user, ip: request.ip).perform
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
head :ok
end
@@ -164,7 +164,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
::Conversations::UnreadCounts::FilteredCountInvalidator.new(Current.account).conversation_changed!
end
def should_update_last_seen?
@@ -2,6 +2,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
@@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
end
render_response(
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message)
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user)
)
end
@@ -6,10 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state,
# Force the Microsoft account picker so an already-signed-in account does not
# silently authorize and re-bind to an existing inbox in the new-inbox flow.
prompt: 'select_account'
state: state
}
)
if redirect_url
@@ -1,71 +1,29 @@
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
ONBOARDING_STEP_KEY = 'onboarding_step'.freeze
STEP_ACCOUNT_DETAILS = 'account_details'.freeze
STEP_INBOX_SETUP = 'inbox_setup'.freeze
ONBOARDING_STEPS = [STEP_ACCOUNT_DETAILS, STEP_INBOX_SETUP].freeze
def update
return render json: { error: 'Invalid onboarding step' }, status: :unprocessable_entity unless ONBOARDING_STEPS.include?(params[:onboarding_step])
@account = Current.account
# The client declares the step it is completing; `account_details` runs
# `complete_account_details`, and so on. The known-step guard above keeps the
# client value from `send`-ing an arbitrary method.
send("complete_#{params[:onboarding_step]}")
finalize = finalizing_account_details?
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
@account.custom_attributes.delete('onboarding_step') if finalize
@account.save!
# TODO: re-enable when the help center generation UI is ready to surface progress
# Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present?
render 'api/v1/accounts/update', format: :json
end
def help_center_generation
render json: help_center_generation_status
end
private
def complete_account_details
# Only act while the cursor still points here, so a stale replay after
# onboarding finished can't re-enter it.
return unless current_step == STEP_ACCOUNT_DETAILS
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
# inbox_setup is a cloud-only step (DEPLOYMENT_ENV config, not a hardcoded
# environment check); self-hosted finishes onboarding here.
if ChatwootApp.chatwoot_cloud?
move_to_step(STEP_INBOX_SETUP)
create_onboarding_inboxes
else
finish_onboarding
end
def finalizing_account_details?
@account.custom_attributes['onboarding_step'] == 'account_details'
end
def complete_inbox_setup
# Only finalize while the cursor still points here, so a stale or out-of-order
# request can't end onboarding early. Replays are no-ops.
return unless current_step == STEP_INBOX_SETUP
finish_onboarding
end
def current_step
@account.custom_attributes[ONBOARDING_STEP_KEY]
end
def move_to_step(step)
@account.custom_attributes[ONBOARDING_STEP_KEY] = step
@account.save!
end
def finish_onboarding
@account.custom_attributes.delete(ONBOARDING_STEP_KEY)
@account.save!
end
def create_onboarding_inboxes
Onboarding::WebWidgetCreationService.new(@account, Current.user).perform
def website
custom_attributes_params[:website]
end
def account_params
@@ -75,15 +33,4 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
end
def help_center_generation_status
{
generation_id: nil,
state: nil,
articles_count: 0,
categories_count: 0
}
end
end
Api::V1::Accounts::OnboardingsController.prepend_mod_with('Api::V1::Accounts::OnboardingsController')
@@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
end
def team_params
params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color)
params.require(:team).permit(:name, :description, :allow_auto_assign)
end
end
@@ -8,10 +8,8 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
validate_embedded_signup_params!
channel = process_embedded_signup
render_success_response(channel.inbox)
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_embedded_signup_error(e)
render_error_response(e)
end
private
@@ -57,7 +55,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
render json: response
end
def render_embedded_signup_error(error)
def render_error_response(error)
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render json: {
@@ -50,7 +50,6 @@ class Api::V1::AccountsController < Api::BaseController
end
def cache_keys
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
render json: { cache_keys: cache_keys_for_account }, status: :ok
end
@@ -93,11 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def cache_keys_for_account
{
label: fetch_value_for_key(params[:id], Label.name.underscore),
inbox: fetch_value_for_key(params[:id], Inbox.name.underscore),
team: fetch_value_for_key(params[:id], Team.name.underscore)
}
@account.cache_keys
end
def fetch_account
@@ -1,36 +0,0 @@
class Api::V1::Profile::SessionsController < Api::BaseController
before_action :set_session, only: [:destroy]
def index
@sessions = current_user.user_sessions.where(client_id: active_token_client_ids).order(last_activity_at: :desc)
@current_client_id = request.headers['client']
end
def destroy
if @session.current?(request.headers['client'])
render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity
return
end
revoke_token!(@session.client_id)
@session.destroy!
head :ok
end
private
def set_session
@session = current_user.user_sessions.find(params[:id])
end
def revoke_token!(client_id)
tokens = current_user.tokens
tokens.delete(client_id)
current_user.update!(tokens: tokens)
end
def active_token_client_ids
now = Time.current.to_i
(current_user.tokens || {}).select { |_, v| v['expiry'].to_i > now }.keys
end
end
@@ -13,11 +13,17 @@ class Api::V1::ProfilesController < Api::BaseController
@user.assign_attributes(profile_params)
@user.custom_attributes.merge!(custom_attributes_params)
@user.save!
# Profile updates can change cached agent fields, including avatar-backed thumbnails.
@user.invalidate_avatar_cache
end
def avatar
@user.avatar.attachment.destroy! if @user.avatar.attached?
@user.reload
# Agent thumbnails are cached separately, and avatar attachment deletes do not dirty user columns.
@user.invalidate_avatar_cache
end
def auto_offline
@@ -2,7 +2,6 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
include WidgetHelper
before_action :validate_hmac, only: [:set_user]
before_action :validate_hmac_for_identified_update, only: [:update]
def show; end
@@ -47,16 +46,6 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
end
# The plain update endpoint is also used for anonymous prechat updates
# (name/email/phone/custom_attributes with no identifier), which must keep
# working on hmac_mandatory inboxes. Only the identity-binding path, where an
# identifier is supplied and the contact can be rebound, requires HMAC.
def validate_hmac_for_identified_update
return if params[:identifier].blank?
validate_hmac
end
def validate_hmac
return unless should_verify_hmac?
@@ -73,15 +62,11 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
end
def valid_hmac?
expected_hash = OpenSSL::HMAC.hexdigest(
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256',
@web_widget.hmac_token,
params[:identifier].to_s
)
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
@@ -10,8 +10,7 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr
response = dyte_processor_service.add_participant_to_meeting(
@message.content_attributes['data']['meeting_id'],
@conversation.contact,
@message
@conversation.contact
)
render_response(response)
end
@@ -51,13 +51,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic')
end
def drilldown
return head :unauthorized unless Current.account_user.administrator?
return head :unprocessable_entity unless valid_drilldown_params?
render json: V2::Reports::DrilldownBuilder.new(Current.account, drilldown_params).build
end
def conversations
return head :unprocessable_entity if params[:type].blank?
@@ -140,22 +133,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
})
end
def drilldown_params
permitted_params = params.permit(
:metric, :id, :since, :until, :group_by, :timezone_offset, :bucket_timestamp, :page, :per_page
).to_h.symbolize_keys
permitted_params.merge(
type: (params[:type].presence || 'account').to_sym,
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
)
end
def valid_drilldown_params?
%i[metric bucket_timestamp since until].all? { |param| params[param].present? } &&
Reports::ReportMetricRegistry.supported?(params[:metric]) &&
V2::Reports::DrilldownBuilder.supported_dimension_type?(params[:type]) && Reports::DrilldownTimestampValidator.valid?(params)
end
def conversation_params
{
type: params[:type].to_sym,
@@ -3,7 +3,6 @@ class ApplicationController < ActionController::Base
include RequestExceptionHandler
include Pundit::Authorization
include SwitchLocale
include TrackSessionActivity
skip_before_action :verify_authenticity_token
@@ -1,29 +0,0 @@
module PortalHomeData
extend ActiveSupport::Concern
private
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
@@ -1,15 +1,8 @@
module RequestExceptionHandler
extend ActiveSupport::Concern
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS = [
'ActiveRecord::QueryCanceled',
'PG::QueryCanceled',
'canceling statement due to statement timeout'
].freeze
included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response
end
private
@@ -25,9 +18,6 @@ module RequestExceptionHandler
rescue ActionController::ParameterMissing => e
log_handled_error(e)
render_could_not_create_error(e.message)
rescue ActiveRecord::QueryCanceled => e
log_handled_error(e)
render_could_not_create_error(database_query_canceled_message)
ensure
# to address the thread variable leak issues in Puma/Thin webserver
Current.reset
@@ -41,8 +31,8 @@ module RequestExceptionHandler
render json: { error: message }, status: :not_found
end
def render_could_not_create_error(error)
render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity
def render_could_not_create_error(message)
render json: { error: message }, status: :unprocessable_entity
end
def render_payment_required(message)
@@ -69,19 +59,4 @@ module RequestExceptionHandler
def log_handled_error(exception)
logger.info("Handled error: #{exception.inspect}")
end
def sanitized_error_message(message)
return database_query_canceled_message if database_query_canceled_message?(message)
message
end
def database_query_canceled_message?(message)
error_message = message.to_s
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS.any? { |pattern| error_message.include?(pattern) }
end
def database_query_canceled_message
I18n.t('errors.database.query_canceled')
end
end
@@ -1,22 +0,0 @@
module TrackSessionActivity
extend ActiveSupport::Concern
included do
after_action :update_session_activity
end
private
def update_session_activity
return unless current_user
return if request.headers['client'].blank?
UserSessionTrackingService.new(
user: current_user,
request: request,
client_id: request.headers['client']
).update_activity!
rescue StandardError => e
Rails.logger.warn "Session activity update failed: #{e.message}"
end
end
-5
View File
@@ -1,6 +1,5 @@
class DashboardController < ActionController::Base
include SwitchLocale
include PortalHomeData
GLOBAL_CONFIG_KEYS = %w[
LOGO
@@ -64,10 +63,6 @@ class DashboardController < ActionController::Base
return unless @portal
@locale = @portal.default_locale
if @portal.layout == 'documentation'
request.variant = :documentation
load_home_data
end
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end
@@ -1,6 +1,4 @@
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
MAX_SESSIONS = ENV.fetch('MAX_USER_SESSIONS', 25).to_i
# Prevent session parameter from being passed
# Unpermitted parameter: session
wrap_parameters format: []
@@ -16,14 +14,12 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
user = find_user_for_authentication
return handle_mfa_required(user) if user&.mfa_enabled?
return if user && enforce_session_limit_for_password_login(user)
# Only proceed with standard authentication if no MFA is required
super
end
def render_create_success
track_user_session unless @impersonation
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
end
@@ -57,8 +53,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def handle_sso_authentication
return if !@impersonation && enforce_session_limit_for_password_login(@resource)
authenticate_resource_with_sso_token
yield @resource if block_given?
render_create_success
@@ -71,10 +65,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def authenticate_resource_with_sso_token
# DTA evicts the earliest-expiring token after save when at max_number_of_devices.
# The short-lived impersonation token would always be that one, so pre-evict to make room.
make_room_for_impersonation_token if @impersonation
@token = @resource.create_token(lifespan: @impersonation ? 2.days.to_i : nil)
@token = @resource.create_token
@resource.save!
sign_in(:user, @resource, store: false, bypass: false)
@@ -82,21 +73,11 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
@resource.invalidate_sso_auth_token(params[:sso_auth_token])
end
def make_room_for_impersonation_token
return if @resource.tokens.size < DeviseTokenAuth.max_number_of_devices
oldest_client_id = @resource.tokens.min_by { |_, v| v['expiry'].to_i }&.first
@resource.tokens.delete(oldest_client_id) if oldest_client_id
end
def process_sso_auth_token
return if params[:email].blank?
user = User.from_email(params[:email])
return unless user&.valid_sso_auth_token?(params[:sso_auth_token])
@resource = user
@impersonation = user.sso_auth_token_impersonation?(params[:sso_auth_token])
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
end
def handle_mfa_required(user)
@@ -122,7 +103,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def sign_in_mfa_user(user)
evict_oldest_session(user) if sessions_limit_reached?(user)
@resource = user
@token = @resource.create_token
@resource.save!
@@ -134,103 +114,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
def render_mfa_error(message_key, status = :bad_request)
render json: { error: I18n.t(message_key) }, status: status
end
def sessions_limit_reached?(user)
active_token_count(user) >= MAX_SESSIONS
end
def active_token_count(user)
now = Time.current.to_i
(user.tokens || {}).count { |_, v| v['expiry'].to_i > now }
end
# Returns true when a response has been rendered (e.g., 409 picker). Non-browser clients
# auto-evict instead of getting stuck on a UI they can't render.
def enforce_session_limit_for_password_login(user)
if revoking_sessions?
revoke_sessions_for_login(user)
return false
end
return false unless sessions_limit_reached?(user)
# Picker only when every token has a tracked session; partial tracking would
# show a misleading count, so fall through to silent eviction instead.
if browser_request? && user.user_sessions.count >= user.tokens.size
handle_sessions_limit_for_login(user)
true
else
evict_oldest_session(user)
false
end
end
def browser_request?
request.user_agent.to_s.include?('Mozilla')
end
def revoking_sessions?
params[:revoke_session_id].present? || params[:revoke_all_sessions].present?
end
def revoke_sessions_for_login(user)
if params[:revoke_all_sessions].present?
user.tokens = {}
user.save!
user.user_sessions.destroy_all
elsif params[:revoke_session_id].present?
session = user.user_sessions.find_by(id: params[:revoke_session_id])
return unless session
user.tokens.delete(session.client_id)
user.save!
session.destroy!
end
end
def evict_oldest_session(user)
# Drop pre-rollout untracked tokens first so freshly tracked logins aren't evicted.
return evict_oldest_token(user) if user.user_sessions.count < user.tokens.size
oldest_session = user.user_sessions.order(Arel.sql('COALESCE(last_activity_at, created_at) ASC')).first
return evict_oldest_token(user) unless oldest_session
user.tokens.delete(oldest_session.client_id)
user.save!
oldest_session.destroy!
end
# Fallback if a token exists without a UserSession row (e.g., legacy data before tracking shipped).
def evict_oldest_token(user)
return if user.tokens.blank?
oldest_client_id = user.tokens.min_by { |_, v| v['expiry'].to_i }&.first
return unless oldest_client_id
user.tokens.delete(oldest_client_id)
user.save!
end
PICKER_SESSION_FIELDS = %i[id browser_name browser_version device_name platform_name platform_version
ip_address city country last_activity_at created_at].freeze
def handle_sessions_limit_for_login(user)
sessions = user.user_sessions.order(last_activity_at: :desc).map { |s| s.slice(*PICKER_SESSION_FIELDS) }
render json: { sessions_limit_reached: true, sessions: sessions }, status: :conflict
end
def track_user_session
client_id = @token&.try(:client) || response.headers['client']
return unless client_id.present? && @resource.present?
UserSessionTrackingService.new(
user: @resource,
request: request,
client_id: client_id
).create_or_update!
rescue StandardError => e
Rails.logger.warn "Session tracking failed: #{e.message}"
end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
@@ -11,8 +11,6 @@ class Instagram::CallbacksController < ApplicationController
end
process_successful_authorization
rescue CustomExceptions::Inbox::LimitExceeded => e
handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -49,14 +47,6 @@ class Instagram::CallbacksController < ApplicationController
redirect_to_error_page(error_info)
end
def handle_limit_error(error)
redirect_to_error_page(
'error_type' => error.class.name,
'code' => Rack::Utils.status_code(error.http_status),
'error_message' => error.message
)
end
# Extract error details from the exception
def extract_error_info(error)
if error.is_a?(OAuth2::Error)
@@ -18,8 +18,7 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
contact: @contact_inbox.contact,
params: permitted_params.to_h.deep_symbolize_keys.except(:identifier)
)
contact_identify_action.perform
@contact_inbox.reload
render json: contact_identify_action.perform
end
private
@@ -36,15 +35,11 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
end
def valid_hmac?
expected_hash = OpenSSL::HMAC.hexdigest(
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256',
@inbox_channel.hmac_token,
params[:identifier].to_s
)
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
@@ -39,11 +39,9 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def switch_locale_with_portal(&)
# Keep @locale as the portal's own locale code (e.g. th_TH) for content queries,
# while UI translations fall back to an available I18n locale (e.g. th).
@locale = params[:locale]
@locale = validate_and_get_locale(params[:locale])
I18n.with_locale(validate_and_get_locale(@locale), &)
I18n.with_locale(@locale, &)
end
def switch_locale_with_article(&)
@@ -51,12 +49,13 @@ class Public::Api::V1::Portals::BaseController < PublicController
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
render_404 && return if article.blank?
@locale = if article.category.present?
article.category.locale
else
article.locale
end
I18n.with_locale(validate_and_get_locale(@locale), &)
article_locale = if article.category.present?
article.category.locale
else
article.locale
end
@locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &)
end
def allow_iframe_requests
@@ -1,6 +1,4 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
include PortalHomeData
before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
@@ -33,4 +31,28 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
@@ -35,8 +35,7 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
#
def resource_params
permitted_params = super
permitted_params[:limits] = permitted_params[:limits].to_h.compact if permitted_params.key?(:limits)
permitted_params[:captain_models] = permitted_params[:captain_models].to_h.compact_blank.presence if permitted_params.key?(:captain_models)
permitted_params[:limits] = permitted_params[:limits].to_h.compact
permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present?
permitted_params
end
@@ -6,8 +6,6 @@ class Tiktok::CallbacksController < ApplicationController
return handle_ungranted_scopes_error unless all_scopes_granted?
process_successful_authorization
rescue CustomExceptions::Inbox::LimitExceeded => e
handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -38,14 +36,6 @@ class Tiktok::CallbacksController < ApplicationController
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
end
def handle_limit_error(error)
redirect_to_error_page(
error_type: error.class.name,
code: Rack::Utils.status_code(error.http_status),
error_message: error.message
)
end
# Handles the case when a user denies permissions or cancels the authorization flow
def handle_authorization_error
redirect_to_error_page(
+1 -11
View File
@@ -35,17 +35,7 @@ class Twilio::CallbackController < ApplicationController
:ExternalUserId,
:ParentExternalUserId,
:ProfileUsername,
:Username,
:ReferralBody,
:ReferralHeadline,
:ReferralSourceId,
:ReferralSourceType,
:ReferralSourceUrl,
:ReferralMediaId,
:ReferralMediaContentType,
:ReferralMediaUrl,
:ReferralNumMedia,
:ReferralCtwaClid
:Username
)
end
end
+1 -4
View File
@@ -18,7 +18,6 @@ class AccountDashboard < Administrate::BaseDashboard
# Add all_features last so it appears after manually_managed_features
attributes[:all_features] = AccountFeaturesField
attributes[:captain_models] = CaptainModelOverridesField
attributes
else
@@ -58,7 +57,6 @@ class AccountDashboard < Administrate::BaseDashboard
attrs = %i[custom_attributes limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
attrs << :captain_models
attrs
else
[]
@@ -81,7 +79,6 @@ class AccountDashboard < Administrate::BaseDashboard
attrs = %i[limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
attrs << :captain_models
attrs
else
[]
@@ -120,7 +117,7 @@ class AccountDashboard < Administrate::BaseDashboard
# to prevent an error from being raised (wrong number of arguments)
# Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204
def permitted_attributes(action)
attrs = super + [limits: {}, captain_models: {}]
attrs = super + [limits: {}]
# Add manually_managed_features to permitted attributes only for Chatwoot Cloud
attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud?
+1 -13
View File
@@ -12,7 +12,6 @@ class ConversationFinder
'waiting_since_asc' => %w[sort_on_waiting_since asc],
'waiting_since_desc' => %w[sort_on_waiting_since desc],
'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc],
'unread' => %w[sort_on_unread desc],
# To be removed in v3.5.0
'latest' => %w[sort_on_last_activity_at desc],
@@ -41,7 +40,7 @@ class ConversationFinder
def perform
set_up
mine_count, unassigned_count, all_count = set_count_for_all_conversations
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
filter_by_assignee_type
@@ -185,17 +184,6 @@ class ConversationFinder
end
def set_count_for_all_conversations
return legacy_count_for_all_conversations if @conversations.limit_value || @conversations.offset_value || @conversations.eager_loading?
counts = @conversations.unscope(:order).pick(
Arel.sql("COUNT(*) FILTER (WHERE assignee_id = #{current_user.id})"),
Arel.sql('COUNT(*) FILTER (WHERE assignee_id IS NULL)'),
Arel.sql('COUNT(*)')
)
counts || [0, 0, 0]
end
def legacy_count_for_all_conversations
[
@conversations.assigned_to(current_user).count,
@conversations.unassigned.count,
+6
View File
@@ -114,4 +114,10 @@ module Api::V1::InboxesHelper
'sms' => Current.account.sms_channels
}[permitted_params[:channel][:type]]
end
def validate_limit
return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
render_payment_required('Account limit exceeded. Upgrade to a higher plan')
end
end
-12
View File
@@ -22,16 +22,4 @@ module BillingHelper
def agents(account)
account.users.count
end
# current_period_end moved to the subscription item in newer Stripe API versions; read both.
def subscription_period_end(subscription)
subscription['current_period_end'] || subscription['items']['data'].first&.[]('current_period_end')
end
def subscription_ends_on(subscription)
period_end = subscription_period_end(subscription)
return if period_end.blank?
Time.zone.at(period_end)
end
end
-10
View File
@@ -68,8 +68,6 @@ module Filters::FilterHelper
when 'text_case_insensitive'
text_case_insensitive_filter(query_hash, filter_operator_value)
else
return text_cast_filter(query_hash, filter_operator_value) if text_search_on_display_id?(query_hash)
default_filter(query_hash, filter_operator_value)
end
end
@@ -84,18 +82,10 @@ module Filters::FilterHelper
"#{filter_operator_value} #{query_hash[:query_operator]}"
end
def text_cast_filter(query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::text #{filter_operator_value} #{query_hash[:query_operator]}"
end
def default_filter(query_hash, filter_operator_value)
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
end
def text_search_on_display_id?(query_hash)
query_hash[:attribute_key] == 'display_id' && %w[contains does_not_contain].include?(query_hash[:filter_operator])
end
def validate_single_condition(condition)
return if condition['query_operator'].nil?
return if condition['query_operator'].empty?
+5 -17
View File
@@ -17,11 +17,15 @@ module PortalHelper
uri.to_s
end
def generate_portal_bg(portal_color, theme)
def generate_portal_bg_color(portal_color, theme)
base_color = theme == 'dark' ? 'black' : 'white'
"color-mix(in srgb, #{portal_color} 20%, #{base_color})"
end
def generate_portal_bg(portal_color, theme)
generate_portal_bg_color(portal_color, theme)
end
def generate_gradient_to_bottom(theme)
base_color = theme == 'dark' ? '#151718' : 'white'
"linear-gradient(to bottom, transparent, #{base_color})"
@@ -37,10 +41,6 @@ module PortalHelper
language_map[locale] || locale
end
def html_lang_attribute(locale)
locale.to_s.tr('_', '-')
end
def theme_query_string(theme)
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
end
@@ -97,18 +97,6 @@ module PortalHelper
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end
# Renders a stored category icon: a bare ri icon name (e.g. `vip-crown-2-fill/line`) saved color, or a plain emoji character.
def render_emoji_or_icon(value, color = nil)
return '' if value.blank?
# Emojis are non-ascii; bare icon names match this safe charset.
return ERB::Util.html_escape(value) unless value.match?(/\A[a-z][a-z0-9-]*\z/)
icon_class = value.start_with?('i-') ? value : "i-ri-#{value}"
style = "color: #{color};" if color.to_s.match?(/\A#\h{3,8}\z/)
tag.span(class: icon_class, style: style, 'aria-hidden': true)
end
def thumbnail_bg_color(username)
colors = ['#6D95BA', '#A4C3C3', '#E19191']
return colors.sample if username.blank?
+10 -2
View File
@@ -19,6 +19,7 @@ import {
verifyServiceWorkerExistence,
} from './helper/pushHelper';
import ReconnectService from 'dashboard/helper/ReconnectService';
import paintStoresFromCache from 'dashboard/helper/CacheHelper/paintStoresFromCache';
import { useUISettings } from 'dashboard/composables/useUISettings';
export default {
@@ -108,14 +109,21 @@ export default {
this.$store.dispatch('setActiveAccount', {
accountId: this.currentAccountId,
});
const { pubsub_token: pubsubToken } = this.currentUser || {};
vueActionCable.init(this.store, pubsubToken);
// Paint cached config from IndexedDB instantly while the cable
// connects. Freshness needs no orchestration here: RoomChannel pushes
// the cache-key map on every (re)subscribe and on every server-side
// change, all through the same account.cache_invalidated event.
await paintStoresFromCache(this.$store, this.currentAccountId);
const account = this.getAccount(this.currentAccountId);
const { locale, latest_chatwoot_version: latestChatwootVersion } =
account;
const { pubsub_token: pubsubToken } = this.currentUser || {};
// If user locale is set, use it; otherwise use account locale
this.setLocale(this.uiSettings?.locale || locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(this.store, pubsubToken);
this.reconnectService = new ReconnectService(this.store, this.router);
window.reconnectService = this.reconnectService;
@@ -5,14 +5,15 @@ import ApiClient from './ApiClient';
class CacheEnabledApiClient extends ApiClient {
constructor(resource, options = {}) {
super(resource, options);
// `cacheModel` is the Rails Model.name.underscore value — simultaneously
// the server cache-key name and the IDB object-store name.
this.cacheModelName = options.cacheModel;
// inbox/label endpoints wrap collections in { payload }; the rest return
// the bare array.
this.payloadEnvelope = options.payloadEnvelope || false;
this.dataManager = new DataManager(this.accountIdFromRoute);
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
throw new Error('cacheModelName is not defined');
}
get(cache = false) {
if (cache) {
return this.getFromCache();
@@ -25,14 +26,14 @@ class CacheEnabledApiClient extends ApiClient {
return axios.get(this.url);
}
// eslint-disable-next-line class-methods-use-this
extractDataFromResponse(response) {
return response.data.payload;
return this.payloadEnvelope ? response.data.payload : response.data;
}
// eslint-disable-next-line class-methods-use-this
marshallData(dataToParse) {
return { data: { payload: dataToParse } };
return this.payloadEnvelope
? { data: { payload: dataToParse } }
: { data: dataToParse };
}
async getFromCache() {
@@ -43,24 +44,23 @@ class CacheEnabledApiClient extends ApiClient {
return this.getFromNetwork();
}
const { data } = await axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
);
const cacheKeyFromApi = data.cache_keys[this.cacheModelName];
const isCacheValid = await this.validateCacheKey(cacheKeyFromApi);
// Trust the IDB cache. Freshness is maintained by the
// account.cache_invalidated event alone: RoomChannel pushes the cache-key
// map on every (re)subscribe — boot and reconnect included — and the
// server broadcasts it on every change. Skipping a per-call /cache_keys
// preflight eliminates N GET requests per cold settings-page load.
const localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
let localData = [];
if (isCacheValid) {
localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
if (localData.length > 0) {
return this.marshallData(localData);
}
if (localData.length === 0) {
return this.refetchAndCommit(cacheKeyFromApi);
}
return this.marshallData(localData);
// Empty IDB (first load or wiped): fetch data without a cache key. The
// next pushed key map won't match the missing key and will refetch once,
// stamping the authoritative key — the client never pulls keys itself.
return this.refetchAndCommit(null);
}
async refetchAndCommit(newKey = null) {
@@ -69,7 +69,9 @@ class CacheEnabledApiClient extends ApiClient {
try {
await this.dataManager.initDb();
this.dataManager.replace({
// Await replace so data is persisted before the cache key is — otherwise
// a concurrent reader could see a fresh key paired with stale data.
await this.dataManager.replace({
modelName: this.cacheModelName,
data: this.extractDataFromResponse(response),
});
@@ -89,8 +91,15 @@ class CacheEnabledApiClient extends ApiClient {
await this.dataManager.initDb();
}
const cachekey = await this.dataManager.getCacheKey(this.cacheModelName);
return cacheKeyFromApi === cachekey;
const cacheKey = await this.dataManager.getCacheKey(this.cacheModelName);
if (cacheKey === undefined) {
const localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
return localData.length === 0;
}
return cacheKeyFromApi === cacheKey;
}
}
-7
View File
@@ -9,13 +9,6 @@ class AccountAPI extends ApiClient {
createAccount(data) {
return axios.post(`${this.apiVersion}/accounts`, data);
}
async getCacheKeys() {
const response = await axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
);
return response.data.cache_keys;
}
}
export default new AccountAPI();
+3 -3
View File
@@ -1,10 +1,10 @@
/* global axios */
import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
class Agents extends ApiClient {
class Agents extends CacheEnabledApiClient {
constructor() {
super('agents', { accountScoped: true });
super('agents', { accountScoped: true, cacheModel: 'account_user' });
}
bulkInvite({ emails }) {
@@ -6,12 +6,9 @@ class AssignableAgents extends ApiClient {
super('assignable_agents', { accountScoped: true });
}
get(inboxIds, { includeAgentBots = false } = {}) {
get(inboxIds) {
return axios.get(this.url, {
params: {
inbox_ids: inboxIds,
...(includeAgentBots ? { include_agent_bots: true } : {}),
},
params: { inbox_ids: inboxIds },
});
}
}
+7 -5
View File
@@ -1,13 +1,15 @@
/* global axios */
import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
class AttributeAPI extends ApiClient {
class AttributeAPI extends CacheEnabledApiClient {
constructor() {
super('custom_attribute_definitions', { accountScoped: true });
super('custom_attribute_definitions', {
accountScoped: true,
cacheModel: 'custom_attribute_definition',
});
}
getAttributesByModel() {
return axios.get(this.url);
return super.get(true);
}
}
-6
View File
@@ -106,10 +106,4 @@ export default {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
getSessions() {
return axios.get('/api/v1/profile/sessions');
},
revokeSession(id) {
return axios.delete(`/api/v1/profile/sessions/${id}`);
},
};
+11 -6
View File
@@ -1,15 +1,20 @@
/* global axios */
import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
class CannedResponse extends ApiClient {
class CannedResponse extends CacheEnabledApiClient {
constructor() {
super('canned_responses', { accountScoped: true });
super('canned_responses', {
accountScoped: true,
cacheModel: 'canned_response',
});
}
get({ searchKey }) {
const url = searchKey ? `${this.url}?search=${searchKey}` : this.url;
return axios.get(url);
get({ searchKey } = {}) {
if (searchKey) {
return axios.get(`${this.url}?search=${searchKey}`);
}
return super.get(true);
}
}
@@ -1,10 +1,6 @@
/* global axios */
import ApiClient from '../ApiClient';
// Viewer's UTC offset in hours, matching the reports API convention so the
// backend can anchor calendar ranges to the viewer's day.
const getTimezoneOffset = () => -new Date().getTimezoneOffset() / 60;
class CaptainAssistant extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
@@ -25,32 +21,6 @@ class CaptainAssistant extends ApiClient {
message_history: messageHistory,
});
}
getStats({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/stats`, {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
getSummary({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/summary`, {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
getDrilldown({ assistantId, metric, range, page, signal }) {
const requestConfig = {
params: {
metric,
range,
timezone_offset: getTimezoneOffset(),
page,
},
};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/drilldown`, requestConfig);
}
}
export default new CaptainAssistant();
@@ -1,9 +0,0 @@
import ApiClient from '../ApiClient';
class MessageReports extends ApiClient {
constructor() {
super('captain/message_reports', { accountScoped: true });
}
}
export default new MessageReports();
-6
View File
@@ -40,12 +40,6 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/conversations`, { params });
}
getAttachments(contactId, page = 1) {
return axios.get(`${this.url}/${contactId}/attachments`, {
params: { page },
});
}
getContactableInboxes(contactId) {
return axios.get(`${this.url}/${contactId}/contactable_inboxes`);
}
@@ -14,10 +14,6 @@ class EnterpriseAccountAPI extends ApiClient {
return axios.post(`${this.url}subscription`);
}
selectBillingCurrency(currency) {
return axios.post(`${this.url}select_billing_currency`, { currency });
}
getLimits() {
return axios.get(`${this.url}limits`);
}
@@ -31,11 +27,6 @@ class EnterpriseAccountAPI extends ApiClient {
createTopupCheckout(credits) {
return axios.post(`${this.url}topup_checkout`, { credits });
}
// Topup packages for the account's billing currency.
getTopupOptions() {
return axios.get(`${this.url}topup_options`);
}
}
export default new EnterpriseAccountAPI();
@@ -16,7 +16,6 @@ class ArticlesAPI extends PortalsAPI {
authorId,
categorySlug,
sort,
query,
}) {
const url = getArticleSearchURL({
pageNumber,
@@ -26,7 +25,6 @@ class ArticlesAPI extends PortalsAPI {
authorId,
categorySlug,
sort,
query,
host: this.url,
});
@@ -62,10 +62,9 @@ class ConversationApi extends ApiClient {
});
}
assignAgent({ conversationId, agentId, assigneeType }) {
assignAgent({ conversationId, agentId }) {
return axios.post(`${this.url}/${conversationId}/assignments`, {
assignee_id: agentId,
assignee_type: assigneeType,
});
}
+5 -12
View File
@@ -3,12 +3,11 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
class Inboxes extends CacheEnabledApiClient {
constructor() {
super('inboxes', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'inbox';
super('inboxes', {
accountScoped: true,
cacheModel: 'inbox',
payloadEnvelope: true,
});
}
getCampaigns(inboxId) {
@@ -60,12 +59,6 @@ class Inboxes extends CacheEnabledApiClient {
disableWhatsappCalling(inboxId) {
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
}
setInboundCalls(inboxId, enabled) {
return axios.post(`${this.url}/${inboxId}/set_inbound_calls`, {
inbound_calls_enabled: enabled,
});
}
}
export default new Inboxes();
+5 -6
View File
@@ -2,12 +2,11 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
class LabelsAPI extends CacheEnabledApiClient {
constructor() {
super('labels', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'label';
super('labels', {
accountScoped: true,
cacheModel: 'label',
payloadEnvelope: true,
});
}
}
@@ -9,10 +9,6 @@ class OnboardingAPI extends ApiClient {
update(data) {
return axios.patch(this.url, data);
}
getHelpCenterGeneration() {
return axios.get(`${this.url}/help_center_generation`);
}
}
export default new OnboardingAPI();
-36
View File
@@ -31,42 +31,6 @@ class ReportsAPI extends ApiClient {
});
}
getDrilldown({
metric,
bucketTimestamp,
from,
to,
type = 'account',
id,
groupBy,
businessHours,
page,
perPage,
signal,
}) {
const requestConfig = {
params: {
metric,
bucket_timestamp: bucketTimestamp,
since: from,
until: to,
type,
id,
group_by: groupBy,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
page,
per_page: perPage,
},
};
if (signal) {
requestConfig.signal = signal;
}
return axios.get(`${this.url}/drilldown`, requestConfig);
}
// eslint-disable-next-line default-param-last
getSummary(since, until, type = 'account', id, groupBy, businessHours) {
return axios.get(`${this.url}/summary`, {
@@ -26,15 +26,5 @@ describe('#AssignableAgentsAPI', () => {
},
});
});
it('#getAssignableAgents with agent bots', () => {
assignableAgentsAPI.get([1], { includeAgentBots: true });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
params: {
inbox_ids: [1],
include_agent_bots: true,
},
});
});
});
});
@@ -90,16 +90,11 @@ describe('#ConversationAPI', () => {
});
it('#assignAgent', () => {
conversationAPI.assignAgent({
conversationId: 12,
agentId: 34,
assigneeType: 'AgentBot',
});
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`,
{
assignee_id: 34,
assignee_type: 'AgentBot',
}
);
});
@@ -1,8 +1,6 @@
import reportsAPI from '../reports';
import ApiClient from '../ApiClient';
const timezoneOffset = () => -new Date().getTimezoneOffset() / 60;
describe('#Reports API', () => {
it('creates correct instance', () => {
expect(reportsAPI).toBeInstanceOf(ApiClient);
@@ -13,7 +11,6 @@ describe('#Reports API', () => {
expect(reportsAPI).toHaveProperty('update');
expect(reportsAPI).toHaveProperty('delete');
expect(reportsAPI).toHaveProperty('getReports');
expect(reportsAPI).toHaveProperty('getDrilldown');
expect(reportsAPI).toHaveProperty('getSummary');
expect(reportsAPI).toHaveProperty('getAgentReports');
expect(reportsAPI).toHaveProperty('getLabelReports');
@@ -45,14 +42,11 @@ describe('#Reports API', () => {
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', {
params: {
business_hours: undefined,
group_by: undefined,
id: undefined,
metric: 'conversations_count',
since: 1621103400,
until: 1621621800,
type: 'account',
timezone_offset: timezoneOffset(),
timezone_offset: -0,
},
});
});
@@ -65,70 +59,13 @@ describe('#Reports API', () => {
group_by: undefined,
id: undefined,
since: 1621103400,
timezone_offset: timezoneOffset(),
timezone_offset: -0,
type: 'account',
until: 1621621800,
},
});
});
it('#getDrilldown', () => {
reportsAPI.getDrilldown({
metric: 'incoming_messages_count',
bucketTimestamp: 1621103400,
from: 1621103400,
to: 1621621800,
type: 'inbox',
id: 1,
groupBy: 'day',
businessHours: false,
page: 2,
perPage: 25,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', {
params: {
metric: 'incoming_messages_count',
bucket_timestamp: 1621103400,
since: 1621103400,
until: 1621621800,
type: 'inbox',
id: 1,
group_by: 'day',
business_hours: false,
timezone_offset: timezoneOffset(),
page: 2,
per_page: 25,
},
});
});
it('#getDrilldown with abort signal', () => {
const controller = new AbortController();
reportsAPI.getDrilldown({
metric: 'incoming_messages_count',
bucketTimestamp: 1621103400,
signal: controller.signal,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', {
params: {
metric: 'incoming_messages_count',
bucket_timestamp: 1621103400,
since: undefined,
until: undefined,
type: 'account',
id: undefined,
group_by: undefined,
business_hours: undefined,
timezone_offset: timezoneOffset(),
page: undefined,
per_page: undefined,
},
signal: controller.signal,
});
});
it('#getAgentReports', () => {
reportsAPI.getAgentReports({
from: 1621103400,
+1 -17
View File
@@ -1,25 +1,9 @@
/* global axios */
// import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
export class TeamsAPI extends CacheEnabledApiClient {
constructor() {
super('teams', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'team';
}
// eslint-disable-next-line class-methods-use-this
extractDataFromResponse(response) {
return response.data;
}
// eslint-disable-next-line class-methods-use-this
marshallData(dataToParse) {
return { data: dataToParse };
super('teams', { accountScoped: true, cacheModel: 'team' });
}
getAgents({ teamId }) {
@@ -145,12 +145,6 @@
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.8;
// Voice call widget - light mode
--call-widget: 33, 34, 38, 0.95;
--call-widget-border: 255, 255, 255, 0.1;
--call-widget-text: 237, 238, 240, 1;
--call-widget-sub-text: 173, 177, 184, 1;
}
.dark {
@@ -297,12 +291,6 @@
--border-blue: 39, 129, 246, 0.5;
--border-container: 255, 255, 255, 0;
--white-alpha: 255, 255, 255, 0.1;
// Voice call widget - dark mode
--call-widget: 50, 53, 61, 1;
--call-widget-border: 255, 255, 255, 0.07;
--call-widget-text: 237, 238, 240, 1;
--call-widget-sub-text: 173, 177, 184, 1;
}
}
// NEXT COLORS END
@@ -26,13 +26,6 @@ const resetForm = () => {
form.description = '';
};
const open = (company = {}) => {
form.name = company.name || '';
form.domain = company.domain || '';
form.description = company.description || '';
dialogRef.value?.open();
};
const handleConfirm = () => {
if (isFormInvalid.value) return;
@@ -52,7 +45,7 @@ const onSuccess = () => {
closeDialog();
};
defineExpose({ dialogRef, onSuccess, open });
defineExpose({ dialogRef, onSuccess });
</script>
<template>
@@ -1,153 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useDebounceFn } from '@vueuse/core';
import CompanyAPI from 'dashboard/api/companies';
import { useAlert } from 'dashboard/composables';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import CompanyCreateDialog from 'dashboard/components-next/Companies/CompanyCreateDialog.vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
// Name of the linked company, so the label shows before the list is loaded.
selectedName: {
type: String,
default: '',
},
isDetailsView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const CREATE_PREFIX = 'create:';
const options = ref([]);
const searchQuery = ref('');
const createDialogRef = ref(null);
const isCreatingCompany = ref(false);
const toOption = company => ({ label: company.name, value: company.id });
const createOption = computed(() => {
const name = searchQuery.value.trim();
if (!name) return null;
const exists = options.value.some(
option => option.label.toLowerCase() === name.toLowerCase()
);
if (exists) return null;
return {
label: t('COMPANIES.SELECTOR.CREATE_OPTION', { name }),
value: `${CREATE_PREFIX}${name}`,
};
});
const comboboxOptions = computed(() => {
const list = [...options.value];
// Keep the linked company visible even when it is not in the loaded results.
if (
props.modelValue &&
props.selectedName &&
!list.some(option => option.value === Number(props.modelValue))
) {
list.unshift({
label: props.selectedName,
value: Number(props.modelValue),
});
}
if (createOption.value) list.push(createOption.value);
return list;
});
const fetchCompanies = async query => {
try {
const {
data: { payload },
} = query
? await CompanyAPI.search(query)
: await CompanyAPI.get({ page: 1 });
options.value = (payload || []).map(toOption);
} catch {
options.value = [];
}
};
// Fetch lazily, only when the dropdown opens, instead of on mount.
const handleOpen = () => {
searchQuery.value = '';
fetchCompanies('');
};
const handleSearch = useDebounceFn(query => {
searchQuery.value = query?.trim() || '';
fetchCompanies(searchQuery.value);
}, 300);
// Open the create dialog (prefilled with the typed name) so the user can add
// domain/description before saving, instead of creating with just a name.
const createCompany = async company => {
isCreatingCompany.value = true;
try {
const {
data: { payload },
} = await CompanyAPI.create({ company });
createDialogRef.value?.onSuccess();
emit('select', { id: payload.id, name: payload.name });
useAlert(t('COMPANIES.CREATE.MESSAGES.SUCCESS'));
} catch {
useAlert(t('COMPANIES.CREATE.MESSAGES.ERROR'));
} finally {
isCreatingCompany.value = false;
}
};
const handleSelect = value => {
if (typeof value === 'string' && value.startsWith(CREATE_PREFIX)) {
createDialogRef.value?.open({ name: value.slice(CREATE_PREFIX.length) });
// Drop the transient "Add " option so the button label doesn't stick to
// it if the dialog is dismissed without creating.
searchQuery.value = '';
return;
}
const id = value ? Number(value) : '';
const selected = comboboxOptions.value.find(option => option.value === id);
emit('select', { id, name: selected?.label || '' });
};
</script>
<template>
<ComboBox
:model-value="modelValue"
:options="comboboxOptions"
:display-label="selectedName"
:placeholder="t('COMPANIES.SELECTOR.PLACEHOLDER')"
:search-placeholder="t('COMPANIES.SEARCH_PLACEHOLDER')"
use-api-results
class="[&>div>button]:h-8 [&>div>div_ul]:max-h-56"
:class="{
'[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
!isDetailsView,
'[&>div>button]:!bg-n-alpha-black2': isDetailsView,
}"
@open="handleOpen"
@search="handleSearch"
@update:model-value="handleSelect"
/>
<CompanyCreateDialog
ref="createDialogRef"
:is-loading="isCreatingCompany"
@create="createCompany"
/>
</template>
@@ -15,7 +15,6 @@ const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
email: { type: String, default: '' },
companyId: { type: [Number, String], default: '' },
additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
@@ -42,7 +41,6 @@ const getInitialContactData = () => ({
id: props.id,
name: props.name,
email: props.email,
companyId: props.companyId,
phoneNumber: props.phoneNumber,
additionalAttributes: props.additionalAttributes,
});
@@ -128,14 +128,9 @@ const closeMobileSidebar = () => {
<!-- Desktop sidebar -->
<div
v-if="slots.sidebar"
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
<slot name="sidebar" />
</div>
<!-- Mobile sidebar container -->
@@ -184,14 +179,9 @@ const closeMobileSidebar = () => {
<div
v-if="isContactSidebarOpen"
id="contact-sidebar-content"
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
<slot name="sidebar" />
</div>
</Transition>
</div>
@@ -5,11 +5,8 @@ import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { splitName } from '@chatwoot/utils';
import countries from 'shared/constants/countries.js';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import CompanySelector from 'dashboard/components-next/Companies/CompanySelector.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
@@ -31,7 +28,6 @@ const props = defineProps({
const emit = defineEmits(['update']);
const { t } = useI18n();
const { currentAccount, isCloudFeatureEnabled } = useAccount();
const FORM_CONFIG = {
FIRST_NAME: { field: 'firstName' },
@@ -58,7 +54,6 @@ const defaultState = {
id: 0,
name: '',
email: '',
companyId: '',
firstName: '',
lastName: '',
phoneNumber: '',
@@ -90,23 +85,6 @@ const validationRules = {
const v$ = useVuelidate(validationRules, state);
const isFormInvalid = computed(() => v$.value.$invalid);
const hasCompaniesFeature = computed(
() =>
currentAccount.value?.id && isCloudFeatureEnabled(FEATURE_FLAGS.COMPANIES)
);
const showCompanySelector = computed(
() =>
hasCompaniesFeature.value &&
(Boolean(state.companyId) || !state.additionalAttributes.companyName)
);
const emitContactUpdate = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
};
const prepareStateBasedOnProps = () => {
if (props.isNewContact) {
@@ -118,7 +96,6 @@ const prepareStateBasedOnProps = () => {
name = '',
email: emailAddress,
phoneNumber,
companyId = '',
additionalAttributes = {},
} = props.contactData || {};
const { firstName, lastName } = splitName(name || '');
@@ -138,7 +115,6 @@ const prepareStateBasedOnProps = () => {
Object.assign(state, {
id,
name,
companyId: companyId || '',
firstName,
lastName,
email: emailAddress,
@@ -224,7 +200,11 @@ const getFormBinding = key => {
}
}
await emitContactUpdate();
const isFormValid = await v$.value.$validate();
if (isFormValid) {
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
}
},
});
};
@@ -241,12 +221,6 @@ const handleCountrySelection = value => {
emit('update', state);
};
const handleCompanySelection = async ({ id, name }) => {
state.companyId = id || '';
state.additionalAttributes.companyName = name || '';
await emitContactUpdate();
};
const resetValidation = () => {
v$.value.$reset();
};
@@ -299,13 +273,6 @@ defineExpose({
:placeholder="item.placeholder"
:show-border="isDetailsView"
/>
<CompanySelector
v-else-if="item.key === 'COMPANY_NAME' && showCompanySelector"
:model-value="state.companyId"
:selected-name="state.additionalAttributes.companyName"
:is-details-view="isDetailsView"
@select="handleCompanySelection"
/>
<Input
v-else
v-model="getFormBinding(item.key).value"
@@ -108,7 +108,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
</script>
<template>
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6">
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6 py-6">
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
<ContactCustomAttributeItem
v-for="attribute in usedAttributes"
@@ -36,7 +36,7 @@ const contactConversations = computed(() =>
</div>
<div
v-else-if="contactConversations.length > 0"
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
class="px-6 py-4 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
>
<ConversationCard
v-for="conversation in contactConversations"
@@ -1,108 +0,0 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import {
MEDIA_TYPES,
NON_FILE_TYPES,
} from 'dashboard/components-next/message/constants';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
import Media from 'dashboard/components-next/SharedAttachments/Media.vue';
import Files from 'dashboard/components-next/SharedAttachments/Files.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const MEDIA_PEEK_LIMIT = 12;
const FILES_PEEK_LIMIT = 6;
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const attachmentsByContact = useMapGetter('contacts/getContactAttachments');
const uiFlags = useMapGetter('contacts/getUIFlags');
const attachments = computed(() =>
attachmentsByContact.value(route.params.contactId)
);
const isFetching = computed(() => uiFlags.value.isFetchingAttachments);
const hasContent = computed(() =>
attachments.value.some(
a => a.data_url && !NON_FILE_TYPES.includes(a.file_type)
)
);
const mediaAttachments = computed(() =>
attachments.value
.filter(a => MEDIA_TYPES.includes(a.file_type) && a.data_url)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
);
const showGallery = ref(false);
const selectedAttachment = ref(null);
const onMediaSelect = attachment => {
selectedAttachment.value = attachment;
showGallery.value = true;
};
const onFileSelect = attachment => {
if (attachment.data_url) {
window.open(attachment.data_url, '_blank', 'noopener,noreferrer');
}
};
const onJumpToMessage = attachment => {
if (!attachment.conversation_id || !attachment.message_id) return;
router.push({
name: 'inbox_conversation',
params: {
accountId: route.params.accountId,
conversation_id: attachment.conversation_id,
},
query: { messageId: attachment.message_id },
});
};
onMounted(() => {
store.dispatch('contacts/fetchAttachments', route.params.contactId);
});
</script>
<template>
<div class="px-6">
<div v-if="isFetching" class="flex justify-center p-3">
<Spinner class="size-5" />
</div>
<p v-else-if="!hasContent" class="p-3 text-sm text-center text-n-slate-11">
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
</p>
<div v-else class="flex flex-col gap-5">
<Media
:attachments="attachments"
:peek-limit="MEDIA_PEEK_LIMIT"
show-jump-to-message
@select="onMediaSelect"
@jump-to-message="onJumpToMessage"
/>
<Files
:attachments="attachments"
:peek-limit="FILES_PEEK_LIMIT"
show-jump-to-message
@select="onFileSelect"
@jump-to-message="onJumpToMessage"
/>
</div>
<GalleryView
v-if="showGallery && selectedAttachment"
v-model:show="showGallery"
:attachment="selectedAttachment"
:all-attachments="mediaAttachments"
auto-play
@close="showGallery = false"
/>
</div>
</template>
@@ -103,7 +103,7 @@ const onMergeContacts = async () => {
</script>
<template>
<div class="flex flex-col gap-8 px-6">
<div class="flex flex-col gap-8 px-6 py-6">
<div class="flex flex-col gap-2">
<h4 class="text-base text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
@@ -55,7 +55,7 @@ useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-6 py-6">
<Editor
v-model="state.message"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
@@ -92,7 +92,6 @@ const handleAvatarHover = (id, isHovered) => {
:id="contact.id"
:name="contact.name"
:email="contact.email"
:company-id="contact.companyId"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
@@ -16,10 +16,6 @@ const props = defineProps({
type: Array,
required: true,
},
contact: {
type: Object,
required: true,
},
});
const { t } = useI18n();
@@ -53,9 +49,7 @@ const unreadMessagesCount = computed(() => {
const hasSlaThreshold = computed(() => {
return (
!props.contact?.blocked &&
slaCardLabelRef.value?.hasSlaThreshold &&
props.conversation?.appliedSla?.id
slaCardLabelRef.value?.hasSlaThreshold && props.conversation?.slaPolicyId
);
});
@@ -126,7 +126,6 @@ const onCardClick = e => {
v-show="!showMessagePreviewWithoutMeta"
ref="cardMessagePreviewWithMetaRef"
:conversation="conversation"
:contact="contact"
:account-labels="accountLabels"
/>
</div>
@@ -51,9 +51,7 @@ const unreadCount = computed(() => props.chat.unread_count);
const slaCardLabel = useTemplateRef('slaCardLabel');
const hasSlaPolicyId = computed(
() =>
!props.currentContact?.blocked &&
(props.chat?.applied_sla?.id || slaCardLabel.value?.hasSlaThreshold)
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
);
const selectedModel = computed({
@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import { evaluateSLAStatus } from '@chatwoot/utils';
const props = defineProps({
conversation: {
@@ -19,8 +19,16 @@ const slaStatus = ref({
icon: null,
});
// TODO: Remove this once we update the helper from utils
// https://github.com/chatwoot/utils/blob/main/src/sla.ts#L73
const convertObjectCamelCaseToSnakeCase = object => {
return Object.keys(object).reduce((acc, key) => {
acc[key.replace(/([A-Z])/g, '_$1').toLowerCase()] = object[key];
return acc;
}, {});
};
const appliedSLA = computed(() => props.conversation?.appliedSla);
const slaEvents = computed(() => props.conversation?.slaEvents);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const hasSlaThreshold = computed(() => {
@@ -33,9 +41,8 @@ const slaStatusText = computed(() => {
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value || {},
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
chat: props.conversation,
slaEvents: slaEvents.value || [],
});
};
@@ -20,7 +20,6 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
@@ -29,18 +28,13 @@ const COLOR_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(props.status)
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
);
const labelKey = computed(() => {
@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import { evaluateSLAStatus } from '@chatwoot/utils';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
@@ -27,7 +27,6 @@ defineOptions({
});
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
@@ -35,7 +34,6 @@ const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value || {},
chat: props.chat,
slaEvents: slaEvents.value || [],
});
};
@@ -1,378 +0,0 @@
<script setup>
import { computed, ref, watch, nextTick, onBeforeUnmount } from 'vue';
import { useEventListener, onKeyStroke, useRafFn } from '@vueuse/core';
import Icon from 'dashboard/components-next/icon/Icon.vue';
// Reorderable list with cross-page drag. It must stay mounted during a page
// fetch hide the list spinner while `dragging`, or the held item is lost.
const props = defineProps({
items: { type: Array, required: true },
itemKey: { type: String, default: 'id' },
disabled: { type: Boolean, default: false },
currentPage: { type: Number, default: 1 },
totalPages: { type: Number, default: 1 },
});
const emit = defineEmits(['reorder', 'navigatePage', 'dragging']);
const DRAG_THRESHOLD = 5;
const EDGE_BAND = 72; // px from a side that arms a page turn
const AUTO_PAGE_DELAY = 600; // ms hovering an edge before it flips
const SCROLL_BAND = 60; // px from top/bottom that autoscrolls the list
const SCROLL_STEP = 10; // px scrolled per frame while parked at an edge
const PILL = 36; // px, the page-turn arrow button on each edge
const root = ref(null);
const isDragging = ref(false);
const dragged = ref(null);
const pointer = ref({ x: 0, y: 0 });
const insertBefore = ref(null); // key the gap sits before, null = end of list
const activeEdge = ref(null);
const bounds = ref({ left: 0, right: 0, top: 0, bottom: 0 }); // visible list rect
const grab = ref({ dx: 0, dy: 0 }); // cursor offset inside the grabbed card
const dragHeight = ref(0);
let press = null; // pending press, before it becomes a drag
let dwell = null; // timer that flips the page after hovering an edge
let paging = false; // waiting for a flipped page to load
let scroller = null; // scrollable ancestor, found when a drag begins
let scrollDir = 0; // -1 up, +1 down, 0 idle
let sourcePage = 1; // page the drag started on, to resolve cross-page end-drops
const keyOf = item => String(item?.[props.itemKey]);
const others = item => props.items.filter(o => keyOf(o) !== keyOf(item));
const isRTL = () =>
document.querySelector('#app[dir]')?.getAttribute('dir') === 'rtl';
const canPage = dir =>
dir === 'next' ? props.currentPage < props.totalPages : props.currentPage > 1;
// Key of the item after `key` in `list`, or null when it is the last one.
const keyAfter = (list, key) => {
const next = list[list.findIndex(o => keyOf(o) === key) + 1];
return next ? keyOf(next) : null;
};
const edges = computed(() =>
['prev', 'next'].filter(canPage).map(dir => {
const onLeft = isRTL() ? dir === 'next' : dir === 'prev';
return {
dir,
onLeft,
icon: onLeft ? 'i-lucide-chevrons-left' : 'i-lucide-chevrons-right',
};
})
);
// Cursor overlay: an edge glow band, a page-turn arrow per edge, and a ghost of
// the dragged row that follows the cursor and shrinks when aimed at an edge.
const EDGE_BAND_W = 80; // px width of the glow band on each pageable edge
const bandStyle = onLeft => {
const x = onLeft ? bounds.value.left : bounds.value.right - EDGE_BAND_W;
return {
width: `${EDGE_BAND_W}px`,
height: `${bounds.value.bottom - bounds.value.top}px`,
transform: `translate(${x}px, ${bounds.value.top}px)`,
};
};
const pillStyle = onLeft => {
const x = onLeft ? bounds.value.left + 12 : bounds.value.right - PILL - 12;
return { transform: `translate(${x}px, ${pointer.value.y - PILL / 2}px)` };
};
const ghostStyle = computed(() => ({
width: `${bounds.value.right - bounds.value.left}px`,
transform: `translate(${pointer.value.x - grab.value.dx}px, ${pointer.value.y - grab.value.dy}px)`,
}));
const scaleStyle = computed(() => ({
transformOrigin: `${grab.value.dx}px ${grab.value.dy}px`,
}));
// The list as shown mid-drag: the dragged row slotted into the gap at `insertBefore`.
const displayItems = computed(() => {
if (!isDragging.value || !dragged.value) return props.items;
const rest = others(dragged.value);
const at = rest.findIndex(o => keyOf(o) === insertBefore.value);
rest.splice(at === -1 ? rest.length : at, 0, dragged.value);
return rest;
});
// Move the gap when the cursor crosses the midpoint of the card it is over.
const aim = () => {
const card = document
.elementFromPoint(pointer.value.x, pointer.value.y)
?.closest('[data-drag-id]');
const key = card?.dataset.dragId;
if (!key || key === keyOf(dragged.value)) return;
const { top, height } = card.getBoundingClientRect();
const above = pointer.value.y < top + height / 2;
insertBefore.value = above ? key : keyAfter(others(dragged.value), key);
};
const flip = dir => {
if (paging || !isDragging.value) return;
paging = true;
emit('navigatePage', props.currentPage + (dir === 'next' ? 1 : -1));
};
const aimEdge = x => {
const rect = root.value?.getBoundingClientRect();
if (!rect) return;
// Clip the list rect to the scroll viewport so the glow band and its top/bottom
// fade always sit on the visible edges, not the far ends of the full content.
const view = scroller?.getBoundingClientRect();
const viewTop = Math.max(view?.top ?? 0, 0);
const viewBottom = Math.min(
view?.bottom ?? window.innerHeight,
window.innerHeight
);
bounds.value = {
left: rect.left,
right: rect.right,
top: Math.max(rect.top, viewTop),
bottom: Math.min(rect.bottom, viewBottom),
};
let dir = null;
if (x <= rect.left + EDGE_BAND) dir = isRTL() ? 'next' : 'prev';
else if (x >= rect.right - EDGE_BAND) dir = isRTL() ? 'prev' : 'next';
if (dir && !canPage(dir)) dir = null;
if (dir === activeEdge.value) return;
activeEdge.value = dir;
clearTimeout(dwell);
if (dir) dwell = setTimeout(() => flip(dir), AUTO_PAGE_DELAY);
};
// Nearest scrollable ancestor, so a drag can reach rows that are off-screen.
const scrollParent = () => {
let el = root.value?.parentElement;
while (el) {
const { overflowY } = getComputedStyle(el);
if (overflowY === 'auto' || overflowY === 'scroll') return el;
el = el.parentElement;
}
return null;
};
// While parked at the top/bottom edge, keep scrolling and re-aim as rows slide by.
const { pause: pauseScroll, resume: resumeScroll } = useRafFn(
() => {
if (!scroller || !scrollDir) return;
scroller.scrollTop += scrollDir * SCROLL_STEP;
aim();
aimEdge(pointer.value.x);
},
{ immediate: false }
);
const updateAutoScroll = y => {
if (!scroller) return;
const rect = scroller.getBoundingClientRect();
const atTop = scroller.scrollTop <= 0;
const atBottom =
scroller.scrollTop >= scroller.scrollHeight - scroller.clientHeight;
if (y < rect.top + SCROLL_BAND && !atTop) scrollDir = -1;
else if (y > rect.bottom - SCROLL_BAND && !atBottom) scrollDir = 1;
else scrollDir = 0;
if (scrollDir) resumeScroll();
else pauseScroll();
};
const reset = () => {
isDragging.value = false;
dragged.value = null;
insertBefore.value = null;
activeEdge.value = null;
clearTimeout(dwell);
paging = false;
scrollDir = 0;
scroller = null;
pauseScroll();
document.body.classList.remove('select-none');
emit('dragging', false);
};
// Positions sit on a gap-of-10 grid; a midpoint (±5 at the ends) slots between two rows.
// Cross-page end-drops are the tricky case: moving an item off its source page pulls the
// target page's boundary row into the vacated slot. So at the leading edge after moving
// DOWN (or the trailing edge after moving UP) we land between the two boundary rows, or
// the item sorts onto the adjacent page and vanishes from view.
const drop = (item, rawBefore) => {
const pos = o => o?.position || 0;
const mid = (a, b) => Math.floor((pos(a) + pos(b)) / 2);
const rest = others(item);
// No other rows to position against e.g. the lone article on a page dropped
// without crossing to another page. Leave the order untouched.
if (!rest.length) return;
// A page flip can leave the aimed key pointing at a row that is no longer on
// this page. Like displayItems, resolve an unknown key to null (end of list),
// so what the user sees and what we save agree.
const before = rest.some(o => keyOf(o) === rawBefore) ? rawBefore : null;
const movedDown = props.currentPage > sourcePage;
const movedUp = props.currentPage < sourcePage;
let position;
if (before === null) {
position =
movedUp && rest.length >= 2
? mid(rest.at(-2), rest.at(-1))
: pos(rest.at(-1)) + 5;
} else {
const i = rest.findIndex(o => keyOf(o) === before);
if (i > 0) {
position = mid(rest[i - 1], rest[i]);
} else if (movedDown) {
// Top-of-page after moving down: the old first row slid up into the source
// page's gap, so land just after it to stay this page's first.
position = rest.length >= 2 ? mid(rest[0], rest[1]) : pos(rest[0]) + 5;
} else {
position = pos(rest[0]) - 5;
}
}
emit('reorder', { [item[props.itemKey]]: position });
};
const startDrag = () => {
isDragging.value = true;
dragged.value = press.item;
grab.value = { dx: press.dx, dy: press.dy };
dragHeight.value = press.h;
insertBefore.value = keyAfter(props.items, keyOf(press.item));
sourcePage = props.currentPage;
scroller = scrollParent();
document.body.classList.add('select-none');
emit('dragging', true);
};
const onPointerDown = (item, e) => {
if (e.button !== 0 || props.disabled) return;
if (e.target.closest('button, a, input, [role="button"]')) return;
const rect = e.currentTarget.getBoundingClientRect();
press = {
item,
x: e.clientX,
y: e.clientY,
dx: e.clientX - rect.left,
dy: e.clientY - rect.top,
h: rect.height,
};
};
useEventListener(window, 'pointermove', e => {
if (!press) return;
if (!isDragging.value) {
const moved = Math.hypot(e.clientX - press.x, e.clientY - press.y);
if (moved < DRAG_THRESHOLD) return;
startDrag();
}
e.preventDefault();
pointer.value = { x: e.clientX, y: e.clientY };
aim();
aimEdge(e.clientX);
updateAutoScroll(e.clientY);
});
useEventListener(window, 'pointerup', () => {
if (!press) return;
press = null;
if (!isDragging.value) return; // a press without movement is a click
const item = dragged.value;
const before = insertBefore.value;
const flipping = paging;
reset();
if (!flipping) drop(item, before);
});
// When the flipped page loads, re-aim under the held cursor so a parked edge keeps flipping.
watch(
() => props.items,
() => {
if (!isDragging.value || !paging) return;
paging = false;
nextTick(() => {
if (!isDragging.value) return;
aim();
activeEdge.value = null;
aimEdge(pointer.value.x);
});
}
);
onKeyStroke('Escape', () => {
if (!isDragging.value) return;
press = null;
reset();
});
onBeforeUnmount(() => {
clearTimeout(dwell);
document.body.classList.remove('select-none');
});
</script>
<template>
<div ref="root" class="relative w-full h-full">
<ul class="w-full h-full space-y-4">
<li
v-for="(item, index) in displayItems"
:key="keyOf(item)"
:data-drag-id="keyOf(item)"
class="relative list-none"
:class="{ 'cursor-grab': !disabled && !isDragging }"
@pointerdown="onPointerDown(item, $event)"
@dragstart.prevent
>
<div
v-if="isDragging && keyOf(item) === keyOf(dragged)"
:style="{ height: `${dragHeight}px` }"
class="border-2 border-dashed rounded-2xl border-n-brand/50 bg-n-brand/5"
/>
<slot v-else name="item" :item="item" :index="index" />
</li>
</ul>
<Teleport v-if="isDragging" to="body">
<div
v-for="edge in edges"
:key="`band-${edge.dir}`"
:style="bandStyle(edge.onLeft)"
class="fixed top-0 left-0 z-40 pointer-events-none from-n-brand/15 to-transparent transition-opacity duration-200 [mask-image:linear-gradient(to_bottom,transparent,#000_56px,#000_calc(100%_-_56px),transparent)] [-webkit-mask-image:linear-gradient(to_bottom,transparent,#000_56px,#000_calc(100%_-_56px),transparent)]"
:class="[
edge.onLeft ? 'bg-gradient-to-r' : 'bg-gradient-to-l',
activeEdge === edge.dir ? 'opacity-100' : 'opacity-0',
]"
/>
<div
v-for="edge in edges"
:key="edge.dir"
:style="pillStyle(edge.onLeft)"
class="fixed top-0 left-0 z-50 flex items-center justify-center transition-all duration-150 border rounded-full pointer-events-none size-9 backdrop-blur-sm"
:class="
activeEdge === edge.dir
? 'scale-110 border-n-brand bg-n-brand/20 text-n-brand shadow-md'
: 'opacity-70 border-n-weak/60 bg-n-solid-1/70 text-n-slate-10'
"
>
<Icon
:icon="edge.icon"
class="size-4"
:class="activeEdge === edge.dir && 'animate-pulse'"
/>
</div>
<div
v-if="dragged"
:style="ghostStyle"
class="fixed top-0 left-0 z-50 pointer-events-none select-none"
>
<div
:style="scaleStyle"
class="transition-transform duration-150 shadow-2xl rounded-2xl"
:class="{ 'scale-50': activeEdge }"
>
<slot name="ghost" :item="dragged" />
</div>
</div>
</Teleport>
</div>
</template>
@@ -1,222 +0,0 @@
import { mount } from '@vue/test-utils';
import { h, nextTick } from 'vue';
import DraggableReorderList from '../DraggableReorderList.vue';
// The component is pointer-driven, so we drive it through real pointer events on
// window while mocking the layout APIs jsdom does not implement: elementFromPoint
// (which card is under the cursor) and getBoundingClientRect (its geometry).
const elementAtPoint = { current: null };
const move = (clientX, clientY) =>
window.dispatchEvent(new MouseEvent('pointermove', { clientX, clientY }));
const release = () => window.dispatchEvent(new MouseEvent('pointerup'));
// Stack the rows 50px apart, each 40px tall, inside a 500px-wide list.
const stubGeometry = wrapper => {
wrapper.element.getBoundingClientRect = () => ({
left: 0,
right: 500,
top: 0,
bottom: 600,
});
wrapper.findAll('[data-drag-id]').forEach((li, index) => {
const top = index * 50;
li.element.getBoundingClientRect = () => ({
top,
height: 40,
bottom: top + 40,
});
});
};
const mountList = (props = {}) =>
mount(DraggableReorderList, {
props: { items: [], ...props },
slots: {
item: scope => h('div', { class: 'card' }, scope.item.title),
ghost: scope => h('div', { class: 'ghost' }, scope.item.title),
},
global: { stubs: { Icon: true, teleport: true } },
});
describe('DraggableReorderList', () => {
let wrapper;
beforeEach(() => {
elementAtPoint.current = null;
document.elementFromPoint = vi.fn(() => elementAtPoint.current);
});
afterEach(() => {
wrapper?.unmount();
vi.useRealTimers();
});
const startDragging = async id => {
stubGeometry(wrapper);
wrapper.find(`[data-drag-id="${id}"]`).element.dispatchEvent(
new MouseEvent('pointerdown', {
button: 0,
clientX: 250,
clientY: 20,
bubbles: true,
})
);
await nextTick();
};
it('renders each item through the item slot', () => {
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha' },
{ id: 2, title: 'Beta' },
],
});
const cards = wrapper.findAll('.card');
expect(cards).toHaveLength(2);
expect(cards[0].text()).toBe('Alpha');
expect(wrapper.find('[data-drag-id="1"]').exists()).toBe(true);
expect(wrapper.find('[data-drag-id="2"]').exists()).toBe(true);
});
it('shows a grab affordance only when enabled', () => {
wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }] });
expect(wrapper.find('[data-drag-id="1"]').classes()).toContain(
'cursor-grab'
);
wrapper.unmount();
wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }], disabled: true });
expect(wrapper.find('[data-drag-id="1"]').classes()).not.toContain(
'cursor-grab'
);
});
it('does not start a drag when disabled', async () => {
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha' },
{ id: 2, title: 'Beta' },
],
disabled: true,
});
await startDragging(1);
move(250, 200);
await nextTick();
expect(wrapper.emitted('dragging')).toBeUndefined();
});
it('emits dragging true then false across a drag', async () => {
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha' },
{ id: 2, title: 'Beta' },
],
});
await startDragging(1);
elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element;
move(250, 60);
await nextTick();
expect(wrapper.emitted('dragging')[0]).toEqual([true]);
release();
await nextTick();
expect(wrapper.emitted('dragging')[1]).toEqual([false]);
});
it('emits the midpoint position when dropped between two rows', async () => {
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha', position: 10 },
{ id: 2, title: 'Beta', position: 20 },
{ id: 3, title: 'Gamma', position: 30 },
],
});
await startDragging(1);
// Hover the lower half of Beta (top 50, height 40 → midpoint 70) so the gap
// sits before Gamma; dropping there lands halfway between Beta and Gamma.
elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element;
move(250, 85);
await nextTick();
release();
await nextTick();
expect(wrapper.emitted('reorder')[0][0]).toEqual({ 1: 25 });
});
it('does not reorder when the only row on a page is dropped in place', async () => {
// P1: dragging the lone article on a later page and releasing without
// crossing to another page must be a no-op, not move it to the top.
wrapper = mountList({
items: [{ id: 5, title: 'Solo', position: 260 }],
currentPage: 2,
totalPages: 2,
});
await startDragging(5);
move(250, 300);
await nextTick();
release();
await nextTick();
expect(wrapper.emitted('dragging')).toEqual([[true], [false]]);
expect(wrapper.emitted('reorder')).toBeUndefined();
});
it('turns the page after dwelling on a pageable edge', async () => {
vi.useFakeTimers();
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha', position: 10 },
{ id: 2, title: 'Beta', position: 20 },
],
currentPage: 1,
totalPages: 2,
});
await startDragging(1);
// Drag to the right edge over blank space (no card) and hold.
elementAtPoint.current = null;
move(490, 20);
await nextTick();
vi.advanceTimersByTime(600);
expect(wrapper.emitted('navigatePage')[0]).toEqual([2]);
});
it('can still turn pages after releasing during a pending flip', async () => {
// Releasing while a flip fetch is in flight must clear paging state, or every
// later drag would be stuck unable to navigate.
vi.useFakeTimers();
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha', position: 10 },
{ id: 2, title: 'Beta', position: 20 },
],
currentPage: 1,
totalPages: 2,
});
// First drag: park at the edge to start a flip, then release before the new
// page arrives (items never change here).
await startDragging(1);
elementAtPoint.current = null;
move(490, 20);
await nextTick();
vi.advanceTimersByTime(600);
release();
await nextTick();
// Second drag must be able to flip again.
await startDragging(1);
elementAtPoint.current = null;
move(490, 20);
await nextTick();
vi.advanceTimersByTime(600);
expect(wrapper.emitted('navigatePage')).toEqual([[2], [2]]);
});
});
@@ -13,7 +13,6 @@ import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -132,7 +131,7 @@ const statusText = computed(() => {
const categoryName = computed(() => {
if (props.category?.slug) {
return props.category.name;
return `${props.category.icon} ${props.category.name}`;
}
return t(
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.CATEGORY.UNCATEGORISED'
@@ -210,9 +209,9 @@ const handleClick = id => {
</div>
</div>
</div>
<div class="flex items-center justify-between w-full gap-2 sm:gap-4">
<div class="flex items-center min-w-0 gap-2 sm:gap-4">
<div class="flex items-center min-w-0 gap-1">
<div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<Avatar
:name="authorName"
:src="authorThumbnailSrc"
@@ -223,17 +222,11 @@ const handleClick = id => {
{{ authorName || '-' }}
</span>
</div>
<span class="flex items-center min-w-0 gap-1 text-sm text-n-slate-11">
<EmojiIcon
v-if="category?.icon"
:value="category.icon"
:color="category.icon_color"
class="flex-shrink-0 size-4"
/>
<span class="truncate">{{ categoryName }}</span>
<span class="block text-sm whitespace-nowrap text-n-slate-11">
{{ categoryName }}
</span>
<div
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap shrink-0"
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
>
<Icon icon="i-lucide-eye" class="size-4" />
<span class="text-sm">
@@ -245,7 +238,7 @@ const handleClick = id => {
</span>
</div>
</div>
<span class="text-sm text-n-slate-11 line-clamp-1 shrink-0">
<span class="text-sm text-n-slate-11 line-clamp-1">
{{ lastUpdatedAt }}
</span>
</div>
@@ -6,7 +6,6 @@ import { useToggle } from '@vueuse/core';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
const props = defineProps({
id: {
@@ -21,10 +20,6 @@ const props = defineProps({
type: String,
required: true,
},
iconColor: {
type: String,
default: '',
},
description: {
type: String,
required: true,
@@ -60,6 +55,10 @@ const categoryMenuItems = [
},
];
const categoryTitleWithIcon = computed(() => {
return `${props.icon} ${props.title}`;
});
const description = computed(() => {
return props.description ? props.description : 'No description added';
});
@@ -84,16 +83,10 @@ const handleAction = ({ action, value }) => {
<div class="flex justify-between w-full gap-2">
<div class="flex items-center justify-start w-full min-w-0 gap-2">
<span
class="flex items-center gap-1.5 text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
@click="handleClick(slug)"
>
<EmojiIcon
v-if="icon"
:value="icon"
:color="iconColor"
class="shrink-0 size-4"
/>
<span class="truncate">{{ title }}</span>
{{ categoryTitleWithIcon }}
</span>
<span
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg bg-n-slate-1 whitespace-nowrap shrink-0 text-n-slate-11 border-n-slate-4"
@@ -67,11 +67,11 @@ const togglePortalSwitcher = () => {
>
<span
v-if="activePortalName"
class="min-w-0 text-xl font-medium truncate text-n-slate-12"
class="text-xl font-medium text-n-slate-12"
>
{{ activePortalName }}
</span>
<div v-if="activePortalName" class="relative shrink-0 group">
<div v-if="activePortalName" class="relative group">
<OnClickOutside @trigger="showPortalSwitcher = false">
<Button
icon="i-lucide-chevron-down"
@@ -91,9 +91,6 @@ const togglePortalSwitcher = () => {
</OnClickOutside>
<CreatePortalDialog ref="createPortalDialogRef" />
</div>
<div class="flex justify-end min-w-0 grow">
<slot name="title-actions" />
</div>
</div>
<slot name="header-actions" />
</div>

Some files were not shown because too many files have changed in this diff Show More