Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef5d2704eb | ||
|
|
c59dac2fcf | ||
|
|
6f26f0e34b | ||
|
|
51e154790c | ||
|
|
7a2578e814 | ||
|
|
c0f47f1319 | ||
|
|
565a26be21 | ||
|
|
428b07157c | ||
|
|
63837ebef3 | ||
|
|
174630bec1 | ||
|
|
a363f22d82 | ||
|
|
a450d6437d | ||
|
|
a33ee3a1d0 | ||
|
|
1a73682c9a | ||
|
|
5f92426e85 | ||
|
|
68feaf99f6 | ||
|
|
7ef91bee53 | ||
|
|
0d2c0a5656 | ||
|
|
0e54bd17e5 | ||
|
|
6830c3273f | ||
|
|
2c728341e0 | ||
|
|
2b8d1a67b3 | ||
|
|
f4e0481b00 | ||
|
|
67dbcca638 | ||
|
|
1957cd15dc | ||
|
|
88d3876c2b | ||
|
|
29eecfafcb | ||
|
|
dd2f3d80cc | ||
|
|
3728d71176 | ||
|
|
1e57cff70a | ||
|
|
a921a38db9 | ||
|
|
7bb90d84cd | ||
|
|
21ea2ae08d | ||
|
|
35f14c7e02 | ||
|
|
40b77ca001 | ||
|
|
e2cd372d2d | ||
|
|
a1fdba2783 | ||
|
|
d55c5660a3 | ||
|
|
7a8903051d | ||
|
|
1e6291616d | ||
|
|
7a73a68753 | ||
|
|
55d68d930d | ||
|
|
e328492463 | ||
|
|
82f28aa7fb | ||
|
|
5c0cbcbaaa | ||
|
|
5d5fa0c21a | ||
|
|
e12e686244 | ||
|
|
cdccbb39aa | ||
|
|
30ee4a61fc |
@@ -2,14 +2,6 @@
|
||||
# It initializes with necessary attributes and provides a perform method
|
||||
# to create a user and account user in a transaction.
|
||||
class AgentBuilder
|
||||
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
|
||||
|
||||
class LimitExceededError < StandardError
|
||||
def initialize
|
||||
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
|
||||
end
|
||||
end
|
||||
|
||||
# Initializes an AgentBuilder with necessary attributes.
|
||||
# @param email [String] the email of the user.
|
||||
# @param name [String] the name of the user.
|
||||
@@ -22,23 +14,15 @@ class AgentBuilder
|
||||
# Creates a user and account user in a transaction.
|
||||
# @return [User] the created user.
|
||||
def perform
|
||||
account.with_lock do
|
||||
raise LimitExceededError unless can_add_agent?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
end
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
end
|
||||
@user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_add_agent?
|
||||
account.usage_limits[:agents] > account.account_users.count
|
||||
end
|
||||
|
||||
# Finds a user by email or creates a new one with a temporary password.
|
||||
# @return [User] the found or created user.
|
||||
def find_or_create_user
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent, except: [:create, :index, :bulk_create]
|
||||
before_action :check_authorization
|
||||
before_action :validate_limit, only: [:create]
|
||||
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
|
||||
|
||||
def index
|
||||
@agents = agents
|
||||
@@ -18,8 +20,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
)
|
||||
|
||||
@agent = builder.perform
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -36,13 +36,25 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
def bulk_create
|
||||
emails = params[:emails]
|
||||
|
||||
bulk_create_agents(emails)
|
||||
emails.each do |email|
|
||||
builder = AgentBuilder.new(
|
||||
email: email,
|
||||
name: email.split('@').first,
|
||||
inviter: current_user,
|
||||
account: Current.account
|
||||
)
|
||||
begin
|
||||
builder.perform
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||
end
|
||||
end
|
||||
|
||||
# This endpoint is used to bulk create agents during onboarding
|
||||
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
|
||||
clear_onboarding_step
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
head :ok
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -75,33 +87,22 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
|
||||
end
|
||||
|
||||
def bulk_create_agents(emails)
|
||||
Current.account.with_lock do
|
||||
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
|
||||
def validate_limit_for_bulk_create
|
||||
limit_available = params[:emails].count <= available_agent_count
|
||||
|
||||
emails.each { |email| create_agent_from_email(email) }
|
||||
end
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
|
||||
end
|
||||
|
||||
def create_agent_from_email(email)
|
||||
builder = AgentBuilder.new(
|
||||
email: email,
|
||||
name: email.split('@').first,
|
||||
inviter: current_user,
|
||||
account: Current.account
|
||||
)
|
||||
builder.perform
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||
end
|
||||
|
||||
def clear_onboarding_step
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
def validate_limit
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
|
||||
end
|
||||
|
||||
def available_agent_count
|
||||
Current.account.usage_limits[:agents] - Current.account.account_users.count
|
||||
Current.account.usage_limits[:agents] - agents.count
|
||||
end
|
||||
|
||||
def can_add_agent?
|
||||
available_agent_count.positive?
|
||||
end
|
||||
|
||||
def delete_user_record(agent)
|
||||
|
||||
@@ -3,6 +3,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
|
||||
before_action :check_authorization
|
||||
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
|
||||
before_action :ensure_execution_delay_allowed, only: [:create, :update]
|
||||
|
||||
def index
|
||||
@automation_rules = Current.account.automation_rules
|
||||
@@ -48,6 +49,9 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
def clone
|
||||
automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id])
|
||||
new_rule = automation_rule.dup
|
||||
# dup copies execution_delay; drop it when the feature is off so clone can't create new
|
||||
# delayed rules that create/update would reject.
|
||||
new_rule.execution_delay = nil unless delayed_automations_enabled?
|
||||
new_rule.save!
|
||||
@automation_rule = new_rule
|
||||
end
|
||||
@@ -55,13 +59,27 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
private
|
||||
|
||||
def automation_rules_permit
|
||||
permitted_attributes = [:name, :description, :event_name, :active]
|
||||
permitted_attributes << :execution_delay if delayed_automations_enabled?
|
||||
|
||||
params.permit(
|
||||
:name, :description, :event_name, :active,
|
||||
*permitted_attributes,
|
||||
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
|
||||
actions: [:action_name, { action_params: [] }]
|
||||
)
|
||||
end
|
||||
|
||||
def ensure_execution_delay_allowed
|
||||
return if delayed_automations_enabled?
|
||||
return if params[:execution_delay].blank?
|
||||
|
||||
render json: { error: 'Delayed automations are not enabled for this account.' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def delayed_automations_enabled?
|
||||
Current.account.feature_enabled?('delayed_automations')
|
||||
end
|
||||
|
||||
def fetch_automation_rule
|
||||
@automation_rule = Current.account.automation_rules.find_by(id: params[:id])
|
||||
end
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
|
||||
private
|
||||
|
||||
# Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
|
||||
# Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
|
||||
def check_authorization
|
||||
authorize(:hook)
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
|
||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_hook, except: [:create]
|
||||
before_action :check_authorization
|
||||
|
||||
@@ -35,6 +35,10 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
|
||||
@hook = Current.account.hooks.find(params[:id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(:hook)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
|
||||
end
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
revoke_linear_token
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
|
||||
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: [:auth]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
before_action :validate_contact, only: [:orders]
|
||||
|
||||
def auth
|
||||
|
||||
@@ -142,8 +142,7 @@ class ConversationFinder
|
||||
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
|
||||
@conversations = @conversations.where(id: conversation_ids)
|
||||
when 'participating'
|
||||
participating_ids = current_user.participating_conversations.where(account_id: current_account.id).select(:id)
|
||||
@conversations = @conversations.where(id: participating_ids)
|
||||
@conversations = current_user.participating_conversations.where(account_id: current_account.id)
|
||||
when 'unattended'
|
||||
@conversations = @conversations.unattended
|
||||
end
|
||||
|
||||
@@ -26,20 +26,13 @@ class CaptainAssistant extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getMetrics({ assistantId, range, signal }) {
|
||||
getStats({ assistantId, range, signal }) {
|
||||
const requestConfig = {
|
||||
params: { range, timezone_offset: getTimezoneOffset() },
|
||||
};
|
||||
if (signal) requestConfig.signal = signal;
|
||||
|
||||
return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig);
|
||||
}
|
||||
|
||||
getFaqStats({ assistantId, signal }) {
|
||||
const requestConfig = {};
|
||||
if (signal) requestConfig.signal = signal;
|
||||
|
||||
return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig);
|
||||
return axios.get(`${this.url}/${assistantId}/stats`, requestConfig);
|
||||
}
|
||||
|
||||
getSummary({ assistantId, range, stats }) {
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
EditorState,
|
||||
Selection,
|
||||
imageResizeView,
|
||||
toggleMark,
|
||||
wrapInList,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import {
|
||||
suggestionsPlugin,
|
||||
@@ -19,6 +17,8 @@ import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
|
||||
import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
|
||||
import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
|
||||
import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds';
|
||||
import { toggleMark } from 'prosemirror-commands';
|
||||
import { wrapInList } from 'prosemirror-schema-list';
|
||||
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { isEscape } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
@@ -33,7 +33,9 @@ import {
|
||||
// constants
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import wootConstants, {
|
||||
META_RESTRICTION_STATUS_URL,
|
||||
} from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
@@ -93,6 +95,7 @@ export default {
|
||||
currentUserId: 'getCurrentUserID',
|
||||
listLoadingStatus: 'getAllMessagesLoaded',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
isOpen() {
|
||||
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
@@ -170,6 +173,13 @@ export default {
|
||||
instagramInbox
|
||||
);
|
||||
},
|
||||
isInstagramRestrictionBannerVisible() {
|
||||
return this.isOnChatwootCloud && this.isAnInstagramChannel;
|
||||
},
|
||||
instagramRestrictionStatusUrl() {
|
||||
return META_RESTRICTION_STATUS_URL;
|
||||
},
|
||||
|
||||
replyWindowBannerMessage() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
|
||||
@@ -454,7 +464,15 @@ export default {
|
||||
>
|
||||
<div ref="topBannerRef">
|
||||
<Banner
|
||||
v-if="!currentChat.can_reply"
|
||||
v-if="isInstagramRestrictionBannerVisible"
|
||||
color-scheme="warning"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
|
||||
:href-link="instagramRestrictionStatusUrl"
|
||||
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
|
||||
/>
|
||||
<Banner
|
||||
v-else-if="!currentChat.can_reply"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="replyWindowBannerMessage"
|
||||
|
||||
@@ -78,3 +78,5 @@ export default {
|
||||
},
|
||||
};
|
||||
export const DEFAULT_REDIRECT_URL = '/app/';
|
||||
export const META_RESTRICTION_STATUS_URL =
|
||||
'https://status.chatwoot.com/incident/948346';
|
||||
|
||||
@@ -14,6 +14,7 @@ export const FEATURE_FLAGS = {
|
||||
CRM: 'crm',
|
||||
CUSTOM_ATTRIBUTES: 'custom_attributes',
|
||||
DATA_IMPORT: 'data_import',
|
||||
DELAYED_AUTOMATIONS: 'delayed_automations',
|
||||
API_AND_WEBHOOKS: 'api_and_webhooks',
|
||||
INBOX_MANAGEMENT: 'inbox_management',
|
||||
INTEGRATIONS: 'integrations',
|
||||
|
||||
@@ -208,6 +208,12 @@ export const generateAutomationPayload = payload => {
|
||||
return automation;
|
||||
};
|
||||
|
||||
export const formatDelay = minutes => {
|
||||
if (minutes % 1440 === 0) return `${minutes / 1440}d`;
|
||||
if (minutes % 60 === 0) return `${minutes / 60}h`;
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
export const isCustomAttribute = (attrs, key) => {
|
||||
return attrs.find(attr => attr.key === key);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import {
|
||||
InputRule,
|
||||
inputRules,
|
||||
MessageMarkdownSerializer,
|
||||
MessageMarkdownTransformer,
|
||||
messageSchema,
|
||||
@@ -11,6 +9,7 @@ import * as Sentry from '@sentry/vue';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import { InputRule, inputRules } from 'prosemirror-inputrules';
|
||||
|
||||
/**
|
||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||
|
||||
@@ -28,6 +28,35 @@
|
||||
"PLACEHOLDER": "Please select one",
|
||||
"ERROR": "Event is required"
|
||||
},
|
||||
"EXECUTE": {
|
||||
"LABEL": "Delayed execution",
|
||||
"AFTER_DELAY": "Run after",
|
||||
"UNITS": {
|
||||
"MINUTES": "Minutes",
|
||||
"HOURS": "Hours",
|
||||
"DAYS": "Days"
|
||||
},
|
||||
"ERROR": "Delay must be between 10 minutes and 30 days",
|
||||
"ENDS_IF_LABEL": "Won't run if",
|
||||
"ENDS_IF": {
|
||||
"STATUS": "the conversation's status changes, or its conditions no longer match.",
|
||||
"CUSTOMER_REPLY": "the customer replies, or the conditions no longer match.",
|
||||
"AGENT_REPLY": "an agent replies, or the conditions no longer match.",
|
||||
"GENERIC": "there is a new reply on the conversation, or the conditions no longer match."
|
||||
},
|
||||
"HELP_TEXT": "Only applies to conversations with activity after the rule is created."
|
||||
},
|
||||
"TRIGGER": {
|
||||
"LABEL": "Trigger",
|
||||
"WHEN_LABEL": "When",
|
||||
"STATUS_LABEL": "Status is",
|
||||
"INBOX_LABEL": "Inbox",
|
||||
"OPTIONS": {
|
||||
"CUSTOMER_UNRESPONSIVE": "Customer unresponsive",
|
||||
"AGENT_UNRESPONSIVE": "Teammate unresponsive",
|
||||
"CONVERSATION_STATUS": "Conversation in a status"
|
||||
}
|
||||
},
|
||||
"CONDITIONS": {
|
||||
"LABEL": "Conditions"
|
||||
},
|
||||
@@ -49,7 +78,13 @@
|
||||
"CREATED_ON": "Created on",
|
||||
"ACTIONS": "Actions"
|
||||
},
|
||||
"404": "No automation rules found"
|
||||
"404": "No automation rules found",
|
||||
"SECTIONS": {
|
||||
"INSTANT": "Automations",
|
||||
"DELAYED": "Delayed execution"
|
||||
},
|
||||
"DELAY_BADGE": "Runs after {delay}",
|
||||
"DELAY_DISABLED_BANNER": "Delayed execution is disabled for this account. Delayed rules won't run until it is enabled again."
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Delete Automation Rule",
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
|
||||
"REPLYING_TO": "You are replying to:",
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
"DOWNLOAD": "Download",
|
||||
|
||||
@@ -58,7 +58,9 @@
|
||||
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
|
||||
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
|
||||
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.",
|
||||
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"STATUS_LINK": "View status update"
|
||||
},
|
||||
"TIKTOK": {
|
||||
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
|
||||
|
||||
@@ -26,28 +26,25 @@ const canDrilldown = computed(() => checkPermissions(['administrator']));
|
||||
const selectedRange = ref('this_month');
|
||||
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
const metricStats = ref(null);
|
||||
const faqStats = ref(null);
|
||||
const isFetchingMetrics = ref(false);
|
||||
const stats = ref(null);
|
||||
const isFetching = ref(false);
|
||||
|
||||
// Increments on every fetch so a response (or retry) from a superseded
|
||||
// range/assistant can't clobber the latest request's state.
|
||||
let metricsFetchToken = 0;
|
||||
let faqStatsFetchToken = 0;
|
||||
let metricsAbortController = null;
|
||||
let faqStatsAbortController = null;
|
||||
let fetchToken = 0;
|
||||
let abortController = null;
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
metricsFetchToken += 1;
|
||||
const token = metricsFetchToken;
|
||||
metricsAbortController?.abort();
|
||||
metricsAbortController = new AbortController();
|
||||
const { signal } = metricsAbortController;
|
||||
metricStats.value = null;
|
||||
isFetchingMetrics.value = true;
|
||||
const fetchStats = async () => {
|
||||
fetchToken += 1;
|
||||
const token = fetchToken;
|
||||
abortController?.abort();
|
||||
abortController = new AbortController();
|
||||
const { signal } = abortController;
|
||||
stats.value = null;
|
||||
isFetching.value = true;
|
||||
|
||||
const requestMetrics = () =>
|
||||
CaptainAssistant.getMetrics({
|
||||
const requestStats = () =>
|
||||
CaptainAssistant.getStats({
|
||||
assistantId: assistantId.value,
|
||||
range: selectedRange.value,
|
||||
signal,
|
||||
@@ -55,54 +52,25 @@ const fetchMetrics = async () => {
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
({ data } = await requestMetrics());
|
||||
({ data } = await requestStats());
|
||||
} catch {
|
||||
// One silent retry before giving up, unless the request was aborted.
|
||||
try {
|
||||
if (token === metricsFetchToken && !signal.aborted)
|
||||
({ data } = await requestMetrics());
|
||||
if (token === fetchToken && !signal.aborted)
|
||||
({ data } = await requestStats());
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (token !== metricsFetchToken || signal.aborted) return;
|
||||
metricStats.value = data;
|
||||
isFetchingMetrics.value = false;
|
||||
if (token !== fetchToken || signal.aborted) return;
|
||||
stats.value = data;
|
||||
isFetching.value = false;
|
||||
};
|
||||
|
||||
const fetchFaqStats = async () => {
|
||||
faqStatsFetchToken += 1;
|
||||
const token = faqStatsFetchToken;
|
||||
faqStatsAbortController?.abort();
|
||||
faqStatsAbortController = new AbortController();
|
||||
const { signal } = faqStatsAbortController;
|
||||
faqStats.value = null;
|
||||
onUnmounted(() => abortController?.abort());
|
||||
|
||||
try {
|
||||
const { data } = await CaptainAssistant.getFaqStats({
|
||||
assistantId: assistantId.value,
|
||||
signal,
|
||||
});
|
||||
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = data;
|
||||
} catch {
|
||||
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const summaryStats = computed(() => {
|
||||
if (!metricStats.value || !faqStats.value) return null;
|
||||
|
||||
return { ...metricStats.value, knowledge: faqStats.value };
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
metricsAbortController?.abort();
|
||||
faqStatsAbortController?.abort();
|
||||
});
|
||||
|
||||
watch([selectedRange, assistantId], fetchMetrics, { immediate: true });
|
||||
watch(assistantId, fetchFaqStats, { immediate: true });
|
||||
watch([selectedRange, assistantId], fetchStats, { immediate: true });
|
||||
|
||||
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
|
||||
// neutral, so we can colour the delta independently of its sign.
|
||||
@@ -122,7 +90,7 @@ const formatDuration = hours =>
|
||||
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
|
||||
|
||||
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
|
||||
const data = metricStats.value?.[statKey];
|
||||
const data = stats.value?.[statKey];
|
||||
if (!data) return { value: '—', trend: '', trendGood: null };
|
||||
|
||||
const sign = data.trend > 0 ? '+' : '';
|
||||
@@ -216,9 +184,9 @@ const closeDrilldown = () => {
|
||||
<div class="flex flex-col gap-6 pb-8">
|
||||
<InboxBanner />
|
||||
|
||||
<CoverageBanner :knowledge="faqStats ?? undefined" />
|
||||
<CoverageBanner :knowledge="stats?.knowledge" />
|
||||
|
||||
<WelcomeCard :range="selectedRange" :stats="summaryStats" />
|
||||
<WelcomeCard :range="selectedRange" :stats="stats" />
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
|
||||
@@ -231,15 +199,13 @@ const closeDrilldown = () => {
|
||||
:trend="metric.trend"
|
||||
:hint="metric.hint"
|
||||
:trend-good="metric.trendGood"
|
||||
:loading="isFetchingMetrics"
|
||||
:clickable="
|
||||
canDrilldown && Boolean(metric.metric) && !isFetchingMetrics
|
||||
"
|
||||
:loading="isFetching"
|
||||
:clickable="canDrilldown && Boolean(metric.metric) && !isFetching"
|
||||
@click="openDrilldown(metric)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<KnowledgeCard :knowledge="faqStats ?? undefined" />
|
||||
<KnowledgeCard :knowledge="stats?.knowledge" />
|
||||
|
||||
<QuickLinks />
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ const START_VALUE = {
|
||||
name: null,
|
||||
description: null,
|
||||
event_name: 'conversation_created',
|
||||
execution_delay: null,
|
||||
conditions: [
|
||||
{
|
||||
attribute_key: 'status',
|
||||
|
||||
+356
-76
@@ -6,6 +6,11 @@ import { useOperators } from 'dashboard/components-next/filter/operators';
|
||||
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
|
||||
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
|
||||
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import {
|
||||
generateAutomationPayload,
|
||||
@@ -14,6 +19,7 @@ import {
|
||||
showActionInput,
|
||||
} from 'dashboard/helper/automationHelper';
|
||||
import { validateAutomation } from 'dashboard/helper/validations';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -71,6 +77,28 @@ const INPUT_TYPE_MAP = {
|
||||
date: 'date',
|
||||
};
|
||||
|
||||
const DEFAULT_DELAY_MINUTES = 240; // 4 hours
|
||||
const MIN_DELAY_MINUTES = 10;
|
||||
const MAX_DELAY_MINUTES = 43200; // 30 days
|
||||
// A delayed rule is expressed as one meaningful trigger instead of a raw event + conditions. Each
|
||||
// trigger maps to the automation's event_name plus a preset condition: message_type for the two
|
||||
// unresponsive cases (reply-chase / awaiting-agent), or a chosen status for conversation_updated.
|
||||
const DELAYED_TRIGGERS = [
|
||||
{ key: 'conversation_status', eventName: 'conversation_updated' },
|
||||
{
|
||||
key: 'customer_unresponsive',
|
||||
eventName: 'message_created',
|
||||
messageType: 'outgoing',
|
||||
},
|
||||
{
|
||||
key: 'agent_unresponsive',
|
||||
eventName: 'message_created',
|
||||
messageType: 'incoming',
|
||||
},
|
||||
];
|
||||
const DEFAULT_TRIGGER = DELAYED_TRIGGERS[0].key;
|
||||
const DEFAULT_TRIGGER_STATUS = 'pending';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const { operators } = useOperators();
|
||||
@@ -78,6 +106,7 @@ const { operators } = useOperators();
|
||||
const dialogRef = ref(null);
|
||||
const conditionsRef = useTemplateRef('conditionsRef');
|
||||
const errors = ref({});
|
||||
const isDelayed = ref(false);
|
||||
|
||||
const isEditMode = computed(() => props.mode === 'edit');
|
||||
|
||||
@@ -184,6 +213,172 @@ const hasActionErrors = computed(() =>
|
||||
Object.keys(errors.value).some(key => key.startsWith('action_'))
|
||||
);
|
||||
|
||||
const allowsDelayedExecution = computed(() =>
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.DELAYED_AUTOMATIONS)
|
||||
);
|
||||
|
||||
// Trigger controls for the delayed flow. They own event_name + conditions while the wait is on.
|
||||
// selectedTrigger / triggerStatus are plain values (FilterSelect); triggerInboxes is an array of
|
||||
// { id, name } (MultiSelect) — empty means the rule applies to every inbox.
|
||||
const selectedTrigger = ref(DEFAULT_TRIGGER);
|
||||
const triggerStatus = ref(DEFAULT_TRIGGER_STATUS);
|
||||
const triggerInboxes = ref([]);
|
||||
|
||||
const isStatusTrigger = computed(
|
||||
() => selectedTrigger.value === 'conversation_status'
|
||||
);
|
||||
|
||||
// FilterSelect expects { label, value }.
|
||||
const statusSelectOptions = computed(() =>
|
||||
(props.getConditionDropdownValues('status') || [])
|
||||
.filter(option => option.id !== 'all')
|
||||
.map(option => ({ value: option.id, label: option.name }))
|
||||
);
|
||||
|
||||
const triggerSelectOptions = computed(() =>
|
||||
DELAYED_TRIGGERS.map(trigger => ({
|
||||
value: trigger.key,
|
||||
label: t(
|
||||
`AUTOMATION.ADD.FORM.TRIGGER.OPTIONS.${trigger.key.toUpperCase()}`
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
// MultiSelect expects (and returns) { id, name } options.
|
||||
const inboxOptions = computed(
|
||||
() => props.getConditionDropdownValues('inbox_id') || []
|
||||
);
|
||||
|
||||
// What ends the wait, mirroring the backend episode that arms the rule. Shown to the user so
|
||||
// they can predict when the rule runs. Conversation rules key on status; message rules key on
|
||||
// the reply that ends the wait (customer reply for outgoing, agent reply for incoming).
|
||||
const waitEndsKey = computed(() => {
|
||||
if (eventName.value !== 'message_created') return 'STATUS';
|
||||
const messageType = (automation.value?.conditions || []).find(
|
||||
condition => condition.attribute_key === 'message_type'
|
||||
);
|
||||
const raw = Array.isArray(messageType?.values)
|
||||
? messageType.values[0]
|
||||
: messageType?.values;
|
||||
// Raw create-mode values are strings ('outgoing'); edit-mode values are option objects.
|
||||
const value = raw && typeof raw === 'object' ? raw.id : raw;
|
||||
if (value === 'outgoing') return 'CUSTOMER_REPLY';
|
||||
if (value === 'incoming') return 'AGENT_REPLY';
|
||||
return 'GENERIC';
|
||||
});
|
||||
|
||||
// DurationInput holds the wait in minutes and clamps to [MIN, MAX]; the unit is display-only.
|
||||
const delayMinutes = ref(DEFAULT_DELAY_MINUTES);
|
||||
const delayUnit = ref(DURATION_UNITS.HOURS);
|
||||
|
||||
const executionDelayInvalid = computed(
|
||||
() => isDelayed.value && !Number.isFinite(delayMinutes.value)
|
||||
);
|
||||
|
||||
// Show the wait in the largest whole unit (240 min → 4 hours). Passed in by open() rather than
|
||||
// read from `automation`, whose model prop only settles a tick later.
|
||||
const syncDelayFromDelay = delay => {
|
||||
isDelayed.value = Boolean(delay);
|
||||
const minutes = delay || DEFAULT_DELAY_MINUTES;
|
||||
if (minutes % 1440 === 0) delayUnit.value = DURATION_UNITS.DAYS;
|
||||
else if (minutes % 60 === 0) delayUnit.value = DURATION_UNITS.HOURS;
|
||||
else delayUnit.value = DURATION_UNITS.MINUTES;
|
||||
delayMinutes.value = minutes;
|
||||
};
|
||||
|
||||
watch([isDelayed, delayMinutes], () => {
|
||||
if (!automation.value || !allowsDelayedExecution.value) return;
|
||||
automation.value.execution_delay = isDelayed.value
|
||||
? delayMinutes.value
|
||||
: null;
|
||||
});
|
||||
|
||||
const buildTriggerCondition = (attributeKey, values) => ({
|
||||
attribute_key: attributeKey,
|
||||
filter_operator: 'equal_to',
|
||||
values,
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
});
|
||||
|
||||
// Write the selected trigger (plus optional inbox scope) onto the rule's event_name + conditions.
|
||||
const applyDelayedTrigger = () => {
|
||||
const trigger = DELAYED_TRIGGERS.find(
|
||||
item => item.key === selectedTrigger.value
|
||||
);
|
||||
if (!automation.value || !trigger) return;
|
||||
automation.value.event_name = trigger.eventName;
|
||||
const conditions = [
|
||||
trigger.messageType
|
||||
? buildTriggerCondition('message_type', trigger.messageType)
|
||||
: buildTriggerCondition('status', triggerStatus.value),
|
||||
];
|
||||
// A private note is an outgoing message, so without this an internal note would read as a reply
|
||||
// and arm the customer-unresponsive wait. Incoming messages are never private.
|
||||
if (trigger.messageType === 'outgoing') {
|
||||
conditions.push(buildTriggerCondition('private_note', [false]));
|
||||
}
|
||||
if (triggerInboxes.value.length) {
|
||||
conditions.push(
|
||||
buildTriggerCondition(
|
||||
'inbox_id',
|
||||
triggerInboxes.value.map(inbox => inbox.id)
|
||||
)
|
||||
);
|
||||
}
|
||||
automation.value.conditions = conditions;
|
||||
};
|
||||
|
||||
// A single value is a raw string in create mode and an option object ({ id }) after edit-mode
|
||||
// formatting; return its plain value either way.
|
||||
const rawConditionValue = condition => {
|
||||
const raw = Array.isArray(condition?.values)
|
||||
? condition.values[0]
|
||||
: condition?.values;
|
||||
return raw && typeof raw === 'object' ? raw.id : raw;
|
||||
};
|
||||
|
||||
// Populate the trigger controls from an existing delayed rule when editing.
|
||||
const hydrateTriggerFromAutomation = () => {
|
||||
const conditions = automation.value?.conditions || [];
|
||||
const byKey = key => conditions.find(c => c.attribute_key === key);
|
||||
const messageType = rawConditionValue(byKey('message_type'));
|
||||
if (messageType === 'incoming') selectedTrigger.value = 'agent_unresponsive';
|
||||
else if (messageType === 'outgoing')
|
||||
selectedTrigger.value = 'customer_unresponsive';
|
||||
else {
|
||||
selectedTrigger.value = 'conversation_status';
|
||||
triggerStatus.value =
|
||||
rawConditionValue(byKey('status')) || DEFAULT_TRIGGER_STATUS;
|
||||
}
|
||||
const inboxValues = byKey('inbox_id')?.values || [];
|
||||
const inboxIds = inboxValues.map(value =>
|
||||
value && typeof value === 'object' ? value.id : value
|
||||
);
|
||||
triggerInboxes.value = inboxOptions.value.filter(inbox =>
|
||||
inboxIds.includes(inbox.id)
|
||||
);
|
||||
};
|
||||
|
||||
// Turning the wait on (create) sets the default trigger's event + conditions.
|
||||
watch(isDelayed, delayed => {
|
||||
if (delayed && automation.value && !isEditMode.value) applyDelayedTrigger();
|
||||
});
|
||||
|
||||
// Any trigger-control change re-derives event_name + conditions. After hydration this simply
|
||||
// re-writes the same values, so it stays idempotent (no reference change → no loop).
|
||||
watch([selectedTrigger, triggerStatus, triggerInboxes], () => {
|
||||
if (isDelayed.value) applyDelayedTrigger();
|
||||
});
|
||||
|
||||
// Opening an existing delayed rule mirrors its event/conditions into the trigger controls.
|
||||
watch(
|
||||
() => automation.value,
|
||||
() => {
|
||||
if (isDelayed.value && automation.value) hydrateTriggerFromAutomation();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => automation.value,
|
||||
() => {
|
||||
@@ -216,8 +411,9 @@ const syncCustomAttributeTypes = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
const open = (executionDelay = null) => {
|
||||
resetValidation();
|
||||
syncDelayFromDelay(executionDelay);
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
@@ -230,8 +426,13 @@ const emitSaveAutomation = () => {
|
||||
syncCustomAttributeTypes();
|
||||
const conditionsValid = isConditionsValid();
|
||||
errors.value = validateAutomation(automation.value);
|
||||
if (allowsDelayedExecution.value && executionDelayInvalid.value) {
|
||||
errors.value.execution_delay = true;
|
||||
}
|
||||
if (Object.keys(errors.value).length === 0 && conditionsValid) {
|
||||
const payload = generateAutomationPayload(automation.value);
|
||||
// The API rejects the param when the feature is off; existing values are kept server-side.
|
||||
if (!allowsDelayedExecution.value) delete payload.execution_delay;
|
||||
emit('save', payload, props.mode);
|
||||
}
|
||||
};
|
||||
@@ -266,84 +467,163 @@ defineExpose({ open, close });
|
||||
:error="errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''"
|
||||
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
|
||||
/>
|
||||
<div class="mb-6">
|
||||
<label :class="{ error: errors.event_name }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
|
||||
<select
|
||||
v-model="automation.event_name"
|
||||
class="m-0"
|
||||
@change="onEventChange()"
|
||||
>
|
||||
<option
|
||||
v-for="event in automationRuleEvents"
|
||||
:key="event.key"
|
||||
:value="event.key"
|
||||
>
|
||||
{{ event.value }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="errors.event_name" class="message">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
|
||||
<!-- Wait Start (choose the delay first, then the trigger) -->
|
||||
<div v-if="allowsDelayedExecution" class="mb-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<label class="mb-0" :class="{ error: errors.execution_delay }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.LABEL') }}
|
||||
</label>
|
||||
<ToggleSwitch v-model="isDelayed" />
|
||||
</div>
|
||||
<div v-if="isDelayed" class="flex flex-wrap items-center gap-2 mt-2">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.AFTER_DELAY') }}
|
||||
</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="!isEditMode && hasAutomationMutated"
|
||||
class="text-xs text-right text-n-teal-10 pt-1"
|
||||
>
|
||||
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Conditions Start -->
|
||||
<section class="mb-5">
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
|
||||
</label>
|
||||
<ul
|
||||
class="grid gap-4 list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1"
|
||||
:class="
|
||||
hasConditionErrors
|
||||
? 'outline-n-ruby-5 bg-n-ruby-2/50'
|
||||
: 'outline-n-weak dark:outline-n-strong'
|
||||
"
|
||||
>
|
||||
<template v-for="(condition, i) in automation.conditions" :key="i">
|
||||
<ConditionRow
|
||||
v-if="i === 0"
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="automation.conditions[i].filter_operator"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="automation.conditions[i].filter_operator"
|
||||
v-model:query-operator="
|
||||
automation.conditions[i - 1].query_operator
|
||||
"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
show-query-operator
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
</template>
|
||||
<div>
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
|
||||
@click="appendNewCondition"
|
||||
<div class="flex items-center gap-2 w-64">
|
||||
<DurationInput
|
||||
v-model="delayMinutes"
|
||||
v-model:unit="delayUnit"
|
||||
:min="MIN_DELAY_MINUTES"
|
||||
:max="MAX_DELAY_MINUTES"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
<!-- Conditions End -->
|
||||
</div>
|
||||
<span
|
||||
v-if="isDelayed && executionDelayInvalid"
|
||||
class="text-xs text-n-ruby-9"
|
||||
>
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.ERROR') }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Wait End -->
|
||||
<!-- Delayed trigger: a curated event + condition, in place of raw Event/Conditions -->
|
||||
<div v-if="isDelayed" class="mb-6">
|
||||
<label class="mb-1">
|
||||
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.LABEL') }}
|
||||
</label>
|
||||
<div
|
||||
class="flex flex-col gap-3 p-4 outline outline-1 -outline-offset-1 rounded-xl outline-n-weak dark:outline-n-strong"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-h-8">
|
||||
<span class="w-20 shrink-0 text-sm text-n-slate-11">
|
||||
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.WHEN_LABEL') }}
|
||||
</span>
|
||||
<FilterSelect
|
||||
v-model="selectedTrigger"
|
||||
:options="triggerSelectOptions"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isStatusTrigger" class="flex items-center gap-3 min-h-8">
|
||||
<span class="w-20 shrink-0 text-sm text-n-slate-11">
|
||||
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.STATUS_LABEL') }}
|
||||
</span>
|
||||
<FilterSelect
|
||||
v-model="triggerStatus"
|
||||
:options="statusSelectOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 min-h-8">
|
||||
<span class="w-20 shrink-0 text-sm text-n-slate-11">
|
||||
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.INBOX_LABEL') }}
|
||||
</span>
|
||||
<MultiSelect v-model="triggerInboxes" :options="inboxOptions" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-n-slate-11 pt-2 mb-0">
|
||||
<span class="text-n-slate-12 font-medium">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.ENDS_IF_LABEL') }}
|
||||
</span>
|
||||
{{ $t(`AUTOMATION.ADD.FORM.EXECUTE.ENDS_IF.${waitEndsKey}`) }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-11 pt-1 mb-0">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.HELP_TEXT') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Instant flow: raw Event + Conditions -->
|
||||
<template v-else>
|
||||
<div class="mb-6">
|
||||
<label :class="{ error: errors.event_name }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
|
||||
<select
|
||||
v-model="automation.event_name"
|
||||
class="m-0"
|
||||
@change="onEventChange()"
|
||||
>
|
||||
<option
|
||||
v-for="event in automationRuleEvents"
|
||||
:key="event.key"
|
||||
:value="event.key"
|
||||
>
|
||||
{{ event.value }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="errors.event_name" class="message">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="!isEditMode && hasAutomationMutated"
|
||||
class="text-xs text-right text-n-teal-10 pt-1"
|
||||
>
|
||||
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Conditions Start -->
|
||||
<section class="mb-5">
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
|
||||
</label>
|
||||
<ul
|
||||
class="grid gap-4 list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1"
|
||||
:class="
|
||||
hasConditionErrors
|
||||
? 'outline-n-ruby-5 bg-n-ruby-2/50'
|
||||
: 'outline-n-weak dark:outline-n-strong'
|
||||
"
|
||||
>
|
||||
<template v-for="(condition, i) in automation.conditions" :key="i">
|
||||
<ConditionRow
|
||||
v-if="i === 0"
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="
|
||||
automation.conditions[i].filter_operator
|
||||
"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="
|
||||
automation.conditions[i].filter_operator
|
||||
"
|
||||
v-model:query-operator="
|
||||
automation.conditions[i - 1].query_operator
|
||||
"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
show-query-operator
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
</template>
|
||||
<div>
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
|
||||
@click="appendNewCondition"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
<!-- Conditions End -->
|
||||
</template>
|
||||
<!-- Actions Start -->
|
||||
<section>
|
||||
<label>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { messageStamp } from 'shared/helpers/timeHelper';
|
||||
import { formatDelay } from 'dashboard/helper/automationHelper';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
|
||||
@@ -43,6 +44,16 @@ const automationActive = computed({
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ automation.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="automation.execution_delay"
|
||||
class="text-xs px-1.5 py-0.5 rounded-md bg-n-alpha-2 text-n-slate-11 whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{{
|
||||
$t('AUTOMATION.LIST.DELAY_BADGE', {
|
||||
delay: formatDelay(automation.execution_delay),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
|
||||
<span class="text-body-main text-n-slate-11 truncate">
|
||||
{{ automation.description }}
|
||||
|
||||
+21
-17
@@ -34,29 +34,33 @@ const {
|
||||
|
||||
const { formatAutomation } = useEditableAutomation();
|
||||
|
||||
const open = () => formRef.value?.open();
|
||||
const syncAutomationFromSelected = (source = props.selectedResponse) => {
|
||||
if (!source?.conditions) return;
|
||||
|
||||
manifestCustomAttributes();
|
||||
automation.value = formatAutomation(
|
||||
source,
|
||||
allCustomAttributes.value,
|
||||
automationTypes,
|
||||
AUTOMATION_ACTION_TYPES
|
||||
);
|
||||
};
|
||||
|
||||
// Format from the rule passed to open(): the prop updates a tick later, so at open() time
|
||||
// automation still holds the previously selected rule (its execution_delay hydrates the form).
|
||||
const open = rule => {
|
||||
syncAutomationFromSelected(rule);
|
||||
formRef.value?.open(rule?.execution_delay);
|
||||
};
|
||||
const close = () => formRef.value?.close();
|
||||
|
||||
const onSave = (payload, mode) => {
|
||||
emit('saveAutomation', payload, mode);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.selectedResponse,
|
||||
value => {
|
||||
if (!value?.conditions) return;
|
||||
|
||||
manifestCustomAttributes();
|
||||
|
||||
automation.value = formatAutomation(
|
||||
value,
|
||||
allCustomAttributes.value,
|
||||
automationTypes,
|
||||
AUTOMATION_ACTION_TYPES
|
||||
);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(() => props.selectedResponse, syncAutomationFromSelected, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
@@ -35,6 +35,31 @@ const filteredRecords = computed(() => {
|
||||
if (!query) return records.value;
|
||||
return picoSearch(records.value, query, ['name', 'description']);
|
||||
});
|
||||
|
||||
// Delayed (wait) rules run on a different lifecycle, so list them in their own section.
|
||||
const hasDelayedRecords = computed(() =>
|
||||
records.value.some(automation => automation.execution_delay)
|
||||
);
|
||||
|
||||
const sections = computed(() => {
|
||||
const instant = [];
|
||||
const delayed = [];
|
||||
filteredRecords.value.forEach(automation =>
|
||||
(automation.execution_delay ? delayed : instant).push(automation)
|
||||
);
|
||||
return [
|
||||
{
|
||||
key: 'instant',
|
||||
label: t('AUTOMATION.LIST.SECTIONS.INSTANT'),
|
||||
items: instant,
|
||||
},
|
||||
{
|
||||
key: 'delayed',
|
||||
label: t('AUTOMATION.LIST.SECTIONS.DELAYED'),
|
||||
items: delayed,
|
||||
},
|
||||
].filter(section => section.items.length);
|
||||
});
|
||||
const uiFlags = computed(() => getters['automations/getUIFlags'].value);
|
||||
const accountId = computed(() => getters.getCurrentAccountId.value);
|
||||
|
||||
@@ -52,6 +77,14 @@ const isSLAEnabled = computed(() =>
|
||||
getters['accounts/isFeatureEnabledonAccount'].value(accountId.value, 'sla')
|
||||
);
|
||||
|
||||
const showDelayDisabledBanner = computed(
|
||||
() =>
|
||||
!getters['accounts/isFeatureEnabledonAccount'].value(
|
||||
accountId.value,
|
||||
'delayed_automations'
|
||||
) && records.value.some(automation => automation.execution_delay)
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('inboxes/get');
|
||||
store.dispatch('agents/get');
|
||||
@@ -74,7 +107,7 @@ const hideAddPopup = () => {
|
||||
|
||||
const openEditPopup = response => {
|
||||
selectedAutomation.value = { ...response };
|
||||
editDialogRef.value?.open();
|
||||
editDialogRef.value?.open(response);
|
||||
};
|
||||
const hideEditPopup = () => {
|
||||
editDialogRef.value?.close();
|
||||
@@ -128,11 +161,11 @@ const submitAutomation = async (payload, mode) => {
|
||||
hideAddPopup();
|
||||
hideEditPopup();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
const fallbackMessage =
|
||||
mode === 'edit'
|
||||
? t('AUTOMATION.EDIT.API.ERROR_MESSAGE')
|
||||
: t('AUTOMATION.ADD.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
useAlert(error?.response?.data?.error || fallbackMessage);
|
||||
}
|
||||
};
|
||||
const toggleAutomation = async ({ id, name, status }) => {
|
||||
@@ -212,25 +245,49 @@ const tableHeaders = computed(() => {
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
v-if="showDelayDisabledBanner"
|
||||
class="px-4 py-3 mb-4 text-sm rounded-lg bg-n-amber-3 text-n-amber-12"
|
||||
>
|
||||
{{ $t('AUTOMATION.LIST.DELAY_DISABLED_BANNER') }}
|
||||
</div>
|
||||
<template v-if="filteredRecords.length">
|
||||
<div
|
||||
v-for="section in sections"
|
||||
:key="section.key"
|
||||
class="mb-6 last:mb-0"
|
||||
>
|
||||
<h4
|
||||
v-if="hasDelayedRecords"
|
||||
class="mb-2 text-sm font-medium text-n-slate-11"
|
||||
>
|
||||
{{ section.label }}
|
||||
</h4>
|
||||
<BaseTable :headers="tableHeaders" :items="section.items">
|
||||
<template #row="{ items }">
|
||||
<AutomationRuleRow
|
||||
v-for="automation in items"
|
||||
:key="automation.id"
|
||||
:automation="automation"
|
||||
:loading="loading[automation.id]"
|
||||
@clone="cloneAutomation"
|
||||
@toggle="toggleAutomation"
|
||||
@edit="openEditPopup"
|
||||
@delete="openDeletePopup"
|
||||
/>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
</template>
|
||||
<BaseTable
|
||||
v-else
|
||||
:headers="tableHeaders"
|
||||
:items="filteredRecords"
|
||||
:items="[]"
|
||||
:no-data-message="
|
||||
searchQuery ? $t('AUTOMATION.NO_RESULTS') : $t('AUTOMATION.LIST.404')
|
||||
"
|
||||
>
|
||||
<template #row="{ items }">
|
||||
<AutomationRuleRow
|
||||
v-for="automation in items"
|
||||
:key="automation.id"
|
||||
:automation="automation"
|
||||
:loading="loading[automation.id]"
|
||||
@clone="cloneAutomation"
|
||||
@toggle="toggleAutomation"
|
||||
@edit="openEditPopup"
|
||||
@delete="openDeletePopup"
|
||||
/>
|
||||
</template>
|
||||
<template #row />
|
||||
</BaseTable>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
@@ -44,9 +46,11 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
|
||||
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
|
||||
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Banner,
|
||||
BotConfiguration,
|
||||
CollaboratorsPage,
|
||||
ConfigurationPage,
|
||||
@@ -80,6 +84,7 @@ export default {
|
||||
WhatsappManualMigrationBanner,
|
||||
Widget,
|
||||
AccessToken,
|
||||
Icon,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
@@ -343,6 +348,12 @@ export default {
|
||||
instagramUnauthorized() {
|
||||
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
|
||||
},
|
||||
showInstagramRestrictionSettingsBanner() {
|
||||
return this.isOnChatwootCloud && this.isAnInstagramChannel;
|
||||
},
|
||||
metaRestrictionStatusUrl() {
|
||||
return META_RESTRICTION_STATUS_URL;
|
||||
},
|
||||
tiktokUnauthorized() {
|
||||
return this.isATiktokChannel && this.inbox.reauthorization_required;
|
||||
},
|
||||
@@ -809,6 +820,29 @@ export default {
|
||||
:class="bannerMaxWidth"
|
||||
@start="openWhatsAppManualMigrationDialog"
|
||||
/>
|
||||
<Banner
|
||||
v-if="showInstagramRestrictionSettingsBanner"
|
||||
color="amber"
|
||||
class="mx-6 mb-4 max-w-4xl"
|
||||
>
|
||||
<div class="flex items-start gap-3 text-start">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 size-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
|
||||
<a
|
||||
:href="metaRestrictionStatusUrl"
|
||||
class="link underline"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<div
|
||||
v-if="selectedTabKey === 'inbox-settings'"
|
||||
|
||||
@@ -11,8 +11,6 @@ import { IFrameHelper } from '../helpers/utils';
|
||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
const TRANSCRIPT_COOLDOWN_MS = 15000;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChatInputWrap,
|
||||
@@ -26,9 +24,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
inReplyTo: null,
|
||||
isSendingTranscript: false,
|
||||
transcriptCooldown: false,
|
||||
transcriptCooldownTimer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -62,9 +57,6 @@ export default {
|
||||
mounted() {
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearTimeout(this.transcriptCooldownTimer);
|
||||
},
|
||||
methods: {
|
||||
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
|
||||
...mapActions('conversationAttributes', ['getAttributes']),
|
||||
@@ -98,35 +90,19 @@ export default {
|
||||
toggleReplyTo(message) {
|
||||
this.inReplyTo = message;
|
||||
},
|
||||
startTranscriptCooldown() {
|
||||
this.transcriptCooldown = true;
|
||||
clearTimeout(this.transcriptCooldownTimer);
|
||||
this.transcriptCooldownTimer = setTimeout(() => {
|
||||
this.transcriptCooldown = false;
|
||||
}, TRANSCRIPT_COOLDOWN_MS);
|
||||
},
|
||||
async sendTranscript() {
|
||||
if (
|
||||
!this.hasEmail ||
|
||||
this.isSendingTranscript ||
|
||||
this.transcriptCooldown
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.isSendingTranscript = true;
|
||||
try {
|
||||
await sendEmailTranscript();
|
||||
this.startTranscriptCooldown();
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
|
||||
});
|
||||
} finally {
|
||||
this.isSendingTranscript = false;
|
||||
if (this.hasEmail) {
|
||||
try {
|
||||
await sendEmailTranscript();
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
emitter.$emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -168,7 +144,6 @@ export default {
|
||||
v-if="showEmailTranscriptButton"
|
||||
type="clear"
|
||||
class="font-normal"
|
||||
:disabled="isSendingTranscript || transcriptCooldown"
|
||||
@click="sendTranscript"
|
||||
>
|
||||
{{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
class AutomationRules::ProcessPendingExecutionJob < ApplicationJob
|
||||
queue_as :medium
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(pending_execution)
|
||||
return if delayed_automations_disabled?
|
||||
# Account flag off pauses (not skips): leave the row pending so re-enabling resumes it.
|
||||
return unless pending_execution.account.feature_enabled?('delayed_automations')
|
||||
# Atomic claim: a duplicate enqueue (overlapping sweep or stale reclaim) loses here and returns.
|
||||
return unless pending_execution.claim!
|
||||
|
||||
skip_reason = skip_reason_for(pending_execution)
|
||||
return pending_execution.update!(status: :skipped, skip_reason: skip_reason) if skip_reason
|
||||
|
||||
execute(pending_execution)
|
||||
rescue StandardError => e
|
||||
# Row stays `processing`; the next sweep reclaims and retries it once the lock goes stale.
|
||||
ChatwootExceptionTracker.new(e, account: pending_execution.account).capture_exception
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def skip_reason_for(pending_execution)
|
||||
return 'expired' if pending_execution.due_at < AutomationRulePendingExecution::DUE_WINDOW.ago
|
||||
|
||||
structural_skip_reason(pending_execution) || behavioral_skip_reason(pending_execution)
|
||||
end
|
||||
|
||||
def structural_skip_reason(pending_execution)
|
||||
rule = pending_execution.automation_rule
|
||||
return 'rule_inactive' if rule.nil? || !rule.active?
|
||||
return 'conversation_gone' if pending_execution.conversation.nil?
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def behavioral_skip_reason(pending_execution)
|
||||
return 'episode_moved' unless pending_execution.episode_current?
|
||||
return AutomationRulePendingExecution::CONDITIONS_CHANGED_SKIP unless conditions_still_match?(pending_execution)
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def conditions_still_match?(pending_execution)
|
||||
AutomationRules::ConditionsFilterService.new(
|
||||
pending_execution.automation_rule,
|
||||
pending_execution.conversation,
|
||||
{ message: pending_execution.message }
|
||||
).perform.present?
|
||||
end
|
||||
|
||||
# Marked before the actions run: a row that dies here stays `executing`, which no sweep reclaims,
|
||||
# so a message/email/webhook is never sent twice. Everything up to this point is still retryable.
|
||||
def execute(pending_execution)
|
||||
pending_execution.update!(status: :executing)
|
||||
AutomationRules::ActionService.new(
|
||||
pending_execution.automation_rule,
|
||||
pending_execution.account,
|
||||
pending_execution.conversation
|
||||
).perform
|
||||
pending_execution.update!(status: :executed)
|
||||
end
|
||||
|
||||
def delayed_automations_disabled?
|
||||
GlobalConfig.get('DISABLE_DELAYED_AUTOMATIONS')['DISABLE_DELAYED_AUTOMATIONS']
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
class AutomationRules::ResumePausedExecutionsJob < ApplicationJob
|
||||
# Enqueued the moment the account flag flips back on, ahead of the next sweep, so overdue rows
|
||||
# are rescheduled before that sweep's per-row jobs could mark them expired.
|
||||
queue_as :medium
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(account)
|
||||
AutomationRulePendingExecution.reschedule_paused(account)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
class AutomationRules::TriggerPendingExecutionsJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
DEFAULT_SWEEP_LIMIT = 1000
|
||||
|
||||
def perform
|
||||
return if delayed_automations_disabled?
|
||||
|
||||
started_at = Time.current
|
||||
purged = AutomationRulePendingExecution.purge_terminal!
|
||||
|
||||
rows = AutomationRulePendingExecution.sweepable.for_enabled_accounts.order(:due_at).limit(sweep_limit).to_a
|
||||
rows.each { |row| AutomationRules::ProcessPendingExecutionJob.perform_later(row) }
|
||||
|
||||
log_summary(enqueued: rows.size, capped: rows.size >= sweep_limit, purged: purged,
|
||||
abandoned: AutomationRulePendingExecution.abandoned.count, started_at: started_at)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def delayed_automations_disabled?
|
||||
GlobalConfig.get('DISABLE_DELAYED_AUTOMATIONS')['DISABLE_DELAYED_AUTOMATIONS']
|
||||
end
|
||||
|
||||
def sweep_limit
|
||||
(InstallationConfig.find_by(name: 'AUTOMATION_PENDING_EXECUTIONS_SWEEP_LIMIT')&.value || DEFAULT_SWEEP_LIMIT).to_i
|
||||
end
|
||||
|
||||
def log_summary(enqueued:, capped:, purged:, abandoned:, started_at:)
|
||||
summary = { event: 'completed', enqueued: enqueued, capped: capped, purged: purged, abandoned: abandoned,
|
||||
duration_ms: ((Time.current - started_at) * 1000).round }
|
||||
Rails.logger.info("[AutomationRules::TriggerPendingExecutionsJob] #{summary.to_json}")
|
||||
end
|
||||
end
|
||||
@@ -19,6 +19,9 @@ class TriggerScheduledItemsJob < ApplicationJob
|
||||
|
||||
# Job to sync whatsapp templates
|
||||
Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later
|
||||
|
||||
# Job to trigger pending executions
|
||||
AutomationRules::TriggerPendingExecutionsJob.perform_later
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class AutomationRuleListener < BaseListener
|
||||
rules.each do |rule|
|
||||
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, message.conversation,
|
||||
{ message: message, changed_attributes: changed_attributes }).perform
|
||||
::AutomationRules::ActionService.new(rule, account, message.conversation).perform if conditions_match.present?
|
||||
execute_rule(rule, account, message.conversation, message: message) if conditions_match.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -46,13 +46,36 @@ class AutomationRuleListener < BaseListener
|
||||
account = conversation.account
|
||||
changed_attributes = event.data[:changed_attributes]
|
||||
|
||||
return unless rule_present?(event_name, account)
|
||||
|
||||
rules = current_account_rules(event_name, account)
|
||||
rules = conversation_rules(event_name, account)
|
||||
return if rules.blank?
|
||||
|
||||
rules.each do |rule|
|
||||
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
|
||||
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
|
||||
execute_rule(rule, account, conversation) if conditions_match.present?
|
||||
end
|
||||
end
|
||||
|
||||
# A delayed conversation rule reads as "the conversation has been in this status for N minutes",
|
||||
# so a conversation created in that status must arm it too. Creation never dispatches
|
||||
# CONVERSATION_UPDATED, and both paths key the episode on the same status_changed_at, so a later
|
||||
# update arming the same episode is deduped by the unique index.
|
||||
def conversation_rules(event_name, account)
|
||||
rules = current_account_rules(event_name, account)
|
||||
return rules unless event_name == 'conversation_created'
|
||||
|
||||
rules + current_account_rules('conversation_updated', account).where.not(execution_delay: nil)
|
||||
end
|
||||
|
||||
# Delayed rules record a pending execution instead of acting; the sweep re-checks and
|
||||
# runs them at due time. Flag off means no arming and no immediate fallback — a delayed
|
||||
# message silently becoming instant is worse than skipping.
|
||||
def execute_rule(rule, account, conversation, message: nil)
|
||||
if rule.execution_delay.present?
|
||||
return unless account.feature_enabled?('delayed_automations')
|
||||
|
||||
AutomationRulePendingExecution.schedule(rule: rule, conversation: conversation, message: message)
|
||||
else
|
||||
::AutomationRules::ActionService.new(rule, account, conversation).perform
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ class Account < ApplicationRecord
|
||||
has_many :articles, dependent: :destroy_async, class_name: '::Article'
|
||||
has_many :assignment_policies, dependent: :destroy_async
|
||||
has_many :automation_rules, dependent: :destroy_async
|
||||
has_many :automation_rule_pending_executions, dependent: :delete_all
|
||||
has_many :macros, dependent: :destroy_async
|
||||
has_many :campaigns, dependent: :destroy_async
|
||||
has_many :canned_responses, dependent: :destroy_async
|
||||
@@ -111,6 +112,7 @@ class Account < ApplicationRecord
|
||||
before_validation :validate_limit_keys
|
||||
after_create_commit :notify_creation
|
||||
after_update_commit :clear_unread_conversation_counts_cache, if: :saved_change_to_feature_conversation_unread_counts?
|
||||
after_update_commit :resume_delayed_automations, if: -> { saved_change_to_feature_delayed_automations? && feature_delayed_automations? }
|
||||
after_destroy :remove_account_sequences
|
||||
|
||||
def agents
|
||||
@@ -189,6 +191,10 @@ class Account < ApplicationRecord
|
||||
::Conversations::UnreadCounts::Store.clear_account!(id)
|
||||
end
|
||||
|
||||
def resume_delayed_automations
|
||||
AutomationRules::ResumePausedExecutionsJob.perform_later(self)
|
||||
end
|
||||
|
||||
trigger.after(:insert).for_each(:row) do
|
||||
"execute format('create sequence IF NOT EXISTS conv_dpid_seq_%s', NEW.id);"
|
||||
end
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
#
|
||||
# Table name: automation_rules
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# actions :jsonb not null
|
||||
# active :boolean default(TRUE), not null
|
||||
# conditions :jsonb not null
|
||||
# description :text
|
||||
# event_name :string not null
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# id :bigint not null, primary key
|
||||
# actions :jsonb not null
|
||||
# active :boolean default(TRUE), not null
|
||||
# conditions :jsonb not null
|
||||
# description :text
|
||||
# event_name :string not null
|
||||
# execution_delay :integer
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
@@ -21,7 +22,13 @@ class AutomationRule < ApplicationRecord
|
||||
include Rails.application.routes.url_helpers
|
||||
include Reauthorizable
|
||||
|
||||
EXECUTION_DELAY_RANGE = (10..43_200) # minutes: 10 min to 30 days
|
||||
# Conversation-level delayed rules key their episode on status; only status and attributes
|
||||
# that never change after the delay (inbox) are safe to also filter on.
|
||||
DELAYED_CONVERSATION_ATTRIBUTES = %w[status inbox_id].freeze
|
||||
|
||||
belongs_to :account
|
||||
has_many :pending_executions, class_name: 'AutomationRulePendingExecution', dependent: :delete_all
|
||||
has_many_attached :files
|
||||
|
||||
validate :json_conditions_format
|
||||
@@ -29,8 +36,13 @@ class AutomationRule < ApplicationRecord
|
||||
validate :query_operator_presence
|
||||
validate :query_operator_value
|
||||
validates :account_id, presence: true
|
||||
validates :execution_delay, numericality: { only_integer: true, in: EXECUTION_DELAY_RANGE }, allow_nil: true
|
||||
validate :execution_delay_supported_conditions
|
||||
validate :execution_delay_supported_event
|
||||
|
||||
after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? }
|
||||
# Discard rows armed under the old definition; they re-arm on the next matching event.
|
||||
after_update :discard_stale_pending_executions, if: :execution_config_changed?
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
|
||||
@@ -95,6 +107,35 @@ class AutomationRule < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
# The fire-time re-check cannot reconstruct changed_attributes, so delayed rules
|
||||
# cannot use attribute_changed conditions.
|
||||
def execution_delay_supported_conditions
|
||||
return if execution_delay.blank? || conditions.blank?
|
||||
return if conditions.none? { |obj| obj['filter_operator'] == 'attribute_changed' }
|
||||
|
||||
errors.add(:execution_delay, 'cannot be used with attribute_changed conditions.')
|
||||
end
|
||||
|
||||
# Conversation-level episodes key on status_changed_at alone. Mutable attributes would collapse
|
||||
# distinct periods into one episode, so only status and immutable filters (inbox) are allowed.
|
||||
def execution_delay_supported_event
|
||||
return if execution_delay.blank? || conditions.blank? || event_name == 'message_created'
|
||||
return if conditions.all? { |obj| DELAYED_CONVERSATION_ATTRIBUTES.include?(obj['attribute_key']) }
|
||||
|
||||
errors.add(:execution_delay, 'only supports status and inbox conditions for conversation-level events.')
|
||||
end
|
||||
|
||||
def execution_config_changed?
|
||||
saved_change_to_execution_delay? || saved_change_to_event_name? ||
|
||||
saved_change_to_conditions? || saved_change_to_actions?
|
||||
end
|
||||
|
||||
def discard_stale_pending_executions
|
||||
# armed = pending + processing, the rows the sweep would otherwise still run. Rows already
|
||||
# executing are left alone: their actions are in flight and cannot be called back.
|
||||
pending_executions.armed.delete_all
|
||||
end
|
||||
|
||||
def validate_single_condition(condition)
|
||||
query_operator = condition['query_operator']
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: automation_rule_pending_executions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# due_at :datetime not null
|
||||
# episode_key :string not null
|
||||
# skip_reason :string
|
||||
# status :integer default("pending"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# automation_rule_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# message_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_automation_rule_pending_executions_on_account_id (account_id)
|
||||
# index_automation_rule_pending_executions_on_automation_rule_id (automation_rule_id)
|
||||
# index_automation_rule_pending_executions_on_conversation_id (conversation_id)
|
||||
# index_automation_rule_pending_executions_on_status_and_due_at (status,due_at)
|
||||
# uniq_automation_pending_execution_episode (automation_rule_id,conversation_id,episode_key) UNIQUE
|
||||
#
|
||||
class AutomationRulePendingExecution < ApplicationRecord
|
||||
# Rows older than this never fire (bounds backlog replay after downtime).
|
||||
DUE_WINDOW = 3.days
|
||||
# A processing row whose lock is older than this is treated as abandoned and reclaimed.
|
||||
STALE_PROCESSING_TIMEOUT = 15.minutes
|
||||
# Terminal rows are purged after this to keep the table bounded.
|
||||
RETENTION_WINDOW = 30.days
|
||||
# Skip reason for a row cancelled only because conditions no longer matched at fire time; unlike
|
||||
# other terminal reasons, a later qualifying message can re-arm it (see .schedule).
|
||||
CONDITIONS_CHANGED_SKIP = 'conditions_changed'.freeze
|
||||
|
||||
belongs_to :automation_rule
|
||||
belongs_to :conversation
|
||||
belongs_to :account
|
||||
belongs_to :message, optional: true
|
||||
|
||||
# `processing` is claimed but not yet acting, so it is safe to reclaim and retry. `executing`
|
||||
# means the actions are running: a row that dies there is never replayed, because the actions
|
||||
# are customer-facing (messages, emails, webhooks) and repeating them is worse than dropping them.
|
||||
enum status: { pending: 0, processing: 1, executed: 2, skipped: 3, executing: 4 }
|
||||
|
||||
# Rows a sweep should hand to a worker: due pending rows, plus processing rows whose lock went stale.
|
||||
scope :sweepable, lambda {
|
||||
pending.where(due_at: ..Time.current).or(processing.where(updated_at: ...STALE_PROCESSING_TIMEOUT.ago))
|
||||
}
|
||||
|
||||
# Rows whose worker died mid-action. Nothing reclaims them; the sweep only counts them so a
|
||||
# crash that strands customer-facing actions is visible instead of silent.
|
||||
scope :abandoned, -> { executing.where(updated_at: ...STALE_PROCESSING_TIMEOUT.ago) }
|
||||
|
||||
# Non-terminal rows still bound to fire (a stale processing row is reclaimed by the sweep).
|
||||
scope :armed, -> { where(status: [statuses[:pending], statuses[:processing]]) }
|
||||
|
||||
# Excludes rows whose account paused delayed automations, so one disabled account's backlog
|
||||
# can't fill the sweep limit and starve enabled accounts (paused rows resume on re-enable).
|
||||
scope :for_enabled_accounts, -> { joins(:account).merge(Account.feature_delayed_automations) }
|
||||
|
||||
def self.schedule(rule:, conversation:, message: nil)
|
||||
# status_changed_at is only written from this feature onwards, so a conversation that predates it
|
||||
# has no status clock. Anchoring on created_at would make every old conversation instantly
|
||||
# overdue and fire on the next sweep; leave them for their next status change to arm.
|
||||
return if message.nil? && conversation.status_changed_at.blank?
|
||||
|
||||
key = arm_episode_key_for(conversation, message)
|
||||
anchor = arm_anchor_for(conversation, message)
|
||||
create!(
|
||||
automation_rule: rule, conversation: conversation, account_id: conversation.account_id,
|
||||
message_id: message&.id, episode_key: key, due_at: rule.execution_delay.minutes.since(anchor)
|
||||
)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
rearm_or_advance_episode(rule, conversation, key, message, anchor)
|
||||
end
|
||||
|
||||
# The episode is already armed. Status episodes keep their first clock (a status change would
|
||||
# give a new key), so only message episodes advance or re-arm here.
|
||||
def self.rearm_or_advance_episode(rule, conversation, key, message, anchor)
|
||||
return unless message
|
||||
|
||||
due_at = rule.execution_delay.minutes.since(anchor)
|
||||
row = find_by!(automation_rule_id: rule.id, conversation_id: conversation.id, episode_key: key)
|
||||
# The lock (and the reload it does) makes the compare-and-write atomic. Two listeners racing on
|
||||
# the same episode would otherwise both read the old message_id and let whichever wrote last
|
||||
# win, so an older message could overwrite a newer one and pull due_at backwards.
|
||||
row.with_lock do
|
||||
# Jobs can arrive out of order; only a strictly newer message advances or re-arms, so a late
|
||||
# older message can't pull due_at backwards and fire before the delay elapses.
|
||||
next unless message.id > row.message_id
|
||||
|
||||
if row.condition_skipped?
|
||||
# A message episode key can recur (no new incoming reply) while conditions swing back into
|
||||
# match, so a later qualifying message re-arms the condition-only skip instead of dropping.
|
||||
row.update!(status: :pending, skip_reason: nil, due_at: due_at, message_id: message.id)
|
||||
elsif row.pending? || row.stale_processing?
|
||||
# Track the newest qualifying message. Reply-chase advances due_at with each agent reply;
|
||||
# awaiting-agent keeps its first clock (its anchor is the stable waiting_since, so due_at is
|
||||
# unchanged). Re-anchoring a row whose worker died mid-run back to pending also keeps a
|
||||
# stale reclaim from firing the old clock instead of the latest one.
|
||||
row.update!(status: :pending, due_at: due_at, message_id: message.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# The wait is measured from when the qualifying event happened, not when this (possibly
|
||||
# backlogged or retried) listener runs, so a late dispatch still fires on schedule. Mirrors
|
||||
# the timestamps the episode keys track.
|
||||
def self.arm_anchor_for(conversation, message)
|
||||
if message.nil?
|
||||
conversation.status_changed_at
|
||||
elsif message.incoming?
|
||||
conversation.waiting_since.presence || message.created_at
|
||||
else
|
||||
message.created_at
|
||||
end
|
||||
end
|
||||
|
||||
# waiting_since is written just after MESSAGE_CREATED dispatches, so it can still be nil when
|
||||
# an awaiting-agent episode arms. It becomes the starting message's created_at, so use that
|
||||
# here; the strict fire-time key (episode_key_for) then matches once waiting_since is settled.
|
||||
def self.arm_episode_key_for(conversation, message)
|
||||
return episode_key_for(conversation, message) unless message&.incoming? && conversation.waiting_since.blank?
|
||||
|
||||
"awaiting_agent:#{microsecond_stamp(message.created_at)}"
|
||||
end
|
||||
|
||||
# Microsecond integer, not a float: epoch seconds carry ~16 significant digits, past float64's
|
||||
# precision, so an in-memory timestamp (arm time) and its DB-reloaded value (fire time) would
|
||||
# round to different floats. strftime is exact on both. Sub-second distinguishes rapid episodes.
|
||||
def self.microsecond_stamp(time)
|
||||
time&.strftime('%s%6N') || '0'
|
||||
end
|
||||
|
||||
# Episode keys identify one qualifying stretch of conversation state; when the recomputed
|
||||
# key no longer matches, the episode ended and the pending action is cancelled at fire time.
|
||||
def self.episode_key_for(conversation, message)
|
||||
if message.nil?
|
||||
# Sub-second precision so a resolve→reopen inside one second still ends the episode.
|
||||
# Integer microseconds (not a float) so an in-memory arm and a DB-reloaded fire agree.
|
||||
"status:#{microsecond_stamp(conversation.status_changed_at)}"
|
||||
elsif message.incoming?
|
||||
# waiting_since is cleared on agent/bot reply, so a reply invalidates this episode. Strict
|
||||
# here: at fire time a nil waiting_since means the agent replied (episode ended).
|
||||
"awaiting_agent:#{microsecond_stamp(conversation.waiting_since)}"
|
||||
else
|
||||
# A new customer message changes the max incoming id, invalidating this episode.
|
||||
"reply_chase:#{conversation.messages.incoming.maximum(:id) || 0}"
|
||||
end
|
||||
end
|
||||
|
||||
def self.purge_terminal!
|
||||
where(status: [statuses[:executed], statuses[:skipped]], updated_at: ...RETENTION_WINDOW.ago)
|
||||
.in_batches(of: 1000).delete_all
|
||||
end
|
||||
|
||||
# Rows that came due while an account had delayed automations paused would expire the moment
|
||||
# the sweep reaches them on resume. Reset their clock so pause/resume replays them (still
|
||||
# subject to the fire-time episode/condition re-checks) instead of silently dropping them.
|
||||
def self.reschedule_paused(account)
|
||||
overdue = pending.where(account_id: account.id, due_at: ...DUE_WINDOW.ago)
|
||||
overdue.find_each { |row| row.update!(due_at: Time.current) }
|
||||
end
|
||||
|
||||
# Atomic claim: only one worker can move a row into processing, so a row re-enqueued by an
|
||||
# overlapping sweep (or after a stale reclaim) cannot double-execute. Refreshing updated_at
|
||||
# renews the lock, keeping the row out of the stale window while this worker holds it.
|
||||
def claim!
|
||||
with_lock do
|
||||
next false unless claimable?
|
||||
|
||||
update!(status: :processing, updated_at: Time.current)
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def episode_current?
|
||||
self.class.episode_key_for(conversation, message) == episode_key
|
||||
end
|
||||
|
||||
# The claim renews updated_at, so a processing row past the timeout means its worker died. Only
|
||||
# then may a re-arm take the row back: pulling a live worker's row to pending would let the sweep
|
||||
# claim it and run the same actions alongside the worker still executing them.
|
||||
def stale_processing?
|
||||
processing? && updated_at < STALE_PROCESSING_TIMEOUT.ago
|
||||
end
|
||||
|
||||
def condition_skipped?
|
||||
skipped? && skip_reason == CONDITIONS_CHANGED_SKIP
|
||||
end
|
||||
|
||||
def terminal?
|
||||
executed? || skipped?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def claimable?
|
||||
# due_at guard: a reply-chase reschedule can push due_at forward after this row was enqueued;
|
||||
# such a row must wait for a later sweep instead of firing early.
|
||||
(pending? && due_at <= Time.current) || stale_processing?
|
||||
end
|
||||
end
|
||||
@@ -15,6 +15,7 @@
|
||||
# priority :integer
|
||||
# snoozed_until :datetime
|
||||
# status :integer default("open"), not null
|
||||
# status_changed_at :datetime
|
||||
# uuid :uuid not null
|
||||
# waiting_since :datetime
|
||||
# created_at :datetime not null
|
||||
@@ -124,8 +125,10 @@ class Conversation < ApplicationRecord
|
||||
has_many :notifications, as: :primary_actor, dependent: :destroy_async
|
||||
has_many :attachments, through: :messages
|
||||
has_many :reporting_events, dependent: :destroy_async
|
||||
has_many :automation_rule_pending_executions, dependent: :delete_all
|
||||
|
||||
before_save :ensure_snooze_until_reset
|
||||
before_save :set_status_changed_at
|
||||
before_create :determine_conversation_status
|
||||
before_create :ensure_waiting_since
|
||||
|
||||
@@ -272,6 +275,10 @@ class Conversation < ApplicationRecord
|
||||
self.snoozed_until = nil unless snoozed?
|
||||
end
|
||||
|
||||
def set_status_changed_at
|
||||
self.status_changed_at = Time.current if new_record? || status_changed?
|
||||
end
|
||||
|
||||
def ensure_waiting_since
|
||||
self.waiting_since = created_at
|
||||
end
|
||||
|
||||
@@ -7,4 +7,5 @@ json.conditions automation_rule.conditions
|
||||
json.actions automation_rule.actions
|
||||
json.created_on automation_rule.created_at.to_i
|
||||
json.active automation_rule.active?
|
||||
json.execution_delay automation_rule.execution_delay
|
||||
json.files automation_rule.file_base_data if automation_rule.files.any?
|
||||
|
||||
@@ -268,3 +268,8 @@
|
||||
display_name: WhatsApp Embedded Signup Flow
|
||||
enabled: false
|
||||
column: feature_flags_ext_1
|
||||
- name: delayed_automations
|
||||
display_name: Delayed Automations
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
column: feature_flags_ext_1
|
||||
|
||||
@@ -567,3 +567,10 @@
|
||||
value: 'https://us.cloud.langfuse.com'
|
||||
locked: false
|
||||
## ---- End of LLM Observability ---- ##
|
||||
|
||||
- name: DISABLE_DELAYED_AUTOMATIONS
|
||||
display_title: 'Disable delayed automations'
|
||||
description: 'Emergency stop for delayed automation rules: halts the pending-execution sweep and per-row execution within one tick'
|
||||
value: false
|
||||
locked: false
|
||||
type: boolean
|
||||
|
||||
+1
-2
@@ -66,8 +66,7 @@ Rails.application.routes.draw do
|
||||
resources :assistants do
|
||||
member do
|
||||
post :playground
|
||||
get :metrics
|
||||
get :faq_stats
|
||||
get :stats
|
||||
get :summary
|
||||
get :drilldown
|
||||
end
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddExecutionDelayToAutomationRules < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :automation_rules, :execution_delay, :integer
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddStatusChangedAtToConversations < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :conversations, :status_changed_at, :datetime
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class CreateAutomationRulePendingExecutions < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :automation_rule_pending_executions do |t|
|
||||
t.references :automation_rule, null: false
|
||||
t.references :conversation, null: false
|
||||
t.references :account, null: false
|
||||
t.bigint :message_id
|
||||
t.datetime :due_at, null: false
|
||||
t.string :episode_key, null: false
|
||||
t.integer :status, null: false, default: 0
|
||||
t.string :skip_reason
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :automation_rule_pending_executions, [:status, :due_at]
|
||||
add_index :automation_rule_pending_executions,
|
||||
[:automation_rule_id, :conversation_id, :episode_key],
|
||||
unique: true, name: 'uniq_automation_pending_execution_episode'
|
||||
end
|
||||
end
|
||||
@@ -277,6 +277,24 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
||||
t.index ["user_id", "user_type"], name: "user_index"
|
||||
end
|
||||
|
||||
create_table "automation_rule_pending_executions", force: :cascade do |t|
|
||||
t.bigint "automation_rule_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "message_id"
|
||||
t.datetime "due_at", null: false
|
||||
t.string "episode_key", null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.string "skip_reason"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_automation_rule_pending_executions_on_account_id"
|
||||
t.index ["automation_rule_id", "conversation_id", "episode_key"], name: "uniq_automation_pending_execution_episode", unique: true
|
||||
t.index ["automation_rule_id"], name: "index_automation_rule_pending_executions_on_automation_rule_id"
|
||||
t.index ["conversation_id"], name: "index_automation_rule_pending_executions_on_conversation_id"
|
||||
t.index ["status", "due_at"], name: "index_automation_rule_pending_executions_on_status_and_due_at"
|
||||
end
|
||||
|
||||
create_table "automation_rules", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", null: false
|
||||
@@ -287,6 +305,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.boolean "active", default: true, null: false
|
||||
t.integer "execution_delay"
|
||||
t.index ["account_id"], name: "index_automation_rules_on_account_id"
|
||||
end
|
||||
|
||||
@@ -788,6 +807,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
||||
t.datetime "waiting_since"
|
||||
t.text "cached_label_list"
|
||||
t.bigint "assignee_agent_bot_id"
|
||||
t.datetime "status_changed_at"
|
||||
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
|
||||
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
|
||||
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
|
||||
|
||||
@@ -37,23 +37,6 @@ class Captain::AssistantStatsBuilder
|
||||
build_metrics(current, previous)
|
||||
end
|
||||
|
||||
# Approved/pending FAQ counts and the document total in a single round trip.
|
||||
def faq_stats
|
||||
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
|
||||
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
|
||||
)
|
||||
total = approved + pending
|
||||
|
||||
{
|
||||
approved: approved,
|
||||
pending: pending,
|
||||
documents: documents,
|
||||
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :window
|
||||
@@ -73,7 +56,8 @@ class Captain::AssistantStatsBuilder
|
||||
handoff_rate: pack(current[:handoff], previous[:handoff], :point),
|
||||
hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
|
||||
reopen_rate: pack(current[:reopen], previous[:reopen], :point),
|
||||
conversation_depth: pack(current[:depth], previous[:depth], :absolute)
|
||||
conversation_depth: pack(current[:depth], previous[:depth], :absolute),
|
||||
knowledge: knowledge
|
||||
}
|
||||
end
|
||||
|
||||
@@ -89,7 +73,7 @@ class Captain::AssistantStatsBuilder
|
||||
auto_resolution: rate(resolution[:resolved], handled),
|
||||
handoff: rate(resolution[:handoff], handled),
|
||||
hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round,
|
||||
reopen: reopen_rate(range, resolution[:resolved]),
|
||||
reopen: reopen_rate(range),
|
||||
depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
|
||||
}
|
||||
end
|
||||
@@ -174,9 +158,7 @@ class Captain::AssistantStatsBuilder
|
||||
# derived from the assistant's handled conversations (not current inbox membership) so a later
|
||||
# inbox reassignment doesn't drop historical resolves, and covers both the evaluated (inference)
|
||||
# and time-based (bot) resolve paths so the denominator matches auto_resolution_rate.
|
||||
def reopen_rate(range, resolved_count)
|
||||
return 0 if resolved_count.zero?
|
||||
|
||||
def reopen_rate(range)
|
||||
resolved_scope = account.reporting_events
|
||||
.where(name: RESOLVED_EVENT_NAMES, created_at: range,
|
||||
conversation_id: handled_scope(range).select(:conversation_id))
|
||||
@@ -196,7 +178,24 @@ class Captain::AssistantStatsBuilder
|
||||
'ON resolves.conversation_id = reporting_events.conversation_id ' \
|
||||
'AND reporting_events.event_end_time >= resolves.event_end_time')
|
||||
.distinct.count('reporting_events.conversation_id')
|
||||
rate(reopened, resolved_count)
|
||||
rate(reopened, resolved_scope.distinct.count(:conversation_id))
|
||||
end
|
||||
|
||||
# Approved/pending FAQ counts and the document total in a single round trip.
|
||||
def knowledge
|
||||
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
|
||||
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
|
||||
)
|
||||
total = approved + pending
|
||||
|
||||
{
|
||||
approved: approved,
|
||||
pending: pending,
|
||||
documents: documents,
|
||||
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
|
||||
}
|
||||
end
|
||||
|
||||
def rate(numerator, denominator)
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::Captain::BaseController
|
||||
class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::BaseController
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Captain::BaseController
|
||||
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
|
||||
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :metrics, :faq_stats, :summary, :drilldown]
|
||||
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown]
|
||||
|
||||
def index
|
||||
@assistants = account_assistants.ordered
|
||||
@@ -42,14 +42,10 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Capt
|
||||
@tools = assistant.available_agent_tools
|
||||
end
|
||||
|
||||
def metrics
|
||||
def stats
|
||||
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
|
||||
end
|
||||
|
||||
def faq_stats
|
||||
render json: Captain::AssistantStatsBuilder.new(@assistant).faq_stats
|
||||
end
|
||||
|
||||
def summary
|
||||
window = Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset])
|
||||
result = cached_or_generated_summary(window, summary_stats)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
class Api::V1::Accounts::Captain::BaseController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_captain_enabled
|
||||
|
||||
private
|
||||
|
||||
# Captain is a premium feature; block API access when neither the v1 nor v2 flag is enabled for the account.
|
||||
# `current_account` resolves and memoizes the account, so this does not depend on before_action ordering.
|
||||
def ensure_captain_enabled
|
||||
return if current_account.feature_enabled?('captain_integration') || current_account.feature_enabled?('captain_integration_v2')
|
||||
|
||||
render json: { error: 'Captain is not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Captain::BaseController
|
||||
class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
before_action :validate_params
|
||||
before_action :type_matches?
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::Captain::BaseController
|
||||
class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::BaseController
|
||||
before_action :set_copilot_thread
|
||||
|
||||
def index
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::Captain::BaseController
|
||||
class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_message, only: :create
|
||||
|
||||
def index
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::Captain::BaseController
|
||||
class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseController
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::Captain::InboxesController < Api::V1::Accounts::Captain::BaseController
|
||||
class Api::V1::Accounts::Captain::InboxesController < Api::V1::Accounts::BaseController
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
|
||||
before_action :set_assistant
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::Captain::BaseController
|
||||
before_action :ensure_captain_v2_enabled
|
||||
class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseController
|
||||
before_action -> { check_authorization(Captain::Scenario) }
|
||||
before_action :set_assistant
|
||||
before_action :set_scenario, only: [:show, :update, :destroy]
|
||||
@@ -25,13 +24,6 @@ class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::Capta
|
||||
|
||||
private
|
||||
|
||||
# Scenarios are a Captain v2-only feature; the shared base guard allows either flag, so require v2 here.
|
||||
def ensure_captain_v2_enabled
|
||||
return if current_account.feature_enabled?('captain_integration_v2')
|
||||
|
||||
render json: { error: 'Captain is not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def set_assistant
|
||||
@assistant = account_assistants.find(params[:assistant_id])
|
||||
end
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Api::V1::Accounts::CustomRolesController < Api::V1::Accounts::EnterpriseAccountsController
|
||||
before_action :ensure_custom_roles_feature_enabled
|
||||
before_action :fetch_custom_role, only: [:show, :update, :destroy]
|
||||
before_action :check_authorization
|
||||
|
||||
@@ -29,8 +28,4 @@ class Api::V1::Accounts::CustomRolesController < Api::V1::Accounts::EnterpriseAc
|
||||
def fetch_custom_role
|
||||
@custom_role = Current.account.custom_roles.find_by(id: params[:id])
|
||||
end
|
||||
|
||||
def ensure_custom_roles_feature_enabled
|
||||
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('custom_roles')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAccountsController
|
||||
before_action :ensure_sla_feature_enabled
|
||||
before_action :fetch_sla, only: [:show, :update, :destroy]
|
||||
before_action :check_authorization
|
||||
|
||||
@@ -30,8 +29,4 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc
|
||||
def fetch_sla
|
||||
@sla_policy = Current.account.sla_policies.find_by(id: params[:id])
|
||||
end
|
||||
|
||||
def ensure_sla_feature_enabled
|
||||
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('sla')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
module Enterprise::Api::V1::Accounts::AgentsController
|
||||
def create
|
||||
super
|
||||
return if @agent.blank?
|
||||
|
||||
associate_agent_with_custom_role
|
||||
end
|
||||
|
||||
@@ -14,9 +12,6 @@ module Enterprise::Api::V1::Accounts::AgentsController
|
||||
private
|
||||
|
||||
def associate_agent_with_custom_role
|
||||
# Custom roles are a premium feature; ignore custom_role_id assignment when the feature is disabled.
|
||||
return unless Current.account.feature_enabled?('custom_roles')
|
||||
|
||||
@agent.current_account_user.update!(custom_role_id: params[:custom_role_id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,9 +16,6 @@ module Enterprise::Api::V1::Accounts::ConversationsController
|
||||
end
|
||||
|
||||
def permitted_update_params
|
||||
# SLA is a premium feature; only accept sla_policy_id assignment when it is enabled for the account.
|
||||
return super unless Current.account.feature_enabled?('sla')
|
||||
|
||||
super.merge(params.permit(:sla_policy_id))
|
||||
end
|
||||
|
||||
|
||||
@@ -7,11 +7,7 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def metrics?
|
||||
true
|
||||
end
|
||||
|
||||
def faq_stats?
|
||||
def stats?
|
||||
true
|
||||
end
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
module Enterprise::ActionService
|
||||
def add_sla(sla_policy_id)
|
||||
return if sla_policy_id.blank?
|
||||
# SLA is a premium feature; automation rules must not keep applying SLAs once it is disabled.
|
||||
return unless @account.feature_enabled?('sla')
|
||||
|
||||
sla_policy = @account.sla_policies.find_by(id: sla_policy_id.first)
|
||||
return if sla_policy.nil?
|
||||
|
||||
@@ -50,14 +50,7 @@ module Enterprise::MessageTemplates::HookExecutionService
|
||||
end
|
||||
|
||||
def should_process_captain_response?
|
||||
conversation.pending? && message.incoming? && inbox.captain_assistant.present? && captain_feature_enabled?
|
||||
end
|
||||
|
||||
# Captain is a premium feature; stop the runtime responder when it has been revoked, even if an
|
||||
# assistant is still linked to the inbox.
|
||||
def captain_feature_enabled?
|
||||
account = conversation.account
|
||||
account.feature_enabled?('captain_integration') || account.feature_enabled?('captain_integration_v2')
|
||||
conversation.pending? && message.incoming? && inbox.captain_assistant.present?
|
||||
end
|
||||
|
||||
def perform_handoff
|
||||
@@ -82,9 +75,7 @@ module Enterprise::MessageTemplates::HookExecutionService
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
||||
end
|
||||
|
||||
# Only let Captain suppress the fallback templates when it can actually reply; otherwise a revoked
|
||||
# feature would leave the contact with neither a Captain response nor a greeting/OOO/email-collect.
|
||||
def captain_handling_conversation?
|
||||
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present? && captain_feature_enabled?
|
||||
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present?
|
||||
end
|
||||
end
|
||||
|
||||
+4
-1
@@ -34,7 +34,7 @@
|
||||
"@amplitude/analytics-browser": "^2.11.10",
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.3.23",
|
||||
"@chatwoot/prosemirror-schema": "1.3.22",
|
||||
"@chatwoot/utils": "^0.0.56",
|
||||
"@formkit/core": "^1.7.2",
|
||||
"@formkit/vue": "^1.7.2",
|
||||
@@ -86,6 +86,9 @@
|
||||
"mitt": "^3.0.1",
|
||||
"opus-recorder": "^8.0.5",
|
||||
"pinia": "^3.0.4",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"semver": "7.6.3",
|
||||
"snakecase-keys": "^8.0.1",
|
||||
|
||||
Generated
+22
-6
@@ -25,8 +25,8 @@ importers:
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3
|
||||
'@chatwoot/prosemirror-schema':
|
||||
specifier: 1.3.23
|
||||
version: 1.3.23
|
||||
specifier: 1.3.22
|
||||
version: 1.3.22
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.56
|
||||
version: 0.0.56
|
||||
@@ -180,6 +180,15 @@ importers:
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2))
|
||||
prosemirror-commands:
|
||||
specifier: ^1.7.1
|
||||
version: 1.7.1
|
||||
prosemirror-inputrules:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
prosemirror-schema-list:
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
@@ -452,8 +461,8 @@ packages:
|
||||
'@chatwoot/ninja-keys@1.2.3':
|
||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.23':
|
||||
resolution: {integrity: sha512-jGxbWELCdlVI64BJiE1wT84ekJHYDXXKiluQIKT3aKPEjPwMR48umKF3A0yHjKoR7IIxCC9oM77TvXOA0ebLtw==}
|
||||
'@chatwoot/prosemirror-schema@1.3.22':
|
||||
resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==}
|
||||
|
||||
'@chatwoot/utils@0.0.56':
|
||||
resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==}
|
||||
@@ -3992,6 +4001,9 @@ packages:
|
||||
prosemirror-tables@1.5.0:
|
||||
resolution: {integrity: sha512-VMx4zlYWm7aBlZ5xtfJHpqa3Xgu3b7srV54fXYnXgsAcIGRqKSrhiK3f89omzzgaAgAtDOV4ImXnLKhVfheVNQ==}
|
||||
|
||||
prosemirror-transform@1.10.0:
|
||||
resolution: {integrity: sha512-9UOgFSgN6Gj2ekQH5CTDJ8Rp/fnKR2IkYfGdzzp5zQMFsS4zDllLVx/+jGcX86YlACpG7UR5fwAXiWzxqWtBTg==}
|
||||
|
||||
prosemirror-transform@1.12.0:
|
||||
resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
|
||||
|
||||
@@ -5124,7 +5136,7 @@ snapshots:
|
||||
hotkeys-js: 3.8.7
|
||||
lit: 2.2.6
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.23':
|
||||
'@chatwoot/prosemirror-schema@1.3.22':
|
||||
dependencies:
|
||||
markdown-it-sup: 2.0.0
|
||||
prosemirror-commands: 1.7.1
|
||||
@@ -9023,7 +9035,7 @@ snapshots:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
prosemirror-state: 1.4.3
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-transform: 1.10.0
|
||||
|
||||
prosemirror-state@1.4.3:
|
||||
dependencies:
|
||||
@@ -9039,6 +9051,10 @@ snapshots:
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.34.1
|
||||
|
||||
prosemirror-transform@1.10.0:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
|
||||
prosemirror-transform@1.12.0:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
|
||||
@@ -23,12 +23,6 @@ RSpec.describe AgentBuilder, type: :model do
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'locks the account while checking and creating the agent' do
|
||||
expect(account).to receive(:with_lock).and_call_original
|
||||
|
||||
agent_builder.perform
|
||||
end
|
||||
|
||||
context 'when user does not exist' do
|
||||
it 'creates a new user' do
|
||||
expect { agent_builder.perform }.to change(User, :count).by(1)
|
||||
@@ -73,17 +67,5 @@ RSpec.describe AgentBuilder, type: :model do
|
||||
expect(user.encrypted_password).not_to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the account has reached its agent limit' do
|
||||
before do
|
||||
allow(account).to receive(:usage_limits).and_return({ agents: account.account_users.count })
|
||||
end
|
||||
|
||||
it 'raises a limit exceeded error without creating a user' do
|
||||
expect { agent_builder.perform }.to raise_error(described_class::LimitExceededError, described_class::LIMIT_EXCEEDED_MESSAGE)
|
||||
|
||||
expect(User.from_email(email)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,9 +13,7 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/integrations/linear' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'deletes the linear integration when the user is an administrator' do
|
||||
it 'deletes the linear integration' do
|
||||
# Stub the HTTP call to Linear's revoke endpoint
|
||||
allow(HTTParty).to receive(:post).with(
|
||||
'https://api.linear.app/oauth/revoke',
|
||||
@@ -23,19 +21,11 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
).and_return(instance_double(HTTParty::Response, success?: true))
|
||||
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/linear",
|
||||
headers: admin.create_new_auth_token,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(account.hooks.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'returns unauthorized for an agent and keeps the integration' do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/linear",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(account.hooks.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/integrations/linear/teams' do
|
||||
|
||||
@@ -159,33 +159,19 @@ RSpec.describe 'Shopify Integration API', type: :request do
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/integrations/shopify' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
before do
|
||||
create(:integrations_hook, :shopify, account: account)
|
||||
end
|
||||
|
||||
context 'when it is an administrator' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'deletes the shopify integration' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change { account.hooks.count }.by(-1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized and keeps the integration' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to(change { account.hooks.count })
|
||||
end.to change { account.hooks.count }.by(-1)
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
|
||||
expect(metrics.keys).to contain_exactly(
|
||||
:conversations_handled, :auto_resolution_rate, :handoff_rate,
|
||||
:hours_saved, :reopen_rate, :conversation_depth
|
||||
:hours_saved, :reopen_rate, :conversation_depth, :knowledge
|
||||
)
|
||||
expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
|
||||
end
|
||||
@@ -229,7 +229,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#faq_stats' do
|
||||
describe '#metrics knowledge' do
|
||||
before do
|
||||
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
|
||||
create(:captain_assistant_response, assistant: assistant, account: account, status: :pending)
|
||||
@@ -237,7 +237,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
end
|
||||
|
||||
it 'returns approved, pending, document counts and coverage' do
|
||||
knowledge = described_class.new(assistant).faq_stats
|
||||
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
|
||||
|
||||
expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
|
||||
end
|
||||
@@ -245,7 +245,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
it 'reports zero coverage when there are no responses' do
|
||||
Captain::AssistantResponse.where(assistant: assistant).delete_all
|
||||
|
||||
knowledge = described_class.new(assistant).faq_stats
|
||||
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
|
||||
|
||||
expect(knowledge[:coverage]).to eq(0)
|
||||
end
|
||||
|
||||
@@ -21,27 +21,6 @@ RSpec.describe 'Agents API', type: :request do
|
||||
expect(response).to have_http_status(:payment_required)
|
||||
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
|
||||
end
|
||||
|
||||
it 'prevents adding an agent if the last seat is consumed before creation' do
|
||||
account.update!(limits: { agents: account.account_users.count + 1 })
|
||||
competing_agent_created = false
|
||||
|
||||
allow(AgentBuilder).to receive(:new).and_wrap_original do |method, *args|
|
||||
unless competing_agent_created
|
||||
create(:user, account: account, role: :agent)
|
||||
competing_agent_created = true
|
||||
end
|
||||
|
||||
method.call(*args)
|
||||
end
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/agents", params: params, headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:payment_required)
|
||||
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
|
||||
expect(User.from_email(params[:email])).to be_nil
|
||||
expect(account.account_users.count).to eq(account.usage_limits[:agents])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-2
@@ -9,8 +9,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
|
||||
let(:another_assistant) { create(:captain_assistant, account: account) }
|
||||
let(:another_document) { create(:captain_document, account: account, assistant: assistant) }
|
||||
|
||||
before { account.enable_features!('captain_integration') }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
@@ -5,8 +5,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before { account.enable_features!('captain_integration') }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
@@ -24,8 +24,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
)
|
||||
end
|
||||
|
||||
before { account.enable_features!('captain_integration') }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
-2
@@ -6,8 +6,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
|
||||
let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, account: account) }
|
||||
|
||||
before { account.enable_features!('captain_integration') }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.id}/copilot_messages' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns all messages' do
|
||||
|
||||
@@ -6,8 +6,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
before { account.enable_features!('captain_integration') }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
@@ -13,8 +13,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
before { account.enable_features!('captain_integration') }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
@@ -9,8 +9,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::Inboxes', type: :request do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before { account.enable_features!('captain_integration') }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
@@ -6,8 +6,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
before { account.enable_features!('captain_integration_v2') }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
@@ -6,8 +6,6 @@ RSpec.describe 'Custom Roles API', type: :request do
|
||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:custom_role) { create(:custom_role, account: account, name: 'Manager') }
|
||||
|
||||
before { account.enable_features!('custom_roles') }
|
||||
|
||||
describe 'GET #index' do
|
||||
context 'when it is an authenticated administrator' do
|
||||
it 'returns all custom roles in the account' do
|
||||
|
||||
@@ -6,7 +6,6 @@ RSpec.describe 'Enterprise SLA API', type: :request do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
account.enable_features!('sla')
|
||||
create(:sla_policy, account: account, name: 'SLA 1')
|
||||
end
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ RSpec.describe 'Enterprise Agents API', type: :request do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let!(:custom_role) { create(:custom_role, account: account) }
|
||||
|
||||
before { account.enable_features!('custom_roles') }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/agents' do
|
||||
let(:params) { { email: 'test@example.com', name: 'Test User', role: 'agent', custom_role_id: custom_role.id } }
|
||||
|
||||
|
||||
-1
@@ -13,7 +13,6 @@ RSpec.describe 'Enterprise Conversations API', type: :request do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
account.enable_features!('sla')
|
||||
create(:inbox_member, user: agent, inbox: conversation.inbox)
|
||||
end
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ RSpec.describe Captain::AssistantPolicy, type: :policy do
|
||||
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
|
||||
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
|
||||
|
||||
permissions :index?, :show?, :playground?, :metrics?, :faq_stats? do
|
||||
permissions :index?, :show?, :playground? do
|
||||
context 'when administrator' do
|
||||
it { expect(assistant_policy).to permit(administrator_context, assistant) }
|
||||
end
|
||||
|
||||
@@ -8,8 +8,6 @@ describe ActionService do
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:action_service) { described_class.new(conversation) }
|
||||
|
||||
before { account.enable_features!('sla') }
|
||||
|
||||
context 'when sla_policy_id is present' do
|
||||
it 'adds the sla policy to the conversation and create applied_sla entry' do
|
||||
action_service.add_sla([sla_policy.id])
|
||||
|
||||
@@ -8,7 +8,6 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
before do
|
||||
account.enable_features!('captain_integration')
|
||||
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
|
||||
end
|
||||
|
||||
@@ -203,29 +202,6 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the Captain feature is disabled but an assistant is still linked' do
|
||||
before do
|
||||
account.disable_features!('captain_integration', 'captain_integration_v2')
|
||||
conversation.update!(status: :pending)
|
||||
end
|
||||
|
||||
it 'does not schedule captain response job' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end
|
||||
|
||||
it 'falls back to the greeting message instead of leaving the contact without a reply' do
|
||||
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.to change { conversation.reload.messages.template.count }.by(1)
|
||||
|
||||
expect(conversation.reload.messages.template.last.content).to eq('Hello! How can we help you?')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Captain is not configured' do
|
||||
before do
|
||||
CaptainInbox.where(inbox: inbox).destroy_all
|
||||
|
||||
Reference in New Issue
Block a user