diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb
index 550b6c893..204bfc95b 100644
--- a/app/controllers/super_admin/app_configs_controller.rb
+++ b/app/controllers/super_admin/app_configs_controller.rb
@@ -32,22 +32,17 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
end
def allowed_configs
- @allowed_configs = case @config
- when 'facebook'
- %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
- when 'shopify'
- %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET]
- when 'microsoft'
- %w[AZURE_APP_ID AZURE_APP_SECRET]
- when 'email'
- ['MAILER_INBOUND_EMAIL_DOMAIN']
- when 'linear'
- %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
- when 'instagram'
- %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
- else
- %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
- end
+ mapping = {
+ 'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
+ 'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
+ 'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
+ 'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
+ 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
+ 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
+ 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
+ }
+
+ @allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
end
end
diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue
index ec2e25fa5..c7b4cde1f 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue
@@ -117,7 +117,7 @@ export default {
>'marked_for_deletion_at' IS NOT NULL")
+ .select { |account| deletion_period_expired?(account) }
+ end
+
+ def deletion_period_expired?(account)
+ deletion_time = account.custom_attributes['marked_for_deletion_at']
+ return false if deletion_time.blank?
+
+ DateTime.parse(deletion_time) <= Time.current
+ end
+end
diff --git a/app/mailers/administrator_notifications/account_compliance_mailer.rb b/app/mailers/administrator_notifications/account_compliance_mailer.rb
new file mode 100644
index 000000000..61ded60d3
--- /dev/null
+++ b/app/mailers/administrator_notifications/account_compliance_mailer.rb
@@ -0,0 +1,52 @@
+class AdministratorNotifications::AccountComplianceMailer < AdministratorNotifications::BaseMailer
+ def account_deleted(account)
+ return if instance_admin_email.blank?
+
+ subject = subject_for(account)
+ meta = build_meta(account)
+
+ send_notification(subject, to: instance_admin_email, meta: meta)
+ end
+
+ private
+
+ def build_meta(account)
+ deleted_users = params[:soft_deleted_users] || []
+
+ user_info_list = deleted_users.map do |user|
+ {
+ 'user_id' => user[:id].to_s,
+ 'user_email' => user[:original_email].to_s
+ }
+ end
+
+ {
+ 'instance_url' => instance_url,
+ 'account_id' => account.id,
+ 'account_name' => account.name,
+ 'deleted_at' => format_time(Time.current.iso8601),
+ 'deletion_reason' => account.custom_attributes['marked_for_deletion_reason'] || 'not specified',
+ 'marked_for_deletion_at' => format_time(account.custom_attributes['marked_for_deletion_at']),
+ 'soft_deleted_users' => user_info_list,
+ 'deleted_user_count' => user_info_list.size
+ }
+ end
+
+ def format_time(time_string)
+ return 'not specified' if time_string.blank?
+
+ Time.zone.parse(time_string).strftime('%B %d, %Y %H:%M:%S %Z')
+ end
+
+ def subject_for(account)
+ "Account Deletion Notice for #{account.id} - #{account.name}"
+ end
+
+ def instance_admin_email
+ GlobalConfig.get('CHATWOOT_INSTANCE_ADMIN_EMAIL')['CHATWOOT_INSTANCE_ADMIN_EMAIL']
+ end
+
+ def instance_url
+ ENV.fetch('FRONTEND_URL', 'not available')
+ end
+end
diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb
index d4e563f81..dfe889bfa 100644
--- a/app/models/integrations/app.rb
+++ b/app/models/integrations/app.rb
@@ -39,7 +39,8 @@ class Integrations::App
def action
case params[:id]
when 'slack'
- "#{params[:action]}&client_id=#{ENV.fetch('SLACK_CLIENT_ID', nil)}&redirect_uri=#{self.class.slack_integration_url}"
+ client_id = GlobalConfigService.load('SLACK_CLIENT_ID', nil)
+ "#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
when 'linear'
build_linear_action
else
@@ -50,7 +51,7 @@ class Integrations::App
def active?(account)
case params[:id]
when 'slack'
- ENV['SLACK_CLIENT_SECRET'].present?
+ GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
when 'linear'
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'shopify'
diff --git a/app/services/account_deletion_service.rb b/app/services/account_deletion_service.rb
new file mode 100644
index 000000000..62bce601b
--- /dev/null
+++ b/app/services/account_deletion_service.rb
@@ -0,0 +1,50 @@
+class AccountDeletionService
+ attr_reader :account, :soft_deleted_users
+
+ def initialize(account:)
+ @account = account
+ @soft_deleted_users = []
+ end
+
+ def perform
+ Rails.logger.info("Deleting account #{account.id} - #{account.name} that was marked for deletion")
+
+ soft_delete_orphaned_users
+ send_compliance_notification
+ DeleteObjectJob.perform_later(account)
+ end
+
+ private
+
+ def send_compliance_notification
+ AdministratorNotifications::AccountComplianceMailer.with(
+ account: account,
+ soft_deleted_users: soft_deleted_users
+ ).account_deleted(account).deliver_later
+ end
+
+ def soft_delete_orphaned_users
+ account.users.each do |user|
+ # Find all account_users for this user excluding the current account
+ other_accounts = user.account_users.where.not(account_id: account.id).count
+
+ # If user has no other accounts, soft delete them
+ next unless other_accounts.zero?
+
+ # Soft delete user by appending -deleted.com to email
+ original_email = user.email
+ user.email = "#{original_email}-deleted.com"
+ user.skip_reconfirmation!
+ user.save!
+
+ user_info = {
+ id: user.id.to_s,
+ original_email: original_email
+ }
+
+ soft_deleted_users << user_info
+
+ Rails.logger.info("Soft deleted user #{user.id} with email #{original_email}")
+ end
+ end
+end
diff --git a/app/views/mailers/administrator_notifications/account_compliance_mailer/account_deleted.liquid b/app/views/mailers/administrator_notifications/account_compliance_mailer/account_deleted.liquid
new file mode 100644
index 000000000..636a5daa2
--- /dev/null
+++ b/app/views/mailers/administrator_notifications/account_compliance_mailer/account_deleted.liquid
@@ -0,0 +1,30 @@
+Hello,
+
+This is a notification to inform you that an account has been permanently deleted from your Chatwoot instance.
+
+
+ Chatwoot Installation: {{ meta.instance_url }}
+ Account ID: {{ meta.account_id }}
+ Account Name: {{ meta.account_name }}
+ Deleted At: {{ meta.deleted_at }}
+ Marked for Deletion at: {{ meta.marked_for_deletion_at }}
+ Deletion Reason: {{ meta.deletion_reason }}
+
+
+{% if meta.deleted_user_count > 0 %}
+
+ Deleted Users ({{ meta.deleted_user_count }}):
+ {% for user in meta.soft_deleted_users %}
+ User ID: {{ user.user_id }}, Email: {{ user.user_email }}{% unless forloop.last %}
{% endunless %}
+ {% endfor %}
+
+{% else %}
+
+ Deleted Users: None
+
+{% endif %}
+
+This email serves as a record for compliance purposes.
+
+Thank you,
+Chatwoot System
\ No newline at end of file
diff --git a/app/views/super_admin/application/_icons.html.erb b/app/views/super_admin/application/_icons.html.erb
index fb10c1035..fabff914b 100644
--- a/app/views/super_admin/application/_icons.html.erb
+++ b/app/views/super_admin/application/_icons.html.erb
@@ -159,4 +159,7 @@
-
\ No newline at end of file
+
+
+
+
diff --git a/config/installation_config.yml b/config/installation_config.yml
index 1a891c420..66538d4ab 100644
--- a/config/installation_config.yml
+++ b/config/installation_config.yml
@@ -235,6 +235,14 @@
description: Used to notify Chatwoot about account abuses, potential threads (Should be a Discord Webhook URL)
# ------- End of Chatwoot Internal Config for Self Hosted ----#
+# ------- Compliance Related Config ----#
+- name: CHATWOOT_INSTANCE_ADMIN_EMAIL
+ display_title: 'Instance Admin Email'
+ value:
+ description: 'The email of the instance administrator to receive compliance-related notifications'
+ locked: false
+# ------- End of Compliance Related Config ----#
+
## ------ Configs added for enterprise clients ------ ##
- name: API_CHANNEL_NAME
value:
@@ -280,6 +288,20 @@
type: secret
## ------ End of Configs added for Linear ------ ##
+## ------ Configs added for Slack ------ ##
+- name: SLACK_CLIENT_ID
+ display_title: 'Slack Client ID'
+ value:
+ locked: false
+ description: 'Slack client ID'
+- name: SLACK_CLIENT_SECRET
+ display_title: 'Slack Client Secret'
+ value:
+ locked: false
+ description: 'Slack client secret'
+ type: secret
+## ------ End of Configs added for Slack ------ ##
+
# ------- Shopify Integration Config ------- #
- name: SHOPIFY_CLIENT_ID
display_title: 'Shopify Client ID'
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 2a2e73d91..9a35197ad 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -213,33 +213,41 @@ en:
online:
delete: '%{contact_name} is Online, please try again later'
integration_apps:
+ # Note: webhooks and dashboard_apps don't need short_description as they use different modal components
dashboard_apps:
name: 'Dashboard Apps'
description: 'Dashboard Apps allow you to create and embed applications that display user information, orders, or payment history, providing more context to your customer support agents.'
dyte:
name: 'Dyte'
+ short_description: 'Start video/voice calls with customers directly from Chatwoot.'
description: 'Dyte is a product that integrates audio and video functionalities into your application. With this integration, your agents can start video/voice calls with your customers directly from Chatwoot.'
meeting_name: '%{agent_name} has started a meeting'
slack:
name: 'Slack'
+ short_description: 'Receive notifications and respond to conversations directly in Slack.'
description: "Integrate Chatwoot with Slack to keep your team in sync. This integration allows you to receive notifications for new conversations and respond to them directly within Slack's interface."
webhooks:
name: 'Webhooks'
description: 'Webhook events provide real-time updates about activities in your Chatwoot account. You can subscribe to your preferred events, and Chatwoot will send you HTTP callbacks with the updates.'
dialogflow:
name: 'Dialogflow'
+ short_description: 'Build chatbots to handle initial queries before transferring to agents.'
description: 'Build chatbots with Dialogflow and easily integrate them into your inbox. These bots can handle initial queries before transferring them to a customer service agent.'
google_translate:
name: 'Google Translate'
+ short_description: 'Automatically translate customer messages for agents.'
description: "Integrate Google Translate to help agents easily translate customer messages. This integration automatically detects the language and converts it to the agent's or admin's preferred language."
openai:
name: 'OpenAI'
+ short_description: 'AI-powered reply suggestions, summarization, and message enhancement.'
description: 'Leverage the power of large language models from OpenAI with the features such as reply suggestions, summarization, message rephrasing, spell-checking, and label classification.'
linear:
name: 'Linear'
+ short_description: 'Create and link Linear issues directly from conversations.'
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
shopify:
name: 'Shopify'
+ short_description: 'Access order details and customer data from your Shopify store.'
description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.'
leadsquared:
name: 'LeadSquared'
diff --git a/config/schedule.yml b/config/schedule.yml
index 2747910ad..c45d395bf 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -39,3 +39,10 @@ process_stale_contacts_job:
cron: '30 04 * * *'
class: 'Internal::ProcessStaleContactsJob'
queue: housekeeping
+
+# executed daily at 0100 UTC
+# to delete accounts marked for deletion
+delete_accounts_job:
+ cron: '0 1 * * *'
+ class: 'Internal::DeleteAccountsJob'
+ queue: scheduled_jobs
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb
index c382206b6..cdea79064 100644
--- a/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb
@@ -19,9 +19,9 @@ module Enterprise::Api::V1::Accounts::ConversationsController
response = Captain::Copilot::ChatService.new(
assistant,
- previous_messages: copilot_params[:previous_messages],
- conversation_history: @conversation.to_llm_text,
- language: @conversation.account.locale_english_name
+ previous_history: copilot_params[:previous_history],
+ conversation_id: @conversation.display_id,
+ user_id: Current.user.id
).generate_response(copilot_params[:message])
render json: { message: response['response'] }
@@ -44,6 +44,6 @@ module Enterprise::Api::V1::Accounts::ConversationsController
private
def copilot_params
- params.permit(:previous_messages, :message, :assistant_id)
+ params.permit(:previous_history, :message, :assistant_id)
end
end
diff --git a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb
index f0b798648..2454295dc 100644
--- a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb
+++ b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb
@@ -33,6 +33,6 @@ module Enterprise::SuperAdmin::AppConfigsController
def internal_config_options
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS BLOCKED_EMAIL_DOMAINS
- CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL]
+ CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL]
end
end
diff --git a/enterprise/app/helpers/captain/chat_helper.rb b/enterprise/app/helpers/captain/chat_helper.rb
index 200f440f7..e326f3094 100644
--- a/enterprise/app/helpers/captain/chat_helper.rb
+++ b/enterprise/app/helpers/captain/chat_helper.rb
@@ -1,6 +1,6 @@
module Captain::ChatHelper
def request_chat_completion
- Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{@messages}" }
+ log_chat_completion_request
response = @client.chat(
parameters: {
@@ -15,13 +15,17 @@ module Captain::ChatHelper
handle_response(response)
end
+ private
+
def handle_response(response)
- Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{response}" }
+ Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
message = response.dig('choices', 0, 'message')
if message['tool_calls']
process_tool_calls(message['tool_calls'])
else
- JSON.parse(message['content'].strip)
+ message = JSON.parse(message['content'].strip)
+ persist_message(message, 'assistant')
+ message
end
end
@@ -41,12 +45,14 @@ module Captain::ChatHelper
if @tool_registry.respond_to?(function_name)
execute_tool(function_name, arguments, tool_call_id)
else
- process_invalid_tool_call(tool_call_id)
+ process_invalid_tool_call(function_name, tool_call_id)
end
end
def execute_tool(function_name, arguments, tool_call_id)
+ persist_message({ content: "Using tool #{function_name}", function_name: function_name }, 'assistant_thinking')
result = @tool_registry.send(function_name, arguments)
+ persist_message({ content: "Completed #{function_name} tool call", function_name: function_name }, 'assistant_thinking')
append_tool_response(result, tool_call_id)
end
@@ -57,7 +63,8 @@ module Captain::ChatHelper
}
end
- def process_invalid_tool_call(tool_call_id)
+ def process_invalid_tool_call(function_name, tool_call_id)
+ persist_message({ content: 'Invalid tool call', function_name: function_name }, 'assistant_thinking')
append_tool_response('Tool not available', tool_call_id)
end
@@ -68,4 +75,12 @@ module Captain::ChatHelper
content: content
}
end
+
+ def log_chat_completion_request
+ Rails.logger.info(
+ "#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion
+ for messages #{@messages} with #{@tool_registry&.registered_tools&.length || 0} tools
+ "
+ )
+ end
end
diff --git a/enterprise/app/helpers/super_admin/features.yml b/enterprise/app/helpers/super_admin/features.yml
index 3767d6468..c20de2dfa 100644
--- a/enterprise/app/helpers/super_admin/features.yml
+++ b/enterprise/app/helpers/super_admin/features.yml
@@ -48,6 +48,12 @@ messenger:
enabled: true
icon: 'icon-messenger-line'
config_key: 'facebook'
+instagram:
+ name: 'Instagram'
+ description: 'Stay connected with your customers on Instagram'
+ enabled: true
+ icon: 'icon-instagram'
+ config_key: 'instagram'
whatsapp:
name: 'WhatsApp'
description: 'Manage your WhatsApp business interactions from Chatwoot.'
@@ -81,19 +87,19 @@ microsoft:
config_key: 'microsoft'
linear:
name: 'Linear'
- description: 'Configuration for setting up Linear'
+ description: 'Configuration for setting up Linear Integration'
enabled: true
icon: 'icon-linear'
config_key: 'linear'
-instagram:
- name: 'Instagram'
- description: 'Configuration for setting up Instagram'
+slack:
+ name: 'Slack'
+ description: 'Configuration for setting up Slack Integration'
enabled: true
- icon: 'icon-instagram'
- config_key: 'instagram'
+ icon: 'icon-slack'
+ config_key: 'slack'
shopify:
name: 'Shopify'
- description: 'Configuration for setting up Shopify'
+ description: 'Configuration for setting up Shopify Integration'
enabled: true
icon: 'icon-shopify'
config_key: 'shopify'
diff --git a/enterprise/app/models/copilot_message.rb b/enterprise/app/models/copilot_message.rb
index 90ec2a090..f3816685c 100644
--- a/enterprise/app/models/copilot_message.rb
+++ b/enterprise/app/models/copilot_message.rb
@@ -25,6 +25,7 @@ class CopilotMessage < ApplicationRecord
validates :message_type, presence: true, inclusion: { in: message_types.keys }
validates :message, presence: true
+ validate :validate_message_attributes
after_create_commit :broadcast_message
@@ -47,4 +48,13 @@ class CopilotMessage < ApplicationRecord
def broadcast_message
Rails.configuration.dispatcher.dispatch(COPILOT_MESSAGE_CREATED, Time.zone.now, copilot_message: self)
end
+
+ def validate_message_attributes
+ return if message.blank?
+
+ allowed_keys = %w[content reasoning function_name]
+ invalid_keys = message.keys - allowed_keys
+
+ errors.add(:message, "contains invalid attributes: #{invalid_keys.join(', ')}") if invalid_keys.any?
+ end
end
diff --git a/enterprise/app/models/copilot_thread.rb b/enterprise/app/models/copilot_thread.rb
index 5dc41c244..c3d8a1821 100644
--- a/enterprise/app/models/copilot_thread.rb
+++ b/enterprise/app/models/copilot_thread.rb
@@ -40,7 +40,7 @@ class CopilotThread < ApplicationRecord
.order(created_at: :asc)
.map do |copilot_message|
{
- content: copilot_message.message,
+ content: copilot_message.message['content'],
role: copilot_message.message_type
}
end
diff --git a/enterprise/app/models/enterprise/concerns/user.rb b/enterprise/app/models/enterprise/concerns/user.rb
index 0e597b8d8..7231decb7 100644
--- a/enterprise/app/models/enterprise/concerns/user.rb
+++ b/enterprise/app/models/enterprise/concerns/user.rb
@@ -6,7 +6,6 @@ module Enterprise::Concerns::User
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
has_many :copilot_threads, dependent: :destroy_async
- has_many :copilot_messages, dependent: :destroy_async
end
def ensure_installation_pricing_plan_quantity
diff --git a/enterprise/app/services/captain/copilot/chat_service.rb b/enterprise/app/services/captain/copilot/chat_service.rb
index 202348825..0c4811c7e 100644
--- a/enterprise/app/services/captain/copilot/chat_service.rb
+++ b/enterprise/app/services/captain/copilot/chat_service.rb
@@ -3,49 +3,110 @@ require 'openai'
class Captain::Copilot::ChatService < Llm::BaseOpenAiService
include Captain::ChatHelper
+ attr_reader :assistant, :account, :user, :copilot_thread, :previous_history, :messages
+
def initialize(assistant, config)
super()
@assistant = assistant
- @conversation_history = config[:conversation_history]
- @previous_messages = config[:previous_messages] || []
- @language = config[:language] || 'english'
-
+ @account = assistant.account
+ @user = nil
+ @copilot_thread = nil
+ @previous_history = []
+ setup_user(config)
+ setup_message_history(config)
register_tools
- @messages = [system_message, conversation_history_context] + @previous_messages
- @response = ''
+ @messages = build_messages(config)
end
def generate_response(input)
@messages << { role: 'user', content: input } if input.present?
response = request_chat_completion
- Rails.logger.info("[CAPTAIN][CopilotChatService] Incrementing response usage for #{@assistant.account.id}")
- @assistant.account.increment_response_usage
+
+ Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
+ Rails.logger.info(
+ "#{self.class.name} Assistant: #{@assistant.id}, Incrementing response usage for account #{@account.id}"
+ )
+ @account.increment_response_usage
response
end
private
+ def setup_user(config)
+ @user = @account.users.find_by(id: config[:user_id]) if config[:user_id].present?
+ end
+
+ def build_messages(config)
+ messages= [system_message]
+ messages << account_id_context
+ messages += @previous_history if @previous_history.present?
+ messages += current_viewing_history(config[:conversation_id]) if config[:conversation_id].present?
+ messages
+ end
+
+ def setup_message_history(config)
+ Rails.logger.info(
+ "#{self.class.name} Assistant: #{@assistant.id}, Previous History: #{config[:previous_history]&.length || 0}, Language: #{config[:language]}"
+ )
+
+ @copilot_thread = @account.copilot_threads.find_by(id: config[:thread_id]) if config[:thread_id].present?
+ @previous_history = if @copilot_thread.present?
+ @copilot_thread.previous_history
+ else
+ config[:previous_history].presence || []
+ end
+ end
+
def register_tools
- @tool_registry = Captain::ToolRegistryService.new(@assistant)
+ @tool_registry = Captain::ToolRegistryService.new(@assistant, user: @user)
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
+ @tool_registry.register_tool(Captain::Tools::Copilot::GetArticleService)
+ @tool_registry.register_tool(Captain::Tools::Copilot::GetContactService)
+ @tool_registry.register_tool(Captain::Tools::Copilot::GetConversationService)
+ @tool_registry.register_tool(Captain::Tools::Copilot::SearchArticlesService)
+ @tool_registry.register_tool(Captain::Tools::Copilot::SearchContactsService)
+ @tool_registry.register_tool(Captain::Tools::Copilot::SearchConversationsService)
+ @tool_registry.register_tool(Captain::Tools::Copilot::SearchLinearIssuesService)
end
def system_message
{
role: 'system',
- content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'], @language)
+ content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'])
}
end
- def conversation_history_context
+ def account_id_context
{
role: 'system',
- content: "
- Message History with the user is below:
- #{@conversation_history}
- "
+ content: "The current account id is #{@account.id}. The account is using #{@account.locale_english_name} as the language."
}
end
+
+ def current_viewing_history(conversation_id)
+ conversation = @account.conversations.find_by(display_id: conversation_id)
+ return [] unless conversation
+
+ Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, Setting viewing history for conversation_id=#{conversation_id}")
+ contact_id = conversation.contact_id
+ [{
+ role: 'system',
+ content: <<~HISTORY.strip
+ You are currently viewing the conversation with the following details:
+ Conversation ID: #{conversation_id}
+ Contact ID: #{contact_id}
+ HISTORY
+ }]
+ end
+
+ def persist_message(message, message_type = 'assistant')
+ return if @copilot_thread.blank?
+
+ @copilot_thread.copilot_messages.create!(
+ message: message,
+ message_type: message_type
+ )
+ end
end
diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb
index 8077b22c9..569931d44 100644
--- a/enterprise/app/services/captain/llm/assistant_chat_service.rb
+++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb
@@ -21,7 +21,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
private
def register_tools
- @tool_registry = Captain::ToolRegistryService.new(@assistant)
+ @tool_registry = Captain::ToolRegistryService.new(@assistant, user: nil)
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
end
@@ -31,4 +31,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.name, @assistant.config['product_name'], @assistant.config)
}
end
+
+ def persist_message(message, message_type = 'assistant')
+ # No need to implement
+ end
end
diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb
index 2d2940a8e..f50ad0c6c 100644
--- a/enterprise/app/services/captain/llm/system_prompts_service.rb
+++ b/enterprise/app/services/captain/llm/system_prompts_service.rb
@@ -56,18 +56,18 @@ class Captain::Llm::SystemPromptsService
SYSTEM_PROMPT_MESSAGE
end
- def copilot_response_generator(product_name, language)
+ def copilot_response_generator(product_name)
<<~SYSTEM_PROMPT_MESSAGE
[Identity]
You are Captain, a helpful and friendly copilot assistant for support agents using the product #{product_name}. Your primary role is to assist support agents by retrieving information, compiling accurate responses, and guiding them through customer interactions.
You should only provide information related to #{product_name} and must not address queries about other products or external events.
[Context]
- You will be provided with the message history between the support agent and the customer. Use this context to understand the conversation flow, identify unresolved queries, and ensure responses are relevant and consistent with previous interactions. Always maintain a coherent and professional tone throughout the conversation.
+ Identify unresolved queries, and ensure responses are relevant and consistent with previous interactions. Always maintain a coherent and professional tone throughout the conversation.
[Response Guidelines]
- Use natural, polite, and conversational language that is clear and easy to follow. Keep sentences short and use simple words.
- - Reply in the language the agent is using, if you're not able to detect the language, reply in #{language}.
+ - Reply in the language the agent is using, if you're not able to detect the language.
- Provide brief and relevant responses—typically one or two sentences unless a more detailed explanation is necessary.
- Do not use your own training data or assumptions to answer queries. Base responses strictly on the provided information.
- If the query is unclear, ask concise clarifying questions instead of making assumptions.
diff --git a/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb b/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb
index 599fc7de9..ac5116303 100644
--- a/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb
+++ b/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb
@@ -46,7 +46,7 @@ class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseS
end
def active?
- @assistant.account.hooks.find_by(app_id: 'linear').present?
+ @user.present? && @assistant.account.hooks.find_by(app_id: 'linear').present?
end
private
diff --git a/lib/global_config_service.rb b/lib/global_config_service.rb
index 8de1c50c9..0649c24af 100644
--- a/lib/global_config_service.rb
+++ b/lib/global_config_service.rb
@@ -1,6 +1,6 @@
class GlobalConfigService
def self.load(config_key, default_value)
- config = ENV.fetch(config_key) { GlobalConfig.get(config_key)[config_key] }
+ config = GlobalConfig.get(config_key)[config_key]
return config if config.present?
# To support migrating existing instance relying on env variables
diff --git a/lib/integrations/slack/hook_builder.rb b/lib/integrations/slack/hook_builder.rb
index 139ef3a5b..e9513df57 100644
--- a/lib/integrations/slack/hook_builder.rb
+++ b/lib/integrations/slack/hook_builder.rb
@@ -32,8 +32,8 @@ class Integrations::Slack::HookBuilder
def fetch_access_token
client = Slack::Web::Client.new
slack_access = client.oauth_v2_access(
- client_id: ENV.fetch('SLACK_CLIENT_ID', 'TEST_CLIENT_ID'),
- client_secret: ENV.fetch('SLACK_CLIENT_SECRET', 'TEST_CLIENT_SECRET'),
+ client_id: GlobalConfigService.load('SLACK_CLIENT_ID', 'TEST_CLIENT_ID'),
+ client_secret: GlobalConfigService.load('SLACK_CLIENT_SECRET', 'TEST_CLIENT_SECRET'),
code: params[:code],
redirect_uri: Integrations::App.slack_integration_url
)
diff --git a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb
index 0a9c82f53..1a775f88f 100644
--- a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb
+++ b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb
@@ -23,6 +23,10 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
end
describe '#omniauth_sucess' do
+ before do
+ GlobalConfig.clear_cache
+ end
+
it 'allows signup' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', FRONTEND_URL: 'http://www.example.com' do
set_omniauth_config('test_not_preset@example.com')
diff --git a/spec/enterprise/models/copilot_thread_spec.rb b/spec/enterprise/models/copilot_thread_spec.rb
index 37421ae76..a6a5f4a44 100644
--- a/spec/enterprise/models/copilot_thread_spec.rb
+++ b/spec/enterprise/models/copilot_thread_spec.rb
@@ -47,9 +47,9 @@ RSpec.describe CopilotThread, type: :model do
expect(history.length).to eq(2)
expect(history[0][:role]).to eq('user')
- expect(history[0][:content]).to eq({ 'content' => 'User message' })
+ expect(history[0][:content]).to eq('User message')
expect(history[1][:role]).to eq('assistant')
- expect(history[1][:content]).to eq({ 'content' => 'Assistant message' })
+ expect(history[1][:content]).to eq('Assistant message')
end
end
diff --git a/spec/enterprise/services/captain/copilot/chat_service_spec.rb b/spec/enterprise/services/captain/copilot/chat_service_spec.rb
index 5daabd5bf..e498fa83a 100644
--- a/spec/enterprise/services/captain/copilot/chat_service_spec.rb
+++ b/spec/enterprise/services/captain/copilot/chat_service_spec.rb
@@ -2,87 +2,245 @@ require 'rails_helper'
RSpec.describe Captain::Copilot::ChatService do
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) }
- let(:captain_inbox_association) { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) }
- let(:mock_captain_agent) { instance_double(Captain::Agent) }
- let(:mock_captain_tool) { instance_double(Captain::Tool) }
- let(:mock_openai_client) { instance_double(OpenAI::Client) }
+ let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
+ let(:contact) { create(:contact, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
+ let(:mock_openai_client) { instance_double(OpenAI::Client) }
+ let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
+ let!(:copilot_message) do
+ create(
+ :captain_copilot_message, account: account, copilot_thread: copilot_thread
+ )
+ end
+ let(:previous_history) { [{ role: copilot_message.message_type, content: copilot_message.message['content'] }] }
+
+ let(:config) do
+ { user_id: user.id, thread_id: copilot_thread.id, conversation_id: conversation.display_id }
+ end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client)
+ allow(mock_openai_client).to receive(:chat).and_return({
+ choices: [{ message: { content: '{ "content": "Hey" }' } }]
+ }.with_indifferent_access)
end
describe '#initialize' do
- it 'sets default language to english when not specified' do
- service = described_class.new(assistant, { previous_messages: [], conversation_history: '' })
- expect(service.instance_variable_get(:@language)).to eq('english')
+ it 'sets up the service with correct instance variables' do
+ service = described_class.new(assistant, config)
+
+ expect(service.assistant).to eq(assistant)
+ expect(service.account).to eq(account)
+ expect(service.user).to eq(user)
+ expect(service.copilot_thread).to eq(copilot_thread)
+ expect(service.previous_history).to eq(previous_history)
end
- it 'uses the specified language when provided' do
- service = described_class.new(assistant, {
- previous_messages: [],
- conversation_history: '',
- language: 'spanish'
- })
- expect(service.instance_variable_get(:@language)).to eq('spanish')
+ it 'builds messages with system message and account context' do
+ service = described_class.new(assistant, config)
+ messages = service.messages
+
+ expect(messages.first[:role]).to eq('system')
+ expect(messages.second[:role]).to eq('system')
+ expect(messages.second[:content]).to include(account.id.to_s)
end
end
describe '#generate_response' do
- before do
- allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client)
- allow(mock_openai_client).to receive(:chat).and_return({ choices: [{ message: { content: '{ "result": "Hey" }' } }] }.with_indifferent_access)
+ let(:service) { described_class.new(assistant, config) }
- allow(Captain::Agent).to receive(:new).and_return(mock_captain_agent)
- allow(mock_captain_agent).to receive(:execute).and_return(true)
- allow(mock_captain_agent).to receive(:register_tool).and_return(true)
+ it 'adds user input to messages when present' do
+ expect do
+ service.generate_response('Hello')
+ end.to(change { service.messages.count }.by(1))
- allow(Captain::Tool).to receive(:new).and_return(mock_captain_tool)
- allow(mock_captain_tool).to receive(:register_method).and_return(true)
-
- allow(account).to receive(:increment_response_usage).and_return(true)
+ last_message = service.messages.last
+ expect(last_message[:role]).to eq('user')
+ expect(last_message[:content]).to eq('Hello')
end
- it 'increments usage' do
- described_class.new(assistant, { previous_messages: ['Hello'], conversation_history: 'Hi' }).generate_response('Hey')
- expect(account).to have_received(:increment_response_usage).once
+ it 'does not add user input to messages when blank' do
+ expect do
+ service.generate_response('')
+ end.not_to(change { service.messages.count })
end
- it 'includes language in system message' do
- service = described_class.new(assistant, {
- previous_messages: [],
- conversation_history: '',
- language: 'spanish'
- })
+ it 'returns the response from request_chat_completion' do
+ expect(service.generate_response('Hello')).to eq({ 'content' => 'Hey' })
+ end
- allow(Captain::Llm::SystemPromptsService).to receive(:copilot_response_generator)
- .with(assistant.config['product_name'], 'spanish')
- .and_return('Spanish system prompt')
+ context 'when response contains tool calls' do
+ before do
+ allow(mock_openai_client).to receive(:chat).and_return(
+ {
+ choices: [{ message: { 'tool_calls' => tool_calls } }]
+ }.with_indifferent_access,
+ {
+ choices: [{ message: { content: '{ "content": "Tool response processed" }' } }]
+ }.with_indifferent_access
+ )
+ end
- system_message = service.send(:system_message)
- expect(system_message[:content]).to eq('Spanish system prompt')
+ context 'when tool call is valid' do
+ let(:tool_calls) do
+ [{
+ 'id' => 'call_123',
+ 'function' => {
+ 'name' => 'get_conversation',
+ 'arguments' => "{ \"conversation_id\": #{conversation.display_id} }"
+ }
+ }]
+ end
+
+ it 'processes tool calls and appends them to messages' do
+ result = service.generate_response("Find conversation #{conversation.id}")
+
+ expect(result).to eq({ 'content' => 'Tool response processed' })
+ expect(service.messages).to include(
+ { role: 'assistant', tool_calls: tool_calls }
+ )
+ expect(service.messages).to include(
+ {
+ role: 'tool', tool_call_id: 'call_123', content: conversation.to_llm_text
+ }
+ )
+
+ expect(result).to eq({ 'content' => 'Tool response processed' })
+ end
+ end
+
+ context 'when tool call is invalid' do
+ let(:tool_calls) do
+ [{
+ 'id' => 'call_123',
+ 'function' => {
+ 'name' => 'get_settings',
+ 'arguments' => '{}'
+ }
+ }]
+ end
+
+ it 'handles invalid tool calls' do
+ result = service.generate_response('Find settings')
+
+ expect(result).to eq({ 'content' => 'Tool response processed' })
+ expect(service.messages).to include(
+ {
+ role: 'assistant', tool_calls: tool_calls
+ }
+ )
+ expect(service.messages).to include(
+ {
+ role: 'tool',
+ tool_call_id: 'call_123',
+ content: 'Tool not available'
+ }
+ )
+ end
+ end
end
end
- describe '#execute' do
- before do
- allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client)
- allow(mock_openai_client).to receive(:chat).and_return({ choices: [{ message: { content: '{ "result": "Hey" }' } }] }.with_indifferent_access)
-
- allow(Captain::Agent).to receive(:new).and_return(mock_captain_agent)
- allow(mock_captain_agent).to receive(:execute).and_return(true)
- allow(mock_captain_agent).to receive(:register_tool).and_return(true)
-
- allow(Captain::Tool).to receive(:new).and_return(mock_captain_tool)
- allow(mock_captain_tool).to receive(:register_method).and_return(true)
-
- allow(account).to receive(:increment_response_usage).and_return(true)
+ describe '#setup_user' do
+ it 'sets user when user_id is present in config' do
+ service = described_class.new(assistant, { user_id: user.id })
+ expect(service.user).to eq(user)
end
- it 'increments usage' do
- described_class.new(assistant, { previous_messages: ['Hello'], conversation_history: 'Hi' }).generate_response('Hey')
- expect(account).to have_received(:increment_response_usage).once
+ it 'does not set user when user_id is not present in config' do
+ service = described_class.new(assistant, {})
+ expect(service.user).to be_nil
+ end
+ end
+
+ describe '#setup_message_history' do
+ context 'when thread_id is present' do
+ it 'finds the copilot thread and sets previous history from it' do
+ service = described_class.new(assistant, { thread_id: copilot_thread.id })
+
+ expect(service.copilot_thread).to eq(copilot_thread)
+ expect(service.previous_history).to eq previous_history
+ end
+ end
+
+ context 'when thread_id is not present' do
+ it 'uses previous_history from config if present' do
+ custom_history = [{ role: 'user', content: 'Custom message' }]
+ service = described_class.new(assistant, { previous_history: custom_history })
+
+ expect(service.copilot_thread).to be_nil
+ expect(service.previous_history).to eq(custom_history)
+ end
+
+ it 'uses empty array if previous_history is not present in config' do
+ service = described_class.new(assistant, {})
+
+ expect(service.copilot_thread).to be_nil
+ expect(service.previous_history).to eq([])
+ end
+ end
+ end
+
+ describe '#build_messages' do
+ it 'includes system message and account context' do
+ service = described_class.new(assistant, {})
+ messages = service.messages
+
+ expect(messages.first[:role]).to eq('system')
+ expect(messages.second[:role]).to eq('system')
+ expect(messages.second[:content]).to include(account.id.to_s)
+ end
+
+ it 'includes previous history when present' do
+ custom_history = [{ role: 'user', content: 'Custom message' }]
+ service = described_class.new(assistant, { previous_history: custom_history })
+ messages = service.messages
+
+ expect(messages.count).to be >= 3
+ expect(messages.any? { |m| m[:content] == 'Custom message' }).to be true
+ end
+
+ it 'includes current viewing history when conversation_id is present' do
+ service = described_class.new(assistant, { conversation_id: conversation.display_id })
+ messages = service.messages
+
+ viewing_history = messages.find { |m| m[:content].include?('You are currently viewing the conversation') }
+ expect(viewing_history).not_to be_nil
+ expect(viewing_history[:content]).to include(conversation.display_id.to_s)
+ expect(viewing_history[:content]).to include(contact.id.to_s)
+ end
+ end
+
+ describe '#persist_message' do
+ context 'when copilot_thread is present' do
+ it 'creates a copilot message' do
+ allow(mock_openai_client).to receive(:chat).and_return({
+ choices: [{ message: { content: '{ "content": "Hey" }' } }]
+ }.with_indifferent_access)
+
+ expect do
+ described_class.new(assistant, { thread_id: copilot_thread.id }).generate_response('Hello')
+ end.to change(CopilotMessage, :count).by(1)
+
+ last_message = CopilotMessage.last
+ expect(last_message.message_type).to eq('assistant')
+ expect(last_message.message['content']).to eq('Hey')
+ end
+ end
+
+ context 'when copilot_thread is not present' do
+ it 'does not create a copilot message' do
+ allow(mock_openai_client).to receive(:chat).and_return({
+ choices: [{ message: { content: '{ "content": "Hey" }' } }]
+ }.with_indifferent_access)
+
+ expect do
+ described_class.new(assistant, {}).generate_response('Hello')
+ end.not_to(change(CopilotMessage, :count))
+ end
end
end
end
diff --git a/spec/enterprise/services/enterprise/clearbit_lookup_service_spec.rb b/spec/enterprise/services/enterprise/clearbit_lookup_service_spec.rb
index 725aa6523..d746b9a83 100644
--- a/spec/enterprise/services/enterprise/clearbit_lookup_service_spec.rb
+++ b/spec/enterprise/services/enterprise/clearbit_lookup_service_spec.rb
@@ -49,6 +49,10 @@ RSpec.describe Enterprise::ClearbitLookupService do
end
context 'when Clearbit is not enabled' do
+ before do
+ GlobalConfig.clear_cache
+ end
+
it 'returns nil without making an API call' do
with_modified_env CLEARBIT_API_KEY: nil do
expect(Net::HTTP).not_to receive(:start)
diff --git a/spec/jobs/internal/delete_accounts_job_spec.rb b/spec/jobs/internal/delete_accounts_job_spec.rb
new file mode 100644
index 000000000..514ecf6ab
--- /dev/null
+++ b/spec/jobs/internal/delete_accounts_job_spec.rb
@@ -0,0 +1,44 @@
+require 'rails_helper'
+
+RSpec.describe Internal::DeleteAccountsJob do
+ subject(:job) { described_class.perform_later }
+
+ let!(:account_marked_for_deletion) { create(:account) }
+ let!(:future_deletion_account) { create(:account) }
+ let!(:active_account) { create(:account) }
+ let(:account_deletion_service) { instance_double(AccountDeletionService, perform: true) }
+
+ before do
+ account_marked_for_deletion.update!(
+ custom_attributes: {
+ 'marked_for_deletion_at' => 1.day.ago.iso8601,
+ 'marked_for_deletion_reason' => 'user_requested'
+ }
+ )
+
+ future_deletion_account.update!(
+ custom_attributes: {
+ 'marked_for_deletion_at' => 3.days.from_now.iso8601,
+ 'marked_for_deletion_reason' => 'user_requested'
+ }
+ )
+
+ allow(AccountDeletionService).to receive(:new).and_return(account_deletion_service)
+ end
+
+ it 'enqueues the job' do
+ expect { job }.to have_enqueued_job(described_class)
+ .on_queue('scheduled_jobs')
+ end
+
+ describe '#perform' do
+ it 'calls AccountDeletionService for accounts past deletion date' do
+ described_class.new.perform
+
+ expect(AccountDeletionService).to have_received(:new).with(account: account_marked_for_deletion)
+ expect(AccountDeletionService).not_to have_received(:new).with(account: future_deletion_account)
+ expect(AccountDeletionService).not_to have_received(:new).with(account: active_account)
+ expect(account_deletion_service).to have_received(:perform)
+ end
+ end
+end
diff --git a/spec/listeners/action_cable_listener_spec.rb b/spec/listeners/action_cable_listener_spec.rb
index ea62d8ca7..55b74116c 100644
--- a/spec/listeners/action_cable_listener_spec.rb
+++ b/spec/listeners/action_cable_listener_spec.rb
@@ -203,24 +203,4 @@ describe ActionCableListener do
listener.conversation_updated(event)
end
end
-
- describe '#copilot_message_created' do
- let(:event_name) { :copilot_message_created }
- let(:account) { create(:account) }
- let(:user) { create(:user, account: account) }
- let(:assistant) { create(:captain_assistant, account: account) }
- let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
- let(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread) }
- let(:event) { Events::Base.new(event_name, Time.zone.now, copilot_message: copilot_message) }
-
- it 'broadcasts message to the user' do
- expect(ActionCableBroadcastJob).to receive(:perform_later).with(
- [user.pubsub_token],
- 'copilot.message.created',
- copilot_message.push_event_data
- )
-
- listener.copilot_message_created(event)
- end
- end
end
diff --git a/spec/mailers/administrator_notifications/account_compliance_mailer_spec.rb b/spec/mailers/administrator_notifications/account_compliance_mailer_spec.rb
new file mode 100644
index 000000000..c7a93337b
--- /dev/null
+++ b/spec/mailers/administrator_notifications/account_compliance_mailer_spec.rb
@@ -0,0 +1,34 @@
+require 'rails_helper'
+
+RSpec.describe AdministratorNotifications::AccountComplianceMailer do
+ let(:account) do
+ create(:account, custom_attributes: { 'marked_for_deletion_at' => 1.day.ago.iso8601, 'marked_for_deletion_reason' => 'user_requested' })
+ end
+ let(:soft_deleted_users) do
+ [
+ { id: 1, original_email: 'user1@example.com' },
+ { id: 2, original_email: 'user2@example.com' }
+ ]
+ end
+
+ describe 'account_deleted' do
+ it 'has the right subject format' do
+ subject = described_class.new.send(:subject_for, account)
+ expect(subject).to eq("Account Deletion Notice for #{account.id} - #{account.name}")
+ end
+
+ it 'includes soft deleted users in meta when provided' do
+ mailer_instance = described_class.new
+ allow(mailer_instance).to receive(:params).and_return(
+ { soft_deleted_users: soft_deleted_users }
+ )
+
+ meta = mailer_instance.send(:build_meta, account)
+
+ expect(meta['deleted_user_count']).to eq(2)
+ expect(meta['soft_deleted_users'].size).to eq(2)
+ expect(meta['soft_deleted_users'].first['user_id']).to eq('1')
+ expect(meta['soft_deleted_users'].first['user_email']).to eq('user1@example.com')
+ end
+ end
+end
diff --git a/spec/services/account_deletion_service_spec.rb b/spec/services/account_deletion_service_spec.rb
new file mode 100644
index 000000000..6b263c5b3
--- /dev/null
+++ b/spec/services/account_deletion_service_spec.rb
@@ -0,0 +1,63 @@
+require 'rails_helper'
+
+RSpec.describe AccountDeletionService do
+ let(:account) { create(:account) }
+ let(:mailer) { instance_double(ActionMailer::MessageDelivery, deliver_later: nil) }
+
+ describe '#perform' do
+ before do
+ allow(DeleteObjectJob).to receive(:perform_later)
+ allow(AdministratorNotifications::AccountComplianceMailer).to receive(:with).and_return(
+ instance_double(AdministratorNotifications::AccountComplianceMailer, account_deleted: mailer)
+ )
+ end
+
+ it 'enqueues DeleteObjectJob with the account' do
+ described_class.new(account: account).perform
+
+ expect(DeleteObjectJob).to have_received(:perform_later).with(account)
+ end
+
+ it 'sends a compliance notification email' do
+ described_class.new(account: account).perform
+
+ expect(AdministratorNotifications::AccountComplianceMailer).to have_received(:with) do |args|
+ expect(args[:account]).to eq(account)
+ expect(args).to include(:soft_deleted_users)
+ end
+ expect(mailer).to have_received(:deliver_later)
+ end
+
+ context 'when handling users' do
+ let(:user_with_one_account) { create(:user) }
+ let(:user_with_multiple_accounts) { create(:user) }
+ let(:second_account) { create(:account) }
+
+ before do
+ create(:account_user, user: user_with_one_account, account: account)
+ create(:account_user, user: user_with_multiple_accounts, account: account)
+ create(:account_user, user: user_with_multiple_accounts, account: second_account)
+ end
+
+ it 'soft deletes users who only belong to the deleted account' do
+ original_email = user_with_one_account.email
+
+ described_class.new(account: account).perform
+
+ # Reload the user to get the updated email
+ user_with_one_account.reload
+ expect(user_with_one_account.email).to eq("#{original_email}-deleted.com")
+ end
+
+ it 'does not modify emails for users belonging to multiple accounts' do
+ original_email = user_with_multiple_accounts.email
+
+ described_class.new(account: account).perform
+
+ # Reload the user to get the updated email
+ user_with_multiple_accounts.reload
+ expect(user_with_multiple_accounts.email).to eq(original_email)
+ end
+ end
+ end
+end
diff --git a/spec/services/conversations/message_window_service_spec.rb b/spec/services/conversations/message_window_service_spec.rb
index 34c5ebbad..32542e87e 100644
--- a/spec/services/conversations/message_window_service_spec.rb
+++ b/spec/services/conversations/message_window_service_spec.rb
@@ -56,6 +56,7 @@ RSpec.describe Conversations::MessageWindowService do
describe 'on Facebook channels' do
before do
stub_request(:post, /graph.facebook.com/)
+ GlobalConfig.clear_cache
end
let!(:facebook_channel) { create(:channel_facebook_page) }