Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b1e8d3c27 | ||
|
|
598ece9a2d | ||
|
|
059506b1db | ||
|
|
397b0bcc9d | ||
|
|
fd69b4c8f2 | ||
|
|
3ea5f258a4 | ||
|
|
42a244369d | ||
|
|
3abe32a2c7 | ||
|
|
f24e7eb231 | ||
|
|
8cfbb75128 | ||
|
|
a1b98a253c | ||
|
|
374d2258c7 | ||
|
|
89da4a2292 | ||
|
|
9aacc0335b | ||
|
|
ab93821d2b | ||
|
|
8d48e05283 |
@@ -68,6 +68,15 @@
|
||||
- Example: `feat(auth): add user authentication`
|
||||
- Don't reference Claude in commit messages
|
||||
|
||||
## PR Description Format
|
||||
|
||||
- Start with a short, user-facing paragraph describing the product change.
|
||||
- Add a `Closes` section with relevant issue links (GitHub, Linear, etc.).
|
||||
- For feature PRs, add `How to test` from a product/UX standpoint.
|
||||
- For bugfix PRs, use `How to reproduce` when helpful.
|
||||
- Optionally add a `What changed` section for implementation highlights.
|
||||
- Do not add a `How this was tested` section listing specs/commands.
|
||||
|
||||
## Project-Specific
|
||||
|
||||
- **Translations**:
|
||||
|
||||
@@ -40,8 +40,12 @@ run:
|
||||
fi
|
||||
|
||||
force_run:
|
||||
rm -f ./.overmind.sock
|
||||
rm -f tmp/pids/*.pid
|
||||
@echo "Cleaning up Overmind processes..."
|
||||
@lsof -ti:3036 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||
@lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||
@rm -f ./.overmind.sock
|
||||
@rm -f tmp/pids/*.pid
|
||||
@echo "Cleanup complete"
|
||||
overmind start -f Procfile.dev
|
||||
|
||||
force_run_tunnel:
|
||||
|
||||
@@ -2,12 +2,17 @@ class Messages::Messenger::MessageBuilder
|
||||
include ::FileTypeHelper
|
||||
|
||||
def process_attachment(attachment)
|
||||
# This check handles very rare case if there are multiple files to attach with only one usupported file
|
||||
# This check handles very rare case if there are multiple files to attach with only one unsupported file
|
||||
return if unsupported_file_type?(attachment['type'])
|
||||
|
||||
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
|
||||
params = attachment_params(attachment)
|
||||
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
|
||||
attachment_obj.save!
|
||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||
if facebook_reel?(attachment)
|
||||
update_facebook_reel_content(attachment)
|
||||
elsif params[:remote_file_url]
|
||||
attach_file(attachment_obj, params[:remote_file_url])
|
||||
end
|
||||
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'
|
||||
@@ -26,7 +31,7 @@ class Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def attachment_params(attachment)
|
||||
file_type = attachment['type'].to_sym
|
||||
file_type = normalize_file_type(attachment['type'])
|
||||
params = { file_type: file_type, account_id: @message.account_id }
|
||||
|
||||
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
|
||||
@@ -100,6 +105,28 @@ class Messages::Messenger::MessageBuilder
|
||||
|
||||
private
|
||||
|
||||
# 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 }.freeze
|
||||
|
||||
def normalize_file_type(type)
|
||||
sym = type.to_sym
|
||||
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
|
||||
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)
|
||||
attachment['type'].to_sym == :reel
|
||||
end
|
||||
|
||||
def update_facebook_reel_content(attachment)
|
||||
url = attachment.dig('payload', 'url')
|
||||
return if url.blank?
|
||||
|
||||
@message.update!(content: url) if @message.content.blank?
|
||||
end
|
||||
|
||||
def unsupported_file_type?(attachment_type)
|
||||
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
|
||||
end
|
||||
|
||||
@@ -40,7 +40,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def reorder
|
||||
Article.update_positions(params[:positions_hash])
|
||||
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :fetch_category, except: [:index, :create]
|
||||
before_action :fetch_category, except: [:index, :create, :reorder]
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
def index
|
||||
@@ -32,6 +32,11 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
||||
head :ok
|
||||
end
|
||||
|
||||
def reorder
|
||||
Category.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_category
|
||||
@@ -39,7 +44,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
||||
end
|
||||
|
||||
def portal
|
||||
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
|
||||
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
|
||||
end
|
||||
|
||||
def related_categories_records
|
||||
|
||||
@@ -107,7 +107,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def toggle_typing_status
|
||||
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params)
|
||||
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, Current.user, params)
|
||||
typing_status_manager.toggle_typing_status
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
|
||||
include Shopify::IntegrationHelper
|
||||
before_action :setup_shopify_context, only: [:orders]
|
||||
before_action :fetch_hook, except: [:complete_install]
|
||||
before_action :fetch_hook, except: [:auth]
|
||||
before_action :validate_contact, only: [:orders]
|
||||
|
||||
def auth
|
||||
shop_domain = params[:shop_domain]
|
||||
return render json: { error: 'Shop domain is required' }, status: :unprocessable_entity if shop_domain.blank?
|
||||
|
||||
state = generate_shopify_token(Current.account.id)
|
||||
|
||||
auth_url = "https://#{shop_domain}/admin/oauth/authorize?"
|
||||
auth_url += URI.encode_www_form(
|
||||
client_id: client_id,
|
||||
scope: REQUIRED_SCOPES.join(','),
|
||||
redirect_uri: redirect_uri,
|
||||
state: state
|
||||
)
|
||||
|
||||
render json: { redirect_url: auth_url }
|
||||
end
|
||||
|
||||
def orders
|
||||
customers = fetch_customers
|
||||
return render json: { orders: [] } if customers.empty?
|
||||
@@ -14,23 +31,6 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def complete_install
|
||||
token_key = "shopify_pending_install:#{params[:pending_install_token]}"
|
||||
data = claim_pending_install_token(token_key, Current.account.id)
|
||||
return render json: { error: data[:error] }, status: :unprocessable_entity if data[:error]
|
||||
|
||||
Current.account.hooks.create!(
|
||||
app_id: 'shopify',
|
||||
access_token: data['access_token'],
|
||||
status: 'enabled',
|
||||
reference_id: data['shop'],
|
||||
settings: { scope: data['scope'] }
|
||||
)
|
||||
|
||||
::Redis::Alfred.delete(token_key)
|
||||
head :ok
|
||||
end
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
head :ok
|
||||
@@ -40,6 +40,10 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba
|
||||
|
||||
private
|
||||
|
||||
def redirect_uri
|
||||
"#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback"
|
||||
end
|
||||
|
||||
def contact
|
||||
@contact ||= Current.account.contacts.find_by(id: params[:contact_id])
|
||||
end
|
||||
@@ -104,31 +108,4 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba
|
||||
render json: { error: 'Contact information missing' },
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def claim_pending_install_token(token_key, account_id)
|
||||
json_data = Redis::SecureStorage.get(token_key)
|
||||
return { error: 'Invalid or expired install token' } if json_data.blank?
|
||||
|
||||
begin
|
||||
data = JSON.parse(json_data)
|
||||
rescue JSON::ParserError
|
||||
return { error: 'Invalid or corrupted install token' }
|
||||
end
|
||||
|
||||
if data['claimed']
|
||||
Redis::SecureStorage.delete(token_key)
|
||||
return { error: 'Install token already used' }
|
||||
end
|
||||
|
||||
# Check if already bound to a different account (prevents token theft)
|
||||
return { error: 'Install token cannot be used by this account' } if data['account_id'].present? && data['account_id'] != account_id
|
||||
|
||||
# Bind to this account and mark as claimed to prevent replay
|
||||
data['claimed'] = true
|
||||
data['account_id'] = account_id
|
||||
ttl = ::Redis::Alfred.ttl(token_key)
|
||||
Redis::SecureStorage.set(token_key, data, [ttl, 60].max) if ttl.positive?
|
||||
|
||||
data
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module AccessTokenAuthHelper
|
||||
BOT_ACCESSIBLE_ENDPOINTS = {
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update custom_attributes],
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
|
||||
'api/v1/accounts/conversations/messages' => ['create'],
|
||||
'api/v1/accounts/conversations/assignments' => ['create']
|
||||
}.freeze
|
||||
@@ -28,7 +28,7 @@ module AccessTokenAuthHelper
|
||||
|
||||
def validate_bot_access_token!
|
||||
return if Current.user.is_a?(User)
|
||||
return if agent_bot_accessible?
|
||||
return if @resource.is_a?(AgentBot) && agent_bot_accessible?
|
||||
|
||||
render_unauthorized('Access to this endpoint is not authorized for bots')
|
||||
end
|
||||
|
||||
@@ -2,74 +2,42 @@ class Shopify::CallbacksController < ApplicationController
|
||||
include Shopify::IntegrationHelper
|
||||
|
||||
def show
|
||||
if chatwoot_initiated?
|
||||
handle_chatwoot_initiated_flow
|
||||
else
|
||||
handle_shopify_initiated_flow
|
||||
end
|
||||
verify_account!
|
||||
|
||||
@response = oauth_client.auth_code.get_token(
|
||||
params[:code],
|
||||
redirect_uri: '/shopify/callback'
|
||||
)
|
||||
|
||||
handle_response
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Shopify callback error: #{e.message}")
|
||||
redirect_to error_redirect_url
|
||||
redirect_to "#{redirect_uri}?error=true"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def chatwoot_initiated?
|
||||
verify_shopify_token(params[:state]).present?
|
||||
end
|
||||
|
||||
def handle_chatwoot_initiated_flow
|
||||
def verify_account!
|
||||
@account_id = verify_shopify_token(params[:state])
|
||||
raise StandardError, 'Invalid state parameter' if account.blank?
|
||||
raise StandardError, 'Invalid HMAC signature' unless valid_hmac?
|
||||
|
||||
@response = oauth_client.auth_code.get_token(params[:code], redirect_uri: redirect_callback_uri)
|
||||
create_hook
|
||||
redirect_to shopify_integration_url
|
||||
end
|
||||
|
||||
def handle_shopify_initiated_flow
|
||||
raise StandardError, 'Invalid shop domain' unless valid_shop_domain?
|
||||
raise StandardError, 'Invalid HMAC signature' unless valid_hmac?
|
||||
|
||||
# Security: HMAC validation ensures params (including shop) haven't been tampered with.
|
||||
# Additionally, the OAuth code is cryptographically bound to the shop that issued it.
|
||||
# Shopify will reject any attempt to exchange a code at a different shop's endpoint.
|
||||
@response = oauth_client.auth_code.get_token(params[:code], redirect_uri: redirect_callback_uri)
|
||||
|
||||
token_key = SecureRandom.hex(16)
|
||||
pending_data = {
|
||||
access_token: parsed_body['access_token'],
|
||||
shop: params[:shop],
|
||||
scope: parsed_body['scope'],
|
||||
claimed: false
|
||||
}
|
||||
|
||||
Redis::SecureStorage.set("shopify_pending_install:#{token_key}", pending_data, 10.minutes)
|
||||
|
||||
redirect_url = "settings/integrations/shopify?shopify_pending_install=#{CGI.escape(token_key)}"
|
||||
redirect_to "#{frontend_url}/app/login?redirect_url=#{CGI.escape(redirect_url)}", allow_other_host: true
|
||||
end
|
||||
|
||||
def create_hook
|
||||
def handle_response
|
||||
account.hooks.create!(
|
||||
app_id: 'shopify',
|
||||
access_token: parsed_body['access_token'],
|
||||
status: 'enabled',
|
||||
reference_id: params[:shop],
|
||||
settings: { scope: parsed_body['scope'] }
|
||||
settings: {
|
||||
scope: parsed_body['scope']
|
||||
}
|
||||
)
|
||||
|
||||
redirect_to shopify_integration_url
|
||||
end
|
||||
|
||||
def parsed_body
|
||||
@parsed_body ||= begin
|
||||
parsed = @response.response.parsed
|
||||
# Handle both SnakyHash (production) and regular Hash (tests)
|
||||
{
|
||||
'access_token' => parsed.respond_to?(:access_token) ? parsed.access_token : parsed['access_token'],
|
||||
'scope' => parsed.respond_to?(:scope) ? parsed.scope : parsed['scope']
|
||||
}
|
||||
end
|
||||
@parsed_body ||= @response.response.parsed
|
||||
end
|
||||
|
||||
def oauth_client
|
||||
@@ -88,51 +56,17 @@ class Shopify::CallbacksController < ApplicationController
|
||||
@account ||= Account.find(@account_id)
|
||||
end
|
||||
|
||||
def redirect_callback_uri
|
||||
"#{frontend_url}/shopify/callback"
|
||||
def account_id
|
||||
@account_id ||= params[:state].split('_').first
|
||||
end
|
||||
|
||||
def shopify_integration_url
|
||||
"#{frontend_url}/app/accounts/#{account.id}/settings/integrations/shopify"
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/shopify"
|
||||
end
|
||||
|
||||
def error_redirect_url
|
||||
if @account_id
|
||||
begin
|
||||
"#{shopify_integration_url}?error=true"
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
"#{frontend_url}?error=true"
|
||||
end
|
||||
else
|
||||
"#{frontend_url}?error=true"
|
||||
end
|
||||
end
|
||||
def redirect_uri
|
||||
return shopify_integration_url if account
|
||||
|
||||
def frontend_url
|
||||
ENV.fetch('FRONTEND_URL', '')
|
||||
end
|
||||
|
||||
def valid_shop_domain?
|
||||
return false if params[:shop].blank?
|
||||
|
||||
# Shopify shop domains must match: *.myshopify.com or *.myshopify.io (for dev shops)
|
||||
params[:shop].match?(/\A[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.(com|io)\z/)
|
||||
end
|
||||
|
||||
def valid_hmac?
|
||||
return false if params[:hmac].blank?
|
||||
|
||||
# Shopify HMAC validation
|
||||
# Reference: https://shopify.dev/docs/apps/build/authentication-authorization/get-access-tokens
|
||||
hmac = params[:hmac]
|
||||
|
||||
# Build query string from params, excluding hmac and Rails-added params
|
||||
query_params = params.except(:hmac, :controller, :action).to_unsafe_h
|
||||
query_string = query_params.sort.map { |k, v| "#{k}=#{v}" }.join('&')
|
||||
|
||||
# Compute HMAC-SHA256
|
||||
computed_hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('SHA256'), client_secret, query_string)
|
||||
|
||||
ActiveSupport::SecurityUtils.secure_compare(computed_hmac, hmac)
|
||||
ENV.fetch('FRONTEND_URL', nil)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -40,7 +40,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
def allowed_configs
|
||||
mapping = {
|
||||
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
|
||||
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET SHOPIFY_APP_STORE_URL],
|
||||
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
|
||||
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
|
||||
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
|
||||
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module Shopify::IntegrationHelper
|
||||
REQUIRED_SCOPES = %w[read_customers read_orders read_fulfillments read_products].freeze
|
||||
REQUIRED_SCOPES = %w[read_customers read_orders read_fulfillments].freeze
|
||||
|
||||
# Generates a signed JWT token for Shopify integration
|
||||
#
|
||||
|
||||
@@ -57,14 +57,14 @@ class ContactAPI extends ApiClient {
|
||||
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
||||
}
|
||||
|
||||
search(search = '', page = 1, sortAttr = 'name', label = '') {
|
||||
search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) {
|
||||
let requestURL = `${this.url}/search?${buildContactParams(
|
||||
page,
|
||||
sortAttr,
|
||||
label,
|
||||
search
|
||||
)}`;
|
||||
return axios.get(requestURL);
|
||||
return axios.get(requestURL, { signal: options.signal });
|
||||
}
|
||||
|
||||
active(page = 1, sortAttr = 'name') {
|
||||
|
||||
@@ -25,6 +25,12 @@ class CategoriesAPI extends PortalsAPI {
|
||||
delete({ portalSlug, categoryId }) {
|
||||
return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`);
|
||||
}
|
||||
|
||||
reorder({ portalSlug, reorderedGroup }) {
|
||||
return axios.post(`${this.url}/${portalSlug}/categories/reorder`, {
|
||||
positions_hash: reorderedGroup,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CategoriesAPI();
|
||||
|
||||
@@ -32,6 +32,12 @@ class IntegrationsAPI extends ApiClient {
|
||||
deleteHook(hookId) {
|
||||
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
|
||||
}
|
||||
|
||||
connectShopify({ shopDomain }) {
|
||||
return axios.post(`${this.baseUrl()}/integrations/shopify/auth`, {
|
||||
shop_domain: shopDomain,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new IntegrationsAPI();
|
||||
|
||||
@@ -12,12 +12,6 @@ class ShopifyAPI extends ApiClient {
|
||||
params: { contact_id: contactId },
|
||||
});
|
||||
}
|
||||
|
||||
completeInstall(pendingInstallToken) {
|
||||
return axios.post(`${this.url}/complete_install`, {
|
||||
pending_install_token: pendingInstallToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ShopifyAPI();
|
||||
|
||||
@@ -68,7 +68,19 @@ describe('#ContactsAPI', () => {
|
||||
it('#search', () => {
|
||||
contactAPI.search('leads', 1, 'date', 'customer-support');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support'
|
||||
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
|
||||
{ signal: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
it('#search with signal', () => {
|
||||
const controller = new AbortController();
|
||||
contactAPI.search('leads', 1, 'date', 'customer-support', {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,5 +8,6 @@ describe('#BulkActionsAPI', () => {
|
||||
expect(categoriesAPI).toHaveProperty('create');
|
||||
expect(categoriesAPI).toHaveProperty('update');
|
||||
expect(categoriesAPI).toHaveProperty('delete');
|
||||
expect(categoriesAPI).toHaveProperty('reorder');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ const props = defineProps({
|
||||
medium: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const emit = defineEmits(['update:modelValue', 'executeCopilotAction']);
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
@@ -113,6 +113,9 @@ watch(
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@execute-copilot-action="
|
||||
(...args) => emit('executeCopilotAction', ...args)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="showCharacterCount || slots.actions"
|
||||
|
||||
+12
-8
@@ -58,18 +58,22 @@ const openArticle = id => {
|
||||
}
|
||||
};
|
||||
|
||||
const onReorder = reorderedGroup => {
|
||||
store.dispatch('articles/reorder', {
|
||||
reorderedGroup,
|
||||
portalSlug: route.params.portalSlug,
|
||||
});
|
||||
const onReorder = async reorderedGroup => {
|
||||
try {
|
||||
await store.dispatch('articles/reorder', {
|
||||
reorderedGroup,
|
||||
portalSlug: route.params.portalSlug,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Reuse existing positions to maintain order within the current group
|
||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
||||
const sortedArticlePositions = localArticles.value
|
||||
.map(article => article.position)
|
||||
.sort((a, b) => a - b); // Use custom sort to handle numeric values correctly
|
||||
.map((article, index) => article.position || index + 1)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const orderedArticles = localArticles.value.map(article => article.id);
|
||||
|
||||
|
||||
+12
@@ -98,6 +98,17 @@ const handleAction = ({ action, id, category: categoryData }) => {
|
||||
deleteCategory(categoryData);
|
||||
}
|
||||
};
|
||||
|
||||
const reorderCategories = async reorderedGroup => {
|
||||
try {
|
||||
await store.dispatch('categories/reorder', {
|
||||
portalSlug: route.params.portalSlug,
|
||||
reorderedGroup,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('HELP_CENTER.REORDER_CATEGORY.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -122,6 +133,7 @@ const handleAction = ({ action, id, category: categoryData }) => {
|
||||
:categories="categories"
|
||||
@click="openCategoryArticles"
|
||||
@action="handleAction"
|
||||
@reorder="reorderCategories"
|
||||
/>
|
||||
<CategoryEmptyState
|
||||
v-else
|
||||
|
||||
+60
-16
@@ -1,14 +1,22 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'action']);
|
||||
const emit = defineEmits(['click', 'action', 'reorder']);
|
||||
|
||||
const localCategories = ref(props.categories);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
return localCategories.value?.length > 1;
|
||||
});
|
||||
|
||||
const handleClick = slug => {
|
||||
emit('click', slug);
|
||||
@@ -17,21 +25,57 @@ const handleClick = slug => {
|
||||
const handleAction = ({ action, value, id }, category) => {
|
||||
emit('action', { action, value, id, category });
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
||||
const sortedPositions = localCategories.value
|
||||
.map((category, index) => category.position || index + 1)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const reorderedGroup = localCategories.value.reduce(
|
||||
(obj, category, index) => {
|
||||
obj[category.id] = sortedPositions[index];
|
||||
return obj;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
emit('reorder', reorderedGroup);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.categories,
|
||||
newCategories => {
|
||||
localCategories.value = newCategories;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CategoryCard
|
||||
v-for="category in categories"
|
||||
:id="category.id"
|
||||
:key="category.id"
|
||||
:title="category.name"
|
||||
:icon="category.icon"
|
||||
:description="category.description"
|
||||
:articles-count="category.meta.articles_count || 0"
|
||||
:slug="category.slug"
|
||||
@click="handleClick(category.slug)"
|
||||
@action="handleAction($event, category)"
|
||||
/>
|
||||
</ul>
|
||||
<Draggable
|
||||
v-model="localCategories"
|
||||
:disabled="!dragEnabled"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
role="list"
|
||||
class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<li class="list-none">
|
||||
<CategoryCard
|
||||
:id="element.id"
|
||||
:title="element.name"
|
||||
:icon="element.icon"
|
||||
:description="element.description"
|
||||
:articles-count="element.meta?.articles_count || 0"
|
||||
:slug="element.slug"
|
||||
:class="{ 'cursor-grab': dragEnabled }"
|
||||
@click="handleClick(element.slug)"
|
||||
@action="handleAction($event, element)"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
</Draggable>
|
||||
</template>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
searchContacts,
|
||||
createContactSearcher,
|
||||
createNewContact,
|
||||
fetchContactableInboxes,
|
||||
processContactableInboxes,
|
||||
@@ -39,6 +39,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const searchContacts = createContactSearcher();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
@@ -107,15 +108,17 @@ const onContactSearch = debounce(
|
||||
isSearching.value = true;
|
||||
contacts.value = [];
|
||||
try {
|
||||
contacts.value = await searchContacts(query);
|
||||
const results = await searchContacts(query);
|
||||
// null means the request was aborted (a newer search is in-flight),
|
||||
if (results === null) return;
|
||||
contacts.value = results;
|
||||
isSearching.value = false;
|
||||
} catch (error) {
|
||||
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
300,
|
||||
400,
|
||||
false
|
||||
);
|
||||
|
||||
@@ -138,6 +141,7 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
|
||||
contact = rest;
|
||||
}
|
||||
selectedContact.value = contact;
|
||||
contacts.value = [];
|
||||
if (contact?.id) {
|
||||
isFetchingInboxes.value = true;
|
||||
try {
|
||||
|
||||
+46
-3
@@ -15,6 +15,9 @@ import {
|
||||
prepareWhatsAppMessagePayload,
|
||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
|
||||
|
||||
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
|
||||
import ContactSelector from './ContactSelector.vue';
|
||||
import InboxSelector from './InboxSelector.vue';
|
||||
import EmailOptions from './EmailOptions.vue';
|
||||
@@ -22,6 +25,7 @@ import MessageEditor from './MessageEditor.vue';
|
||||
import ActionButtons from './ActionButtons.vue';
|
||||
import InboxEmptyState from './InboxEmptyState.vue';
|
||||
import AttachmentPreviews from './AttachmentPreviews.vue';
|
||||
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contacts: { type: Array, default: () => [] },
|
||||
@@ -42,6 +46,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits([
|
||||
'searchContacts',
|
||||
'resetContactSearch',
|
||||
'discard',
|
||||
'updateSelectedContact',
|
||||
'updateTargetInbox',
|
||||
@@ -51,6 +56,8 @@ const emit = defineEmits([
|
||||
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
|
||||
const copilot = useCopilotReply();
|
||||
|
||||
const showContactsDropdown = ref(false);
|
||||
const showInboxesDropdown = ref(false);
|
||||
const showCcEmailsDropdown = ref(false);
|
||||
@@ -157,7 +164,7 @@ const isAnyDropdownActive = computed(() => {
|
||||
});
|
||||
|
||||
const handleContactSearch = value => {
|
||||
showContactsDropdown.value = true;
|
||||
showContactsDropdown.value = value.trim().length > 1;
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
@@ -172,12 +179,16 @@ const handleDropdownUpdate = (type, value) => {
|
||||
};
|
||||
|
||||
const searchCcEmails = value => {
|
||||
showCcEmailsDropdown.value = true;
|
||||
showBccEmailsDropdown.value = false;
|
||||
emit('resetContactSearch');
|
||||
showCcEmailsDropdown.value = value.trim().length >= 2;
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
const searchBccEmails = value => {
|
||||
showBccEmailsDropdown.value = true;
|
||||
showCcEmailsDropdown.value = false;
|
||||
emit('resetContactSearch');
|
||||
showBccEmailsDropdown.value = value.trim().length >= 2;
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
@@ -196,6 +207,7 @@ const stripMessageFormatting = channelType => {
|
||||
|
||||
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
|
||||
v$.value.$reset();
|
||||
copilot.reset(false);
|
||||
|
||||
// Strip unsupported formatting when changing the target inbox
|
||||
if (channelType) {
|
||||
@@ -222,6 +234,7 @@ const removeSignatureFromMessage = () => {
|
||||
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
copilot.reset(false);
|
||||
removeSignatureFromMessage();
|
||||
|
||||
stripMessageFormatting(DEFAULT_FORMATTING);
|
||||
@@ -231,6 +244,7 @@ const removeTargetInbox = value => {
|
||||
};
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
copilot.reset(false);
|
||||
removeSignatureFromMessage();
|
||||
emit('clearSelectedContact');
|
||||
state.message = '';
|
||||
@@ -262,6 +276,7 @@ const handleAttachFile = files => {
|
||||
};
|
||||
|
||||
const clearForm = () => {
|
||||
copilot.reset(false);
|
||||
Object.assign(state, {
|
||||
message: '',
|
||||
subject: '',
|
||||
@@ -324,6 +339,24 @@ const shouldShowMessageEditor = computed(() => {
|
||||
!inboxTypes.value.isTwilioWhatsapp
|
||||
);
|
||||
});
|
||||
|
||||
const isCopilotActive = computed(() => copilot.isActive?.value ?? false);
|
||||
|
||||
const onSubmitCopilotReply = () => {
|
||||
const acceptedMessage = copilot.accept();
|
||||
state.message = acceptedMessage;
|
||||
};
|
||||
|
||||
useKeyboardEvents({
|
||||
'$mod+Enter': {
|
||||
action: () => {
|
||||
if (isCopilotActive.value && !copilot.isButtonDisabled.value) {
|
||||
onSubmitCopilotReply();
|
||||
}
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -354,6 +387,7 @@ const shouldShowMessageEditor = computed(() => {
|
||||
:show-inboxes-dropdown="showInboxesDropdown"
|
||||
:contactable-inboxes-list="contactableInboxesList"
|
||||
:has-errors="validationStates.isInboxInvalid"
|
||||
:is-fetching-inboxes="isFetchingInboxes"
|
||||
@update-inbox="removeTargetInbox"
|
||||
@toggle-dropdown="showInboxesDropdown = $event"
|
||||
@handle-inbox-action="handleInboxAction"
|
||||
@@ -382,6 +416,7 @@ const shouldShowMessageEditor = computed(() => {
|
||||
:has-errors="validationStates.isMessageInvalid"
|
||||
:channel-type="inboxChannelType"
|
||||
:medium="targetInbox?.medium || ''"
|
||||
:copilot="copilot"
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
@@ -391,7 +426,15 @@ const shouldShowMessageEditor = computed(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CopilotReplyBottomPanel
|
||||
v-if="isCopilotActive"
|
||||
:is-generating-content="copilot.isButtonDisabled.value"
|
||||
class="h-[3.25rem] !px-4 !py-2"
|
||||
@submit="onSubmitCopilotReply"
|
||||
@cancel="copilot.reset"
|
||||
/>
|
||||
<ActionButtons
|
||||
v-else
|
||||
:attached-files="state.attachedFiles"
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
|
||||
@@ -99,7 +99,6 @@ const inputClass = computed(() => {
|
||||
type="email"
|
||||
allow-create
|
||||
class="flex-1 min-h-7"
|
||||
@focus="emit('updateDropdown', 'cc', true)"
|
||||
@input="emit('searchCcEmails', $event)"
|
||||
@on-click-outside="emit('updateDropdown', 'cc', false)"
|
||||
@update:model-value="handleCcUpdate"
|
||||
@@ -133,7 +132,6 @@ const inputClass = computed(() => {
|
||||
allow-create
|
||||
class="flex-1 min-h-7"
|
||||
focus-on-mount
|
||||
@focus="emit('updateDropdown', 'bcc', true)"
|
||||
@input="emit('searchBccEmails', $event)"
|
||||
@on-click-outside="emit('updateDropdown', 'bcc', false)"
|
||||
@update:model-value="handleBccUpdate"
|
||||
|
||||
+7
-1
@@ -1,9 +1,15 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3"
|
||||
>
|
||||
<span class="text-sm dark:text-n-amber-11 text-n-amber-11">
|
||||
{{ $t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { generateLabelForContactableInboxesList } from 'dashboard/components-nex
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
targetInbox: {
|
||||
@@ -28,6 +29,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isFetchingInboxes: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -71,7 +76,9 @@ const targetInboxLabel = computed(() => {
|
||||
v-on-click-outside="() => emit('toggleDropdown', false)"
|
||||
class="relative flex items-center h-7"
|
||||
>
|
||||
<Spinner v-if="isFetchingInboxes" :size="16" />
|
||||
<Button
|
||||
v-else
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
|
||||
+61
-21
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import CopilotEditorSection from 'dashboard/components/widgets/conversation/CopilotEditorSection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
hasErrors: { type: Boolean, default: false },
|
||||
@@ -10,6 +11,7 @@ const props = defineProps({
|
||||
messageSignature: { type: String, default: '' },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
copilot: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
|
||||
@@ -20,29 +22,67 @@ const modelValue = defineModel({
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const isCopilotActive = computed(() => props.copilot?.isActive?.value ?? false);
|
||||
|
||||
const executeCopilotAction = (action, data) => {
|
||||
if (props.copilot) {
|
||||
props.copilot.execute(action, data);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 h-full">
|
||||
<Editor
|
||||
v-model="modelValue"
|
||||
:editor-key="editorKey"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
/>
|
||||
<div class="flex-1 h-full px-4 py-4">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
>
|
||||
<div
|
||||
:key="copilot ? copilot.editorTransitionKey.value : 'rich'"
|
||||
class="h-full"
|
||||
>
|
||||
<CopilotEditorSection
|
||||
v-if="isCopilotActive"
|
||||
:show-copilot-editor="copilot.showEditor.value"
|
||||
:is-generating-content="copilot.isGenerating.value"
|
||||
:generated-content="copilot.generatedContent.value"
|
||||
class="!mb-0"
|
||||
@focus="() => {}"
|
||||
@blur="() => {}"
|
||||
@clear-selection="() => {}"
|
||||
@content-ready="copilot.setContentReady"
|
||||
@send="copilot.sendFollowUp"
|
||||
/>
|
||||
<Editor
|
||||
v-else
|
||||
v-model="modelValue"
|
||||
:editor-key="editorKey"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-0 [&>div]:py-0 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[12rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
enable-captain-tools
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
@execute-copilot-action="executeCopilotAction"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+35
-12
@@ -177,19 +177,42 @@ export const prepareWhatsAppMessagePayload = ({
|
||||
};
|
||||
|
||||
// API Calls
|
||||
export const searchContacts = async query => {
|
||||
const trimmed = typeof query === 'string' ? query.trim() : '';
|
||||
if (!trimmed) return [];
|
||||
const MIN_SEARCH_LENGTH = 2;
|
||||
|
||||
const {
|
||||
data: { payload },
|
||||
} = await ContactAPI.search(trimmed);
|
||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||
// Filter contacts that have either phone_number or email
|
||||
const filteredPayload = camelCasedPayload?.filter(
|
||||
contact => contact.phoneNumber || contact.email
|
||||
);
|
||||
return filteredPayload || [];
|
||||
export const createContactSearcher = () => {
|
||||
let controller = null;
|
||||
|
||||
return async (query, { skipMinLength = false } = {}) => {
|
||||
const trimmed = typeof query === 'string' ? query.trim() : '';
|
||||
|
||||
controller?.abort();
|
||||
|
||||
if (!trimmed || (!skipMinLength && trimmed.length < MIN_SEARCH_LENGTH))
|
||||
return [];
|
||||
|
||||
controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
|
||||
|
||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||
// Filter contacts that have either phone_number or email
|
||||
const filteredPayload = camelCasedPayload?.filter(
|
||||
contact => contact.phoneNumber || contact.email
|
||||
);
|
||||
return filteredPayload || [];
|
||||
} catch (error) {
|
||||
// Return null for aborted requests so callers can distinguish
|
||||
// "request was cancelled" from "no results found"
|
||||
if (error?.name === 'AbortError' || error?.name === 'CanceledError') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const createNewContact = async input => {
|
||||
|
||||
+97
-7
@@ -337,7 +337,12 @@ describe('composeConversationHelper', () => {
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
describe('searchContacts', () => {
|
||||
describe('createContactSearcher', () => {
|
||||
let searchContacts;
|
||||
beforeEach(() => {
|
||||
searchContacts = helpers.createContactSearcher();
|
||||
});
|
||||
|
||||
it('searches contacts and returns camelCase results', async () => {
|
||||
const mockPayload = [
|
||||
{
|
||||
@@ -353,7 +358,7 @@ describe('composeConversationHelper', () => {
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts('john');
|
||||
const result = await searchContacts('john');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -365,7 +370,56 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith('john');
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith(
|
||||
'john',
|
||||
1,
|
||||
'name',
|
||||
'',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty array for queries shorter than 2 characters', async () => {
|
||||
const result = await searchContacts('j');
|
||||
expect(result).toEqual([]);
|
||||
expect(ContactAPI.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns empty array for empty or whitespace-only queries', async () => {
|
||||
expect(await searchContacts('')).toEqual([]);
|
||||
expect(await searchContacts(' ')).toEqual([]);
|
||||
expect(await searchContacts(null)).toEqual([]);
|
||||
expect(ContactAPI.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts previous in-flight request when a new search starts', async () => {
|
||||
const mockPayload = [
|
||||
{ id: 1, name: 'Result', email: 'r@test.com', phone_number: null },
|
||||
];
|
||||
|
||||
let resolveFirst;
|
||||
const firstCall = new Promise(resolve => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
ContactAPI.search
|
||||
.mockReturnValueOnce(firstCall)
|
||||
.mockResolvedValueOnce({ data: { payload: mockPayload } });
|
||||
|
||||
// Start first search (will hang)
|
||||
const first = searchContacts('alpha');
|
||||
// Start second search (aborts first)
|
||||
const second = searchContacts('beta');
|
||||
|
||||
// Resolve the first call with CanceledError (simulating axios abort)
|
||||
const canceledError = new Error('canceled');
|
||||
canceledError.name = 'CanceledError';
|
||||
resolveFirst(Promise.reject(canceledError));
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
expect(firstResult).toBeNull();
|
||||
expect(secondResult).toEqual([
|
||||
{ id: 1, name: 'Result', email: 'r@test.com', phoneNumber: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('searches contacts and returns only contacts with email or phone number', async () => {
|
||||
@@ -397,7 +451,7 @@ describe('composeConversationHelper', () => {
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts('john');
|
||||
const result = await searchContacts('john');
|
||||
|
||||
// Should only return contacts with either email or phone number
|
||||
expect(result).toEqual([
|
||||
@@ -417,7 +471,13 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith('john');
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith(
|
||||
'john',
|
||||
1,
|
||||
'name',
|
||||
'',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty search results', async () => {
|
||||
@@ -425,7 +485,7 @@ describe('composeConversationHelper', () => {
|
||||
data: { payload: [] },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts('nonexistent');
|
||||
const result = await searchContacts('nonexistent');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -452,7 +512,7 @@ describe('composeConversationHelper', () => {
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts('test');
|
||||
const result = await searchContacts('test');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -474,6 +534,36 @@ describe('composeConversationHelper', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createContactSearcher isolation', () => {
|
||||
it('creates isolated searcher instances that do not cancel each other', async () => {
|
||||
const searcherA = helpers.createContactSearcher();
|
||||
const searcherB = helpers.createContactSearcher();
|
||||
|
||||
const payloadA = [
|
||||
{ id: 1, name: 'Alice', email: 'a@test.com', phone_number: null },
|
||||
];
|
||||
const payloadB = [
|
||||
{ id: 2, name: 'Bob', email: 'b@test.com', phone_number: null },
|
||||
];
|
||||
|
||||
ContactAPI.search
|
||||
.mockResolvedValueOnce({ data: { payload: payloadA } })
|
||||
.mockResolvedValueOnce({ data: { payload: payloadB } });
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
searcherA('alice'),
|
||||
searcherB('bob'),
|
||||
]);
|
||||
|
||||
expect(resultA).toEqual([
|
||||
{ id: 1, name: 'Alice', email: 'a@test.com', phoneNumber: null },
|
||||
]);
|
||||
expect(resultB).toEqual([
|
||||
{ id: 2, name: 'Bob', email: 'b@test.com', phoneNumber: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNewContact', () => {
|
||||
it('creates new contact with capitalized name', async () => {
|
||||
const mockContact = { id: 1, name: 'John', email: 'john@example.com' };
|
||||
|
||||
@@ -81,6 +81,7 @@ const isDelivered = computed(() => {
|
||||
isATwilioChannel.value ||
|
||||
isASmsInbox.value ||
|
||||
isAFacebookInbox.value ||
|
||||
isAnInstagramChannel.value ||
|
||||
isATiktokChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||
|
||||
@@ -72,7 +72,7 @@ const isNewTagInValidType = computed(() =>
|
||||
|
||||
const showInput = computed(() =>
|
||||
props.mode === MODE.SINGLE
|
||||
? isFocused.value && !tags.value.length
|
||||
? !tags.value.length
|
||||
: isFocused.value || !tags.value.length
|
||||
);
|
||||
|
||||
|
||||
@@ -10,11 +10,23 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
hasSelection: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEditorMenuPopover: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
editorContent: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['executeCopilotAction']);
|
||||
@@ -25,6 +37,13 @@ const { draftMessage } = useCaptain();
|
||||
|
||||
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
|
||||
|
||||
// When editorContent prop is passed, use it exclusively (even if empty)
|
||||
// This ensures each editor instance shows menu items based on its own content
|
||||
// Falls back to global draftMessage only when editorContent is not provided
|
||||
const effectiveContent = computed(() =>
|
||||
props.editorContent !== undefined ? props.editorContent : draftMessage.value
|
||||
);
|
||||
|
||||
// Selection-based menu items (when text is selected)
|
||||
const menuItems = computed(() => {
|
||||
const items = [];
|
||||
@@ -42,8 +61,9 @@ const menuItems = computed(() => {
|
||||
icon: 'i-fluent-pen-sparkle-24-regular',
|
||||
});
|
||||
} else if (
|
||||
props.conversationId &&
|
||||
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
|
||||
draftMessage.value
|
||||
effectiveContent.value
|
||||
) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
|
||||
@@ -52,7 +72,7 @@ const menuItems = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
if (draftMessage.value) {
|
||||
if (effectiveContent.value) {
|
||||
items.push(
|
||||
{
|
||||
label: t(
|
||||
@@ -105,7 +125,7 @@ const menuItems = computed(() => {
|
||||
|
||||
const generalMenuItems = computed(() => {
|
||||
const items = [];
|
||||
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
|
||||
if (props.conversationId && replyMode.value === REPLY_EDITOR_MODES.REPLY) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
|
||||
key: 'reply_suggestion',
|
||||
@@ -113,7 +133,10 @@ const generalMenuItems = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
|
||||
if (
|
||||
props.conversationId &&
|
||||
(replyMode.value === REPLY_EDITOR_MODES.NOTE || true)
|
||||
) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
|
||||
key: 'summarize',
|
||||
@@ -176,8 +199,8 @@ const handleSubMenuItemClick = (parentItem, subItem) => {
|
||||
<DropdownBody
|
||||
ref="menuRef"
|
||||
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
|
||||
:class="{ 'selection-menu': hasSelection }"
|
||||
:style="hasSelection ? selectionMenuStyle : {}"
|
||||
:class="{ 'selection-menu': hasSelection && isEditorMenuPopover }"
|
||||
:style="hasSelection && isEditorMenuPopover ? selectionMenuStyle : {}"
|
||||
>
|
||||
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
|
||||
<div
|
||||
|
||||
@@ -202,6 +202,11 @@ const editorRoot = useTemplateRef('editorRoot');
|
||||
const imageUpload = useTemplateRef('imageUpload');
|
||||
const editor = useTemplateRef('editor');
|
||||
|
||||
const isEditorMenuPopover = computed(
|
||||
() =>
|
||||
editorRoot.value?.classList.contains('popover-prosemirror-menu') ?? false
|
||||
);
|
||||
|
||||
const handleCopilotAction = actionKey => {
|
||||
if (actionKey === 'improve_selection' && editorView?.state) {
|
||||
const { from, to } = editorView.state.selection;
|
||||
@@ -211,7 +216,7 @@ const handleCopilotAction = actionKey => {
|
||||
emit('executeCopilotAction', 'improve', selectedText);
|
||||
}
|
||||
} else {
|
||||
emit('executeCopilotAction', actionKey);
|
||||
emit('executeCopilotAction', actionKey, props.modelValue);
|
||||
}
|
||||
|
||||
showSelectionMenu.value = false;
|
||||
@@ -484,6 +489,7 @@ function setToolbarPosition() {
|
||||
function setMenubarPosition({ selection } = {}) {
|
||||
const wrapper = editorRoot.value;
|
||||
if (!selection || !wrapper) return;
|
||||
if (!isEditorMenuPopover.value) return;
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
|
||||
@@ -866,8 +872,12 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
v-if="showSelectionMenu"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
:has-selection="isTextSelected"
|
||||
:is-editor-menu-popover="isEditorMenuPopover"
|
||||
:editor-content="modelValue"
|
||||
:conversation-id="conversationId"
|
||||
:show-selection-menu="showSelectionMenu"
|
||||
:show-general-menu="false"
|
||||
class="copilot-editor-menu"
|
||||
@execute-copilot-action="handleCopilotAction"
|
||||
/>
|
||||
<input
|
||||
@@ -1026,6 +1036,17 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
|
||||
}
|
||||
|
||||
// Default copilot menu position (non-popover editors like components-next/Editor)
|
||||
// When popover-prosemirror-menu is NOT on the wrapper, anchor below the menubar
|
||||
:not(.popover-prosemirror-menu) > .copilot-editor-menu {
|
||||
top: 1.5rem !important;
|
||||
|
||||
[dir='rtl'] & {
|
||||
left: auto !important;
|
||||
right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Float editor menu
|
||||
.popover-prosemirror-menu {
|
||||
position: relative;
|
||||
|
||||
@@ -49,6 +49,10 @@ export default {
|
||||
type: Number,
|
||||
default: () => 0,
|
||||
},
|
||||
editorContent: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
|
||||
setup(props, { emit }) {
|
||||
@@ -73,8 +77,8 @@ export default {
|
||||
const { captainTasksEnabled } = useCaptain();
|
||||
const showCopilotMenu = ref(false);
|
||||
|
||||
const handleCopilotAction = actionKey => {
|
||||
emit('executeCopilotAction', actionKey);
|
||||
const handleCopilotAction = (actionKey, data) => {
|
||||
emit('executeCopilotAction', actionKey, data || props.editorContent);
|
||||
showCopilotMenu.value = false;
|
||||
};
|
||||
|
||||
@@ -174,6 +178,8 @@ export default {
|
||||
v-if="showCopilotMenu"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
:has-selection="false"
|
||||
:editor-content="editorContent"
|
||||
:conversation-id="conversationId"
|
||||
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
|
||||
@execute-copilot-action="handleCopilotAction"
|
||||
/>
|
||||
|
||||
@@ -1245,6 +1245,7 @@ export default {
|
||||
:is-editor-disabled="isEditorDisabled"
|
||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||
:characters-remaining="charactersRemaining"
|
||||
:editor-content="message"
|
||||
:popout-reply-box="popOutReplyBox"
|
||||
@set-reply-mode="setReplyMode"
|
||||
@toggle-popout="togglePopout"
|
||||
|
||||
@@ -20,7 +20,6 @@ const FEATURE_HELP_URLS = {
|
||||
billing: 'https://chwt.app/pricing',
|
||||
saml: 'https://chwt.app/hc/saml',
|
||||
captain_billing: 'https://chwt.app/hc/captain_billing',
|
||||
shopify: 'https://chwt.app/hc/shopify',
|
||||
};
|
||||
|
||||
export function getHelpUrlForFeature(featureName) {
|
||||
|
||||
@@ -613,7 +613,7 @@
|
||||
"NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.",
|
||||
"CONTACT_SELECTOR": {
|
||||
"LABEL": "To:",
|
||||
"TAG_INPUT_PLACEHOLDER": "Search for a contact with name, email or phone number",
|
||||
"TAG_INPUT_PLACEHOLDER": "Enter at least 2 characters to search by name, email, or phone number",
|
||||
"CONTACT_CREATING": "Creating contact..."
|
||||
},
|
||||
"INBOX_SELECTOR": {
|
||||
@@ -624,9 +624,9 @@
|
||||
"SUBJECT_LABEL": "Subject :",
|
||||
"SUBJECT_PLACEHOLDER": "Enter your email subject here",
|
||||
"CC_LABEL": "Cc:",
|
||||
"CC_PLACEHOLDER": "Search for a contact with their email address",
|
||||
"CC_PLACEHOLDER": "Enter at least 2 characters to search by email",
|
||||
"BCC_LABEL": "Bcc:",
|
||||
"BCC_PLACEHOLDER": "Search for a contact with their email address",
|
||||
"BCC_PLACEHOLDER": "Enter at least 2 characters to search by email",
|
||||
"BCC_BUTTON": "Bcc"
|
||||
},
|
||||
"MESSAGE_EDITOR": {
|
||||
|
||||
@@ -374,6 +374,16 @@
|
||||
"ERROR_MESSAGE": "Error while deleting article"
|
||||
}
|
||||
},
|
||||
"REORDER_ARTICLE": {
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "Unable to reorder articles. Please try again."
|
||||
}
|
||||
},
|
||||
"REORDER_CATEGORY": {
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "Unable to reorder categories. Please try again."
|
||||
}
|
||||
},
|
||||
"CREATE_ARTICLE": {
|
||||
"ERROR_MESSAGE": "Please add the article heading and content then only you can update the settings"
|
||||
},
|
||||
@@ -839,7 +849,7 @@
|
||||
"STATUS": {
|
||||
"UPLOADED": "Ready",
|
||||
"PROCESSING": "Processing",
|
||||
"PROCESSED": "Completed",
|
||||
"PROCESSED": "Completed",
|
||||
"FAILED": "Failed"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,18 +11,9 @@
|
||||
"LABEL": "Store URL",
|
||||
"PLACEHOLDER": "your-store.myshopify.com",
|
||||
"HELP": "Enter your Shopify store's myshopify.com URL",
|
||||
"INVALID_URL": "Please enter a valid Shopify store URL (e.g., your-store.myshopify.com)",
|
||||
"CANCEL": "Cancel",
|
||||
"SUBMIT": "Connect Store"
|
||||
},
|
||||
"PENDING_INSTALL": {
|
||||
"SUCCESS": "Shopify integration connected successfully.",
|
||||
"ERROR": "Failed to complete Shopify installation. The link may have expired."
|
||||
},
|
||||
"HELP_TEXT": {
|
||||
"TITLE": "How to use the Shopify Integration?",
|
||||
"BODY": "With this integration, your Shopify store ***{storeDomain}*** is connected to your Chatwoot workspace. Here's what you can do:\n\n**Track orders in conversations:** When you open a conversation, the Shopify sidebar will automatically display recent orders for the customer based on their email address. This gives your support team instant context without switching tabs.\n\n**Access order details:** View order status, fulfillment status, total amount, and individual line items directly within the conversation panel."
|
||||
},
|
||||
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
|
||||
},
|
||||
"HEADER": "Integrations",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { searchContacts } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { fetchContactDetails } from '../helpers/searchHelper';
|
||||
|
||||
@@ -18,6 +18,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
const searchContacts = createContactSearcher();
|
||||
|
||||
const FROM_TYPE = {
|
||||
CONTACT: 'contact',
|
||||
AGENT: 'agent',
|
||||
@@ -119,7 +121,10 @@ const debouncedSearch = debounce(async query => {
|
||||
}
|
||||
|
||||
try {
|
||||
const contacts = await searchContacts(query);
|
||||
const contacts = await searchContacts(query, { skipMinLength: true });
|
||||
|
||||
// null means the request was aborted (a newer search is in-flight),
|
||||
if (contacts === null) return;
|
||||
|
||||
// Add selected contact to top if not already in results
|
||||
const allContacts = selectedContact.value
|
||||
@@ -130,9 +135,8 @@ const debouncedSearch = debounce(async query => {
|
||||
: contacts;
|
||||
|
||||
searchedContacts.value = allContacts;
|
||||
isSearching.value = false;
|
||||
} catch {
|
||||
// Ignore error
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
}, 300);
|
||||
|
||||
+9
-1
@@ -11,7 +11,10 @@ const store = useStore();
|
||||
|
||||
const pageNumber = ref(1);
|
||||
|
||||
const articles = useMapGetter('articles/allArticles');
|
||||
const allArticles = useMapGetter('articles/allArticles');
|
||||
const articlesSortedByPosition = useMapGetter(
|
||||
'articles/allArticlesSortedByPosition'
|
||||
);
|
||||
const categories = useMapGetter('categories/allCategories');
|
||||
const meta = useMapGetter('articles/getMeta');
|
||||
const portalMeta = useMapGetter('portals/getMeta');
|
||||
@@ -58,6 +61,11 @@ const isCategoryArticles = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
// Use position-sorted articles for category views and categories filter view (where drag reorder is enabled)
|
||||
const articles = computed(() =>
|
||||
isCategoryArticles.value ? articlesSortedByPosition.value : allArticles.value
|
||||
);
|
||||
|
||||
const fetchArticles = ({ pageNumber: pageNumberParam } = {}) => {
|
||||
store.dispatch('articles/index', {
|
||||
pageNumber: pageNumberParam || pageNumber.value,
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import CategoriesPage from 'dashboard/components-next/HelpCenter/Pages/CategoryP
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const categories = useMapGetter('categories/allCategories');
|
||||
const categories = useMapGetter('categories/allCategoriesSortedByPosition');
|
||||
|
||||
const selectedPortalSlug = computed(() => route.params.portalSlug);
|
||||
const getPortalBySlug = useMapGetter('portals/portalBySlug');
|
||||
|
||||
@@ -160,6 +160,7 @@ export default {
|
||||
@submit.prevent="updateAccount"
|
||||
>
|
||||
<WithLabel
|
||||
name="account-name"
|
||||
:has-error="v$.name.$error"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.NAME.LABEL')"
|
||||
:error-message="$t('GENERAL_SETTINGS.FORM.NAME.ERROR')"
|
||||
@@ -173,6 +174,7 @@ export default {
|
||||
/>
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
name="site-language"
|
||||
:has-error="v$.locale.$error"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL')"
|
||||
:error-message="$t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR')"
|
||||
@@ -189,6 +191,7 @@ export default {
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
v-if="featureCustomReplyDomainEnabled"
|
||||
name="custom-domain"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL')"
|
||||
>
|
||||
<NextInput
|
||||
@@ -211,6 +214,7 @@ export default {
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
v-if="featureCustomReplyEmailEnabled"
|
||||
name="support-email"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL')"
|
||||
>
|
||||
<NextInput
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
useFunctionGetter,
|
||||
useMapGetter,
|
||||
useStore,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Integration from './Integration.vue';
|
||||
import shopifyAPI from 'dashboard/api/integrations/shopify';
|
||||
import integrationAPI from 'dashboard/api/integrations';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
@@ -24,11 +23,12 @@ defineProps({
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const integrationLoaded = ref(false);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
const dialogRef = ref(null);
|
||||
const integrationLoaded = ref(false);
|
||||
const storeUrl = ref('');
|
||||
const isSubmitting = ref(false);
|
||||
const storeUrlError = ref('');
|
||||
const integration = useFunctionGetter('integrations/getIntegration', 'shopify');
|
||||
const uiFlags = useMapGetter('integrations/getUIFlags');
|
||||
|
||||
@@ -39,43 +39,50 @@ const integrationAction = computed(() => {
|
||||
return 'connect';
|
||||
});
|
||||
|
||||
const hook = computed(() => {
|
||||
const { hooks = [] } = integration.value || {};
|
||||
const [firstHook] = hooks;
|
||||
return firstHook || {};
|
||||
});
|
||||
const hideStoreUrlModal = () => {
|
||||
storeUrl.value = '';
|
||||
storeUrlError.value = '';
|
||||
isSubmitting.value = false;
|
||||
};
|
||||
|
||||
const storeDomain = computed(() => hook.value.reference_id || '');
|
||||
const validateStoreUrl = url => {
|
||||
const pattern = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/;
|
||||
return pattern.test(url);
|
||||
};
|
||||
|
||||
const formattedHelpText = computed(() => {
|
||||
return formatMessage(
|
||||
t('INTEGRATION_SETTINGS.SHOPIFY.HELP_TEXT.BODY', {
|
||||
storeDomain: storeDomain.value,
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
const openStoreUrlDialog = () => {
|
||||
if (dialogRef.value) {
|
||||
dialogRef.value.open();
|
||||
}
|
||||
};
|
||||
|
||||
const completePendingInstall = async token => {
|
||||
const handleStoreUrlSubmit = async () => {
|
||||
try {
|
||||
await shopifyAPI.completeInstall(token);
|
||||
await store.dispatch('integrations/get', 'shopify');
|
||||
useAlert(t('INTEGRATION_SETTINGS.SHOPIFY.PENDING_INSTALL.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('INTEGRATION_SETTINGS.SHOPIFY.PENDING_INSTALL.ERROR'));
|
||||
storeUrlError.value = '';
|
||||
if (!validateStoreUrl(storeUrl.value)) {
|
||||
storeUrlError.value =
|
||||
'Please enter a valid Shopify store URL (e.g., your-store.myshopify.com)';
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
const { data } = await integrationAPI.connectShopify({
|
||||
shopDomain: storeUrl.value,
|
||||
});
|
||||
|
||||
if (data.redirect_url) {
|
||||
window.location.href = data.redirect_url;
|
||||
}
|
||||
} catch (error) {
|
||||
storeUrlError.value = error.message;
|
||||
} finally {
|
||||
router.replace({ query: {} });
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const initializeShopifyIntegration = async () => {
|
||||
await store.dispatch('integrations/get', 'shopify');
|
||||
integrationLoaded.value = true;
|
||||
|
||||
const pendingInstallToken = route.query.shopify_pending_install;
|
||||
if (pendingInstallToken) {
|
||||
await completePendingInstall(pendingInstallToken);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
@@ -115,27 +122,35 @@ onMounted(() => {
|
||||
/>
|
||||
</template>
|
||||
</Integration>
|
||||
|
||||
<div
|
||||
v-if="integration.enabled"
|
||||
class="flex-1 w-full px-6 py-5 rounded-md shadow outline outline-n-container outline-1 bg-n-alpha-3"
|
||||
>
|
||||
<div class="max-w-5xl prose-lg">
|
||||
<h5 class="tracking-tight text-n-slate-12">
|
||||
{{ $t('INTEGRATION_SETTINGS.SHOPIFY.HELP_TEXT.TITLE') }}
|
||||
</h5>
|
||||
<div v-dompurify-html="formattedHelpText" class="text-n-slate-11" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="flex items-center justify-center flex-1 p-6 rounded-md shadow outline outline-n-container outline-1 bg-n-alpha-3"
|
||||
class="flex items-center justify-center flex-1 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow p-6"
|
||||
>
|
||||
<p class="text-n-ruby-9">
|
||||
{{ t('INTEGRATION_SETTINGS.SHOPIFY.ERROR') }}
|
||||
</p>
|
||||
</div>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.TITLE')"
|
||||
:is-loading="isSubmitting"
|
||||
@confirm="handleStoreUrlSubmit"
|
||||
@close="hideStoreUrlModal"
|
||||
>
|
||||
<Input
|
||||
v-model="storeUrl"
|
||||
:label="t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.LABEL')"
|
||||
:placeholder="
|
||||
t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.PLACEHOLDER')
|
||||
"
|
||||
:message="
|
||||
!storeUrlError
|
||||
? t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.HELP')
|
||||
: storeUrlError
|
||||
"
|
||||
:message-type="storeUrlError ? 'error' : 'info'"
|
||||
/>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
|
||||
@@ -28,10 +28,6 @@ export const validateAuthenticateRoutePermission = (to, next) => {
|
||||
}
|
||||
|
||||
if (to.name === 'no_accounts' || !to.name) {
|
||||
const { redirect_url: redirectUrl } = to.query || {};
|
||||
if (redirectUrl) {
|
||||
return next(frontendURL(`accounts/${accountId}/${redirectUrl}`));
|
||||
}
|
||||
return next(frontendURL(`accounts/${accountId}/dashboard`));
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,17 @@ export const actions = {
|
||||
return fileUrl;
|
||||
},
|
||||
|
||||
reorder: async (_, { portalSlug, categorySlug, reorderedGroup }) => {
|
||||
reorder: async (
|
||||
{ commit, state },
|
||||
{ portalSlug, categorySlug, reorderedGroup }
|
||||
) => {
|
||||
// Save old positions so we can rollback on failure
|
||||
const oldPositions = Object.keys(reorderedGroup).reduce((map, id) => {
|
||||
map[id] = state.articles.byId[id]?.position;
|
||||
return map;
|
||||
}, {});
|
||||
// Update positions in the store immediately so subsequent mutations preserve correct positions
|
||||
commit(types.SET_ARTICLE_POSITIONS, reorderedGroup);
|
||||
try {
|
||||
await articlesAPI.reorderArticles({
|
||||
portalSlug,
|
||||
@@ -175,9 +185,8 @@ export const actions = {
|
||||
categorySlug,
|
||||
});
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
commit(types.SET_ARTICLE_POSITIONS, oldPositions);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,6 +22,16 @@ export const getters = {
|
||||
.filter(article => article !== undefined);
|
||||
return articles;
|
||||
},
|
||||
allArticlesSortedByPosition: (...getterArguments) => {
|
||||
const [state, _getters] = getterArguments;
|
||||
const articles = state.articles.allIds
|
||||
.map(id => _getters.articleById(id))
|
||||
.filter(article => article !== undefined);
|
||||
// Sort by position so reordered articles stay in correct order after store updates
|
||||
return articles.sort(
|
||||
(a, b) => (a.position ?? Infinity) - (b.position ?? Infinity)
|
||||
);
|
||||
},
|
||||
articleStatus:
|
||||
(...getterArguments) =>
|
||||
articleId => {
|
||||
|
||||
@@ -64,6 +64,18 @@ export const mutations = {
|
||||
...uiFlags,
|
||||
};
|
||||
},
|
||||
[types.SET_ARTICLE_POSITIONS]: ($state, positionsHash) => {
|
||||
const { byId, allIds } = $state.articles;
|
||||
// Update position on each article record
|
||||
Object.entries(positionsHash).forEach(([id, position]) => {
|
||||
if (byId[id]) byId[id] = { ...byId[id], position };
|
||||
});
|
||||
// Re-sort allIds so every consumer sees the new order
|
||||
allIds.sort(
|
||||
(a, b) =>
|
||||
(byId[a]?.position ?? Infinity) - (byId[b]?.position ?? Infinity)
|
||||
);
|
||||
},
|
||||
[types.UPDATE_ARTICLE]: ($state, updatedArticle) => {
|
||||
const articleId = updatedArticle.id;
|
||||
if ($state.articles.byId[articleId]) {
|
||||
|
||||
@@ -279,4 +279,63 @@ describe('#actions', () => {
|
||||
).rejects.toThrow('Upload failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#reorder', () => {
|
||||
const state = {
|
||||
articles: {
|
||||
byId: {
|
||||
1: { id: 1, title: 'Article 1', position: 10 },
|
||||
2: { id: 2, title: 'Article 2', position: 20 },
|
||||
3: { id: 3, title: 'Article 3', position: 30 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('commits SET_ARTICLE_POSITIONS and calls API when reorder is successful', async () => {
|
||||
axios.post.mockResolvedValue({ data: {} });
|
||||
const reorderedGroup = { 1: 1, 2: 2, 3: 3 };
|
||||
|
||||
await actions.reorder(
|
||||
{ commit, state },
|
||||
{
|
||||
portalSlug: 'test-portal',
|
||||
categorySlug: 'test-category',
|
||||
reorderedGroup,
|
||||
}
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.default.SET_ARTICLE_POSITIONS,
|
||||
reorderedGroup
|
||||
);
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/portals/test-portal/articles/reorder'),
|
||||
{ positions_hash: reorderedGroup, category_slug: 'test-category' }
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back positions and throws when API call fails', async () => {
|
||||
axios.post.mockRejectedValue({ message: 'Network error' });
|
||||
const reorderedGroup = { 1: 1, 2: 2 };
|
||||
|
||||
await expect(
|
||||
actions.reorder(
|
||||
{ commit, state },
|
||||
{
|
||||
portalSlug: 'test-portal',
|
||||
reorderedGroup,
|
||||
}
|
||||
)
|
||||
).rejects.toEqual({ message: 'Network error' });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.default.SET_ARTICLE_POSITIONS,
|
||||
reorderedGroup
|
||||
);
|
||||
expect(commit).toHaveBeenCalledWith(types.default.SET_ARTICLE_POSITIONS, {
|
||||
1: 10,
|
||||
2: 20,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,4 +41,82 @@ describe('#getters', () => {
|
||||
it('isFetchingArticles', () => {
|
||||
expect(getters.isFetching(state)).toEqual(true);
|
||||
});
|
||||
|
||||
describe('allArticlesSortedByPosition', () => {
|
||||
it('returns articles sorted by position in ascending order', () => {
|
||||
const stateWithPositions = {
|
||||
...state,
|
||||
articles: {
|
||||
...state.articles,
|
||||
byId: {
|
||||
1: { id: 1, title: 'Article 1', position: 3 },
|
||||
2: { id: 2, title: 'Article 2', position: 1 },
|
||||
3: { id: 3, title: 'Article 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
articleById: getters.articleById(stateWithPositions),
|
||||
};
|
||||
|
||||
const result = getters.allArticlesSortedByPosition(
|
||||
stateWithPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(a => a.id)).toEqual([2, 3, 1]);
|
||||
expect(result.map(a => a.position)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('places articles with null position at the end', () => {
|
||||
const stateWithNullPositions = {
|
||||
...state,
|
||||
articles: {
|
||||
...state.articles,
|
||||
byId: {
|
||||
1: { id: 1, title: 'Article 1', position: 1 },
|
||||
2: { id: 2, title: 'Article 2', position: null },
|
||||
3: { id: 3, title: 'Article 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
articleById: getters.articleById(stateWithNullPositions),
|
||||
};
|
||||
|
||||
const result = getters.allArticlesSortedByPosition(
|
||||
stateWithNullPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(a => a.id)).toEqual([1, 3, 2]);
|
||||
});
|
||||
|
||||
it('handles articles with undefined position', () => {
|
||||
const stateWithUndefinedPositions = {
|
||||
...state,
|
||||
articles: {
|
||||
...state.articles,
|
||||
byId: {
|
||||
1: { id: 1, title: 'Article 1', position: 1 },
|
||||
2: { id: 2, title: 'Article 2' },
|
||||
3: { id: 3, title: 'Article 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
articleById: getters.articleById(stateWithUndefinedPositions),
|
||||
};
|
||||
|
||||
const result = getters.allArticlesSortedByPosition(
|
||||
stateWithUndefinedPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(a => a.id)).toEqual([1, 3, 2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import types from '../../../mutation-types';
|
||||
describe('#mutations', () => {
|
||||
let state = {};
|
||||
beforeEach(() => {
|
||||
state = article;
|
||||
state = JSON.parse(JSON.stringify(article));
|
||||
});
|
||||
|
||||
describe('#SET_UI_FLAG', () => {
|
||||
@@ -93,9 +93,9 @@ describe('#mutations', () => {
|
||||
mutations[types.ADD_ARTICLE_ID](state, 3);
|
||||
expect(state.articles.allIds).toEqual([1, 2, 3]);
|
||||
});
|
||||
it('Does not invalid article with empty data passed', () => {
|
||||
mutations[types.ADD_ARTICLE_ID](state, {});
|
||||
expect(state).toEqual(article);
|
||||
it('does not add duplicate article id to state', () => {
|
||||
mutations[types.ADD_ARTICLE_ID](state, 1);
|
||||
expect(state.articles.allIds).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,4 +154,53 @@ describe('#mutations', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_ARTICLE_POSITIONS', () => {
|
||||
it('updates positions for articles in the store', () => {
|
||||
const positionsHash = { 1: 1, 2: 2 };
|
||||
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.articles.byId[1].position).toEqual(1);
|
||||
expect(state.articles.byId[2].position).toEqual(2);
|
||||
});
|
||||
|
||||
it('does not update articles that are not in the store', () => {
|
||||
const positionsHash = { 999: 5 };
|
||||
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.articles.byId[999]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves other article properties when updating position', () => {
|
||||
const originalTitle = state.articles.byId[1].title;
|
||||
const positionsHash = { 1: 3 };
|
||||
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.articles.byId[1].position).toEqual(3);
|
||||
expect(state.articles.byId[1].title).toEqual(originalTitle);
|
||||
});
|
||||
|
||||
it('re-sorts allIds by position after update', () => {
|
||||
state.articles.byId[1].position = 1;
|
||||
state.articles.byId[2].position = 2;
|
||||
state.articles.allIds = [1, 2];
|
||||
|
||||
mutations[types.SET_ARTICLE_POSITIONS](state, { 1: 3, 2: 1 });
|
||||
|
||||
expect(state.articles.allIds).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it('UPDATE_ARTICLE preserves reordered position after SET_ARTICLE_POSITIONS', () => {
|
||||
mutations[types.SET_ARTICLE_POSITIONS](state, { 2: 1 });
|
||||
expect(state.articles.byId[2].position).toEqual(1);
|
||||
|
||||
mutations[types.UPDATE_ARTICLE](state, {
|
||||
id: 2,
|
||||
title: 'Updated Title',
|
||||
status: 'published',
|
||||
});
|
||||
expect(state.articles.byId[2].position).toEqual(1);
|
||||
expect(state.articles.byId[2].title).toEqual('Updated Title');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,4 +92,23 @@ export const actions = {
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
reorder: async ({ commit, state }, { portalSlug, reorderedGroup }) => {
|
||||
// Save old positions so we can rollback on failure
|
||||
const oldPositions = Object.keys(reorderedGroup).reduce((map, id) => {
|
||||
map[id] = state.categories.byId[id]?.position;
|
||||
return map;
|
||||
}, {});
|
||||
// Update positions in the store immediately so subsequent mutations preserve correct positions
|
||||
commit(types.SET_CATEGORY_POSITIONS, reorderedGroup);
|
||||
try {
|
||||
await categoriesAPI.reorder({
|
||||
portalSlug,
|
||||
reorderedGroup,
|
||||
});
|
||||
} catch (error) {
|
||||
commit(types.SET_CATEGORY_POSITIONS, oldPositions);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -21,6 +21,16 @@ export const getters = {
|
||||
});
|
||||
return categories;
|
||||
},
|
||||
allCategoriesSortedByPosition: (...getterArguments) => {
|
||||
const [state, _getters] = getterArguments;
|
||||
const categories = state.categories.allIds
|
||||
.map(id => _getters.categoryById(id))
|
||||
.filter(category => category !== undefined);
|
||||
// Sort by position so reordered categories stay in correct order after store updates
|
||||
return categories.sort(
|
||||
(a, b) => (a.position ?? Infinity) - (b.position ?? Infinity)
|
||||
);
|
||||
},
|
||||
categoriesByLocaleCode:
|
||||
(...getterArguments) =>
|
||||
localeCode => {
|
||||
|
||||
@@ -49,6 +49,18 @@ export const mutations = {
|
||||
...uiFlags,
|
||||
};
|
||||
},
|
||||
[types.SET_CATEGORY_POSITIONS]: ($state, positionsHash) => {
|
||||
const { byId, allIds } = $state.categories;
|
||||
// Update position on each category record
|
||||
Object.entries(positionsHash).forEach(([id, position]) => {
|
||||
if (byId[id]) byId[id] = { ...byId[id], position };
|
||||
});
|
||||
// Re-sort allIds so every consumer sees the new order
|
||||
allIds.sort(
|
||||
(a, b) =>
|
||||
(byId[a]?.position ?? Infinity) - (byId[b]?.position ?? Infinity)
|
||||
);
|
||||
},
|
||||
[types.UPDATE_CATEGORY]($state, category) {
|
||||
const categoryId = category.id;
|
||||
|
||||
|
||||
@@ -161,4 +161,63 @@ describe('#actions', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#reorder', () => {
|
||||
const state = {
|
||||
categories: {
|
||||
byId: {
|
||||
1: { id: 1, name: 'Category 1', position: 10 },
|
||||
2: { id: 2, name: 'Category 2', position: 20 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('commits SET_CATEGORY_POSITIONS and calls API when reorder is successful', async () => {
|
||||
axios.post.mockResolvedValue({ data: {} });
|
||||
const reorderedGroup = { 2: 1, 1: 2 };
|
||||
|
||||
await actions.reorder(
|
||||
{ commit, state },
|
||||
{
|
||||
portalSlug: 'room-rental',
|
||||
reorderedGroup,
|
||||
}
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.default.SET_CATEGORY_POSITIONS,
|
||||
reorderedGroup
|
||||
);
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/portals/room-rental/categories/reorder'),
|
||||
{
|
||||
positions_hash: { 2: 1, 1: 2 },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back positions and throws when API call fails', async () => {
|
||||
axios.post.mockRejectedValue({ message: 'Incorrect header' });
|
||||
const reorderedGroup = { 2: 1, 1: 2 };
|
||||
|
||||
await expect(
|
||||
actions.reorder(
|
||||
{ commit, state },
|
||||
{
|
||||
portalSlug: 'room-rental',
|
||||
reorderedGroup,
|
||||
}
|
||||
)
|
||||
).rejects.toEqual({ message: 'Incorrect header' });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.default.SET_CATEGORY_POSITIONS,
|
||||
reorderedGroup
|
||||
);
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.default.SET_CATEGORY_POSITIONS,
|
||||
{ 1: 10, 2: 20 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,4 +25,82 @@ describe('#getters', () => {
|
||||
it('isFetchingCategories', () => {
|
||||
expect(getters.isFetching(state)).toEqual(true);
|
||||
});
|
||||
|
||||
describe('allCategoriesSortedByPosition', () => {
|
||||
it('returns categories sorted by position in ascending order', () => {
|
||||
const stateWithPositions = {
|
||||
...state,
|
||||
categories: {
|
||||
...state.categories,
|
||||
byId: {
|
||||
1: { id: 1, name: 'Category 1', position: 3 },
|
||||
2: { id: 2, name: 'Category 2', position: 1 },
|
||||
3: { id: 3, name: 'Category 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
categoryById: getters.categoryById(stateWithPositions),
|
||||
};
|
||||
|
||||
const result = getters.allCategoriesSortedByPosition(
|
||||
stateWithPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(c => c.id)).toEqual([2, 3, 1]);
|
||||
expect(result.map(c => c.position)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('places categories with null position at the end', () => {
|
||||
const stateWithNullPositions = {
|
||||
...state,
|
||||
categories: {
|
||||
...state.categories,
|
||||
byId: {
|
||||
1: { id: 1, name: 'Category 1', position: 1 },
|
||||
2: { id: 2, name: 'Category 2', position: null },
|
||||
3: { id: 3, name: 'Category 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
categoryById: getters.categoryById(stateWithNullPositions),
|
||||
};
|
||||
|
||||
const result = getters.allCategoriesSortedByPosition(
|
||||
stateWithNullPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(c => c.id)).toEqual([1, 3, 2]);
|
||||
});
|
||||
|
||||
it('handles categories with undefined position', () => {
|
||||
const stateWithUndefinedPositions = {
|
||||
...state,
|
||||
categories: {
|
||||
...state.categories,
|
||||
byId: {
|
||||
1: { id: 1, name: 'Category 1', position: 1 },
|
||||
2: { id: 2, name: 'Category 2' },
|
||||
3: { id: 3, name: 'Category 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
categoryById: getters.categoryById(stateWithUndefinedPositions),
|
||||
};
|
||||
|
||||
const result = getters.allCategoriesSortedByPosition(
|
||||
stateWithUndefinedPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(c => c.id)).toEqual([1, 3, 2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+39
-3
@@ -4,7 +4,7 @@ import { categoriesState, categoriesPayload } from './fixtures';
|
||||
describe('#mutations', () => {
|
||||
let state = {};
|
||||
beforeEach(() => {
|
||||
state = categoriesState;
|
||||
state = JSON.parse(JSON.stringify(categoriesState));
|
||||
});
|
||||
|
||||
describe('#SET_UI_FLAG', () => {
|
||||
@@ -53,9 +53,9 @@ describe('#mutations', () => {
|
||||
mutations[types.ADD_CATEGORY_ID](state, 3);
|
||||
expect(state.categories.allIds).toEqual([1, 2, 3]);
|
||||
});
|
||||
it('Does not invalid category with empty data passed', () => {
|
||||
it('pushes the given id to allIds', () => {
|
||||
mutations[types.ADD_CATEGORY_ID](state, {});
|
||||
expect(state).toEqual(categoriesState);
|
||||
expect(state.categories.allIds).toEqual([1, 2, {}]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,4 +98,40 @@ describe('#mutations', () => {
|
||||
// expect(state.categories.uiFlags).toEqual({});
|
||||
// });
|
||||
// });
|
||||
|
||||
describe('#SET_CATEGORY_POSITIONS', () => {
|
||||
it('updates positions for categories in the store', () => {
|
||||
const positionsHash = { 1: 1, 2: 2 };
|
||||
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.categories.byId[1].position).toEqual(1);
|
||||
expect(state.categories.byId[2].position).toEqual(2);
|
||||
});
|
||||
|
||||
it('does not update categories that are not in the store', () => {
|
||||
const positionsHash = { 999: 5 };
|
||||
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.categories.byId[999]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves other category properties when updating position', () => {
|
||||
const originalName = state.categories.byId[1].name;
|
||||
const positionsHash = { 1: 3 };
|
||||
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.categories.byId[1].position).toEqual(3);
|
||||
expect(state.categories.byId[1].name).toEqual(originalName);
|
||||
});
|
||||
|
||||
it('re-sorts allIds by position after update', () => {
|
||||
state.categories.byId[1].position = 1;
|
||||
state.categories.byId[2].position = 2;
|
||||
state.categories.allIds = [1, 2];
|
||||
|
||||
mutations[types.SET_CATEGORY_POSITIONS](state, { 1: 3, 2: 1 });
|
||||
|
||||
expect(state.categories.allIds).toEqual([2, 1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,6 +290,7 @@ export default {
|
||||
REMOVE_ARTICLE: 'REMOVE_ARTICLE',
|
||||
REMOVE_ARTICLE_ID: 'REMOVE_ARTICLE_ID',
|
||||
SET_UI_FLAG: 'SET_UI_FLAG',
|
||||
SET_ARTICLE_POSITIONS: 'SET_ARTICLE_POSITIONS',
|
||||
|
||||
// Help Center -- Categories
|
||||
ADD_CATEGORY: 'ADD_CATEGORY',
|
||||
@@ -301,6 +302,7 @@ export default {
|
||||
UPDATE_CATEGORY: 'UPDATE_CATEGORY',
|
||||
REMOVE_CATEGORY: 'REMOVE_CATEGORY',
|
||||
REMOVE_CATEGORY_ID: 'REMOVE_CATEGORY_ID',
|
||||
SET_CATEGORY_POSITIONS: 'SET_CATEGORY_POSITIONS',
|
||||
|
||||
// Agent Bots
|
||||
SET_AGENT_BOT_UI_FLAG: 'SET_AGENT_BOT_UI_FLAG',
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
export const login = async ({
|
||||
ssoAccountId,
|
||||
ssoConversationId,
|
||||
redirectUrl,
|
||||
...credentials
|
||||
}) => {
|
||||
try {
|
||||
@@ -32,7 +31,6 @@ export const login = async ({
|
||||
window.location = getLoginRedirectURL({
|
||||
ssoAccountId,
|
||||
ssoConversationId,
|
||||
redirectUrl,
|
||||
user: response.data.data,
|
||||
});
|
||||
return null;
|
||||
|
||||
@@ -40,16 +40,8 @@ export const getCredentialsFromEmail = email => {
|
||||
export const getLoginRedirectURL = ({
|
||||
ssoAccountId,
|
||||
ssoConversationId,
|
||||
redirectUrl,
|
||||
user,
|
||||
}) => {
|
||||
if (redirectUrl) {
|
||||
const { accounts = [], account_id = null } = user || {};
|
||||
const accountId = account_id || accounts[0]?.id;
|
||||
if (accountId) {
|
||||
return frontendURL(`accounts/${accountId}/${redirectUrl}`);
|
||||
}
|
||||
}
|
||||
const accountPath = getSSOAccountPath({ ssoAccountId, user });
|
||||
if (accountPath) {
|
||||
if (ssoConversationId) {
|
||||
|
||||
@@ -29,11 +29,7 @@ export const validateRouteAccess = (to, next, chatwootConfig = {}) => {
|
||||
// Redirect to dashboard if a cookie is present, the cookie
|
||||
// cleanup and token validation happens in the application pack.
|
||||
if (hasAuthCookie()) {
|
||||
const { redirect_url: redirectUrl } = to.query || {};
|
||||
const redirectTarget = redirectUrl
|
||||
? `${DEFAULT_REDIRECT_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`
|
||||
: DEFAULT_REDIRECT_URL;
|
||||
replaceRouteWithReload(redirectTarget);
|
||||
replaceRouteWithReload(DEFAULT_REDIRECT_URL);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ export default {
|
||||
ssoConversationId: { type: String, default: '' },
|
||||
email: { type: String, default: '' },
|
||||
authError: { type: String, default: '' },
|
||||
redirectUrl: { type: String, default: '' },
|
||||
},
|
||||
setup() {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
@@ -170,7 +169,6 @@ export default {
|
||||
sso_auth_token: this.ssoAuthToken,
|
||||
ssoAccountId: this.ssoAccountId,
|
||||
ssoConversationId: this.ssoConversationId,
|
||||
redirectUrl: this.redirectUrl,
|
||||
};
|
||||
|
||||
login(credentials)
|
||||
|
||||
@@ -19,7 +19,6 @@ export default [
|
||||
ssoAccountId: route.query.sso_account_id,
|
||||
ssoConversationId: route.query.sso_conversation_id,
|
||||
authError: route.query.error,
|
||||
redirectUrl: route.query.redirect_url,
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
class AgentBots::WebhookJob < WebhookJob
|
||||
queue_as :high
|
||||
retry_on RestClient::TooManyRequests, RestClient::InternalServerError, wait: 3.seconds, attempts: 3 do |job, error|
|
||||
url, payload, webhook_type = job.arguments
|
||||
Webhooks::Trigger.new(url, payload, webhook_type || :agent_bot_webhook).handle_failure(error)
|
||||
end
|
||||
|
||||
def perform(url, payload, webhook_type = :agent_bot_webhook)
|
||||
super(url, payload, webhook_type)
|
||||
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
|
||||
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name}")
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class Avatar::AvatarFromFaviconJob < ApplicationJob
|
||||
queue_as :purgable
|
||||
|
||||
def perform(company)
|
||||
return if company.domain.blank?
|
||||
return if company.avatar.attached?
|
||||
|
||||
favicon_url = "https://www.google.com/s2/favicons?domain=#{company.domain}&sz=256"
|
||||
Avatar::AvatarFromUrlJob.perform_now(company, favicon_url)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
class Companies::FetchAvatarsJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(account_id)
|
||||
account = Account.find(account_id)
|
||||
companies = account.companies.where.not(domain: [nil, ''])
|
||||
.left_joins(:avatar_attachment)
|
||||
.where(active_storage_attachments: { id: nil })
|
||||
|
||||
total_companies = companies.count
|
||||
companies.find_each do |company|
|
||||
Avatar::AvatarFromFaviconJob.perform_later(company)
|
||||
end
|
||||
|
||||
Rails.logger.info "Queued #{total_companies} companies from account #{account_id} for favicon fetch"
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,6 @@
|
||||
class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
class Webhooks::WhatsappEventsJob < MutexApplicationJob
|
||||
queue_as :low
|
||||
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
|
||||
|
||||
def perform(params = {})
|
||||
channel = find_channel_from_whatsapp_business_payload(params)
|
||||
@@ -9,10 +10,14 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
return
|
||||
end
|
||||
|
||||
if message_echo_event?(params)
|
||||
handle_message_echo(channel, params)
|
||||
sender_id = extract_sender_id(params)
|
||||
if sender_id
|
||||
key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, sender_id: sender_id, phone_number: channel.phone_number)
|
||||
with_lock(key, 30.seconds) do
|
||||
process_events(channel, params)
|
||||
end
|
||||
else
|
||||
handle_message_events(channel, params)
|
||||
process_events(channel, params)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -54,8 +59,14 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
params.dig(:entry, 0, :changes, 0, :field) == 'smb_message_echoes'
|
||||
end
|
||||
|
||||
def handle_message_echo(channel, params)
|
||||
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params, outgoing_echo: true).perform
|
||||
private
|
||||
|
||||
def process_events(channel, params)
|
||||
if message_echo_event?(params)
|
||||
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params, outgoing_echo: true).perform
|
||||
else
|
||||
handle_message_events(channel, params)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_message_events(channel, params)
|
||||
@@ -67,7 +78,16 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# Extracts the contact's phone number from the webhook payload for use as a mutex key.
|
||||
# For incoming messages, the contact is the sender (from field).
|
||||
# For echo messages, the contact is the recipient (to field).
|
||||
# For status updates, returns nil (no lock needed as they don't create conversations).
|
||||
def extract_sender_id(params)
|
||||
value = params.dig(:entry, 0, :changes, 0, :value)
|
||||
return unless value
|
||||
|
||||
value.dig(:messages, 0, :from) || value.dig(:contacts, 0, :wa_id) || value.dig(:message_echoes, 0, :to)
|
||||
end
|
||||
|
||||
def channel_is_inactive?(channel)
|
||||
return true if channel.blank?
|
||||
|
||||
@@ -180,8 +180,14 @@ class ActionCableListener < BaseListener
|
||||
end
|
||||
|
||||
def typing_event_listener_tokens(account, conversation, user)
|
||||
current_user_token = user.is_a?(Contact) ? conversation.contact_inbox.pubsub_token : user.pubsub_token
|
||||
(user_tokens(account, conversation.inbox.members) + [conversation.contact_inbox.pubsub_token]) - [current_user_token]
|
||||
current_user_token = if user.is_a?(Contact)
|
||||
conversation.contact_inbox.pubsub_token
|
||||
elsif user.respond_to?(:pubsub_token)
|
||||
user.pubsub_token
|
||||
end
|
||||
|
||||
tokens = user_tokens(account, conversation.inbox.members) + [conversation.contact_inbox.pubsub_token]
|
||||
current_user_token.present? ? tokens - [current_user_token] : tokens
|
||||
end
|
||||
|
||||
def user_tokens(account, agents)
|
||||
|
||||
@@ -132,11 +132,13 @@ class Article < ApplicationRecord
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def self.update_positions(positions_hash)
|
||||
positions_hash.each do |article_id, new_position|
|
||||
# Find the article by its ID and update its position
|
||||
article = Article.find(article_id)
|
||||
article.update!(position: new_position)
|
||||
def self.update_positions(portal:, positions_hash:)
|
||||
return if positions_hash.blank?
|
||||
|
||||
transaction do
|
||||
positions_hash.each do |article_id, new_position|
|
||||
portal.articles.find(article_id).update!(position: new_position)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ class Attachment < ApplicationRecord
|
||||
when :embed
|
||||
embed_data
|
||||
else
|
||||
file_metadata
|
||||
file.attached? ? file_metadata : { data_url: external_url, thumb_url: '' }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -73,6 +73,16 @@ class Category < ApplicationRecord
|
||||
params[:page] || 1
|
||||
end
|
||||
|
||||
def self.update_positions(portal:, positions_hash:)
|
||||
return if positions_hash.blank?
|
||||
|
||||
transaction do
|
||||
positions_hash.each do |category_id, new_position|
|
||||
portal.categories.find(category_id).update!(position: new_position)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_account_id
|
||||
|
||||
@@ -43,8 +43,6 @@ class Integrations::App
|
||||
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
|
||||
when 'linear'
|
||||
build_linear_action
|
||||
when 'shopify'
|
||||
GlobalConfigService.load('SHOPIFY_APP_STORE_URL', nil)
|
||||
else
|
||||
params[:action]
|
||||
end
|
||||
|
||||
@@ -310,6 +310,7 @@ class Message < ApplicationRecord
|
||||
def execute_after_create_commit_callbacks
|
||||
# rails issue with order of active record callbacks being executed https://github.com/rails/rails/issues/20911
|
||||
reopen_conversation
|
||||
mark_pending_conversation_as_open_for_human_response
|
||||
set_conversation_activity
|
||||
dispatch_create_events
|
||||
send_reply
|
||||
@@ -390,6 +391,18 @@ class Message < ApplicationRecord
|
||||
reopen_resolved_conversation if conversation.resolved?
|
||||
end
|
||||
|
||||
def mark_pending_conversation_as_open_for_human_response
|
||||
return unless captain_pending_conversation?
|
||||
return unless human_response?
|
||||
return if private?
|
||||
|
||||
conversation.open!
|
||||
end
|
||||
|
||||
def captain_pending_conversation?
|
||||
false
|
||||
end
|
||||
|
||||
def reopen_resolved_conversation
|
||||
# mark resolved bot conversation as pending to be reopened by bot processor service
|
||||
if conversation.inbox.active_bot?
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
class Portal < ApplicationRecord
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
DEFAULT_COLOR = '#1f93ff'.freeze
|
||||
|
||||
belongs_to :account
|
||||
has_many :categories, dependent: :destroy_async
|
||||
has_many :folders, through: :categories
|
||||
@@ -62,6 +64,14 @@ class Portal < ApplicationRecord
|
||||
config['default_locale'] || 'en'
|
||||
end
|
||||
|
||||
def color
|
||||
self[:color].presence || DEFAULT_COLOR
|
||||
end
|
||||
|
||||
def display_title
|
||||
page_title.presence || name
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def config_json_format
|
||||
|
||||
@@ -22,6 +22,10 @@ class CategoryPolicy < ApplicationPolicy
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def reorder?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
CategoryPolicy.prepend_mod_with('CategoryPolicy')
|
||||
|
||||
@@ -10,8 +10,7 @@ class Conversations::TypingStatusManager
|
||||
end
|
||||
|
||||
def trigger_typing_event(event, is_private)
|
||||
user = @user.presence || @resource
|
||||
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: is_private)
|
||||
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: @user, is_private: is_private)
|
||||
end
|
||||
|
||||
def toggle_typing_status
|
||||
|
||||
@@ -49,7 +49,7 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||
recipient: { id: contact.get_source_id(inbox.id) },
|
||||
message: fb_text_message_payload,
|
||||
messaging_type: 'MESSAGE_TAG',
|
||||
tag: 'ACCOUNT_UPDATE'
|
||||
tag: message_tag
|
||||
}
|
||||
end
|
||||
|
||||
@@ -90,10 +90,14 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||
}
|
||||
},
|
||||
messaging_type: 'MESSAGE_TAG',
|
||||
tag: 'ACCOUNT_UPDATE'
|
||||
tag: message_tag
|
||||
}
|
||||
end
|
||||
|
||||
def message_tag
|
||||
@message_tag ||= GlobalConfigService.load('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT', nil) ? 'HUMAN_AGENT' : 'ACCOUNT_UPDATE'
|
||||
end
|
||||
|
||||
def attachment_type(attachment)
|
||||
return attachment.file_type if %w[image audio video file].include? attachment.file_type
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
class Integrations::Shopify::ClientService
|
||||
include Shopify::IntegrationHelper
|
||||
|
||||
API_VERSION = '2025-01'.freeze
|
||||
|
||||
def initialize(account:)
|
||||
@account = account
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize
|
||||
def fetch_client
|
||||
Rails.logger.info("[Integrations::Shopify::ClientService] fetch_client account_id=#{@account.id}")
|
||||
unless hook
|
||||
Rails.logger.warn("[Integrations::Shopify::ClientService] not_connected account_id=#{@account.id}")
|
||||
return failure(:not_connected, 'Shopify integration is not connected.')
|
||||
end
|
||||
|
||||
setup_shopify_context
|
||||
|
||||
Rails.logger.info(
|
||||
"[Integrations::Shopify::ClientService] connected account_id=#{@account.id} " \
|
||||
"shop=#{hook.reference_id} scopes=#{granted_scopes.join(',')}"
|
||||
)
|
||||
|
||||
success(
|
||||
client: ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session),
|
||||
hook: hook,
|
||||
scopes: granted_scopes
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Integrations::Shopify::ClientService] #{e.class}: #{e.message}")
|
||||
failure(:provider_error, 'Unable to communicate with Shopify.')
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize
|
||||
|
||||
def scopes_include?(*required_scopes)
|
||||
required_scopes.flatten.all? { |scope| granted_scopes.include?(scope) }
|
||||
end
|
||||
|
||||
def granted_scopes
|
||||
parse_scopes(scope_value)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def hook
|
||||
@hook ||= Integrations::Hook.find_by(account: @account, app_id: 'shopify', status: :enabled)
|
||||
end
|
||||
|
||||
def setup_shopify_context
|
||||
raise 'Shopify client credentials are missing.' if client_id.blank? || client_secret.blank?
|
||||
|
||||
ShopifyAPI::Context.setup(
|
||||
api_key: client_id,
|
||||
api_secret_key: client_secret,
|
||||
api_version: API_VERSION,
|
||||
scope: REQUIRED_SCOPES.join(','),
|
||||
is_embedded: true,
|
||||
is_private: false
|
||||
)
|
||||
end
|
||||
|
||||
def shopify_session
|
||||
ShopifyAPI::Auth::Session.new(shop: hook.reference_id, access_token: hook.access_token)
|
||||
end
|
||||
|
||||
def scope_value
|
||||
settings = hook&.settings || {}
|
||||
settings['scope'] || settings[:scope]
|
||||
end
|
||||
|
||||
def parse_scopes(raw_scope)
|
||||
raw_scope.to_s.split(',').map(&:strip).reject(&:blank?).uniq
|
||||
end
|
||||
|
||||
def success(data)
|
||||
{ ok: true, data: data }
|
||||
end
|
||||
|
||||
def failure(code, message)
|
||||
{ ok: false, error: { code: code, message: message } }
|
||||
end
|
||||
end
|
||||
@@ -1,162 +0,0 @@
|
||||
class Integrations::Shopify::OrdersService
|
||||
REQUIRED_SCOPES = %w[read_customers read_orders].freeze
|
||||
MAX_LIMIT = 10
|
||||
MAX_LINE_ITEMS = 5
|
||||
|
||||
def initialize(account:)
|
||||
@account = account
|
||||
@client_service = Integrations::Shopify::ClientService.new(account: account)
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
||||
def orders_for_contact(email:, phone_number:, limit: MAX_LIMIT)
|
||||
Rails.logger.info(
|
||||
"[Integrations::Shopify::OrdersService] orders_for_contact account_id=#{@account.id} " \
|
||||
"email_present=#{email.present?} phone_present=#{phone_number.present?} limit=#{limit}"
|
||||
)
|
||||
|
||||
identifiers = normalized_identifiers(email, phone_number)
|
||||
return missing_identifier_result if identifiers[:email].blank? && identifiers[:phone_number].blank?
|
||||
|
||||
client_result = @client_service.fetch_client
|
||||
return client_result unless client_result[:ok]
|
||||
|
||||
unless orders_scopes_granted?
|
||||
Rails.logger.warn(
|
||||
"[Integrations::Shopify::OrdersService] missing_scope account_id=#{@account.id} " \
|
||||
"required=#{REQUIRED_SCOPES.join(',')} granted=#{@client_service.granted_scopes.join(',')}"
|
||||
)
|
||||
return insufficient_scope_result
|
||||
end
|
||||
|
||||
customers = fetch_customers(client_result[:data][:client], identifiers[:email], identifiers[:phone_number])
|
||||
Rails.logger.info(
|
||||
"[Integrations::Shopify::OrdersService] customer_lookup account_id=#{@account.id} " \
|
||||
"customers_count=#{customers.length}"
|
||||
)
|
||||
return no_customer_result if customers.empty?
|
||||
|
||||
orders = fetch_orders(client_result[:data][:client], customers.first['id'], normalized_limit(limit))
|
||||
Rails.logger.info(
|
||||
"[Integrations::Shopify::OrdersService] orders_lookup account_id=#{@account.id} " \
|
||||
"customer_id=#{customers.first['id']} orders_count=#{orders.length}"
|
||||
)
|
||||
return failure(:no_results, 'No orders found for the customer.') if orders.empty?
|
||||
|
||||
success(
|
||||
orders: orders.map { |order| normalize_order(order, client_result[:data][:hook].reference_id) }
|
||||
)
|
||||
rescue ShopifyAPI::Errors::HttpResponseError => e
|
||||
Rails.logger.error("[Integrations::Shopify::OrdersService] Shopify error: #{e.message}")
|
||||
failure(:provider_error, 'Shopify order lookup failed.')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Integrations::Shopify::OrdersService] #{e.class}: #{e.message}")
|
||||
failure(:provider_error, 'Shopify order lookup failed.')
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
||||
|
||||
private
|
||||
|
||||
def normalized_identifiers(email, phone_number)
|
||||
{
|
||||
email: email.to_s.strip,
|
||||
phone_number: phone_number.to_s.strip
|
||||
}
|
||||
end
|
||||
|
||||
def missing_identifier_result
|
||||
Rails.logger.info("[Integrations::Shopify::OrdersService] missing_identifier account_id=#{@account.id}")
|
||||
failure(:missing_identifier, 'A contact email or phone number is required.')
|
||||
end
|
||||
|
||||
def no_customer_result
|
||||
Rails.logger.info("[Integrations::Shopify::OrdersService] no_customer_result account_id=#{@account.id}")
|
||||
failure(:no_results, 'No matching Shopify customer found.')
|
||||
end
|
||||
|
||||
def orders_scopes_granted?
|
||||
@client_service.scopes_include?(REQUIRED_SCOPES)
|
||||
end
|
||||
|
||||
def insufficient_scope_result
|
||||
failure(
|
||||
:insufficient_scope,
|
||||
'Shopify integration is missing customer/order read scopes.'
|
||||
)
|
||||
end
|
||||
|
||||
def fetch_customers(client, email, phone_number)
|
||||
query_parts = []
|
||||
query_parts << "email:#{email}" if email.present?
|
||||
query_parts << "phone:#{phone_number}" if phone_number.present?
|
||||
|
||||
response = client.get(
|
||||
path: 'customers/search.json',
|
||||
query: {
|
||||
query: query_parts.join(' OR '),
|
||||
fields: 'id,email,phone'
|
||||
}
|
||||
)
|
||||
|
||||
response.body['customers'] || []
|
||||
end
|
||||
|
||||
def fetch_orders(client, customer_id, limit)
|
||||
response = client.get(
|
||||
path: 'orders.json',
|
||||
query: {
|
||||
customer_id: customer_id,
|
||||
status: 'any',
|
||||
limit: limit,
|
||||
fields: 'id,name,created_at,total_price,currency,fulfillment_status,financial_status,line_items'
|
||||
}
|
||||
)
|
||||
|
||||
response.body['orders'] || []
|
||||
end
|
||||
|
||||
def normalize_order(order, shop_domain)
|
||||
{
|
||||
id: order['id'],
|
||||
name: order['name'],
|
||||
created_at: order['created_at'],
|
||||
total_price: order['total_price'],
|
||||
currency: order['currency'],
|
||||
financial_status: order['financial_status'],
|
||||
fulfillment_status: order['fulfillment_status'],
|
||||
line_items: normalize_line_items(order['line_items']),
|
||||
admin_url: admin_url(shop_domain, order['id'])
|
||||
}
|
||||
end
|
||||
|
||||
def normalize_line_items(line_items)
|
||||
Array(line_items).first(MAX_LINE_ITEMS).map do |line_item|
|
||||
{
|
||||
title: line_item['title'],
|
||||
quantity: line_item['quantity'],
|
||||
price: line_item['price']
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def admin_url(shop_domain, order_id)
|
||||
return nil if shop_domain.blank? || order_id.blank?
|
||||
|
||||
"https://#{shop_domain}/admin/orders/#{order_id}"
|
||||
end
|
||||
|
||||
def normalized_limit(limit)
|
||||
value = limit.to_i
|
||||
return MAX_LIMIT if value <= 0
|
||||
|
||||
[value, MAX_LIMIT].min
|
||||
end
|
||||
|
||||
def success(data)
|
||||
{ ok: true, data: data }
|
||||
end
|
||||
|
||||
def failure(code, message)
|
||||
{ ok: false, error: { code: code, message: message } }
|
||||
end
|
||||
end
|
||||
@@ -1,168 +0,0 @@
|
||||
class Integrations::Shopify::ProductsService
|
||||
REQUIRED_SCOPE = 'read_products'.freeze
|
||||
MAX_LIMIT = 10
|
||||
FALLBACK_MULTIPLIER = 5
|
||||
MAX_FALLBACK_LIMIT = 50
|
||||
|
||||
def initialize(account:)
|
||||
@account = account
|
||||
@client_service = Integrations::Shopify::ClientService.new(account: account)
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
||||
def search_products(query:, limit: MAX_LIMIT)
|
||||
Rails.logger.info(
|
||||
"[Integrations::Shopify::ProductsService] search_products account_id=#{@account.id} " \
|
||||
"query=#{query.inspect} limit=#{limit}"
|
||||
)
|
||||
return no_products_result(query) if query.blank?
|
||||
|
||||
client_result = @client_service.fetch_client
|
||||
return client_result unless client_result[:ok]
|
||||
|
||||
unless product_scope_granted?
|
||||
Rails.logger.warn(
|
||||
"[Integrations::Shopify::ProductsService] missing_scope account_id=#{@account.id} " \
|
||||
"required=#{REQUIRED_SCOPE} granted=#{@client_service.granted_scopes.join(',')}"
|
||||
)
|
||||
return failure(:insufficient_scope, 'Shopify integration is missing read_products scope.')
|
||||
end
|
||||
|
||||
products = fetch_products(client_result[:data][:client], query, normalized_limit(limit))
|
||||
Rails.logger.info(
|
||||
"[Integrations::Shopify::ProductsService] shopify_response account_id=#{@account.id} " \
|
||||
"query=#{query.inspect} products_count=#{products.length}"
|
||||
)
|
||||
|
||||
return no_products_result(query) if products.empty?
|
||||
|
||||
success(
|
||||
products: products.map { |product| normalize_product(product, client_result[:data][:hook].reference_id) }
|
||||
)
|
||||
rescue ShopifyAPI::Errors::HttpResponseError => e
|
||||
Rails.logger.error("[Integrations::Shopify::ProductsService] Shopify error: #{e.message}")
|
||||
failure(:provider_error, 'Shopify product search failed.')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Integrations::Shopify::ProductsService] #{e.class}: #{e.message}")
|
||||
failure(:provider_error, 'Shopify product search failed.')
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
||||
|
||||
private
|
||||
|
||||
def product_scope_granted?
|
||||
@client_service.scopes_include?(REQUIRED_SCOPE)
|
||||
end
|
||||
|
||||
def no_products_result(query)
|
||||
Rails.logger.info(
|
||||
"[Integrations::Shopify::ProductsService] no_results account_id=#{@account.id} query=#{query.inspect}"
|
||||
)
|
||||
failure(:no_results, 'No products found for the provided query.')
|
||||
end
|
||||
|
||||
def fetch_products(client, query, limit)
|
||||
products = fetch_products_by_title(client, query, limit)
|
||||
return products if products.any?
|
||||
|
||||
fallback_limit = [(limit * FALLBACK_MULTIPLIER), MAX_FALLBACK_LIMIT].min
|
||||
Rails.logger.info(
|
||||
"[Integrations::Shopify::ProductsService] fallback_keyword_search account_id=#{@account.id} " \
|
||||
"query=#{query.inspect} fallback_limit=#{fallback_limit}"
|
||||
)
|
||||
|
||||
fallback_products = fetch_active_products(client, fallback_limit)
|
||||
filter_products_by_keyword(fallback_products, query).first(limit)
|
||||
end
|
||||
|
||||
def fetch_products_by_title(client, query, limit)
|
||||
response = client.get(
|
||||
path: 'products.json',
|
||||
query: product_query(limit: limit, title: query)
|
||||
)
|
||||
response.body['products'] || []
|
||||
end
|
||||
|
||||
def fetch_active_products(client, limit)
|
||||
response = client.get(
|
||||
path: 'products.json',
|
||||
query: product_query(limit: limit)
|
||||
)
|
||||
response.body['products'] || []
|
||||
end
|
||||
|
||||
def product_query(limit:, title: nil)
|
||||
{
|
||||
title: title,
|
||||
status: 'active',
|
||||
limit: limit,
|
||||
fields: 'id,title,vendor,product_type,handle,variants'
|
||||
}.compact
|
||||
end
|
||||
|
||||
def filter_products_by_keyword(products, query)
|
||||
keyword = query.to_s.downcase.strip
|
||||
return [] if keyword.blank?
|
||||
|
||||
products.select do |product|
|
||||
searchable_text = [product['title'], product['vendor'], product['product_type']].compact.join(' ').downcase
|
||||
searchable_text.include?(keyword)
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_product(product, shop_domain)
|
||||
variants = Array(product['variants'])
|
||||
first_variant = variants.first || {}
|
||||
|
||||
{
|
||||
id: product['id'],
|
||||
title: product['title'],
|
||||
vendor: product['vendor'],
|
||||
product_type: product['product_type'],
|
||||
handle: product['handle'],
|
||||
storefront_url: storefront_url(shop_domain, product['handle']),
|
||||
price: first_variant['price'],
|
||||
availability: availability_summary(variants)
|
||||
}
|
||||
end
|
||||
|
||||
def storefront_url(shop_domain, handle)
|
||||
return nil if shop_domain.blank? || handle.blank?
|
||||
|
||||
"https://#{shop_domain}/products/#{handle}"
|
||||
end
|
||||
|
||||
def availability_summary(variants)
|
||||
return 'Out of stock' if variants.empty?
|
||||
|
||||
available_count = variants.count { |variant| variant_available?(variant) }
|
||||
return 'Out of stock' if available_count.zero?
|
||||
return 'In stock' if available_count == variants.size
|
||||
|
||||
"Partially in stock (#{available_count}/#{variants.size} variants)"
|
||||
end
|
||||
|
||||
def variant_available?(variant)
|
||||
return variant['available'] if [true, false].include?(variant['available'])
|
||||
|
||||
quantity = variant['inventory_quantity']
|
||||
return quantity.to_i.positive? unless quantity.nil?
|
||||
|
||||
variant['inventory_policy'] == 'continue'
|
||||
end
|
||||
|
||||
def normalized_limit(limit)
|
||||
value = limit.to_i
|
||||
return MAX_LIMIT if value <= 0
|
||||
|
||||
[value, MAX_LIMIT].min
|
||||
end
|
||||
|
||||
def success(data)
|
||||
{ ok: true, data: data }
|
||||
end
|
||||
|
||||
def failure(code, message)
|
||||
{ ok: false, error: { code: code, message: message } }
|
||||
end
|
||||
end
|
||||
@@ -34,7 +34,7 @@ By default, it renders:
|
||||
<% if content_for?(:head) %>
|
||||
<%= yield(:head) %>
|
||||
<% else %>
|
||||
<title><%= @portal.page_title%></title>
|
||||
<title><%= @portal.display_title %></title>
|
||||
<% end %>
|
||||
|
||||
<% if @portal.logo.present? %>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<% content_for :head do %>
|
||||
<title><%= @portal.name %></title>
|
||||
<meta name="title" content="<%= @portal.name %>">
|
||||
<title><%= @portal.display_title %></title>
|
||||
<meta name="title" content="<%= @portal.display_title %>">
|
||||
|
||||
<% if @og_image_url.present? %>
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% content_for :head do %>
|
||||
<title><%= @article.title %> | <%= @portal.name %></title>
|
||||
<title><%= @article.title %> | <%= @portal.display_title %></title>
|
||||
<% if @article.meta["title"].present? %>
|
||||
<meta name="title" content="<%= @article.meta["title"] %>">
|
||||
<meta property="og:title" content="<%= @article.meta["title"] %>">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<% content_for :head do %>
|
||||
<title><%= @category.name %> | <%= @portal.name %></title>
|
||||
<meta name="title" content="<%= @category.name %> | <%= @portal.name %>">
|
||||
<title><%= @category.name %> | <%= @portal.display_title %></title>
|
||||
<meta name="title" content="<%= @category.name %> | <%= @portal.display_title %>">
|
||||
<% if @category.description.present? %>
|
||||
<meta name="description" content="<%= @category.description %>">
|
||||
<meta property="og:description" content="<%= @category.description %>">
|
||||
|
||||
@@ -30,16 +30,6 @@
|
||||
description: 'Search FAQ responses using semantic similarity'
|
||||
icon: 'search'
|
||||
|
||||
- id: shopify_search_products
|
||||
title: 'Shopify: Search Products'
|
||||
description: 'Search products in the connected Shopify store'
|
||||
icon: 'shopping-bag'
|
||||
|
||||
- id: shopify_get_orders
|
||||
title: 'Shopify: Get Orders'
|
||||
description: "Look up a customer's orders from Shopify"
|
||||
icon: 'shopping-cart'
|
||||
|
||||
- id: resolve_conversation
|
||||
title: 'Resolve Conversation'
|
||||
description: 'Resolve a conversation when the issue has been addressed'
|
||||
|
||||
+2
-3
@@ -74,10 +74,9 @@
|
||||
- name: voice_recorder
|
||||
display_name: Voice Recorder
|
||||
enabled: true
|
||||
- name: mobile_v2
|
||||
display_name: Mobile App V2
|
||||
- name: report_rollup
|
||||
display_name: Report Rollup
|
||||
enabled: false
|
||||
deprecated: true
|
||||
- name: channel_website
|
||||
display_name: Website Channel
|
||||
enabled: true
|
||||
|
||||
@@ -387,10 +387,6 @@
|
||||
description: 'The Client Secret (API Secret Key) from your Shopify Partner account'
|
||||
locked: false
|
||||
type: secret
|
||||
- name: SHOPIFY_APP_STORE_URL
|
||||
display_title: 'Shopify App Store URL'
|
||||
description: 'The Shopify App Store listing URL (e.g., https://apps.shopify.com/your-app)'
|
||||
locked: false
|
||||
# ------- End of Shopify Related Config ------- #
|
||||
|
||||
# ------- Instagram Channel Related Config ------- #
|
||||
|
||||
@@ -236,6 +236,7 @@ en:
|
||||
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
|
||||
resolved_by_tool: 'Conversation was marked resolved by %{user_name}: %{reason}'
|
||||
open: 'Conversation was marked open by %{user_name}'
|
||||
auto_opened_after_agent_reply: 'Conversation was marked open automatically after an agent reply'
|
||||
agent_bot:
|
||||
error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
|
||||
status:
|
||||
|
||||
+4
-2
@@ -317,8 +317,8 @@ Rails.application.routes.draw do
|
||||
end
|
||||
resource :shopify, controller: 'shopify', only: [:destroy] do
|
||||
collection do
|
||||
post :auth
|
||||
get :orders
|
||||
post :complete_install
|
||||
end
|
||||
end
|
||||
resource :linear, controller: 'linear', only: [] do
|
||||
@@ -348,7 +348,9 @@ Rails.application.routes.draw do
|
||||
post :send_instructions
|
||||
get :ssl_status
|
||||
end
|
||||
resources :categories
|
||||
resources :categories do
|
||||
post :reorder, on: :collection
|
||||
end
|
||||
resources :articles do
|
||||
post :reorder, on: :collection
|
||||
end
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
class DisableReportRollupForAllAccounts < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
Account.feature_report_rollup.find_each(batch_size: 100) do |account|
|
||||
account.disable_features(:report_rollup)
|
||||
account.save!(validate: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
|
||||
@@ -8,6 +8,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
@inbox = conversation.inbox
|
||||
@assistant = assistant
|
||||
|
||||
return unless conversation_pending?
|
||||
|
||||
Current.executed_by = @assistant
|
||||
|
||||
if captain_v2_enabled?
|
||||
@@ -15,9 +17,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
else
|
||||
generate_and_process_response
|
||||
end
|
||||
rescue ActiveStorage::FileNotFoundError, Faraday::BadRequestError => e
|
||||
handle_error(e)
|
||||
raise e
|
||||
rescue StandardError => e
|
||||
raise e if e.is_a?(ActiveStorage::FileNotFoundError) || e.is_a?(Faraday::BadRequestError)
|
||||
|
||||
handle_error(e)
|
||||
ensure
|
||||
Current.executed_by = nil
|
||||
@@ -42,6 +45,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def process_response
|
||||
return unless conversation_pending?
|
||||
|
||||
if handoff_requested?
|
||||
process_action('handoff')
|
||||
else
|
||||
@@ -144,4 +149,9 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
def captain_v2_enabled?
|
||||
account.feature_enabled?('captain_integration_v2')
|
||||
end
|
||||
|
||||
def conversation_pending?
|
||||
status = Conversation.where(id: @conversation.id).pick(:status)
|
||||
status == 'pending' || status == Conversation.statuses[:pending]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,7 +37,6 @@ class Captain::Assistant < ApplicationRecord
|
||||
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
|
||||
|
||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name
|
||||
SHOPIFY_TOOL_IDS = %w[shopify_search_products shopify_get_orders].freeze
|
||||
|
||||
validates :name, presence: true
|
||||
validates :description, presence: true
|
||||
@@ -53,7 +52,6 @@ class Captain::Assistant < ApplicationRecord
|
||||
|
||||
def available_agent_tools
|
||||
tools = self.class.built_in_agent_tools.dup
|
||||
tools = filter_shopify_tool_metadata(tools)
|
||||
|
||||
custom_tools = account.captain_custom_tools.enabled.map(&:to_tool_metadata)
|
||||
tools.concat(custom_tools)
|
||||
@@ -94,25 +92,10 @@ class Captain::Assistant < ApplicationRecord
|
||||
end
|
||||
|
||||
def agent_tools
|
||||
tools = [
|
||||
[
|
||||
self.class.resolve_tool_class('faq_lookup').new(self),
|
||||
self.class.resolve_tool_class('handoff').new(self)
|
||||
]
|
||||
|
||||
if shopify_tools_enabled_for_v2?
|
||||
tools.concat(
|
||||
SHOPIFY_TOOL_IDS.filter_map do |tool_id|
|
||||
tool_class = self.class.resolve_tool_class(tool_id)
|
||||
tool_class&.new(self)
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
tools
|
||||
end
|
||||
|
||||
def shopify_tools_enabled_for_v2?
|
||||
account.feature_enabled?('captain_integration_v2') && shopify_connected?
|
||||
end
|
||||
|
||||
def prompt_context
|
||||
@@ -135,14 +118,4 @@ class Captain::Assistant < ApplicationRecord
|
||||
def default_avatar_url
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/assets/images/dashboard/captain/logo.svg"
|
||||
end
|
||||
|
||||
def filter_shopify_tool_metadata(tools)
|
||||
return tools if shopify_tools_enabled_for_v2?
|
||||
|
||||
tools.reject { |tool| SHOPIFY_TOOL_IDS.include?(tool[:id]) }
|
||||
end
|
||||
|
||||
def shopify_connected?
|
||||
Integrations::Hook.exists?(account_id: account_id, app_id: 'shopify', status: :enabled)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,11 +30,13 @@ class Company < ApplicationRecord
|
||||
|
||||
belongs_to :account
|
||||
has_many :contacts, dependent: :nullify
|
||||
after_create_commit :fetch_favicon, if: -> { domain.present? }
|
||||
|
||||
scope :ordered_by_name, -> { order(:name) }
|
||||
scope :search_by_name_or_domain, lambda { |query|
|
||||
where('name ILIKE :search OR domain ILIKE :search', search: "%#{query.strip}%")
|
||||
}
|
||||
|
||||
scope :order_on_contacts_count, lambda { |direction|
|
||||
order(
|
||||
Arel::Nodes::SqlLiteral.new(
|
||||
@@ -42,4 +44,10 @@ class Company < ApplicationRecord
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private
|
||||
|
||||
def fetch_favicon
|
||||
Avatar::AvatarFromFaviconJob.set(wait: 5.seconds).perform_later(self)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,7 +22,7 @@ module Concerns::CaptainToolsHelpers
|
||||
# @param tool_id [String] The snake_case tool identifier
|
||||
# @return [Class, nil] The tool class if found, nil if not resolvable
|
||||
def resolve_tool_class(tool_id)
|
||||
class_name = "Captain::Tools::#{tool_id.camelize}Tool"
|
||||
class_name = "Captain::Tools::#{tool_id.classify}Tool"
|
||||
class_name.safe_constantize
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
module Enterprise::Message
|
||||
private
|
||||
|
||||
def mark_pending_conversation_as_open_for_human_response
|
||||
return unless captain_pending_conversation?
|
||||
return unless human_response?
|
||||
return if private?
|
||||
|
||||
previous_user = Current.user
|
||||
previous_executed_by = Current.executed_by
|
||||
Current.user = nil
|
||||
Current.executed_by = nil
|
||||
|
||||
begin
|
||||
conversation.open!
|
||||
return unless conversation.saved_change_to_status?
|
||||
|
||||
create_captain_auto_open_activity_message
|
||||
ensure
|
||||
Current.user = previous_user
|
||||
Current.executed_by = previous_executed_by
|
||||
end
|
||||
end
|
||||
|
||||
def captain_pending_conversation?
|
||||
return false unless conversation.pending?
|
||||
|
||||
::CaptainInbox.exists?(inbox_id: conversation.inbox_id)
|
||||
end
|
||||
|
||||
def create_captain_auto_open_activity_message
|
||||
::Conversations::ActivityMessageJob.perform_later(
|
||||
conversation,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: I18n.t('conversations.activity.captain.auto_opened_after_agent_reply', locale: conversation.account.locale)
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -22,4 +22,8 @@ module Enterprise::CategoryPolicy
|
||||
def destroy?
|
||||
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
|
||||
end
|
||||
|
||||
def reorder?
|
||||
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Captain::Llm::ContactAttributesService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
def initialize(assistant, conversation)
|
||||
super()
|
||||
@assistant = assistant
|
||||
@@ -52,7 +53,7 @@ class Captain::Llm::ContactAttributesService < Llm::BaseAiService
|
||||
def parse_response(content)
|
||||
return [] if content.nil?
|
||||
|
||||
JSON.parse(content.strip).fetch('attributes', [])
|
||||
JSON.parse(sanitize_json_response(content)).fetch('attributes', [])
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "Error in parsing GPT processed response: #{e.message}"
|
||||
[]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Captain::Llm::ContactNotesService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
def initialize(assistant, conversation)
|
||||
super()
|
||||
@assistant = assistant
|
||||
@@ -55,7 +56,7 @@ class Captain::Llm::ContactNotesService < Llm::BaseAiService
|
||||
def parse_response(response)
|
||||
return [] if response.nil?
|
||||
|
||||
JSON.parse(response.strip).fetch('notes', [])
|
||||
JSON.parse(sanitize_json_response(response)).fetch('notes', [])
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "Error in parsing GPT processed response: #{e.message}"
|
||||
[]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Captain::Llm::ConversationFaqService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
DISTANCE_THRESHOLD = 0.3
|
||||
|
||||
def initialize(assistant, conversation)
|
||||
@@ -118,7 +119,7 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
|
||||
def parse_response(response)
|
||||
return [] if response.nil?
|
||||
|
||||
JSON.parse(response.strip).fetch('faqs', [])
|
||||
JSON.parse(sanitize_json_response(response)).fetch('faqs', [])
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "Error in parsing GPT processed response: #{e.message}"
|
||||
[]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user