From 7caa4e0bbccb8fab2f3b764c79005794a3ed22c0 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 13 Jul 2026 14:48:47 +0530 Subject: [PATCH 01/19] fix: pin pnpm version in CircleCI node orb install step (#14999) CI runs were failing because the CircleCI Node orb's pnpm auto-install step matches the wrong "version" field in pnpm's package.json, producing an invalid install command since pnpm 11.12.0. Ref: https://github.com/CircleCI-Public/node-orb/issues/262 ### What changed - Pinned `pnpm-version: 10.2.0` (matching `packageManager` in `package.json`) on all three `node/install-pnpm` steps in `.circleci/config.yml`, bypassing the orb's broken version auto-detection. --- .circleci/config.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 59702c139..c533e1402 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -77,7 +77,8 @@ jobs: - node/install: node-version: '24.13' - - node/install-pnpm + - node/install-pnpm: + version: '10.2.0' - node/install-packages: pkg-manager: pnpm override-ci-command: pnpm i @@ -118,7 +119,8 @@ jobs: - checkout - node/install: node-version: '24.13' - - node/install-pnpm + - node/install-pnpm: + version: '10.2.0' - node/install-packages: pkg-manager: pnpm override-ci-command: pnpm i @@ -149,7 +151,8 @@ jobs: - checkout - node/install: node-version: '24.13' - - node/install-pnpm + - node/install-pnpm: + version: '10.2.0' - node/install-packages: pkg-manager: pnpm override-ci-command: pnpm i From 8fc5c7a5c890193f142c5ffa8945f5a4a2b24ce0 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 13 Jul 2026 13:26:09 +0400 Subject: [PATCH 02/19] feat: add captain general guidelines migration helpers (#14909) This PR adds internal tooling and planning docs for migrating existing Captain assistant instructions into the new General Guidelines structure. **Summary** This PR adds a controlled migration path for moving existing Captain V1 assistant instructions into the structured Captain architecture. It introduces a classifier that reads the current `config.instructions` and produces reviewed migration drafts with separate sections for: - assistant description / business context - response guidelines - guardrails - scenario candidates - conversation messages - FAQ/document candidates - needs-review items The migration is intentionally staged. It only targets V1-style assistants that still have custom instructions, are connected to inboxes, and do not already have structured response guidelines, guardrails, or scenario records. When applied, the task writes the extracted business context to the assistant description, response guidelines to `response_guidelines`, guardrails to `guardrails`, and stores scenario candidates / FAQ candidates / review notes under `config["assistant_migration"]`. Scenario candidates are also flattened into response guidelines for now so customer behavior is preserved before we create real `Captain::Scenario` records in a later rollout. The applier stores the original assistant values under migration metadata so conversation message config can be restored if needed. It does not create scenario records yet. **How to generate drafts** For specific assistant IDs: ```bash bundle exec rake captain:assistant_migration:generate \ IDS=546,636,819 \ LIMIT=0 \ OUTPUT=tmp/captain_migration_drafts.jsonl ``` For the first 50 eligible assistants: ```bash bundle exec rake captain:assistant_migration:generate \ OUTPUT=tmp/captain_migration_drafts.jsonl ``` For all eligible assistants: ```bash bundle exec rake captain:assistant_migration:generate \ LIMIT=0 \ OUTPUT=tmp/captain_migration_drafts.jsonl ``` **How to apply drafts** Dry run first: ```bash bundle exec rake captain:assistant_migration:apply \ INPUT=tmp/captain_migration_drafts.jsonl \ DRY_RUN=true ``` Apply changes: ```bash bundle exec rake captain:assistant_migration:apply \ INPUT=tmp/captain_migration_drafts.jsonl \ DRY_RUN=false ``` **How to restore conversation messages** If extracted `welcome_message`, `handoff_message`, or `resolution_message` need to be reverted to their pre-migration values: ```bash bundle exec rake captain:assistant_migration:restore_messages \ IDS=546,636,819 \ DRY_RUN=true ``` ```bash bundle exec rake captain:assistant_migration:restore_messages \ IDS=546,636,819 \ DRY_RUN=false ``` **Notes** - `LIMIT=0` means no limit. - `generate` overwrites the output file. - The apply task skips assistants that are no longer V1 migration candidates. - This PR does not create `Captain::Scenario` records; scenario candidates are staged in assistant config for a future migration. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: aakashb95 Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> --- .../assistant_migration/draft_applier.rb | 199 +++++++++++++ .../instruction_classifier.rb | 148 ++++++++++ .../instruction_classifier_schema.rb | 91 ++++++ .../prompts/instruction_classifier.liquid | 137 +++++++++ lib/tasks/captain_assistant_migration.rake | 278 ++++++++++++++++++ .../assistant_migration/draft_applier_spec.rb | 116 ++++++++ 6 files changed, 969 insertions(+) create mode 100644 enterprise/app/services/captain/assistant_migration/draft_applier.rb create mode 100644 enterprise/app/services/captain/assistant_migration/instruction_classifier.rb create mode 100644 enterprise/app/services/captain/assistant_migration/instruction_classifier_schema.rb create mode 100644 enterprise/lib/captain/prompts/instruction_classifier.liquid create mode 100644 lib/tasks/captain_assistant_migration.rake create mode 100644 spec/enterprise/services/captain/assistant_migration/draft_applier_spec.rb diff --git a/enterprise/app/services/captain/assistant_migration/draft_applier.rb b/enterprise/app/services/captain/assistant_migration/draft_applier.rb new file mode 100644 index 000000000..df03624b4 --- /dev/null +++ b/enterprise/app/services/captain/assistant_migration/draft_applier.rb @@ -0,0 +1,199 @@ +class Captain::AssistantMigration::DraftApplier + ASSISTANT_DESCRIPTION_LIMIT = 500 + CONFIG_KEY = 'assistant_migration'.freeze + SCENARIO_DESCRIPTION_LIMIT = 500 + ORIGINAL_VALUES_KEY = 'original_values'.freeze + + pattr_initialize [:assistant!, :draft!, { dry_run: true }] + + def perform + changes = build_changes + apply_changes(changes) unless dry_run + + { + assistant_id: assistant.id, + dry_run: dry_run, + changes: changes + } + end + + private + + def build_changes + { + description: description_change, + response_guidelines: array_change(:response_guidelines, response_guidelines), + guardrails: array_change(:guardrails, guardrails), + config: config_change + }.compact + end + + def apply_changes(changes) + assistant.transaction do + assistant.update!(assistant_update_attributes(changes)) if assistant_update_attributes(changes).present? + end + end + + def assistant_update_attributes(changes) + {}.tap do |attributes| + attributes[:description] = changes.dig(:description, :to) if changes[:description].present? + attributes[:response_guidelines] = changes.dig(:response_guidelines, :to) if changes[:response_guidelines].present? + attributes[:guardrails] = changes.dig(:guardrails, :to) if changes[:guardrails].present? + attributes[:config] = changes.dig(:config, :to) if changes[:config].present? + end + end + + def description_change + value = assistant_description_value + return if value.blank? || value == assistant.description + + { from: assistant.description, to: value } + end + + def assistant_description_value + value = item_values(:business_product_context).join(' ').presence + return if value.blank? + + raise ArgumentError, "Assistant description exceeds #{ASSISTANT_DESCRIPTION_LIMIT} characters" if value.length > ASSISTANT_DESCRIPTION_LIMIT + + value + end + + def response_guidelines + (item_values(:response_guidelines) + scenario_response_guidelines).uniq + end + + def guardrails + item_values(:guardrails) + end + + def array_change(field, values) + return if values.blank? + + current = Array(assistant.public_send(field)).map(&:to_s) + return if current == values + + { from: current, to: values } + end + + def config_change + updated_config = assistant.config.deep_dup + conversation_messages.each do |key, value| + next if value.blank? + next if updated_config[key].present? + + updated_config[key] = value + end + updated_config[CONFIG_KEY] = migration_config + + return if updated_config == assistant.config + + { from: assistant.config, to: updated_config } + end + + def migration_config + existing_migration_config.merge( + ORIGINAL_VALUES_KEY => existing_original_values, + 'scenario_candidates' => staged_scenario_candidates, + 'faq_document_candidates' => normalized_faq_document_candidates, + 'needs_review' => normalized_instruction_items(:needs_review) + ) + end + + def existing_migration_config + config = assistant.config[CONFIG_KEY] + config.is_a?(Hash) ? config : {} + end + + def existing_original_values + existing_migration_config[ORIGINAL_VALUES_KEY].presence || original_values + end + + def original_values + { + 'name' => assistant.name, + 'description' => assistant.description, + 'config' => original_config, + 'response_guidelines' => Array(assistant.response_guidelines), + 'guardrails' => Array(assistant.guardrails) + } + end + + def original_config + assistant.config.except(CONFIG_KEY) + end + + def conversation_messages + messages = draft_hash.fetch(:conversation_messages, {}) + messages = messages.deep_stringify_keys + + { + 'welcome_message' => messages['welcome_message'].to_s.strip, + 'handoff_message' => messages['handoff_message'].to_s.strip, + 'resolution_message' => messages['resolution_message'].to_s.strip + } + end + + def staged_scenario_candidates + scenario_candidates.map do |candidate| + candidate.transform_keys(&:to_s) + end + end + + def scenario_response_guidelines + scenario_candidates.filter_map { |candidate| candidate[:response_guideline].presence } + end + + def scenario_tool_ids(tool_ids) + Array(tool_ids).filter_map { |tool_id| tool_id.to_s.squish.presence }.uniq + end + + def scenario_candidates + Array(draft_hash[:scenario_candidates]).filter_map do |candidate| + normalized_scenario_candidate(candidate) + end + end + + def normalized_scenario_candidate(candidate) + return unless candidate.is_a?(Hash) + + candidate = candidate.deep_symbolize_keys + normalized_candidate = { + title: candidate[:title].to_s.squish, + description: candidate[:description].to_s.squish.truncate(SCENARIO_DESCRIPTION_LIMIT), + instruction: candidate[:instruction].to_s.squish, + response_guideline: candidate[:response_guideline].to_s.squish, + tool_ids: scenario_tool_ids(candidate[:tool_ids]) + } + return if normalized_candidate.values_at(:title, :description, :instruction).any?(&:blank?) + + normalized_candidate + end + + def item_values(key) + Array(draft_hash[key]).filter_map do |item| + item.to_s.squish.presence + end.uniq + end + + def normalized_instruction_items(key) + item_values(key) + end + + def normalized_faq_document_candidates + Array(draft_hash[:faq_document_candidates]).map do |candidate| + raise ArgumentError, 'FAQ document candidates must be question and answer objects' unless candidate.is_a?(Hash) + + candidate = candidate.deep_symbolize_keys + question = candidate[:question].to_s.squish + answer = candidate[:answer].to_s.squish + raise ArgumentError, 'FAQ document candidates must include a question and answer' if question.blank? || answer.blank? + + { 'question' => question, 'answer' => answer } + end.uniq + end + + def draft_hash + @draft_hash ||= draft.deep_symbolize_keys + end +end diff --git a/enterprise/app/services/captain/assistant_migration/instruction_classifier.rb b/enterprise/app/services/captain/assistant_migration/instruction_classifier.rb new file mode 100644 index 000000000..989989855 --- /dev/null +++ b/enterprise/app/services/captain/assistant_migration/instruction_classifier.rb @@ -0,0 +1,148 @@ +class Captain::AssistantMigration::InstructionClassifier < Captain::BaseTaskService + RESPONSE_SCHEMA = Captain::AssistantMigration::InstructionClassifierSchema + CLASSIFIER_MODEL = 'gpt-5.2'.freeze + MAX_INSTRUCTIONS_LENGTH = 20_000 + + pattr_initialize [:assistant!] + + def perform + response = make_api_call(model: CLASSIFIER_MODEL, messages: messages, schema: RESPONSE_SCHEMA) + return error_response(response) if response[:error] + + { + assistant: assistant_metadata, + draft: normalized_payload(response[:message]), + usage: response[:usage], + request_messages: response[:request_messages] + } + end + + private + + def account + assistant.account + end + + def messages + [ + { role: 'system', content: system_prompt }, + { role: 'user', content: user_prompt } + ] + end + + def system_prompt + Captain::PromptRenderer.render('instruction_classifier') + end + + def user_prompt + JSON.pretty_generate(assistant_payload) + end + + def assistant_payload # rubocop:disable Metrics/AbcSize + { + assistant_id: assistant.id, + account_id: assistant.account_id, + account_name: assistant.account.name, + name: assistant.name, + description: assistant.description, + product_name: assistant.config['product_name'], + instructions: truncated_instructions, + welcome_message: assistant.config['welcome_message'], + handoff_message: assistant.config['handoff_message'], + resolution_message: assistant.config['resolution_message'], + existing_response_guidelines: assistant.response_guidelines || [], + existing_guardrails: assistant.guardrails || [], + existing_scenarios: existing_scenarios, + available_agent_tools: available_agent_tools, + feature_settings: feature_settings + } + end + + def truncated_instructions + instructions = assistant.config['instructions'].to_s + return instructions if instructions.length <= MAX_INSTRUCTIONS_LENGTH + + "#{instructions.first(MAX_INSTRUCTIONS_LENGTH)}\n\n[TRUNCATED]" + end + + def existing_scenarios + assistant.scenarios.map do |scenario| + { + id: scenario.id, + title: scenario.title, + description: scenario.description, + instruction: scenario.instruction, + enabled: scenario.enabled + } + end + end + + def available_agent_tools + tools = assistant.respond_to?(:available_agent_tools) ? assistant.available_agent_tools : Captain::Assistant.built_in_agent_tools + tools.map { |tool| tool.slice(:id, :title, :description) } + end + + def feature_settings + assistant.config.slice( + 'feature_faq', + 'feature_memory', + 'feature_citation', + 'feature_contact_attributes', + 'temperature' + ) + end + + def normalized_payload(message) + payload = message.is_a?(Hash) ? message.deep_symbolize_keys : {} + payload.reverse_merge( + business_product_context: [], + response_guidelines: [], + guardrails: [], + scenario_candidates: [], + conversation_messages: {}, + faq_document_candidates: [], + needs_review: [], + classification_notes: [] + ) + end + + def assistant_metadata # rubocop:disable Metrics/AbcSize + { + id: assistant.id, + name: assistant.name, + account_id: assistant.account_id, + account_name: assistant.account.name, + inbox_count: assistant.captain_inboxes.size, + instruction_length: assistant.config['instructions'].to_s.length, + original_instructions: assistant.config['instructions'].to_s, + welcome_message: assistant.config['welcome_message'].to_s, + handoff_message: assistant.config['handoff_message'].to_s, + resolution_message: assistant.config['resolution_message'].to_s + } + end + + def error_response(response) + { + assistant: assistant_metadata, + error: response[:error], + error_code: response[:error_code], + request_messages: response[:request_messages] + } + end + + def event_name + 'assistant_migration_instruction_classifier' + end + + def captain_tasks_enabled? + true + end + + def counts_toward_usage? + false + end + + def build_follow_up_context? + false + end +end diff --git a/enterprise/app/services/captain/assistant_migration/instruction_classifier_schema.rb b/enterprise/app/services/captain/assistant_migration/instruction_classifier_schema.rb new file mode 100644 index 000000000..3e42bb49d --- /dev/null +++ b/enterprise/app/services/captain/assistant_migration/instruction_classifier_schema.rb @@ -0,0 +1,91 @@ +class Captain::AssistantMigration::InstructionClassifierSchema < RubyLLM::Schema + DESCRIPTION_LENGTH_LIMIT = 500 + + def self.instruction_items(field_name, description:, max_items: 20) + array field_name, + description: "#{description} Return plain standalone sentences without numbering, bullets, or section labels.", + max_items: max_items, + of: :string + end + + array :business_product_context, + description: "Single compact root assistant description for the root orchestrator prompt, maximum #{DESCRIPTION_LENGTH_LIMIT} characters: " \ + 'preserve the existing assistant description and enrich it only with relevant business/product context from the ' \ + 'custom instructions. Include assistant identity, product scope, high-level mission, and high-level source/routing ' \ + 'priorities only. Do not include workflows, procedures, attribute glossaries, policy details, or long inventories. ' \ + 'Return complete plain prose without numbering, bullets, section labels, or a truncated final sentence.', + min_items: 1, + max_items: 1 do + string max_length: DESCRIPTION_LENGTH_LIMIT + end + + instruction_items :response_guidelines, + description: 'Tone, language, answer length, formatting, and clarification behavior.', + max_items: 20 + + instruction_items :guardrails, + description: 'Refusal rules, escalation boundaries, source boundaries, safety limits, and things the assistant must not do.', + max_items: 20 + + array :scenario_candidates, + description: 'Review-stage specialized-agent candidates. These are also temporarily flattened into response guidelines.', + max_items: 15 do + object do + string :title, + description: 'Short scenario agent title for a distinct user-intent workflow.', + max_length: 80 + string :description, + description: 'When this specialized scenario should be used. This is shown to the orchestrator for routing.', + max_length: 500 + string :instruction, + description: 'How the specialized agent should handle the workflow. Include only evidence-backed markdown tool links. ' \ + 'Do not include confidence labels or review notes.', + max_length: 2000 + string :response_guideline, + description: 'Same-language, customer-visible response guideline that preserves this scenario behavior when flattened. ' \ + 'Do not include tool syntax, tool names, labels, private-note instructions, or internal implementation details.', + max_length: 1000 + array :tool_ids, + description: 'Available tool IDs explicitly referenced in instruction using markdown links. Empty when no tools are required.', + max_items: 10, + of: :string + end + end + + object :conversation_messages, description: 'Exact globally reusable customer-facing message copy found in instructions. ' \ + 'Leave empty for conditional, placeholder, or workflow-specific copy.' do + string :welcome_message, description: 'Exact globally reusable initial greeting copy from instructions, or empty string. ' \ + 'Do not convert an instruction about greeting into message copy.', + max_length: 1000 + string :handoff_message, + description: 'Exact globally reusable human-handoff message copy from instructions, or empty string. ' \ + 'Do not use scenario-specific, team-specific, placeholder, or conditional handoff copy.', + max_length: 1000 + string :resolution_message, + description: 'Exact globally reusable resolution/closing message copy from instructions, or empty string. ' \ + 'Do not use conditional or placeholder closing copy.', + max_length: 1000 + end + + array :faq_document_candidates, + description: 'Pending FAQ candidates for factual or product-specific knowledge such as pricing, policy, setup, troubleshooting, ' \ + 'or operational details. These candidates remain inactive until reviewed and approved.', + max_items: 25 do + object do + string :question, + description: 'Natural, standalone customer question about factual product or business knowledge.', + max_length: 255 + string :answer, + description: 'Self-contained factual answer using only the existing instructions. Do not include assistant behavior, ' \ + 'tool use, or message copy. Preserve exact values, conditions, and exceptions.', + max_length: 2000 + end + end + + instruction_items :needs_review, + description: 'Unclear, conflicting, risky, duplicated, or uncertain content that needs human review. ' \ + 'Include the reason in the item text.', + max_items: 20 + + array :classification_notes, description: 'Short notes about important migration decisions or risks.', max_items: 10, of: :string +end diff --git a/enterprise/lib/captain/prompts/instruction_classifier.liquid b/enterprise/lib/captain/prompts/instruction_classifier.liquid new file mode 100644 index 000000000..abc58ff60 --- /dev/null +++ b/enterprise/lib/captain/prompts/instruction_classifier.liquid @@ -0,0 +1,137 @@ +You are migrating Captain assistant instructions into a structured configuration. + +Classify the existing assistant instructions into these sections: +1. Business/Product Context +2. Response Guidelines +3. Guardrails +4. Scenario Candidates +5. Conversation Messages +6. FAQs/Documents Candidates +7. Needs Review + +## General Rules + +- Preserve behavior as closely as possible. +- Do not duplicate the same content across sections. +- Return clean migrated values only. Do not include source excerpts, source labels, citations, or "Source:" text in any migrated field. +- Do not rewrite customer-facing message copy unless necessary to classify an exact copy from instructions. +- Do not include confidence labels, review labels, bracketed reviewer comments, or schema labels inside migrated values. +- For Business/Product Context, Response Guidelines, and Guardrails, return each item as a plain standalone sentence. + Do not prefix items with numbers, bullets, section labels, or list markers such as "1.", "-", or "*". +- When several instructions share the same trigger, condition, or subject, combine them into one concise item instead + of repeating the same trigger across multiple items. Preserve every required action, prohibition, and routing + outcome from the source instruction when combining. +- If unsure, place content in Needs Review and include the reason in that item. +- Return data that matches the provided schema. + +## Business/Product Context + +- Business/Product Context maps to the root assistant description and is injected into the root orchestrator prompt. +- Return exactly one Business/Product Context item. +- Start with the existing assistant description and preserve its meaning. +- Enrich it only with relevant business or product context found in the custom instructions. +- Produce one coherent description rather than appending a second context block or repeating the existing description. +- Keep it at most 500 characters because that is the assistant description limit in the UI and model. +- Prefer roughly 300-450 characters when the source needs detail, leaving room below the hard limit. +- Finish the description cleanly. Never end mid-word, mid-clause, after an opening bracket, or with a dangling separator. +- Make it a compact summary of assistant identity, product scope, high-level mission, and high-level source or routing priorities. +- Do not include detailed workflows, step-by-step procedures, long support-scope inventories, attribute glossaries, + policy details, scenario-specific handling, tool instructions, or customer-facing message copy. + +## Conversation Messages + +- Existing welcome_message, handoff_message, and resolution_message config values are provided separately. +- Treat welcome_message, handoff_message, and resolution_message as conversation message config fields. +- Extract exact welcome, handoff, or resolution message copy from instructions into conversation_messages when present. +- Only classify handoff copy as conversation_messages.handoff_message when it is generic enough to reuse for any human handoff. +- If handoff copy is scenario-specific, keep it inside that scenario instruction; if it is only a rule about when or how to hand off, classify it as a Response Guideline or Guardrail. +- Do not extract a conversation message from an instruction about what to say, from a placeholder template, + from conditional copy, from role/team-specific copy, or from text that only applies inside one workflow. +- If a message contains placeholders such as a blank name, team name, bracketed variable, business-hours state, + or dynamic runtime condition, do not place it in conversation_messages. Keep it in the relevant workflow or + Needs Review. +- Do not copy message values from existing config into conversation_messages. +- Do not decide whether existing config values should be overwritten. Migration code handles applying extracted + conversation_messages only when the corresponding config value is blank. + +## Scenario Candidates + +- In the current architecture, a scenario becomes a specialized sub-agent with its own title, description, + instructions, and optional tools. +- During this migration, scenario candidates are also temporarily flattened into response guidelines so existing + assistant behavior is preserved before scenario records are created. +- For every scenario candidate, write a response_guideline that is the flattened version of that scenario for + the root assistant's response guidelines. +- The response_guideline must be in the same language as the original scenario or source instruction. +- The response_guideline must preserve the intended customer-visible behavior, trigger, information to collect, + and routing/escalation outcome. +- The response_guideline must not include tool syntax, tool:// links, markdown tool links, tool names, label + updates, priority updates, private-note instructions, custom-tool instructions, or internal implementation details. +- If the scenario uses internal tools such as labels, priorities, private notes, or custom tools, describe only + the customer-visible behavior and expected routing/escalation outcome in response_guideline. +- If human handoff is needed, describe it in natural language such as route/escalate/transfer to a human; do not + mention the handoff tool in response_guideline. +- Keep scenario titles, descriptions, instructions, and response_guidelines clear, self-contained, and reviewable. +- Only create scenario candidates for distinct user-intent workflows that should be routed to a specialized agent. + A candidate must be narrow enough to become a named specialist assistant with domain-specific handling instructions. +- Good scenario candidates include multi-step intake workflows, qualification flows, specialized troubleshooting + workflows, booking flows, lead-capture flows, recommendation flows, fulfillment workflows, or tool-use procedures + for a specific user intent. +- A scenario candidate should answer "yes" to this test: would a named specialist sub-agent improve handling + beyond the base assistant's global FAQ, guardrail, response-guideline, and human-handoff behavior? +- Do not create scenario candidates for global escalation rules, generic handoff policy, missing-information + behavior, source-boundary rules, refusal rules, tone, formatting, answer length, or one-step fallback behavior. +- Do not create scenario candidates whose main purpose is to escalate or hand off. "Identify the trigger, avoid + guessing, tell the user support will review, and hand off" is a guardrail/handoff boundary, not a scenario, + even though it contains multiple statements. +- Do create scenario candidates when the instructions define a concrete intake, qualification, troubleshooting, + booking, lead-capture, recommendation, or fulfillment workflow, even when the workflow eventually hands off + to a human. +- Do not create scenario candidates for simple routing triggers such as "user asks for a human", "immediately + hand off this category", or "route sales questions to the sales team" when there is no concrete workflow to run. +- Handoff behavior is a scenario candidate only when part of a larger intake, qualification, or specialized handling workflow. +- Global rules like "if not in docs, escalate", "ask one clarifying question", "do not answer account-specific + questions", or "tell the user support will review" belong in Guardrails or Response Guidelines, not Scenario Candidates. +- Broad buckets like "account-specific issue escalation", "unknown question escalation", "contact support", + "fallback to human", or "documentation unavailable" are not scenario candidates. + +## Tool Use + +- If a scenario candidate requires tools, reference the available tool explicitly inside the scenario instruction + using markdown tool links such as [Handoff to Human](tool://handoff). +- Use only tool IDs listed in available_agent_tools. If a needed tool is unavailable or the workflow depends on + unavailable runtime data such as FAQ relevance scores or business-hours status, place it in Needs Review instead. +- Do not map an unavailable named tool to a different available tool. For example, do not treat FAQ Lookup as + Product Search, Order Status, website browsing, pricing lookup, agent availability, business-hours detection, + ticket creation, or custom-attribute assignment unless the instructions explicitly say that the available + tool provides that behavior. +- If a workflow cannot run without an unavailable tool or runtime signal, do not create a tool-backed scenario + for it. Preserve the instruction in Needs Review with the missing capability named. + +## FAQs/Documents Candidates + +- Convert factual or product-specific knowledge into pending FAQ candidates with a natural customer question and a self-contained answer. +- FAQ candidates are review-stage data only. They are not active assistant knowledge until a human reviews and approves them. +- Use only facts stated in the existing instructions. Do not invent, generalize, update, or fill in missing details. +- Preserve exact prices, limits, dates, time zones, conditions, exceptions, product names, and operational details in the answer. +- Write each question as a standalone question a customer might naturally ask. Make it specific enough to retrieve the corresponding answer. +- Write each answer so it fully answers its question without relying on another FAQ candidate or surrounding context. +- Split unrelated facts into separate candidates. Keep related conditions and exceptions together when separating them would make an answer incomplete. +- Do not create FAQ candidates about what the assistant should say or do, how it should use sources or tools, when it should route or escalate, + or which exact message it should send. Classify those as Response Guidelines, Guardrails, Scenario Candidates, Conversation Messages, + or Needs Review as appropriate. +- FAQ questions must ask about the product or business, not about the assistant. Do not write questions such as "What should the assistant answer?", + "What should I say?", "Which source should the assistant use?", or "Which tool should be called?". +- FAQ answers must contain customer-facing knowledge, not instructions to call tools, inspect internal data, update records, transfer conversations, + or follow internal workflows. +- When factual sources conflict and the instructions do not explicitly establish which fact overrides the others, put the conflict in Needs Review + instead of creating an FAQ candidate. Use an explicitly stated override or superseding fact when one is present. +- Only factual or product-specific knowledge should become FAQs/Documents candidates. +- Generic capability statements such as "answer product questions", "help with billing", + "troubleshoot common issues", or "direct to documentation" are not FAQ/document candidates. + Put them in Business/Product Context or Response Guidelines when useful. +- Product facts, pricing, policies, setup steps, troubleshooting facts, support hours, emergency contacts, + and operational details should become pending FAQ candidates, not Response Guidelines or trusted approved knowledge. +- Do not create FAQ/document candidates for topic labels or unsupported capabilities when the factual content is + missing. Put "pricing details are needed", "same-day delivery schedule details are needed", or similar gaps in + Needs Review instead. diff --git a/lib/tasks/captain_assistant_migration.rake b/lib/tasks/captain_assistant_migration.rake new file mode 100644 index 000000000..7cb8c2e1c --- /dev/null +++ b/lib/tasks/captain_assistant_migration.rake @@ -0,0 +1,278 @@ +require 'json' +require 'fileutils' +require 'csv' + +# rubocop:disable Metrics/BlockLength +namespace :captain do + namespace :assistant_migration do + desc 'Generate structured migration drafts. Usage: rake captain:assistant_migration:generate IDS=1,2,3 LIMIT=50 ' \ + 'OUTPUT=tmp/captain_migration.jsonl' + task generate: :environment do + assistants = CaptainAssistantMigrationTask.assistants + output_path = ENV.fetch('OUTPUT', Rails.root.join('tmp/captain_assistant_migration_drafts.jsonl').to_s) + + FileUtils.mkdir_p(File.dirname(output_path)) + processed = 0 + + File.open(output_path, 'w') do |file| + CaptainAssistantMigrationTask.each_assistant(assistants) do |assistant| + result = Captain::AssistantMigration::InstructionClassifier.new(assistant: assistant).perform + file.puts(JSON.generate(result)) + processed += 1 + puts "Generated migration draft for assistant #{assistant.id} (#{processed}/#{CaptainAssistantMigrationTask.assistant_count(assistants)})" + end + end + + puts "Wrote #{processed} migration drafts to #{output_path}" + end + + desc 'Apply reviewed migration drafts. Usage: rake captain:assistant_migration:apply INPUT=tmp/reviewed.jsonl DRY_RUN=true' + task apply: :environment do + input_path = ENV.fetch('INPUT') + dry_run = CaptainAssistantMigrationTask.truthy?('DRY_RUN', default: true) + + results = CaptainAssistantMigrationTask.apply_drafts( + input_path: input_path, + dry_run: dry_run + ) + + results.each { |result| puts(JSON.generate(result)) } + puts "Processed #{results.size} migration drafts from #{input_path}" + puts 'Dry run only. Re-run with DRY_RUN=false to write changes.' if dry_run + end + + desc 'Restore conversation message config from migration backup. Usage: rake captain:assistant_migration:restore_messages IDS=1,2 DRY_RUN=true' + task restore_messages: :environment do + dry_run = CaptainAssistantMigrationTask.truthy?('DRY_RUN', default: true) + results = CaptainAssistantMigrationTask.restore_conversation_messages(dry_run: dry_run) + + results.each { |result| puts(JSON.generate(result)) } + puts "Processed #{results.size} assistant message restores" + puts 'Dry run only. Re-run with DRY_RUN=false to restore conversation messages.' if dry_run + end + end +end +# rubocop:enable Metrics/BlockLength + +# rubocop:disable Style/OneClassPerFile +class CaptainAssistantMigrationTask + CsvAccount = Struct.new(:id, :name, keyword_init: true) do + def captain_models + {} + end + + def conversations + CsvRelation.new + end + end + + CsvAssociation = Struct.new(:inbox_count, keyword_init: true) do + def size + inbox_count + end + end + + class CsvRelation + def find_by(*) + nil + end + + def exists? + false + end + end + + CsvAssistant = Struct.new( + :id, + :name, + :account_id, + :account, + :description, + :config, + :response_guidelines, + :guardrails, + :captain_inboxes, + :scenarios, + keyword_init: true + ) + + class << self + def assistants + return csv_assistants if ENV['CSV_INPUT'].present? + + scope = Captain::Assistant.includes(:account, :captain_inboxes, :scenarios) + + ids = ENV.fetch('IDS', '').split(',').filter_map { |id| id.strip.presence } + scope = scope.where(id: ids) if ids.any? + + scope = migration_eligible_scope(scope).order(:id) + + limit = ENV.fetch('LIMIT', 50).to_i + limit.positive? ? scope.limit(limit) : scope + end + + def each_assistant(assistants, &) + return assistants.find_each(&) if assistants.respond_to?(:find_each) + + assistants.each(&) + end + + def assistant_count(assistants) + assistants.respond_to?(:size) ? assistants.size : assistants.count + end + + def restore_conversation_messages(dry_run:) + ENV.fetch('IDS').split(',').filter_map { |id| id.strip.presence }.map do |assistant_id| + assistant = Captain::Assistant.find(assistant_id) + restore_conversation_messages_for(assistant, dry_run: dry_run) + rescue ActiveRecord::RecordNotFound + { assistant_id: assistant_id, error: 'Assistant not found' } + end + end + + def apply_drafts(input_path:, dry_run:) + File.readlines(input_path, chomp: true).filter_map.with_index(1) do |line, line_number| + next if line.blank? + + apply_draft(JSON.parse(line), line_number: line_number, dry_run: dry_run) + rescue JSON::ParserError => e + { line_number: line_number, error: "Invalid JSON: #{e.message}" } + end + end + + def apply_draft(payload, line_number:, dry_run:) + return { line_number: line_number, skipped: true, reason: payload['error'] } if payload['error'].present? + + assistant_id = payload.dig('assistant', 'id') || payload['assistant_id'] + assistant = Captain::Assistant.find(assistant_id) + return skipped_result(line_number, assistant_id, 'Assistant is not a V1 migration candidate') unless migration_candidate?(assistant) + + draft = payload['draft'] || payload + + Captain::AssistantMigration::DraftApplier.new( + assistant: assistant, + draft: draft, + dry_run: dry_run + ).perform.merge(line_number: line_number) + rescue ActiveRecord::RecordNotFound + { line_number: line_number, assistant_id: assistant_id, error: 'Assistant not found' } + end + + def truthy?(key, default:) + value = ENV.fetch(key, nil) + return default if value.nil? + + value.to_s.downcase.in?(%w[1 true yes y]) + end + + private + + def restore_conversation_messages_for(assistant, dry_run:) + original_config = assistant.config.dig( + Captain::AssistantMigration::DraftApplier::CONFIG_KEY, + Captain::AssistantMigration::DraftApplier::ORIGINAL_VALUES_KEY, + 'config' + ) + return skipped_result(nil, assistant.id, 'No stored migration original config found') if original_config.nil? + + config, changes = restored_message_config(assistant.config.deep_dup, original_config) + assistant.update!(config: config) if !dry_run && changes.present? + + { assistant_id: assistant.id, dry_run: dry_run, changes: changes } + end + + def restored_message_config(config, original_config) + changes = {} + %w[welcome_message handoff_message resolution_message].each do |key| + original_present = original_config.key?(key) + next if config[key] == original_config[key] && config.key?(key) == original_present + + changes[key] = { from: config[key], to: original_config[key] } + original_present ? config[key] = original_config[key] : config.delete(key) + end + [config, changes] + end + + def skipped_result(line_number, assistant_id, reason) + { + line_number: line_number, + assistant_id: assistant_id, + skipped: true, + reason: reason + } + end + + def migration_eligible_scope(scope) + scope.left_outer_joins(:scenarios) + .joins(:captain_inboxes) + .where("NULLIF(captain_assistants.config->>'instructions', '') IS NOT NULL") + .where("captain_assistants.response_guidelines IS NULL OR captain_assistants.response_guidelines = '[]'::jsonb") + .where("captain_assistants.guardrails IS NULL OR captain_assistants.guardrails = '[]'::jsonb") + .where(captain_scenarios: { id: nil }) + .distinct + end + + def migration_candidate?(assistant) + assistant.config['instructions'].present? && + assistant.captain_inboxes.size.positive? && + Array(assistant.response_guidelines).blank? && + Array(assistant.guardrails).blank? && + !scenarios_exist?(assistant) + end + + def scenarios_exist?(assistant) + scenarios = assistant.scenarios + return scenarios.exists? if scenarios.respond_to?(:exists?) + + scenarios.present? + end + + def csv_assistants # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + rows = CSV.read(ENV.fetch('CSV_INPUT'), headers: true) + ids = ENV.fetch('IDS', '').split(',').filter_map { |id| id.strip.presence } + status = ENV.fetch('STATUS', '').presence + + assistants = rows.filter_map do |row| + next if ids.any? && ids.exclude?(row['id'].to_s) + next if status.present? && row['status'].to_s != status + + assistant = csv_assistant(row) + next unless migration_candidate?(assistant) + + assistant + end + + limit = ENV.fetch('LIMIT', 50).to_i + limit.positive? ? assistants.first(limit) : assistants + end + + def csv_assistant(row) + config = parse_json(row['config'], fallback: {}) + CsvAssistant.new( + id: normalize_integer(row['id']), + name: row['name'].to_s, + account_id: normalize_integer(row['account_id']), + account: CsvAccount.new(id: normalize_integer(row['account_id']), name: row['account_name'].to_s), + description: row['description'].to_s, + config: config, + response_guidelines: parse_json(row['response_guidelines'], fallback: []), + guardrails: parse_json(row['guardrails'], fallback: []), + captain_inboxes: CsvAssociation.new(inbox_count: normalize_integer(row['inbox_count'])), + scenarios: [] + ) + end + + def parse_json(value, fallback:) + return fallback if value.blank? + + JSON.parse(value) + rescue JSON::ParserError + fallback + end + + def normalize_integer(value) + value.to_s.delete(',').to_i + end + end +end +# rubocop:enable Style/OneClassPerFile diff --git a/spec/enterprise/services/captain/assistant_migration/draft_applier_spec.rb b/spec/enterprise/services/captain/assistant_migration/draft_applier_spec.rb new file mode 100644 index 000000000..0e2f420ac --- /dev/null +++ b/spec/enterprise/services/captain/assistant_migration/draft_applier_spec.rb @@ -0,0 +1,116 @@ +require 'rails_helper' + +RSpec.describe Captain::AssistantMigration::DraftApplier do + let(:account) { create(:account) } + let(:assistant) do + create( + :captain_assistant, + account: account, + config: { 'product_name' => 'Test Product', 'instructions' => 'Legacy V1 custom instructions.' }, + response_guidelines: [], + guardrails: [] + ) + end + let(:scenario_candidate) do + { + 'title' => 'Billing Investigation', + 'description' => 'Use when a customer reports an account-specific billing issue.', + 'instruction' => 'Collect the invoice number and summarize the issue before escalating.', + 'response_guideline' => 'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.', + 'tool_ids' => [] + } + end + let(:faq_document_candidate) do + { + 'question' => 'When is support available?', + 'answer' => 'Support is available Monday to Friday.' + } + end + let(:draft) do + { + business_product_context: ['Support assistant for Test Product.'], + response_guidelines: ['Be concise.'], + guardrails: ['Do not guess.'], + conversation_messages: {}, + scenario_candidates: [scenario_candidate], + faq_document_candidates: [faq_document_candidate], + needs_review: ['Pricing details are missing because factual details are absent from the source instructions.'] + } + end + + describe '#perform' do + it 'reports staged scenario candidates in dry run without writing to the assistant' do + result = described_class.new(assistant: assistant, draft: draft, dry_run: true).perform + + expect(result.dig(:changes, :config, :to, 'assistant_migration', 'scenario_candidates')).to eq([scenario_candidate]) + expect(result.dig(:changes, :response_guidelines, :to)).to include( + 'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.' + ) + expect(assistant.reload.config).not_to have_key('assistant_migration') + expect(assistant.scenarios.count).to eq(0) + end + + it 'stores scenario candidates in assistant config and flattens them into response guidelines' do + described_class.new(assistant: assistant, draft: draft, dry_run: false).perform + + assistant.reload + expect(assistant.config.dig('assistant_migration', 'scenario_candidates')).to eq([scenario_candidate]) + expect(assistant.config.dig('assistant_migration', 'faq_document_candidates')).to contain_exactly(faq_document_candidate) + expect(assistant.config.dig('assistant_migration', 'needs_review')).to contain_exactly( + 'Pricing details are missing because factual details are absent from the source instructions.' + ) + expect(assistant.response_guidelines).to include( + 'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.' + ) + expect(assistant.response_guidelines).not_to include(faq_document_candidate['answer']) + expect(assistant.scenarios.count).to eq(0) + end + + it 'rejects stale drafts whose FAQ candidates use the old string format' do + stale_draft = draft.merge(faq_document_candidates: ['Support is available Monday to Friday.']) + + expect do + described_class.new(assistant: assistant, draft: stale_draft, dry_run: false).perform + end.to raise_error(ArgumentError, 'FAQ document candidates must be question and answer objects') + + expect(assistant.reload.config).not_to have_key('assistant_migration') + end + + it 'preserves original values in migration config before applying classifier output' do + assistant.update!( + description: 'Existing assistant description.', + response_guidelines: ['Use plain language.'], + guardrails: ['Do not disclose internal notes.'] + ) + + described_class.new(assistant: assistant, draft: draft, dry_run: false).perform + + assistant.reload + expect(assistant.description).to eq('Support assistant for Test Product.') + expect(assistant.response_guidelines).to include('Be concise.') + expect(assistant.guardrails).to eq(['Do not guess.']) + expect(assistant.config.dig('assistant_migration', 'original_values')).to include( + 'name' => assistant.name, + 'description' => 'Existing assistant description.', + 'config' => { 'product_name' => 'Test Product', 'instructions' => 'Legacy V1 custom instructions.' }, + 'response_guidelines' => ['Use plain language.'], + 'guardrails' => ['Do not disclose internal notes.'] + ) + end + + it 'rejects an oversized assistant description from a stale draft' do + long_context = 'This assistant supports a very broad product surface with many long details. ' * 10 + original_description = assistant.description + + expect do + described_class.new( + assistant: assistant, + draft: draft.merge(business_product_context: [long_context]), + dry_run: false + ).perform + end.to raise_error(ArgumentError, 'Assistant description exceeds 500 characters') + + expect(assistant.reload.description).to eq(original_description) + end + end +end From 11c65f3b9a86b18e2ae599abe0028034054fc12a Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Mon, 13 Jul 2026 15:25:07 +0530 Subject: [PATCH 03/19] feat: Intercom import workflow (#14922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Adds an admin-only Intercom import workflow under Settings > Data. Admins can connect an Intercom access token, start named historical contact/conversation imports, monitor active and previous import runs, review paginated skip/error logs, download skip logs, and route imported conversations into source-bucket API inboxes that can be renamed later. The import path stores durable source mappings, batches Intercom contact/conversation pages through Sidekiq, records already-imported records as skipped, and writes historical messages without normal outbound delivery callbacks. The PR also includes the Intercom import PRD/TDD document for review context. Closes [CW-7519](https://linear.app/chatwoot/issue/CW-7519/explore-intercom-import) ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [x] This change requires a documentation update ## How Has This Been Tested? Tested importing using actual data through integration. Screenshots: Screenshot 2026-07-02 at 10 48
48 PM Screenshot 2026-07-02 at 10 49
03 PM Screenshot 2026-07-02 at 10 49
21 PM Screenshot 2026-07-02 at 10 49
38 PM Passed locally: ```sh eval "$(rbenv init -)" && bundle exec rspec spec/models/data_import_spec.rb spec/jobs/data_import_job_spec.rb spec/requests/api/v1/accounts/data_imports_spec.rb spec/requests/api/v1/accounts/integrations/intercom_spec.rb spec/jobs/data_imports/intercom/import_jobs_spec.rb spec/services/data_imports/intercom/importer_spec.rb spec/services/data_imports/intercom/placeholder_inbox_builder_spec.rb spec/services/data_imports/intercom/source_bucket_spec.rb ``` ```sh eval "$(rbenv init -)" && bundle exec rubocop app/controllers/api/v1/accounts/data_imports_controller.rb app/controllers/api/v1/accounts/integrations/intercom_controller.rb app/jobs/data_imports/intercom app/models/data_import.rb app/models/data_import_error.rb app/models/data_import_item.rb app/models/data_import_mapping.rb app/models/integrations/hook.rb app/policies/data_import_policy.rb app/policies/hook_policy.rb app/services/data_imports/intercom db/migrate/20260702000000_expand_data_imports_for_intercom_imports.rb db/migrate/20260702000001_create_data_import_items.rb db/migrate/20260702000002_create_data_import_mappings.rb db/migrate/20260702000003_create_data_import_errors.rb spec/jobs/data_imports/intercom spec/requests/api/v1/accounts/data_imports_spec.rb spec/requests/api/v1/accounts/integrations/intercom_spec.rb spec/services/data_imports/intercom ``` ```sh pnpm exec eslint app/javascript/dashboard/api/dataImports.js app/javascript/dashboard/api/integrations.js app/javascript/dashboard/routes/dashboard/settings/data/Index.vue app/javascript/dashboard/routes/dashboard/settings/data/Show.vue app/javascript/dashboard/routes/dashboard/settings/data/data.routes.js app/javascript/dashboard/routes/dashboard/settings/data/importStatus.js app/javascript/dashboard/routes/dashboard/settings/integrations/Intercom.vue app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js app/javascript/dashboard/routes/dashboard/settings/settings.routes.js app/javascript/dashboard/components-next/sidebar/Sidebar.vue app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue ``` ```sh git diff --check ``` Note: the RSpec boot logs the existing local `chatwoot_dev` purge warning because other database sessions are open, then continues and completes with 52 examples, 0 failures. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Shivam Mishra Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin --- .../v1/accounts/data_imports_controller.rb | 159 +++ app/finders/data_import_error_finder.rb | 11 + app/finders/data_import_skip_log_finder.rb | 35 + app/javascript/dashboard/api/dataImports.js | 39 + .../components-next/sidebar/Sidebar.vue | 17 + app/javascript/dashboard/components/Modal.vue | 4 +- app/javascript/dashboard/featureFlags.js | 1 + .../dashboard/i18n/locale/en/settings.json | 99 ++ .../components/BaseSettingsHeader.vue | 8 +- .../routes/dashboard/settings/data/Index.vue | 378 +++++++ .../settings/data/NewImportDialog.vue | 210 ++++ .../routes/dashboard/settings/data/Show.vue | 238 +++++ .../data/components/ImportDetailHeader.vue | 128 +++ .../data/components/ImportErrorsSection.vue | 78 ++ .../data/components/ImportLogSection.vue | 105 ++ .../data/components/ImportProgress.vue | 100 ++ .../data/components/ImportSkipLogsSection.vue | 127 +++ .../data/components/ImportSummaryTiles.vue | 110 ++ .../dashboard/settings/data/data.routes.js | 34 + .../dashboard/settings/data/importSources.js | 17 + .../dashboard/settings/data/importStatus.js | 84 ++ .../settings/data/specs/importStatus.spec.js | 92 ++ .../data/specs/pollingLifecycle.spec.js | 121 +++ .../routes/dashboard/settings/inbox/Index.vue | 6 +- .../dashboard/settings/settings.routes.js | 2 + app/jobs/data_imports/intercom/base_job.rb | 43 + .../intercom/contacts_page_job.rb | 26 + .../intercom/conversations_page_job.rb | 16 + app/jobs/data_imports/intercom/import_job.rb | 24 + app/models/data_import.rb | 88 +- app/models/data_import_error.rb | 33 + app/models/data_import_item.rb | 35 + app/models/data_import_mapping.rb | 33 + app/policies/data_import_policy.rb | 41 + .../intercom/activity_content_builder.rb | 84 ++ app/services/data_imports/intercom/client.rb | 107 ++ .../data_imports/intercom/creation_service.rb | 67 ++ .../intercom/credentials_validator.rb | 36 + .../data_imports/intercom/importer.rb | 991 ++++++++++++++++++ .../intercom/placeholder_inbox_builder.rb | 41 + .../data_imports/intercom/restart_service.rb | 55 + .../data_imports/intercom/source_bucket.rb | 23 + .../data_imports/_data_import.json.jbuilder | 24 + .../accounts/data_imports/index.json.jbuilder | 5 + .../accounts/data_imports/show.json.jbuilder | 31 + config/features.yml | 4 + config/locales/en.yml | 22 + config/routes.rb | 11 + ...xpand_data_imports_for_intercom_imports.rb | 31 + ...20260702000001_create_data_import_items.rb | 35 + ...60702000002_create_data_import_mappings.rb | 22 + ...0260702000003_create_data_import_errors.rb | 17 + db/schema.rb | 66 ++ .../images/integrations/intercom.png | Bin 0 -> 3016 bytes spec/factories/data_import.rb | 9 + spec/finders/data_import_error_finder_spec.rb | 26 + .../data_import_skip_log_finder_spec.rb | 38 + .../data_imports/intercom/import_jobs_spec.rb | 218 ++++ spec/models/account_spec.rb | 8 +- spec/models/data_import_spec.rb | 44 + .../api/v1/accounts/data_imports_spec.rb | 438 ++++++++ .../intercom/activity_content_builder_spec.rb | 51 + .../data_imports/intercom/client_spec.rb | 16 + .../intercom/creation_service_spec.rb | 52 + .../intercom/credentials_validator_spec.rb | 54 + .../data_imports/intercom/importer_spec.rb | 968 +++++++++++++++++ .../placeholder_inbox_builder_spec.rb | 33 + .../intercom/restart_service_spec.rb | 64 ++ .../intercom/source_bucket_spec.rb | 17 + 69 files changed, 6239 insertions(+), 11 deletions(-) create mode 100644 app/controllers/api/v1/accounts/data_imports_controller.rb create mode 100644 app/finders/data_import_error_finder.rb create mode 100644 app/finders/data_import_skip_log_finder.rb create mode 100644 app/javascript/dashboard/api/dataImports.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/data/Index.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/data/NewImportDialog.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/data/Show.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/data/components/ImportDetailHeader.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/data/components/ImportErrorsSection.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/data/components/ImportLogSection.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/data/components/ImportProgress.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/data/components/ImportSkipLogsSection.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/data/components/ImportSummaryTiles.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/data/data.routes.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/data/importSources.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/data/importStatus.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/data/specs/importStatus.spec.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/data/specs/pollingLifecycle.spec.js create mode 100644 app/jobs/data_imports/intercom/base_job.rb create mode 100644 app/jobs/data_imports/intercom/contacts_page_job.rb create mode 100644 app/jobs/data_imports/intercom/conversations_page_job.rb create mode 100644 app/jobs/data_imports/intercom/import_job.rb create mode 100644 app/models/data_import_error.rb create mode 100644 app/models/data_import_item.rb create mode 100644 app/models/data_import_mapping.rb create mode 100644 app/policies/data_import_policy.rb create mode 100644 app/services/data_imports/intercom/activity_content_builder.rb create mode 100644 app/services/data_imports/intercom/client.rb create mode 100644 app/services/data_imports/intercom/creation_service.rb create mode 100644 app/services/data_imports/intercom/credentials_validator.rb create mode 100644 app/services/data_imports/intercom/importer.rb create mode 100644 app/services/data_imports/intercom/placeholder_inbox_builder.rb create mode 100644 app/services/data_imports/intercom/restart_service.rb create mode 100644 app/services/data_imports/intercom/source_bucket.rb create mode 100644 app/views/api/v1/accounts/data_imports/_data_import.json.jbuilder create mode 100644 app/views/api/v1/accounts/data_imports/index.json.jbuilder create mode 100644 app/views/api/v1/accounts/data_imports/show.json.jbuilder create mode 100644 db/migrate/20260702000000_expand_data_imports_for_intercom_imports.rb create mode 100644 db/migrate/20260702000001_create_data_import_items.rb create mode 100644 db/migrate/20260702000002_create_data_import_mappings.rb create mode 100644 db/migrate/20260702000003_create_data_import_errors.rb create mode 100644 public/dashboard/images/integrations/intercom.png create mode 100644 spec/finders/data_import_error_finder_spec.rb create mode 100644 spec/finders/data_import_skip_log_finder_spec.rb create mode 100644 spec/jobs/data_imports/intercom/import_jobs_spec.rb create mode 100644 spec/requests/api/v1/accounts/data_imports_spec.rb create mode 100644 spec/services/data_imports/intercom/activity_content_builder_spec.rb create mode 100644 spec/services/data_imports/intercom/client_spec.rb create mode 100644 spec/services/data_imports/intercom/creation_service_spec.rb create mode 100644 spec/services/data_imports/intercom/credentials_validator_spec.rb create mode 100644 spec/services/data_imports/intercom/importer_spec.rb create mode 100644 spec/services/data_imports/intercom/placeholder_inbox_builder_spec.rb create mode 100644 spec/services/data_imports/intercom/restart_service_spec.rb create mode 100644 spec/services/data_imports/intercom/source_bucket_spec.rb diff --git a/app/controllers/api/v1/accounts/data_imports_controller.rb b/app/controllers/api/v1/accounts/data_imports_controller.rb new file mode 100644 index 000000000..7f0d28e81 --- /dev/null +++ b/app/controllers/api/v1/accounts/data_imports_controller.rb @@ -0,0 +1,159 @@ +require 'csv' + +class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseController + DATA_IMPORT_FEATURE = 'data_import'.freeze + + before_action :ensure_data_import_feature_enabled + before_action :set_data_import, only: [:show, :start, :abandon, :error_logs, :skip_logs] + before_action :check_authorization + + def index + @data_imports = policy_scope(Current.account.data_imports).includes(:initiated_by).order(created_at: :desc) + data_import_ids = @data_imports.map(&:id) + @import_errors_counts = DataImportError.non_skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count + @skip_logs_counts = DataImportError.skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count + end + + def show + render_show + end + + def validate_source + totals = validate_intercom_source + render json: { valid: true, totals: totals } + rescue DataImports::Intercom::Client::AuthenticationError + render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.') + rescue DataImports::Intercom::Client::Error + render_source_validation_error('Intercom could not be reached. Please try again.') + rescue ArgumentError => e + render_source_validation_error(e.message) + end + + def create + @data_import = creation_service.perform + unless @data_import + render json: { message: 'Another data import is already in progress.' }, status: :unprocessable_entity + return + end + + DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id) + render_show + rescue DataImports::Intercom::Client::AuthenticationError + render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.') + rescue DataImports::Intercom::Client::Error + render_source_validation_error('Intercom could not be reached. Please try again.') + rescue ArgumentError => e + render_source_validation_error(e.message) + end + + def start + restart_service = DataImports::Intercom::RestartService.new(account: Current.account, data_import: @data_import) + restart_result = restart_service.perform + @data_import = restart_service.data_import + if restart_result == :access_token_missing + render json: { message: 'The Intercom access key for this import is unavailable.' }, status: :unprocessable_entity + return + end + + DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id) if restart_result == :enqueue + render_show + end + + def abandon + @data_import.abandon! + render_show + end + + def skip_logs + send_data( + skip_logs_csv, + filename: "data-import-#{@data_import.id}-skip-logs.csv", + type: 'text/csv' + ) + end + + def error_logs + send_data( + error_logs_csv, + filename: "data-import-#{@data_import.id}-error-logs.csv", + type: 'text/csv' + ) + end + + private + + def ensure_data_import_feature_enabled + raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?(DATA_IMPORT_FEATURE) + end + + def set_data_import + @data_import = Current.account.data_imports.find(params[:id]) + end + + def check_authorization + authorize(@data_import || DataImport) + end + + def permitted_params + params.permit(:name, :source_provider, :access_token, import_types: []) + end + + def creation_service + DataImports::Intercom::CreationService.new( + account: Current.account, + initiated_by: Current.user, + source_params: permitted_params.to_h + ) + end + + def import_types + return DataImports::Intercom::Importer::DEFAULT_IMPORT_TYPES unless permitted_params.key?(:import_types) + + Array(permitted_params[:import_types]).compact_blank + end + + def validate_intercom_source + raise ArgumentError, 'Unsupported import source.' unless permitted_params[:source_provider] == 'intercom' + + DataImports::Intercom::CredentialsValidator.new( + access_token: permitted_params[:access_token], + import_types: import_types + ).perform + end + + def render_source_validation_error(message) + render json: { valid: false, message: message }, status: :unprocessable_entity + end + + def render_show + @import_errors_finder = DataImportErrorFinder.new(@data_import) + @skip_logs_finder = DataImportSkipLogFinder.new(@data_import, params) + render :show + end + + def skip_logs_csv + logs_csv(@data_import.import_errors.skip_logs) + end + + def error_logs_csv + logs_csv(@data_import.import_errors.non_skip_logs) + end + + def logs_csv(logs) + CSV.generate(headers: true) do |csv| + csv << %w[created_at kind source_object_type source_object_id error_code message details] + + logs.order(:created_at).find_each do |log| + csv << [ + log.created_at.iso8601, + log.details['kind'], + log.source_object_type, + log.source_object_id, + log.error_code, + log.message, + log.details.to_json + ] + end + end + end +end diff --git a/app/finders/data_import_error_finder.rb b/app/finders/data_import_error_finder.rb new file mode 100644 index 000000000..69d85b23c --- /dev/null +++ b/app/finders/data_import_error_finder.rb @@ -0,0 +1,11 @@ +class DataImportErrorFinder + RESULTS_LIMIT = 5 + + def initialize(data_import) + @data_import = data_import + end + + def import_errors + @data_import.import_errors.non_skip_logs.order(created_at: :desc).limit(RESULTS_LIMIT) + end +end diff --git a/app/finders/data_import_skip_log_finder.rb b/app/finders/data_import_skip_log_finder.rb new file mode 100644 index 000000000..ee932f1da --- /dev/null +++ b/app/finders/data_import_skip_log_finder.rb @@ -0,0 +1,35 @@ +class DataImportSkipLogFinder + RESULTS_LIMIT = 5 + SOURCE_OBJECT_TYPES = %w[contact conversation message].freeze + + attr_reader :selected_source_object_type + + def initialize(data_import, params = {}) + @data_import = data_import + @selected_source_object_type = valid_source_object_type(params[:skip_logs_type]) + end + + def skip_logs + filtered_scope.order(created_at: :desc).limit(RESULTS_LIMIT) + end + + def counts_by_type + base_scope.group(:source_object_type).count + end + + private + + def base_scope + @base_scope ||= @data_import.import_errors.skip_logs + end + + def filtered_scope + return base_scope if selected_source_object_type.blank? + + base_scope.where(source_object_type: selected_source_object_type) + end + + def valid_source_object_type(source_object_type) + source_object_type if SOURCE_OBJECT_TYPES.include?(source_object_type) + end +end diff --git a/app/javascript/dashboard/api/dataImports.js b/app/javascript/dashboard/api/dataImports.js new file mode 100644 index 000000000..b4c15b98a --- /dev/null +++ b/app/javascript/dashboard/api/dataImports.js @@ -0,0 +1,39 @@ +/* global axios */ + +import ApiClient from './ApiClient'; + +class DataImportsAPI extends ApiClient { + constructor() { + super('data_imports', { accountScoped: true }); + } + + start(id) { + return axios.post(`${this.url}/${id}/start`); + } + + abandon(id) { + return axios.post(`${this.url}/${id}/abandon`); + } + + show(id, params = {}) { + return axios.get(`${this.url}/${id}`, { params }); + } + + validateSource(payload) { + return axios.post(`${this.url}/validate_source`, payload); + } + + downloadSkipLogs(id) { + return axios.get(`${this.url}/${id}/skip_logs.csv`, { + responseType: 'blob', + }); + } + + downloadErrorLogs(id) { + return axios.get(`${this.url}/${id}/error_logs.csv`, { + responseType: 'blob', + }); + } +} + +export default new DataImportsAPI(); diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index a78460ca2..294e0dd5d 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -85,6 +85,13 @@ const hasFilteredUnreadCounts = computed(() => { ); }); +const hasDataImport = computed(() => { + return isFeatureEnabledonAccount.value( + accountId.value, + FEATURE_FLAGS.DATA_IMPORT + ); +}); + const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => { if (!currentAccountId) return; @@ -856,6 +863,16 @@ const menuItems = computed(() => { icon: 'i-lucide-blocks', to: accountScopedRoute('settings_applications'), }, + ...(hasDataImport.value + ? [ + { + name: 'Settings Data', + label: t('SIDEBAR.DATA'), + icon: 'i-lucide-database', + to: accountScopedRoute('settings_data_imports'), + }, + ] + : []), { name: 'Settings Audit Logs', label: t('SIDEBAR.AUDIT_LOGS'), diff --git a/app/javascript/dashboard/components/Modal.vue b/app/javascript/dashboard/components/Modal.vue index 48d4e8d6b..1936a5c3c 100644 --- a/app/javascript/dashboard/components/Modal.vue +++ b/app/javascript/dashboard/components/Modal.vue @@ -7,7 +7,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; const { modalType, closeOnBackdropClick, onClose } = defineProps({ closeOnBackdropClick: { type: Boolean, default: true }, showCloseButton: { type: Boolean, default: true }, - onClose: { type: Function, required: true }, + onClose: { type: Function, default: null }, fullWidth: { type: Boolean, default: false }, modalType: { type: String, default: 'centered' }, size: { type: String, default: '' }, @@ -35,7 +35,7 @@ const handleMouseDown = () => { const close = () => { show.value = false; emit('close'); - onClose(); + onClose?.(); }; const onMouseUp = () => { diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 34404f0eb..058921eea 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -11,6 +11,7 @@ export const FEATURE_FLAGS = { CANNED_RESPONSES: 'canned_responses', CRM: 'crm', CUSTOM_ATTRIBUTES: 'custom_attributes', + DATA_IMPORT: 'data_import', INBOX_MANAGEMENT: 'inbox_management', INTEGRATIONS: 'integrations', LABELS: 'labels', diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index b621e63b1..eaecd7b80 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -340,6 +340,7 @@ "NOTIFICATIONS": "Notifications", "CANNED_RESPONSES": "Canned Responses", "INTEGRATIONS": "Integrations", + "DATA": "Data", "PROFILE_SETTINGS": "Profile Settings", "ACCOUNT_SETTINGS": "Account Settings", "APPLICATIONS": "Applications", @@ -411,6 +412,104 @@ "CAPTAIN_AI": "Captain", "CONVERSATION_WORKFLOW": "Conversation Workflow" }, + "DATA_IMPORTS": { + "HEADER": "Data", + "DESCRIPTION": "Bring your existing contacts and past conversations into this account from another support tool. Each import runs in the background, so you can keep working while it finishes, track its progress, and review anything that was skipped along the way.", + "LOADING": "Fetching imports", + "DEFAULT_IMPORT_NAME": "Intercom import", + "TABS": { + "IMPORT": "Import", + "EXPORT": "Export" + }, + "TYPES": { + "CONTACTS": "Contacts", + "CONVERSATIONS": "Conversations", + "MESSAGES": "Messages" + }, + "DRAWER": { + "TITLE": "New import", + "SOURCE": "Source", + "NAME": "Import name", + "NAME_PLACEHOLDER": "July Intercom migration", + "ACCESS_KEY": "Intercom access key", + "ACCESS_KEY_PLACEHOLDER": "Paste your Intercom access key", + "DATA_TYPES": "Data to import", + "VALIDATING": "Validating access key...", + "VALID_KEY": "Access key validated.", + "INVALID_KEY": "Could not validate this access key.", + "ACTIVE_IMPORT": "Wait for the active import to finish before starting another one.", + "CANCEL": "Cancel", + "IMPORT": "Import" + }, + "EXPORT": { + "TITLE": "Exports are on the way", + "DESCRIPTION": "Export your contacts and conversations out of this account. This workflow is coming soon.", + "COMING_SOON": "Coming soon" + }, + "TABLE": { + "TITLE": "Recent imports", + "EMPTY": "No imports yet", + "EMPTY_DESCRIPTION": "Start an import to bring your existing customer history into this account.", + "NEW_IMPORT": "Import", + "COUNT": "{count} imports", + "UNNAMED": "Untitled import", + "IMPORTED_COUNT": "{count} imported", + "VIEW": "View import", + "NAME": "Name", + "TYPE": "Type", + "STATUS": "Status", + "IMPORTED": "Imported", + "CREATED": "Created", + "ABANDON": "Abandon" + }, + "DETAIL": { + "BACK": "Back to imports", + "ERRORS": "Errors", + "SKIP_LOGS": "Skip logs", + "SOURCE": "Source", + "IMPORT_TYPES": "Import types", + "CREATED": "Created", + "DURATION": "Duration", + "INITIATED_BY": "Started by", + "PROGRESS": "Import progress", + "PROGRESS_WITH_TOTAL": "{imported} of {total} imported", + "PROGRESS_WITHOUT_TOTAL": "{imported} imported", + "PROGRESS_OF_TOTAL": "of {total} imported", + "PROGRESS_IMPORTED": "imported", + "LAST_UPDATED_TOOLTIP": "Last updated {time}", + "NO_SKIP_LOGS": "No skipped or failed records recorded.", + "DOWNLOAD_SKIP_LOGS": "Download CSV", + "DOWNLOAD_ERROR_LOGS": "Download CSV", + "ALL_SKIP_LOGS": "All", + "KIND": "Kind", + "NO_ERRORS": "No errors recorded.", + "ERROR_CODE": "Code", + "SOURCE_OBJECT": "Source object", + "MESSAGE": "Message" + }, + "MONITOR": { + "LIVE": "Live updates every {seconds}s", + "LAST_UPDATED": "Last updated {time}", + "REFRESH": "Refresh", + "REFRESHING": "Refreshing", + "STAGES": { + "unknown": "Waiting for update", + "queued": "Queued", + "contacts": "Importing contacts", + "conversations": "Importing conversations", + "finalizing": "Finalizing import", + "completed": "Completed", + "completed_with_errors": "Completed with errors", + "failed": "Failed", + "abandoned": "Abandoned" + } + }, + "ALERTS": { + "IMPORT_STARTED": "Intercom import has started.", + "IMPORT_ABANDONED": "Intercom import has been abandoned.", + "IMPORT_FAILED": "Could not start the Intercom import." + } + }, "CAPTAIN_SETTINGS": { "TITLE": "Captain Settings", "DESCRIPTION": "Configure your AI models and features for Captain. Captain follows a credit based billing, you will be charged credits for every action Captain takes based on the model selected.", diff --git a/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue index 167d13cf5..2fa3bf0b8 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue @@ -52,9 +52,11 @@ const helpURL = getHelpUrlForFeature(props.featureName); v-if="title" class="flex items-center justify-between w-full gap-4 min-h-8 mb-2" > -

- {{ title }} -

+ +

+ {{ title }} +

+
+import { + computed, + onActivated, + onBeforeUnmount, + onDeactivated, + ref, +} from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useRouter } from 'vue-router'; +import { useStoreGetters } from 'dashboard/composables/store'; + +import Button from 'dashboard/components-next/button/Button.vue'; +import Icon from 'dashboard/components-next/icon/Icon.vue'; +import TabBar from 'dashboard/components-next/tabbar/TabBar.vue'; +import SettingsLayout from '../SettingsLayout.vue'; +import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; +import DataImportsAPI from 'dashboard/api/dataImports'; +import NewImportDialog from './NewImportDialog.vue'; +import { importSourceFor } from './importSources'; +import { + POLL_INTERVAL_MS, + formatDate, + formatStatus, + importedCount, + isActiveImport, + isActiveIntercomImport, + statusDotClass, +} from './importStatus'; + +const { t } = useI18n(); +const getters = useStoreGetters(); +const router = useRouter(); + +const dataImports = ref([]); +const isLoading = ref(true); +const isRefreshing = ref(false); +const isPolling = ref(false); +const showImportDrawer = ref(false); +const activeTab = ref('import'); +let pollTimer; +let isPageActive = false; + +const accountId = getters.getCurrentAccountId; + +const tabs = computed(() => [ + { key: 'import', label: t('DATA_IMPORTS.TABS.IMPORT') }, + { key: 'export', label: t('DATA_IMPORTS.TABS.EXPORT') }, +]); + +const activeTabIndex = computed(() => + tabs.value.findIndex(tab => tab.key === activeTab.value) +); + +const hasActiveImport = computed(() => dataImports.value.some(isActiveImport)); +const hasActiveIntercomImport = computed(() => + dataImports.value.some(isActiveIntercomImport) +); + +const dataImportRoute = dataImport => ({ + name: 'settings_data_import_show', + params: { accountId: accountId.value, dataImportId: dataImport.id }, +}); + +const importTypesFor = dataImport => + dataImport.import_types?.length + ? dataImport.import_types + : [dataImport.data_type]; + +const importTypeLabel = dataImport => + importTypesFor(dataImport) + .map(type => { + if (type === 'contacts') return t('DATA_IMPORTS.TYPES.CONTACTS'); + if (type === 'conversations') { + return t('DATA_IMPORTS.TYPES.CONVERSATIONS'); + } + return type; + }) + .join(', '); + +const fetchImports = async () => { + const response = await DataImportsAPI.get(); + dataImports.value = response.data.payload || []; +}; + +const stopPolling = () => { + if (!pollTimer) return; + + window.clearInterval(pollTimer); + pollTimer = null; +}; + +const refreshImportsInBackground = async () => { + if ( + !isPageActive || + isPolling.value || + !hasActiveImport.value || + document.hidden + ) { + return; + } + + isPolling.value = true; + try { + await fetchImports(); + } finally { + isPolling.value = false; + if (!hasActiveImport.value) stopPolling(); + } +}; + +const startPolling = () => { + stopPolling(); + if (!isPageActive || !hasActiveImport.value) return; + + pollTimer = window.setInterval(refreshImportsInBackground, POLL_INTERVAL_MS); +}; + +const refresh = async ({ showLoader = true } = {}) => { + if (showLoader) isLoading.value = true; + else isRefreshing.value = true; + + try { + await fetchImports(); + } finally { + isLoading.value = false; + isRefreshing.value = false; + if (isPageActive) { + if (hasActiveImport.value && !pollTimer) startPolling(); + if (!hasActiveImport.value) stopPolling(); + } + } +}; + +const openImport = dataImport => { + router.push(dataImportRoute(dataImport)); +}; + +const openImportDrawer = () => { + if (!hasActiveIntercomImport.value) showImportDrawer.value = true; +}; + +const onImportCreated = dataImportId => { + showImportDrawer.value = false; + router.push({ + name: 'settings_data_import_show', + params: { accountId: accountId.value, dataImportId }, + }); +}; + +const onTabChanged = tab => { + activeTab.value = tab.key; +}; + +const handleVisibilityChange = () => { + if (isPageActive && !document.hidden && hasActiveImport.value) { + refreshImportsInBackground(); + } +}; + +onActivated(async () => { + isPageActive = true; + await refresh(); + if (!isPageActive) return; + + startPolling(); + document.addEventListener('visibilitychange', handleVisibilityChange); +}); + +onDeactivated(() => { + isPageActive = false; + stopPolling(); + document.removeEventListener('visibilitychange', handleVisibilityChange); +}); + +onBeforeUnmount(() => { + isPageActive = false; + stopPolling(); + document.removeEventListener('visibilitychange', handleVisibilityChange); +}); + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/NewImportDialog.vue b/app/javascript/dashboard/routes/dashboard/settings/data/NewImportDialog.vue new file mode 100644 index 000000000..56fda08f3 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/data/NewImportDialog.vue @@ -0,0 +1,210 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/Show.vue b/app/javascript/dashboard/routes/dashboard/settings/data/Show.vue new file mode 100644 index 000000000..ed89c66a9 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/data/Show.vue @@ -0,0 +1,238 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportDetailHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportDetailHeader.vue new file mode 100644 index 000000000..935d63b87 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportDetailHeader.vue @@ -0,0 +1,128 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportErrorsSection.vue b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportErrorsSection.vue new file mode 100644 index 000000000..0cbfd9d2b --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportErrorsSection.vue @@ -0,0 +1,78 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportLogSection.vue b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportLogSection.vue new file mode 100644 index 000000000..8ccbaf5fe --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportLogSection.vue @@ -0,0 +1,105 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportProgress.vue b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportProgress.vue new file mode 100644 index 000000000..fb2cd003c --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportProgress.vue @@ -0,0 +1,100 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportSkipLogsSection.vue b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportSkipLogsSection.vue new file mode 100644 index 000000000..2b3d9c467 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportSkipLogsSection.vue @@ -0,0 +1,127 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportSummaryTiles.vue b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportSummaryTiles.vue new file mode 100644 index 000000000..01e239b32 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportSummaryTiles.vue @@ -0,0 +1,110 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/data.routes.js b/app/javascript/dashboard/routes/dashboard/settings/data/data.routes.js new file mode 100644 index 000000000..1e81d3e9a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/data/data.routes.js @@ -0,0 +1,34 @@ +import { FEATURE_FLAGS } from '../../../../featureFlags'; +import { frontendURL } from '../../../../helper/URLHelper'; +import SettingsWrapper from '../SettingsWrapper.vue'; +import Index from './Index.vue'; +import Show from './Show.vue'; + +export default { + routes: [ + { + path: frontendURL('accounts/:accountId/settings/data'), + component: SettingsWrapper, + children: [ + { + path: '', + name: 'settings_data_imports', + component: Index, + meta: { + featureFlag: FEATURE_FLAGS.DATA_IMPORT, + permissions: ['administrator'], + }, + }, + { + path: ':dataImportId', + name: 'settings_data_import_show', + component: Show, + meta: { + featureFlag: FEATURE_FLAGS.DATA_IMPORT, + permissions: ['administrator'], + }, + }, + ], + }, + ], +}; diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/importSources.js b/app/javascript/dashboard/routes/dashboard/settings/data/importSources.js new file mode 100644 index 000000000..85e832a6c --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/data/importSources.js @@ -0,0 +1,17 @@ +export const IMPORT_SOURCES = [ + { + value: 'intercom', + label: 'Intercom', + icon: '/dashboard/images/integrations/intercom.png', + }, +]; + +const DEFAULT_IMPORT_SOURCE = { + value: 'file', + label: 'File import', + iconClass: 'i-lucide-file-text', +}; + +export const importSourceFor = dataImport => + IMPORT_SOURCES.find(source => source.value === dataImport?.source_provider) || + DEFAULT_IMPORT_SOURCE; diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/importStatus.js b/app/javascript/dashboard/routes/dashboard/settings/data/importStatus.js new file mode 100644 index 000000000..f658ee04c --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/data/importStatus.js @@ -0,0 +1,84 @@ +export const POLL_INTERVAL_MS = 5000; + +export const ACTIVE_IMPORT_STATUSES = ['pending', 'processing']; + +export const isActiveImport = dataImport => + ACTIVE_IMPORT_STATUSES.includes(dataImport?.status); + +export const isIntercomImport = dataImport => + dataImport?.data_type === 'intercom' && + dataImport?.source_provider === 'intercom'; + +export const isActiveIntercomImport = dataImport => + isIntercomImport(dataImport) && isActiveImport(dataImport); + +export const isAbandonableImport = dataImport => + isActiveIntercomImport(dataImport); + +export const importedCount = dataImport => { + if (!isIntercomImport(dataImport)) { + return Number(dataImport?.processed_records || 0); + } + + return ['contacts', 'conversations', 'messages'].reduce( + (total, key) => total + Number(dataImport?.stats?.[key]?.imported || 0), + 0 + ); +}; + +export const importStageKey = dataImport => { + if (!dataImport) return 'unknown'; + + if (dataImport.status === 'completed') return 'completed'; + if (dataImport.status === 'completed_with_errors') { + return 'completed_with_errors'; + } + if (dataImport.status === 'failed') return 'failed'; + if (dataImport.status === 'abandoned') return 'abandoned'; + if (dataImport.status === 'pending') return 'queued'; + + const importTypes = dataImport.import_types?.length + ? dataImport.import_types + : [dataImport.data_type]; + const cursor = dataImport.cursor || {}; + + if (importTypes.includes('contacts') && !cursor.contacts?.completed) { + return 'contacts'; + } + + if ( + importTypes.includes('conversations') && + !cursor.conversations?.completed + ) { + return 'conversations'; + } + + return 'finalizing'; +}; + +export const formatStatus = value => value?.replaceAll('_', ' ') || '-'; + +export const sourceObjectLabel = record => + [record.source_object_type, record.source_object_id] + .filter(Boolean) + .join(': ') || '-'; + +export const formatDate = value => { + if (!value) return '-'; + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)); +}; + +const STATUS_DOT_CLASS = { + pending: 'bg-n-amber-9', + processing: 'bg-n-blue-9', + completed: 'bg-n-teal-9', + completed_with_errors: 'bg-n-amber-9', + failed: 'bg-n-ruby-9', + abandoned: 'bg-n-slate-9', +}; + +export const statusDotClass = status => + STATUS_DOT_CLASS[status] || 'bg-n-slate-9'; diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/specs/importStatus.spec.js b/app/javascript/dashboard/routes/dashboard/settings/data/specs/importStatus.spec.js new file mode 100644 index 000000000..dcfe7d9bd --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/data/specs/importStatus.spec.js @@ -0,0 +1,92 @@ +import { + formatDate, + importedCount, + isActiveIntercomImport, + statusDotClass, +} from '../importStatus'; + +describe('importStatus', () => { + describe('isActiveIntercomImport', () => { + it('only treats pending or processing Intercom imports as active', () => { + expect( + isActiveIntercomImport({ + data_type: 'intercom', + source_provider: 'intercom', + status: 'processing', + }) + ).toBe(true); + expect( + isActiveIntercomImport({ + data_type: 'contacts', + source_provider: null, + status: 'processing', + }) + ).toBe(false); + expect( + isActiveIntercomImport({ + data_type: 'intercom', + source_provider: 'intercom', + status: 'completed', + }) + ).toBe(false); + }); + }); + + describe('importedCount', () => { + it('sums Intercom imported stats', () => { + expect( + importedCount({ + data_type: 'intercom', + source_provider: 'intercom', + processed_records: 20, + stats: { + contacts: { imported: 2 }, + conversations: { imported: 3 }, + messages: { imported: 10 }, + }, + }) + ).toBe(15); + }); + + it('uses processed records for legacy imports', () => { + expect( + importedCount({ + data_type: 'contacts', + source_provider: null, + processed_records: 7, + stats: {}, + }) + ).toBe(7); + }); + }); + + describe('statusDotClass', () => { + it('maps each status to its dot color class', () => { + expect(statusDotClass('pending')).toBe('bg-n-amber-9'); + expect(statusDotClass('processing')).toBe('bg-n-blue-9'); + expect(statusDotClass('completed')).toBe('bg-n-teal-9'); + expect(statusDotClass('completed_with_errors')).toBe('bg-n-amber-9'); + expect(statusDotClass('failed')).toBe('bg-n-ruby-9'); + expect(statusDotClass('abandoned')).toBe('bg-n-slate-9'); + }); + + it('falls back to slate for unknown or missing status', () => { + expect(statusDotClass('unknown')).toBe('bg-n-slate-9'); + expect(statusDotClass(undefined)).toBe('bg-n-slate-9'); + }); + }); + + describe('formatDate', () => { + it('returns a dash for empty values', () => { + expect(formatDate(null)).toBe('-'); + expect(formatDate('')).toBe('-'); + expect(formatDate(undefined)).toBe('-'); + }); + + it('formats a valid date into a readable string', () => { + const formatted = formatDate('2026-07-10T18:09:00Z'); + expect(formatted).not.toBe('-'); + expect(formatted).toContain('2026'); + }); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/specs/pollingLifecycle.spec.js b/app/javascript/dashboard/routes/dashboard/settings/data/specs/pollingLifecycle.spec.js new file mode 100644 index 000000000..7e74bd565 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/data/specs/pollingLifecycle.spec.js @@ -0,0 +1,121 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { KeepAlive, defineComponent, h, nextTick, ref } from 'vue'; +import DataImportsAPI from 'dashboard/api/dataImports'; +import Index from '../Index.vue'; +import Show from '../Show.vue'; + +vi.mock('dashboard/api/dataImports', () => ({ + default: { + get: vi.fn(), + show: vi.fn(), + }, +})); + +vi.mock('dashboard/composables/store', () => ({ + useStoreGetters: () => ({ getCurrentAccountId: { value: 1 } }), +})); + +vi.mock('dashboard/composables', () => ({ + useAlert: vi.fn(), +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: key => key }), +})); + +vi.mock('vue-router', async importOriginal => ({ + ...(await importOriginal()), + useRoute: () => ({ params: { dataImportId: 1 } }), + useRouter: () => ({ push: vi.fn() }), +})); + +const deferredRequest = () => { + let resolve; + const promise = new Promise(resolvePromise => { + resolve = resolvePromise; + }); + return { promise, resolve }; +}; + +const mountKeptAlive = component => { + const Host = defineComponent({ + setup() { + const visible = ref(true); + return { visible }; + }, + render() { + return h(KeepAlive, null, { + default: () => (this.visible ? h(component) : null), + }); + }, + }); + + return mount(Host, { + global: { + stubs: { + SettingsLayout: true, + BaseSettingsHeader: true, + Button: true, + Icon: true, + TabBar: true, + NewImportDialog: true, + ImportDetailHeader: true, + ImportSummaryTiles: true, + ImportProgress: true, + ImportErrorsSection: true, + ImportSkipLogsSection: true, + }, + mocks: { + $t: key => key, + }, + }, + }); +}; + +describe('data import polling lifecycle', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('does not start list polling after the page deactivates', async () => { + const request = deferredRequest(); + DataImportsAPI.get.mockReturnValue(request.promise); + const wrapper = mountKeptAlive(Index); + await nextTick(); + + wrapper.vm.visible = false; + await nextTick(); + request.resolve({ data: { payload: [{ status: 'processing' }] } }); + await flushPromises(); + await vi.advanceTimersByTimeAsync(5000); + + expect(DataImportsAPI.get).toHaveBeenCalledTimes(1); + wrapper.unmount(); + }); + + it('does not start detail polling after the page deactivates', async () => { + const request = deferredRequest(); + DataImportsAPI.show.mockReturnValue(request.promise); + const wrapper = mountKeptAlive(Show); + await nextTick(); + + wrapper.vm.visible = false; + await nextTick(); + request.resolve({ + data: { + status: 'processing', + skip_logs_filters: {}, + }, + }); + await flushPromises(); + await vi.advanceTimersByTimeAsync(5000); + + expect(DataImportsAPI.show).toHaveBeenCalledTimes(1); + wrapper.unmount(); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue index 884c198c4..0026cd8a9 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue @@ -1,5 +1,5 @@ ' + } + + expect(described_class.new(part).perform).to eq( + 'Intercom teammate closed the conversation: Customer confirmed resolution' + ) + end +end diff --git a/spec/services/data_imports/intercom/client_spec.rb b/spec/services/data_imports/intercom/client_spec.rb new file mode 100644 index 000000000..78498121a --- /dev/null +++ b/spec/services/data_imports/intercom/client_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +RSpec.describe DataImports::Intercom::Client do + let(:client) { described_class.new(access_token: 'intercom-token') } + + describe '#list_contacts' do + it 'wraps transport failures in a retryable client error', :aggregate_failures do + allow(HTTParty).to receive(:get).and_raise(SocketError, 'getaddrinfo failed') + + expect { client.list_contacts }.to raise_error(DataImports::Intercom::Client::Error) do |error| + expect(error.message).to eq('Intercom API request failed before receiving a response: getaddrinfo failed') + expect(error.body).to include(transport_error_class: 'SocketError') + end + end + end +end diff --git a/spec/services/data_imports/intercom/creation_service_spec.rb b/spec/services/data_imports/intercom/creation_service_spec.rb new file mode 100644 index 000000000..4345b3715 --- /dev/null +++ b/spec/services/data_imports/intercom/creation_service_spec.rb @@ -0,0 +1,52 @@ +require 'rails_helper' + +RSpec.describe DataImports::Intercom::CreationService do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:validator) { instance_double(DataImports::Intercom::CredentialsValidator, perform: { 'contacts' => 12 }) } + + before do + allow(DataImports::Intercom::CredentialsValidator).to receive(:new).and_return(validator) + end + + it 'validates and creates an import with its credentials and totals', :aggregate_failures do + data_import = described_class.new( + account: account, + initiated_by: user, + source_params: { + name: 'Migration run', + source_provider: 'intercom', + access_token: ' intercom-token ', + import_types: %w[contacts] + } + ).perform + + expect(data_import).to have_attributes( + name: 'Migration run', + source_type: 'api', + source_provider: 'intercom', + import_types: %w[contacts], + access_token: 'intercom-token', + initiated_by_id: user.id + ) + expect(data_import.stats.dig('contacts', 'total')).to eq(12) + expect(data_import.active_intercom_import_run_id).to be_present + end + + it 'returns no import without validating when another import is active' do + create(:data_import, :intercom, account: account, status: :processing) + + data_import = described_class.new( + account: account, + initiated_by: user, + source_params: { + name: 'Second run', + source_provider: 'intercom', + access_token: 'intercom-token' + } + ).perform + + expect(data_import).to be_nil + expect(validator).not_to have_received(:perform) + end +end diff --git a/spec/services/data_imports/intercom/credentials_validator_spec.rb b/spec/services/data_imports/intercom/credentials_validator_spec.rb new file mode 100644 index 000000000..3d3f9698b --- /dev/null +++ b/spec/services/data_imports/intercom/credentials_validator_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' + +RSpec.describe DataImports::Intercom::CredentialsValidator do + let(:client) { instance_double(DataImports::Intercom::Client) } + + before do + allow(DataImports::Intercom::Client).to receive(:new).with(access_token: 'intercom-token').and_return(client) + allow(client).to receive(:list_contacts) + allow(client).to receive(:list_conversations) + end + + it 'validates and counts only contacts when conversations are not selected' do + allow(client).to receive(:list_contacts).with(per_page: 1).and_return('total_count' => 42) + + totals = described_class.new(access_token: ' intercom-token ', import_types: %w[contacts]).perform + + expect(totals).to eq('contacts' => 42) + expect(client).not_to have_received(:list_conversations) + end + + it 'validates contact access and counts only conversations when contacts are not selected' do + allow(client).to receive(:list_contacts).with(per_page: 1).and_return('total_count' => 42) + allow(client).to receive(:list_conversations).with(per_page: 1).and_return('total_count' => 17) + + totals = described_class.new(access_token: 'intercom-token', import_types: %w[conversations]).perform + + expect(totals).to eq('conversations' => 17) + expect(client).to have_received(:list_contacts).with(per_page: 1) + end + + it 'keeps an undiscovered total absent' do + allow(client).to receive(:list_contacts).with(per_page: 1).and_return('data' => []) + + totals = described_class.new(access_token: 'intercom-token', import_types: %w[contacts]).perform + + expect(totals).to be_empty + end + + it 'preserves a known zero total' do + allow(client).to receive(:list_contacts).with(per_page: 1).and_return('total_count' => 0) + + totals = described_class.new(access_token: 'intercom-token', import_types: %w[contacts]).perform + + expect(totals).to eq('contacts' => 0) + end + + it 'rejects an empty access key before calling Intercom' do + expect do + described_class.new(access_token: '', import_types: %w[contacts]).perform + end.to raise_error(ArgumentError, 'Intercom access key is required.') + + expect(DataImports::Intercom::Client).not_to have_received(:new) + end +end diff --git a/spec/services/data_imports/intercom/importer_spec.rb b/spec/services/data_imports/intercom/importer_spec.rb new file mode 100644 index 000000000..67c2ec576 --- /dev/null +++ b/spec/services/data_imports/intercom/importer_spec.rb @@ -0,0 +1,968 @@ +require 'rails_helper' + +RSpec.describe DataImports::Intercom::Importer do + let(:account) { create(:account) } + let(:data_import) do + create( + :data_import, :intercom, + account: account + ) + end + let(:client) { instance_double(DataImports::Intercom::Client) } + let(:contact_payload) do + { + 'id' => 'contact_1', + 'external_id' => 'external_1', + 'email' => 'CUSTOMER@Example.com', + 'phone' => '15551234567', + 'name' => 'Customer One', + 'created_at' => 1_700_000_000, + 'updated_at' => 1_700_000_100 + } + end + let(:conversation_payload) do + { + 'id' => 'conversation_1', + 'created_at' => 1_700_000_000, + 'updated_at' => 1_700_000_200, + 'state' => 'closed', + 'open' => false, + 'admin_assignee_id' => 123, + 'team_assignee_id' => 456, + 'contacts' => { 'contacts' => [{ 'id' => 'contact_1' }] }, + 'source' => { + 'id' => 'source_1', + 'type' => 'email', + 'delivered_as' => 'customer_initiated', + 'subject' => 'Need help', + 'body' => '

Hello there

', + 'author' => { 'type' => 'user', 'id' => 'contact_1', 'email' => 'CUSTOMER@example.com' } + }, + 'conversation_parts' => { + 'conversation_parts' => [ + { + 'id' => 'part_1', + 'part_type' => 'comment', + 'body' => '

Admin reply

', + 'created_at' => 1_700_000_100, + 'updated_at' => 1_700_000_100, + 'author' => { 'type' => 'admin', 'id' => 'admin_1' }, + 'attachments' => [] + }, + { + 'id' => 'part_2', + 'part_type' => 'note', + 'body' => 'Internal note', + 'created_at' => 1_700_000_150, + 'updated_at' => 1_700_000_150, + 'author' => { 'type' => 'admin', 'id' => 'admin_1' }, + 'attachments' => [] + } + ] + } + } + end + + before do + account.enable_features!('data_import') + allow(DataImports::Intercom::Client).to receive(:new).with(access_token: 'intercom-token').and_return(client) + allow(client).to receive(:list_contacts).with(starting_after: nil).and_return( + 'data' => [contact_payload], + 'total_count' => 1, + 'pages' => { 'next' => nil } + ) + allow(client).to receive(:list_conversations).with(starting_after: nil).and_return( + 'conversations' => [{ 'id' => 'conversation_1' }], + 'total_count' => 1, + 'pages' => { 'next' => nil } + ) + allow(client).to receive(:retrieve_conversation).with('conversation_1').and_return(conversation_payload) + allow(client).to receive(:retrieve_contact).with('contact_1').and_return(contact_payload) + end + + it 'imports contacts, conversations, messages, and source-bucket inboxes without normal message creation callbacks', :aggregate_failures do + described_class.new(data_import: data_import).perform + + contact = account.contacts.find_by!(email: 'customer@example.com') + expect(contact.name).to eq('Customer One') + expect(contact.phone_number).to eq('+15551234567') + expect(contact).to be_lead + expect(contact.custom_attributes).to include('intercom_contact_id' => 'contact_1') + + inbox = account.inboxes.find_by!(name: 'Intercom Import - Email') + expect(inbox.channel.additional_attributes).to include('source_bucket' => 'email', 'import_placeholder' => true) + + conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1') + expect(conversation).to have_attributes( + status: 'resolved', + inbox_id: inbox.id, + contact_id: contact.id + ) + expect(conversation.additional_attributes.dig('source', 'routing_method')).to eq('source_bucket_api_inbox') + + expect(conversation.messages.order(:created_at).pluck(:content)).to eq(["Need help\n\nHello there", 'Admin reply', 'Internal note']) + expect(conversation.messages.order(:created_at).map(&:message_type)).to eq(%w[incoming outgoing outgoing]) + expect(conversation.messages.order(:created_at).last.private).to be(true) + + expect(data_import.reload).to be_completed + expect(data_import.stats).to include( + 'contacts' => include('imported' => 1, 'skipped' => 0, 'total' => 1), + 'conversations' => include('imported' => 1, 'skipped' => 0, 'total' => 1), + 'messages' => include('imported' => 3, 'skipped' => 0, 'total' => 3), + 'errors' => { 'count' => 0 } + ) + expect(data_import.processed_records).to eq(5) + expect(data_import.items.imported.count).to eq(2) + expect(DataImportMapping.where(data_import: data_import).count).to eq(5) + end + + it 'imports historical records without dispatching record events or outbound side effects', :aggregate_failures do + dispatched_events = [] + allow(Rails.configuration.dispatcher).to receive(:dispatch) do |event_name, *_args| + dispatched_events << event_name + end + clear_enqueued_jobs + + described_class.new(data_import: data_import).perform + + record_events = [ + Events::Types::CONTACT_CREATED, + Events::Types::CONTACT_UPDATED, + Events::Types::CONVERSATION_CREATED, + Events::Types::CONVERSATION_UPDATED, + Events::Types::CONVERSATION_STATUS_CHANGED, + Events::Types::ASSIGNEE_CHANGED, + Events::Types::TEAM_CHANGED, + Events::Types::MESSAGE_CREATED, + Events::Types::FIRST_REPLY_CREATED, + Events::Types::REPLY_CREATED + ] + side_effect_jobs = [SendReplyJob, EventDispatcherJob, ActionCableBroadcastJob, WebhookJob, HookJob] + + expect(dispatched_events & record_events).to be_empty + expect(enqueued_jobs.pluck(:job) & side_effect_jobs).to be_empty + expect(Notification.where(account: account)).to be_empty + end + + context 'when Intercom contact activity timestamps are available' do + let(:contact_payload) do + super().merge('last_seen_at' => 1_700_000_050, 'last_replied_at' => 1_700_000_090) + end + + it 'prefers last_seen_at for contact activity' do + described_class.new(data_import: data_import).import_contacts_page + + contact = account.contacts.find_by!(email: 'customer@example.com') + expect(contact.last_activity_at).to eq(Time.zone.at(1_700_000_050)) + end + end + + context 'when Intercom contact last_seen_at is unavailable' do + let(:contact_payload) do + super().merge('last_seen_at' => nil, 'last_replied_at' => 1_700_000_090) + end + + it 'falls back to last_replied_at for contact activity' do + described_class.new(data_import: data_import).import_contacts_page + + contact = account.contacts.find_by!(email: 'customer@example.com') + expect(contact.last_activity_at).to eq(Time.zone.at(1_700_000_090)) + end + end + + it 'leaves contact activity blank when Intercom activity timestamps are unavailable' do + described_class.new(data_import: data_import).import_contacts_page + + contact = account.contacts.find_by!(email: 'customer@example.com') + expect(contact.last_activity_at).to be_nil + end + + it 'updates message totals by delta when a conversation page is retried' do + importer = described_class.new(data_import: data_import) + + importer.import_conversations_page + importer.import_conversations_page + + expect(data_import.reload.stats.dig('messages', 'total')).to eq(3) + item = data_import.items.find_by!(source_object_type: 'conversation', source_object_id: 'conversation_1') + expect(item.metadata['message_total_contribution']).to eq(3) + end + + it 'reconciles imported message stats from same-run mappings on retry' do + described_class.new(data_import: data_import).import_conversations_page + stats = data_import.reload.stats.deep_dup + stats['messages']['imported'] = 0 + data_import.update!(stats: stats) + + described_class.new(data_import: data_import).import_conversations_page + + expect(data_import.reload.stats.dig('messages', 'imported')).to eq(3) + end + + it 'indexes imported messages for advanced search' do + allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true) + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) + reindexed_message_ids = [] + original_reindex_for_search = Message.instance_method(:reindex_for_search) + Message.define_method(:reindex_for_search) { reindexed_message_ids << id } + Message.__send__(:private, :reindex_for_search) + + described_class.new(data_import: data_import).perform + + expect(reindexed_message_ids).to match_array(Message.where(account_id: account.id).pluck(:id)) + ensure + Message.define_method(:reindex_for_search, original_reindex_for_search) + Message.__send__(:private, :reindex_for_search) + end + + it 'keeps imported messages successful when search reindexing fails', :aggregate_failures do + allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true) + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(Message).to receive(:reindex_for_search).and_raise(StandardError, 'search unavailable') + # rubocop:enable RSpec/AnyInstance + + described_class.new(data_import: data_import).perform + + message = account.messages.find_by!(source_id: 'intercom:conversation:conversation_1:source:source_1') + mapping = data_import.mappings.find_by!(source_object_type: 'message', source_object_id: 'conversation:conversation_1:source:source_1') + expect(mapping.chatwoot_record).to eq(message) + expect(data_import.reload).to be_completed + expect(data_import.import_errors.exists?).to be(false) + expect(data_import.stats.dig('messages', 'imported')).to eq(3) + end + + describe '#start!' do + it 'does not overwrite an import abandoned by another process', :aggregate_failures do + importer = described_class.new(data_import: data_import) + + DataImport.find(data_import.id).update!( + status: :abandoned, + abandoned_at: Time.current + ) + + expect(importer.start!).to be_nil + expect(data_import.reload).to be_abandoned + expect(data_import.started_at).to be_nil + end + end + + describe '#perform' do + it 'stops when the import was abandoned before processing starts' do + importer = described_class.new(data_import: data_import) + DataImport.find(data_import.id).update!( + status: :abandoned, + abandoned_at: Time.current + ) + + expect(client).not_to receive(:list_contacts) + + importer.perform + + expect(data_import.reload).to be_abandoned + end + end + + describe '#import_conversations_page' do + it 'stops an in-flight page when a newer import run takes over', :aggregate_failures do + run_id = 'intercom-run-1' + data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => run_id }) + allow(client).to receive(:list_conversations).with(starting_after: nil).and_return( + 'conversations' => [{ 'id' => 'conversation_1' }, { 'id' => 'conversation_2' }], + 'pages' => { 'next' => { 'starting_after' => 'next-conversation-cursor' } } + ) + allow(client).to receive(:retrieve_conversation).with('conversation_1') do + data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' }) + conversation_payload + end + + result = described_class.new(data_import: data_import, run_id: run_id).import_conversations_page + + expect(result).to be_done + expect(client).not_to have_received(:retrieve_conversation).with('conversation_2') + expect(account.conversations.where(identifier: 'intercom:conversation_1')).to be_empty + expect(account.contacts.where(email: 'customer@example.com')).to be_empty + expect(data_import.reload.cursor.dig('conversations', 'starting_after')).to be_nil + end + + it 'rolls back a newly inserted conversation when mapping persistence fails', :aggregate_failures do + importer = described_class.new(data_import: data_import) + allow(importer).to receive(:record_mapping).and_wrap_original do |method, object_type, source_id, record, metadata:| + raise StandardError, 'mapping failed' if object_type == 'conversation' + + method.call(object_type, source_id, record, metadata: metadata) + end + + importer.import_conversations_page + + expect(account.conversations.where(identifier: 'intercom:conversation_1')).to be_empty + item = data_import.items.find_by!(source_object_type: 'conversation', source_object_id: 'conversation_1') + expect(item).to be_failed + expect(item.last_error_message).to eq('mapping failed') + end + + it 'rolls back a newly inserted contact when mapping persistence fails', :aggregate_failures do + sparse_contact = contact_payload.slice('id', 'name', 'created_at', 'updated_at') + allow(client).to receive(:retrieve_contact).with('contact_1').and_return(sparse_contact) + importer = described_class.new(data_import: data_import) + allow(importer).to receive(:record_mapping).and_wrap_original do |method, object_type, source_id, record, metadata:| + raise StandardError, 'mapping failed' if object_type == 'contact' + + method.call(object_type, source_id, record, metadata: metadata) + end + + importer.import_conversations_page + + expect(account.contacts.where(name: 'Customer One')).to be_empty + expect(data_import.mappings.where(source_object_type: 'contact', source_object_id: 'contact_1')).to be_empty + contact_item = data_import.items.find_by!(source_object_type: 'contact', source_object_id: 'contact_1') + expect(contact_item).to be_failed + expect(contact_item.last_error_message).to eq('mapping failed') + end + + it 'rolls back a newly inserted message when mapping persistence fails', :aggregate_failures do + importer = described_class.new(data_import: data_import) + allow(importer).to receive(:record_mapping).and_wrap_original do |method, object_type, source_id, record, metadata:| + raise StandardError, 'mapping failed' if object_type == 'message' + + method.call(object_type, source_id, record, metadata: metadata) + end + + importer.import_conversations_page + + conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1') + expect(conversation.messages.where(source_id: 'intercom:conversation:conversation_1:source:source_1')).to be_empty + error = data_import.import_errors.find_by!( + source_object_type: 'message', + source_object_id: 'conversation:conversation_1:source:source_1' + ) + expect(error).to have_attributes(error_code: 'StandardError', message: 'mapping failed') + end + end + + describe '#finish!' do + it 'does not overwrite an import abandoned by another process' do + data_import.update!(status: :processing) + importer = described_class.new(data_import: data_import) + + DataImport.find(data_import.id).update!( + status: :abandoned, + abandoned_at: Time.current + ) + + importer.finish! + + expect(data_import.reload).to be_abandoned + expect(data_import.completed_at).to be_nil + end + end + + describe '#fail!' do + it 'does not overwrite an import abandoned by another process', :aggregate_failures do + data_import.update!(status: :processing) + importer = described_class.new(data_import: data_import) + + DataImport.find(data_import.id).update!( + status: :abandoned, + abandoned_at: Time.current + ) + + importer.fail!(StandardError.new('boom')) + + expect(data_import.reload).to be_abandoned + expect(data_import.last_error_at).to be_nil + expect(data_import.import_errors.exists?).to be(false) + end + end + + context 'when the Intercom records were imported by an earlier run' do + let(:next_data_import) do + create( + :data_import, :intercom, + account: account + ) + end + + it 'records the already mapped records as skipped for the current import run', :aggregate_failures do + described_class.new(data_import: data_import).perform + + described_class.new(data_import: next_data_import).perform + + expect(next_data_import.reload.stats).to include( + 'contacts' => include('imported' => 0, 'skipped' => 1, 'total' => 1), + 'conversations' => include('imported' => 0, 'skipped' => 1, 'total' => 1), + 'messages' => include('imported' => 0, 'skipped' => 3, 'total' => 3), + 'errors' => { 'count' => 0 } + ) + expect(next_data_import).to be_completed + expect(next_data_import.total_records).to eq(5) + expect(next_data_import.processed_records).to eq(0) + expect(next_data_import.items.skipped.count).to eq(2) + expect(next_data_import.import_errors.skip_logs.group(:source_object_type).count).to eq( + 'contact' => 1, + 'conversation' => 1, + 'message' => 3 + ) + expect(next_data_import.import_errors.skip_logs.pluck(:details).map { |details| details['reason'] }.uniq).to eq(['already_imported']) + end + + it 'recreates messages when existing message mappings point to deleted records', :aggregate_failures do + described_class.new(data_import: data_import).perform + conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1') + Message.where(conversation_id: conversation.id).delete_all + + described_class.new(data_import: next_data_import).perform + + expect(conversation.reload.messages.pluck(:source_id)).to match_array( + %w[ + intercom:conversation:conversation_1:source:source_1 + intercom:conversation:conversation_1:part:part_1 + intercom:conversation:conversation_1:part:part_2 + ] + ) + expect(next_data_import.reload.stats).to include( + 'contacts' => include('imported' => 0, 'skipped' => 1, 'total' => 1), + 'conversations' => include('imported' => 0, 'skipped' => 1, 'total' => 1), + 'messages' => include('imported' => 3, 'skipped' => 0, 'total' => 3), + 'errors' => { 'count' => 0 } + ) + expect(next_data_import.import_errors.skip_logs.where(source_object_type: 'message')).to be_empty + message_mappings = DataImportMapping.where(account: account, source_provider: 'intercom', source_object_type: 'message') + expect(message_mappings.filter_map(&:chatwoot_record).count).to eq(3) + end + + it 'updates conversation activity when a later import adds new messages to the mapped conversation', :aggregate_failures do + new_part = { + 'id' => 'part_3', + 'part_type' => 'comment', + 'body' => '

Follow-up reply

', + 'created_at' => 1_700_000_300, + 'updated_at' => 1_700_000_300, + 'author' => { 'type' => 'admin', 'id' => 'admin_1' }, + 'attachments' => [] + } + updated_conversation_payload = conversation_payload.deep_dup + updated_conversation_payload['updated_at'] = 1_700_000_300 + updated_conversation_payload['conversation_parts']['conversation_parts'] << new_part + allow(client).to receive(:retrieve_conversation).with('conversation_1').and_return( + conversation_payload, + updated_conversation_payload + ) + + described_class.new(data_import: data_import).perform + conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1') + + described_class.new(data_import: next_data_import).perform + + expect(conversation.reload.last_activity_at).to eq(Time.zone.at(1_700_000_300)) + expect(conversation.messages.find_by!(source_id: 'intercom:conversation:conversation_1:part:part_3').content).to eq('Follow-up reply') + end + end + + context 'when a conversation references an already mapped contact' do + it 'reuses the mapped contact without hydrating the sparse reference' do + described_class.new(data_import: data_import).import_contacts_page + + expect(client).not_to receive(:retrieve_contact) + + described_class.new(data_import: data_import).import_conversations_page + end + end + + context 'when a same-run contact mapping outlives its item progress' do + let!(:mapped_contact) { create(:contact, account: account) } + + before do + DataImportMapping.create!( + account: account, + data_import: data_import, + source_provider: 'intercom', + source_object_type: 'contact', + source_object_id: 'contact_1', + chatwoot_record_type: 'Contact', + chatwoot_record_id: mapped_contact.id, + metadata: {} + ) + data_import.items.create!( + source_provider: 'intercom', + source_object_type: 'contact', + source_object_id: 'contact_1', + status: :processing, + metadata: contact_payload + ) + end + + it 'repairs the item and imported count on retry', :aggregate_failures do + described_class.new(data_import: data_import).import_contacts_page + + item = data_import.items.find_by!(source_object_type: 'contact', source_object_id: 'contact_1') + expect(item).to be_imported + expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: mapped_contact.id) + expect(data_import.reload.stats.dig('contacts', 'imported')).to eq(1) + end + end + + context 'when an existing contact has the same email but a different external id' do + let(:contact_payload) do + super().merge('last_replied_at' => 1_700_000_090) + end + let!(:existing_contact) { create(:contact, account: account, email: 'customer@example.com', identifier: nil) } + + it 'updates the existing contact instead of creating a duplicate', :aggregate_failures do + described_class.new(data_import: data_import).import_contacts_page + + expect(existing_contact.reload.identifier).to eq('external_1') + expect(existing_contact.last_activity_at).to eq(Time.zone.at(1_700_000_090)) + expect(account.contacts.where(email: 'customer@example.com').count).to eq(1) + item = data_import.items.imported.find_by!(source_object_type: 'contact', source_object_id: 'contact_1') + expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: existing_contact.id) + end + end + + context 'when an existing contact has the same phone but a different external id' do + let(:contact_payload) do + super().merge('email' => nil) + end + let!(:existing_contact) { create(:contact, account: account, phone_number: '+15551234567', identifier: nil) } + + it 'updates the existing contact instead of creating a duplicate', :aggregate_failures do + described_class.new(data_import: data_import).import_contacts_page + + expect(existing_contact.reload.identifier).to eq('external_1') + expect(account.contacts.where(phone_number: '+15551234567').count).to eq(1) + item = data_import.items.imported.find_by!(source_object_type: 'contact', source_object_id: 'contact_1') + expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: existing_contact.id) + end + end + + context 'when an existing contact has the same phone but Intercom sends a new email' do + let!(:existing_contact) { create(:contact, account: account, phone_number: '+15551234567', identifier: nil) } + + it 'falls through to the phone match after the email lookup misses', :aggregate_failures do + described_class.new(data_import: data_import).import_contacts_page + + expect(existing_contact.reload.email).to eq('customer@example.com') + expect(existing_contact.identifier).to eq('external_1') + expect(account.contacts.where(phone_number: '+15551234567').count).to eq(1) + item = data_import.items.imported.find_by!(source_object_type: 'contact', source_object_id: 'contact_1') + expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: existing_contact.id) + end + end + + context 'when an existing visitor contact matches the Intercom external id' do + let!(:existing_contact) { create(:contact, account: account, identifier: 'external_1') } + + it 'promotes the contact to a lead when adding email or phone', :aggregate_failures do + expect(existing_contact).to be_visitor + + described_class.new(data_import: data_import).import_contacts_page + + expect(existing_contact.reload).to be_lead + expect(existing_contact.email).to eq('customer@example.com') + expect(existing_contact.phone_number).to eq('+15551234567') + end + end + + context 'when an identifier match has contact details owned by another contact' do + let!(:existing_contact) { create(:contact, account: account, identifier: 'external_1') } + let!(:email_owner) { create(:contact, account: account, email: 'customer@example.com') } + let!(:phone_owner) { create(:contact, account: account, phone_number: '+15551234567') } + + it 'does not copy the conflicting email or phone number', :aggregate_failures do + described_class.new(data_import: data_import).import_contacts_page + + expect(existing_contact.reload.email).to be_nil + expect(existing_contact.phone_number).to be_nil + expect(existing_contact).to be_visitor + expect(email_owner.reload.email).to eq('customer@example.com') + expect(phone_owner.reload.phone_number).to eq('+15551234567') + expect(account.contacts.where(email: 'customer@example.com').count).to eq(1) + expect(account.contacts.where(phone_number: '+15551234567').count).to eq(1) + + item = data_import.items.imported.find_by!(source_object_type: 'contact', source_object_id: 'contact_1') + expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: existing_contact.id) + end + end + + context 'when Intercom rate limits a conversation detail request' do + before do + allow(client).to receive(:retrieve_conversation).with('conversation_1').and_raise( + DataImports::Intercom::Client::RateLimitError.new('rate limited', status: 429) + ) + end + + it 're-raises the provider error so the page job can retry', :aggregate_failures do + expect { described_class.new(data_import: data_import).import_conversations_page } + .to raise_error(DataImports::Intercom::Client::RateLimitError) + + item = data_import.items.find_by!(source_object_type: 'conversation', source_object_id: 'conversation_1') + expect(item).to be_processing + expect(data_import.import_errors.exists?).to be(false) + end + end + + context 'when Intercom rate limits a contact hydration request' do + before do + allow(client).to receive(:retrieve_contact).with('contact_1').and_raise( + DataImports::Intercom::Client::RateLimitError.new('rate limited', status: 429) + ) + end + + it 're-raises the provider error instead of importing a sparse contact', :aggregate_failures do + expect { described_class.new(data_import: data_import).import_conversations_page } + .to raise_error(DataImports::Intercom::Client::RateLimitError) + + expect(data_import.items.exists?(source_object_type: 'contact')).to be(false) + expect(data_import.import_errors.exists?).to be(false) + end + end + + context 'when Intercom no longer has a sparse contact referenced by a conversation' do + before do + allow(client).to receive(:retrieve_contact).with('contact_1').and_raise( + DataImports::Intercom::Client::Error.new('not found', status: 404) + ) + end + + it 'falls back to the conversation contact reference', :aggregate_failures do + expect { described_class.new(data_import: data_import).import_conversations_page }.not_to raise_error + + expect(data_import.items.imported.exists?(source_object_type: 'contact', source_object_id: 'contact_1')).to be(true) + expect(data_import.import_errors.exists?).to be(false) + end + end + + context 'when the Intercom source message only has attachments' do + let(:conversation_payload) do + super().deep_merge( + 'source' => { + 'subject' => nil, + 'body' => nil, + 'attachments' => [{ 'name' => 'invoice.pdf', 'url' => 'https://example.com/invoice.pdf' }] + }, + 'conversation_parts' => { + 'conversation_parts' => [] + } + ) + end + + it 'imports the source message attachment placeholder', :aggregate_failures do + described_class.new(data_import: data_import).perform + + conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1') + expect(conversation.messages.pluck(:content)).to eq(['[Intercom attachment skipped: 1]']) + expect(conversation.messages.first.additional_attributes.dig('source', 'attachments')).to eq( + [{ 'name' => 'invoice.pdf', 'url' => 'https://example.com/invoice.pdf' }] + ) + expect(data_import.reload.stats.dig('messages', 'imported')).to eq(1) + end + end + + context 'when the Intercom source message has text and attachments' do + let(:conversation_payload) do + super().deep_merge( + 'source' => { + 'attachments' => [{ 'name' => 'invoice.pdf', 'url' => 'https://example.com/invoice.pdf' }] + } + ) + end + + it 'adds an attachment omission marker to the imported message', :aggregate_failures do + described_class.new(data_import: data_import).perform + + message = account.messages.find_by!(source_id: 'intercom:conversation:conversation_1:source:source_1') + expect(message.content).to eq("Need help\n\nHello there\n\n[Intercom attachment skipped: 1]") + expect(message.additional_attributes.dig('source', 'attachments')).to eq( + [{ 'name' => 'invoice.pdf', 'url' => 'https://example.com/invoice.pdf' }] + ) + expect(data_import.reload.stats.dig('messages', 'skipped')).to eq(0) + end + end + + context 'when Intercom omits the conversation source' do + let(:conversation_payload) do + super().merge( + 'source' => nil, + 'first_contact_reply' => { + 'type' => 'whatsapp', + 'created_at' => 1_700_000_000, + 'url' => nil + } + ) + end + + it 'routes the conversation from the first contact reply type', :aggregate_failures do + described_class.new(data_import: data_import).perform + + inbox = account.inboxes.find_by!(name: 'Intercom Import - WhatsApp') + conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1') + + expect(conversation.inbox).to eq(inbox) + expect(conversation.additional_attributes.dig('source', 'source_type')).to eq('whatsapp') + end + end + + context 'when an Intercom chat message part cannot be imported' do + let(:conversation_payload) do + super().deep_merge( + 'conversation_parts' => { + 'conversation_parts' => [ + { + 'id' => 'blank_part', + 'part_type' => 'comment', + 'body' => nil, + 'created_at' => 1_700_000_175, + 'updated_at' => 1_700_000_175, + 'author' => { 'type' => 'admin', 'id' => 'admin_1' }, + 'attachments' => [] + } + ] + } + ) + end + + it 'records a skip log with the Intercom message source id', :aggregate_failures do + described_class.new(data_import: data_import).perform + + skip_log = data_import.import_errors.skip_logs.find_by!(source_object_type: 'message') + expect(skip_log).to have_attributes( + source_object_id: 'conversation:conversation_1:part:blank_part', + error_code: 'DataImports::Intercom::SkippedMessage', + message: 'Skipped Intercom comment event blank_part: no message body or attachments to import.' + ) + expect(skip_log.details).to include( + 'kind' => 'skipped', + 'reason' => 'blank_or_unsupported_intercom_part', + 'reason_details' => 'no message body or attachments to import', + 'event_name' => 'comment', + 'event_type' => 'comment', + 'author_type' => 'admin' + ) + expect(data_import.reload.stats.dig('messages', 'skipped')).to eq(1) + end + + it 'records the skip log again for a later import run', :aggregate_failures do + described_class.new(data_import: data_import).perform + next_data_import = create( + :data_import, :intercom, + account: account + ) + + described_class.new(data_import: next_data_import).perform + + skip_log = next_data_import.import_errors.skip_logs.find_by!( + source_object_type: 'message', + source_object_id: 'conversation:conversation_1:part:blank_part', + error_code: 'DataImports::Intercom::SkippedMessage' + ) + expect(skip_log).to have_attributes( + source_object_id: 'conversation:conversation_1:part:blank_part', + error_code: 'DataImports::Intercom::SkippedMessage' + ) + expect(next_data_import.reload.stats.dig('messages', 'skipped')).to eq(2) + end + + it 'reconciles a same-run skipped mapping and missing skip log on retry', :aggregate_failures do + described_class.new(data_import: data_import).import_conversations_page + data_import.import_errors.where(source_object_type: 'message').delete_all + stats = data_import.reload.stats.deep_dup + stats['messages']['skipped'] = 0 + data_import.update!(stats: stats) + + described_class.new(data_import: data_import).import_conversations_page + + expect(data_import.reload.stats.dig('messages', 'skipped')).to eq(1) + expect(data_import.import_errors.skip_logs.exists?(source_object_id: 'conversation:conversation_1:part:blank_part')).to be(true) + end + + it 'repairs a previously skipped mapping when the part is now an activity', :aggregate_failures do + described_class.new(data_import: data_import).perform + previous_skip_log = data_import.import_errors.skip_logs.find_by!(source_object_id: 'conversation:conversation_1:part:blank_part') + conversation_payload.dig('conversation_parts', 'conversation_parts').first.merge!( + 'part_type' => 'assignment', + 'assigned_to' => { 'name' => 'Support' } + ) + next_data_import = create(:data_import, :intercom, account: account) + + described_class.new(data_import: next_data_import).perform + + activity = account.messages.find_by!(source_id: 'intercom:conversation:conversation_1:part:blank_part') + mapping = DataImportMapping.find_by!( + account: account, + source_provider: 'intercom', + source_object_type: 'message', + source_object_id: 'conversation:conversation_1:part:blank_part' + ) + expect(activity).to be_activity + expect(activity.content).to eq('Intercom teammate assigned the conversation to Support') + expect(mapping.chatwoot_record).to eq(activity) + expect(data_import.import_errors.skip_logs).to include(previous_skip_log) + expect(next_data_import.import_errors.skip_logs.where(source_object_id: mapping.source_object_id)).to be_empty + end + end + + context 'when Intercom returns bodyless lifecycle events' do + let(:conversation_payload) do + super().deep_merge( + 'conversation_parts' => { + 'total_count' => 1, + 'conversation_parts' => [ + { + 'id' => 'assignment_part', + 'part_type' => 'assignment', + 'body' => nil, + 'created_at' => 1_700_000_175, + 'author' => { 'type' => 'admin', 'name' => 'Avery' }, + 'assigned_to' => { 'type' => 'team', 'name' => 'Support' }, + 'state' => 'open', + 'tags' => { 'tags' => [{ 'name' => 'priority' }] }, + 'event_details' => { 'source' => 'workflow' }, + 'app_package_code' => 'workflow' + } + ] + } + ) + end + + it 'imports events as public activity messages with source metadata', :aggregate_failures do + described_class.new(data_import: data_import).perform + + activity = account.messages.find_by!(source_id: 'intercom:conversation:conversation_1:part:assignment_part') + expect(activity).to have_attributes( + message_type: 'activity', + content: 'Avery assigned the conversation to Support', + private: false, + sender: nil, + created_at: Time.zone.at(1_700_000_175) + ) + expect(activity.additional_attributes['source']).to include( + 'part_type' => 'assignment', + 'assigned_to' => include('name' => 'Support'), + 'state' => 'open', + 'event_details' => include('source' => 'workflow'), + 'app_package_code' => 'workflow' + ) + expect(data_import.reload.stats['messages']).to include('imported' => 2, 'skipped' => 0, 'total' => 2) + expect(data_import.import_errors.skip_logs).to be_empty + end + end + + context 'when Intercom omits older conversation parts from the retrieved conversation' do + let(:conversation_payload) do + super().deep_merge( + 'conversation_parts' => { + 'total_count' => 503 + }, + 'statistics' => { + 'count_conversation_parts' => 503 + } + ) + end + + it 'records an incomplete import error and completes with errors', :aggregate_failures do + described_class.new(data_import: data_import).perform + + error = data_import.import_errors.non_skip_logs.find_by!( + source_object_type: 'conversation', + source_object_id: 'conversation_1', + error_code: 'DataImports::Intercom::TruncatedConversationParts' + ) + expect(error.message).to eq('Intercom returned 2 of 503 conversation parts.') + expect(error.details).to include( + 'kind' => 'incomplete', + 'imported_parts_count' => 2, + 'total_parts_count' => 503 + ) + expect(data_import.reload).to be_completed_with_errors + expect(data_import.stats.dig('errors', 'count')).to eq(1) + end + end + + context 'when the conversation parts total matches the returned parts' do + let(:conversation_payload) do + super().deep_merge( + 'conversation_parts' => { + 'total_count' => 2 + }, + 'statistics' => { + 'count_conversation_parts' => 2 + } + ) + end + + it 'does not record a truncated parts error', :aggregate_failures do + described_class.new(data_import: data_import).perform + + expect(data_import.import_errors.non_skip_logs).to be_empty + expect(data_import.reload).to be_completed + expect(data_import.stats.dig('errors', 'count')).to eq(0) + end + end + + context 'when Intercom statistics count is higher than the conversation parts total' do + let(:conversation_payload) do + super().deep_merge( + 'source' => {}, + 'conversation_parts' => { + 'total_count' => 2 + }, + 'statistics' => { + 'count_conversation_parts' => 3 + } + ) + end + + it 'trusts the returned conversation parts total over the statistics counter', :aggregate_failures do + described_class.new(data_import: data_import).perform + + expect(data_import.import_errors.non_skip_logs).to be_empty + expect(data_import.reload).to be_completed + expect(data_import.stats.dig('errors', 'count')).to eq(0) + end + end + + context 'when a specific Intercom message part fails to persist' do + let(:conversation_payload) do + super().deep_merge( + 'conversation_parts' => { + 'conversation_parts' => [ + { + 'id' => 'bad_part', + 'part_type' => 'comment', + 'body' => '

Message that cannot be stored

', + 'created_at' => 1_700_000_175, + 'updated_at' => 1_700_000_175, + 'author' => { 'type' => 'admin', 'id' => 'admin_1' }, + 'attachments' => [] + } + ] + } + ) + end + + before do + allow(Message).to receive(:insert_all!).and_wrap_original do |method, records, **kwargs| + raise ActiveRecord::StatementInvalid, 'bad message' if records.first[:source_id] == 'intercom:conversation:conversation_1:part:bad_part' + + method.call(records, **kwargs) + end + end + + it 'records a skip log with the Intercom message part id', :aggregate_failures do + described_class.new(data_import: data_import).perform + + skip_log = data_import.import_errors.skip_logs.find_by!(source_object_type: 'message') + expect(skip_log).to have_attributes( + source_object_id: 'conversation:conversation_1:part:bad_part', + error_code: 'ActiveRecord::StatementInvalid', + message: 'bad message' + ) + expect(skip_log.details).to include( + 'kind' => 'failed', + 'conversation_id' => 'intercom:conversation_1' + ) + expect(data_import.reload).to be_completed_with_errors + expect(data_import.stats.dig('errors', 'count')).to eq(1) + end + end +end diff --git a/spec/services/data_imports/intercom/placeholder_inbox_builder_spec.rb b/spec/services/data_imports/intercom/placeholder_inbox_builder_spec.rb new file mode 100644 index 000000000..c1a7baab5 --- /dev/null +++ b/spec/services/data_imports/intercom/placeholder_inbox_builder_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +RSpec.describe DataImports::Intercom::PlaceholderInboxBuilder do + let(:account) { create(:account) } + + describe '#inbox_for' do + it 'creates a source-bucket API inbox for an Intercom conversation source' do + inbox = described_class.new(account: account).inbox_for('email') + + expect(inbox.name).to eq('Intercom Import - Email') + expect(inbox.channel).to be_a(Channel::Api) + expect(inbox.enable_auto_assignment).to be(false) + expect(inbox.allow_messages_after_resolved).to be(false) + expect(inbox.channel.additional_attributes).to include( + 'source_provider' => 'intercom', + 'source_bucket' => 'email', + 'import_placeholder' => true, + 'agent_reply_time_window' => 1 + ) + end + + it 'reuses an existing placeholder inbox for the same source bucket' do + builder = described_class.new(account: account) + + first_inbox = builder.inbox_for('phone_call') + expect(account).not_to receive(:inboxes) + second_inbox = builder.inbox_for('phone_switch') + + expect(second_inbox).to eq(first_inbox) + expect(Inbox.where(account: account, channel_type: 'Channel::Api').count).to eq(1) + end + end +end diff --git a/spec/services/data_imports/intercom/restart_service_spec.rb b/spec/services/data_imports/intercom/restart_service_spec.rb new file mode 100644 index 000000000..5dbc8f5ab --- /dev/null +++ b/spec/services/data_imports/intercom/restart_service_spec.rb @@ -0,0 +1,64 @@ +require 'rails_helper' + +RSpec.describe DataImports::Intercom::RestartService do + let(:account) { create(:account) } + let(:data_import) { create(:data_import, :intercom, account: account, status: :abandoned, abandoned_at: 1.hour.ago) } + + it 'prepares a failed or abandoned import for another run', :aggregate_failures do + data_import.update!( + stats: { + 'contacts' => { 'imported' => 1, 'skipped' => 9, 'total' => 10 }, + 'conversations' => { 'imported' => 2, 'skipped' => 8, 'total' => 10 }, + 'messages' => { 'imported' => 3, 'skipped' => 7, 'total' => 10 }, + 'errors' => { 'count' => 6 } + } + ) + data_import.import_errors.create!(error_code: 'StandardError', message: 'old run error') + data_import.import_errors.create!( + error_code: 'ContactFailed', + message: 'old contact error', + source_object_type: 'contact', + details: { kind: 'failed' } + ) + retained_skip_log = data_import.import_errors.create!( + error_code: DataImports::Intercom::Importer::ALREADY_IMPORTED_ERROR_CODE, + message: 'old skip log', + source_object_type: 'contact', + details: { kind: 'skipped' } + ) + previous_run_id = data_import.assign_active_intercom_import_run_id + data_import.save! + service = described_class.new(account: account, data_import: data_import) + + expect(service.perform).to eq(:enqueue) + expect(service.data_import).to be_pending + expect(service.data_import.abandoned_at).to be_nil + expect(service.data_import.started_at).to be_nil + expect(service.data_import.active_intercom_import_run_id).not_to eq(previous_run_id) + expect(service.data_import.import_errors).to contain_exactly(retained_skip_log) + expect(service.data_import.stats).to eq( + 'contacts' => { 'imported' => 1, 'skipped' => 1, 'total' => 10 }, + 'conversations' => { 'imported' => 2, 'skipped' => 0, 'total' => 10 }, + 'messages' => { 'imported' => 3, 'skipped' => 0, 'total' => 10 }, + 'errors' => { 'count' => 0 } + ) + end + + it 'returns the active import instead of restarting another import', :aggregate_failures do + active_import = create(:data_import, :intercom, account: account, status: :processing) + service = described_class.new(account: account, data_import: data_import) + + expect(service.perform).to eq(:render_show) + expect(service.data_import).to eq(active_import) + expect(data_import.reload).to be_abandoned + end + + it 'does not restart when the stored access token is missing' do + data_import.update!(access_token: nil) + + result = described_class.new(account: account, data_import: data_import).perform + + expect(result).to eq(:access_token_missing) + expect(data_import.reload).to be_abandoned + end +end diff --git a/spec/services/data_imports/intercom/source_bucket_spec.rb b/spec/services/data_imports/intercom/source_bucket_spec.rb new file mode 100644 index 000000000..b3db4294c --- /dev/null +++ b/spec/services/data_imports/intercom/source_bucket_spec.rb @@ -0,0 +1,17 @@ +require 'rails_helper' + +RSpec.describe DataImports::Intercom::SourceBucket do + describe '.for' do + it 'maps Intercom source types to Chatwoot inbox buckets' do + expect(described_class.for('email')).to eq({ key: 'email', name: 'Email' }) + expect(described_class.for('phone_switch')).to eq({ key: 'phone', name: 'Phone' }) + expect(described_class.for('inapp')).to eq({ key: 'messenger', name: 'Messenger' }) + expect(described_class.for('messenger')).to eq({ key: 'messenger', name: 'Messenger' }) + expect(described_class.for('push')).to eq({ key: 'messenger', name: 'Messenger' }) + end + + it 'uses an unknown bucket for unsupported source types' do + expect(described_class.for('unsupported_source')).to eq({ key: 'unknown', name: 'Unknown' }) + end + end +end From 08260f3be7069601b188f6655fd174661e9562d7 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 13 Jul 2026 16:17:00 +0400 Subject: [PATCH 04/19] feat: show crawled document details and FAQ counts in Captain (#14863) - Add a document details view that surfaces crawled content, source metadata, and generated FAQ counts. - Rename the document card action to open details and show the FAQ count inline in the list. - Return `responses_count` from the documents API efficiently and expose document content in the show payload. - Update related Captain copy to reflect the new details-oriented flow. **Preview** CleanShot 2026-06-26 at 09 25
15@2x --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Sony Mathew Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> --- .../captain/assistant/DocumentCard.vue | 30 +- .../document/DocumentDetails.spec.js | 86 ++++ .../document/DocumentDetails.vue | 374 ++++++++++++++++++ .../document/RelatedResponses.vue | 71 ---- .../i18n/locale/en/integrations.json | 25 +- .../dashboard/captain/documents/Index.vue | 29 +- .../shared/helpers/MessageFormatter.js | 5 + .../helpers/specs/MessageFormatter.spec.js | 19 + .../accounts/captain/documents_controller.rb | 27 +- .../v1/models/captain/_document.json.jbuilder | 2 + .../captain/documents_controller_spec.rb | 16 + 11 files changed, 582 insertions(+), 102 deletions(-) create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/document/DocumentDetails.spec.js create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/document/DocumentDetails.vue delete mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue diff --git a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue index 9d6d574ec..8ff38b2eb 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue @@ -66,6 +66,10 @@ const props = defineProps({ type: Number, default: null, }, + responsesCount: { + type: Number, + default: 0, + }, isSelected: { type: Boolean, default: false, @@ -112,10 +116,10 @@ const showSyncStatus = computed(() => !isPdf.value); const menuItems = computed(() => { const allOptions = [ { - label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'), - value: 'viewRelatedQuestions', - action: 'viewRelatedQuestions', - icon: 'i-ph-tree-view-duotone', + label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_DETAILS'), + value: 'viewDetails', + action: 'viewDetails', + icon: 'i-lucide-eye', }, ]; @@ -143,6 +147,9 @@ const menuItems = computed(() => { }); const createdAtLabel = computed(() => dynamicTime(props.createdAt)); +const responsesCountLabel = computed(() => + t('CAPTAIN.DOCUMENTS.FAQ_COUNT', { n: props.responsesCount }) +); const displayLink = computed(() => isPdf.value @@ -158,6 +165,10 @@ const handleAction = ({ action, value }) => { emit('action', { action, value, id: props.id }); }; +const handleViewDetails = () => { + emit('action', { action: 'viewDetails', id: props.id }); +}; + const handleRetry = () => { emit('action', { action: 'sync', id: props.id }); }; @@ -177,9 +188,13 @@ const handleRetry = () => {
- +
{ {{ displayLink }} + + {{ responsesCountLabel }} + ({ + dispatch: vi.fn(), + getterValues: { + 'captainResponses/getUIFlags': { value: { fetchingList: false } }, + 'captainResponses/getRecords': { value: [] }, + 'captainResponses/getMeta': { value: { totalCount: 26, page: 1 } }, + }, +})); + +vi.mock('dashboard/composables/store', () => ({ + useStore: () => ({ dispatch }), + useMapGetter: key => getterValues[key], +})); + +vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() })); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: key => key }), +})); + +const captainDocument = { + id: 42, + name: 'FAQ source', + external_link: 'https://example.com/docs', + assistant: { id: 7 }, + content: 'Document content', + pdf_document: false, +}; + +const DialogStub = { + name: 'Dialog', + template: '
', +}; + +const TabBarStub = { + name: 'TabBar', + template: + '
- ''; + } + get formattedMessage() { return this.formatMessage(); } diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js index 3350399eb..20d64005a 100644 --- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js +++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js @@ -68,6 +68,25 @@ describe('#MessageFormatter', () => { }); }); + describe('#disableImageRendering', () => { + it('omits nested and reference images with relative URLs', () => { + const message = `Before ![nested [alt]](/relative.png) + +![reference][logo] + +[logo]: /logo.png + +After`; + const formatter = new MessageFormatter(message); + + formatter.disableImageRendering(); + + expect(formatter.formattedMessage).not.toContain(' { it('should return the same string if not tags or @mentions', () => { const message = 'Chatwoot is an opensource tool'; diff --git a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb index 273c082b1..d88cc6b48 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb @@ -9,16 +9,10 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC RESULTS_PER_PAGE = 25 def index - base_query = @documents - base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present? - base_query = apply_source_filter(base_query, permitted_params[:source]) - base_query = apply_filter(base_query, permitted_params[:filter]) - base_query = apply_search(base_query, permitted_params[:search_key]) - base_query = apply_sort(base_query, permitted_params[:sort]) - - @documents_count = base_query.count + @documents = filtered_documents + @documents_count = @documents.count @sync_interval_hours = current_sync_interval&.in_hours&.to_i - @documents = base_query.page(@current_page).per(RESULTS_PER_PAGE) + @documents = with_responses_count(@documents).page(@current_page).per(RESULTS_PER_PAGE) end def show; end @@ -59,6 +53,21 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC @documents = Current.account.captain_documents.with_attached_pdf_file.includes(:assistant) end + def filtered_documents + documents = @documents + documents = documents.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present? + documents = apply_source_filter(documents, permitted_params[:source]) + documents = apply_filter(documents, permitted_params[:filter]) + documents = apply_search(documents, permitted_params[:search_key]) + apply_sort(documents, permitted_params[:sort]) + end + + def with_responses_count(scope) + scope.left_joins(:responses) + .select('captain_documents.*, COUNT(captain_assistant_responses.id) AS responses_count') + .group('captain_documents.id') + end + def set_document @document = @documents.find(permitted_params[:id]) end diff --git a/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder index 56260f675..0ab031dbf 100644 --- a/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder +++ b/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder @@ -9,6 +9,8 @@ json.external_link resource.external_link json.display_url resource.display_url json.file_size resource.file_size json.pdf_document resource.pdf_document? +responses_count = resource.respond_to?(:responses_count) ? resource.responses_count : resource.responses.count +json.responses_count responses_count.to_i json.id resource.id json.name resource.name json.status resource.status diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb index 77cb25f49..4d4b10fcb 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb @@ -51,6 +51,18 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do expect(json_response[:payload].length).to eq(5) expect(json_response[:meta]).to eq({ page: 2, total_count: 30 }) end + + it 'returns the generated FAQ count for each document' do + document = create(:captain_document, assistant: assistant, account: account) + create_list(:captain_assistant_response, 2, + assistant: assistant, account: account, documentable: document) + + get "/api/v1/accounts/#{account.id}/captain/documents", + headers: agent.create_new_auth_token, as: :json + + matching_document = json_response[:payload].find { |item| item[:id] == document.id } + expect(matching_document[:responses_count]).to eq(2) + end end context 'when filtering by assistant_id' do @@ -142,6 +154,10 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do expect(json_response[:external_link]).to eq(document.external_link) end + it 'returns the crawled content for the document' do + expect(json_response[:content]).to eq(document.content) + end + it 'returns sync metadata when the document has been synced' do synced_at = 1.hour.ago document.update!(sync_status: :synced, last_synced_at: synced_at) From 056b5eb89d41760a055662c2e893f9a6d446206b Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Mon, 13 Jul 2026 18:15:25 +0530 Subject: [PATCH 05/19] fix: avoid full scan in IMAP email dedup on large inboxes (#14981) ## Description `Imap::BaseFetchEmailService#email_already_present?` used `find_by(source_id:)`, which inherits `Message`'s `default_scope { order(created_at: :asc) }`, adding an `ORDER BY created_at ASC LIMIT 1` to what is only a presence check. On inboxes with a large message history, that `ORDER BY` lets Postgres satisfy the sort by walking `index_messages_on_created_at` instead of the selective `index_messages_on_source_id`. For a not-yet-seen `source_id` (every new email) it can scan the whole table before returning, taking seconds per message. The dedup loop runs with no IMAP activity in between, so the idle socket is dropped by the mail server and the fetch job aborts with `closed stream`. The inbox then stops ingesting mail entirely, while smaller inboxes on the same server keep working. `exists?` issues `SELECT 1 ... LIMIT 1` with no `ORDER BY`, so the planner uses `index_messages_on_source_id` regardless of table size. No schema change is required. The fix lives in the shared base class, so it covers both the IMAP and Microsoft fetch paths. Fixes #14682 --- app/services/imap/base_fetch_email_service.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/services/imap/base_fetch_email_service.rb b/app/services/imap/base_fetch_email_service.rb index e55355f3a..9c5b8e27b 100644 --- a/app/services/imap/base_fetch_email_service.rb +++ b/app/services/imap/base_fetch_email_service.rb @@ -38,7 +38,8 @@ class Imap::BaseFetchEmailService end def email_already_present?(channel, message_id) - channel.inbox.messages.find_by(source_id: message_id).present? || deleted_message_tracker.deleted?(message_id) + # exists? avoids Message's default_scope ORDER BY, which full-scans large inboxes + channel.inbox.messages.exists?(source_id: message_id) || deleted_message_tracker.deleted?(message_id) end def deleted_message_tracker From df7f1376570474f1a339faeb7a27a16f7f09d1e2 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 13 Jul 2026 18:18:20 +0530 Subject: [PATCH 06/19] feat: add captain sessions model [CW-7485] (#14970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a `captain_sessions` table to log every Captain run, starting with Assistant Responses and Copilot Responses. Each session records the assistant, model, credits consumed, the FAQs/documents/scenario that contributed to the response, and the full run context — giving customers visibility into how a response was generated and giving us durable stats on credit, FAQ, and document usage (which today only exist as ephemeral trace metadata and an aggregate account counter). ## What changed - New `Captain::Session` model with a `session_type` enum (`assistant`, `copilot`). The subject (`Conversation` / `CopilotThread`) and result (`Message` / `CopilotMessage`) classes are inferred from the session type, so the table stores plain `subject_id` / `result_id` ids. `result_id` is nullable so failed runs that still consumed credits can be logged. - Composite indexes on `[session_type, subject_id]`, `[session_type, result_id]`, and `[account_id, session_type, created_at]` for lookup and usage-stats queries. - Factory and model specs. This PR is schema + model only; the writer/instrumentation that records sessions from the assistant and copilot flows will follow. --------- Co-authored-by: Sony Mathew --- .../20260709091147_create_agent_sessions.rb | 24 +++ db/schema.rb | 25 +++ .../app/models/captain/agent_session.rb | 86 ++++++++++ enterprise/app/models/captain/assistant.rb | 1 + .../app/models/enterprise/concerns/account.rb | 1 + .../models/captain/agent_session_spec.rb | 160 ++++++++++++++++++ spec/factories/captain/agent_session.rb | 14 ++ 7 files changed, 311 insertions(+) create mode 100644 db/migrate/20260709091147_create_agent_sessions.rb create mode 100644 enterprise/app/models/captain/agent_session.rb create mode 100644 spec/enterprise/models/captain/agent_session_spec.rb create mode 100644 spec/factories/captain/agent_session.rb diff --git a/db/migrate/20260709091147_create_agent_sessions.rb b/db/migrate/20260709091147_create_agent_sessions.rb new file mode 100644 index 000000000..a2e3e9f0f --- /dev/null +++ b/db/migrate/20260709091147_create_agent_sessions.rb @@ -0,0 +1,24 @@ +class CreateAgentSessions < ActiveRecord::Migration[7.1] + def change + create_table :agent_sessions do |t| + t.integer :session_type, null: false + t.references :subject, polymorphic: true, null: false, index: false + t.references :result, polymorphic: true, index: false + t.references :account, null: false, index: true + t.references :assistant, null: false, index: true + t.references :user, index: true + t.string :llm_model + t.float :credits_consumed + t.jsonb :faq_ids, default: [] + t.jsonb :document_ids, default: [] + t.jsonb :scenario_ids, default: [] + t.jsonb :run_context, default: {} + + t.timestamps + end + + add_index :agent_sessions, [:account_id, :session_type, :created_at] + add_index :agent_sessions, [:account_id, :subject_type, :subject_id] + add_index :agent_sessions, [:account_id, :result_type, :result_id] + end +end diff --git a/db/schema.rb b/db/schema.rb index e01dc34c1..f02b613d9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -146,6 +146,31 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do t.index ["account_id"], name: "index_agent_capacity_policies_on_account_id" end + create_table "agent_sessions", force: :cascade do |t| + t.integer "session_type", null: false + t.string "subject_type", null: false + t.bigint "subject_id", null: false + t.string "result_type" + t.bigint "result_id" + t.bigint "account_id", null: false + t.bigint "assistant_id", null: false + t.bigint "user_id" + t.string "llm_model" + t.float "credits_consumed" + t.jsonb "faq_ids", default: [] + t.jsonb "document_ids", default: [] + t.jsonb "scenario_ids", default: [] + t.jsonb "run_context", default: {} + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "result_type", "result_id"], name: "idx_on_account_id_result_type_result_id_ca66c00cd7" + t.index ["account_id", "session_type", "created_at"], name: "idx_on_account_id_session_type_created_at_c20a14bd4e" + t.index ["account_id", "subject_type", "subject_id"], name: "idx_on_account_id_subject_type_subject_id_6d60963b3d" + t.index ["account_id"], name: "index_agent_sessions_on_account_id" + t.index ["assistant_id"], name: "index_agent_sessions_on_assistant_id" + t.index ["user_id"], name: "index_agent_sessions_on_user_id" + end + create_table "applied_slas", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "sla_policy_id", null: false diff --git a/enterprise/app/models/captain/agent_session.rb b/enterprise/app/models/captain/agent_session.rb new file mode 100644 index 000000000..d02dffcab --- /dev/null +++ b/enterprise/app/models/captain/agent_session.rb @@ -0,0 +1,86 @@ +# == Schema Information +# +# Table name: agent_sessions +# +# id :bigint not null, primary key +# credits_consumed :float +# document_ids :jsonb +# faq_ids :jsonb +# llm_model :string +# result_type :string +# run_context :jsonb +# scenario_ids :jsonb +# session_type :integer not null +# subject_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# assistant_id :bigint not null +# result_id :bigint +# subject_id :bigint not null +# user_id :bigint +# +# Indexes +# +# idx_on_account_id_result_type_result_id_ca66c00cd7 (account_id,result_type,result_id) +# idx_on_account_id_session_type_created_at_c20a14bd4e (account_id,session_type,created_at) +# idx_on_account_id_subject_type_subject_id_6d60963b3d (account_id,subject_type,subject_id) +# index_agent_sessions_on_account_id (account_id) +# index_agent_sessions_on_assistant_id (assistant_id) +# index_agent_sessions_on_user_id (user_id) +# +class Captain::AgentSession < ApplicationRecord + self.table_name = 'agent_sessions' + + SUBJECT_TYPES = { 'assistant' => 'Conversation', 'copilot' => 'CopilotThread' }.freeze + RESULT_TYPES = { 'assistant' => 'Message', 'copilot' => 'CopilotMessage' }.freeze + + belongs_to :account + belongs_to :assistant, class_name: 'Captain::Assistant' + belongs_to :user, optional: true + belongs_to :subject, ->(session) { where(account_id: session.account_id) }, polymorphic: true + belongs_to :result, ->(session) { where(account_id: session.account_id) }, polymorphic: true, optional: true + + enum :session_type, { assistant: 0, copilot: 1 }, prefix: :session + + before_validation :ensure_account + + validate :subject_type_matches_session_type + validate :result_type_matches_session_type, if: -> { result_type.present? } + validate :subject_belongs_to_account + validate :result_belongs_to_account, if: -> { result_id.present? } + + private + + def ensure_account + self.account = assistant&.account + end + + def subject_type_matches_session_type + expected_type = SUBJECT_TYPES[session_type] + return if subject_type == expected_type + + errors.add(:subject_type, "must be #{expected_type} for #{session_type} sessions") + end + + def result_type_matches_session_type + expected_type = RESULT_TYPES[session_type] + return if result_type == expected_type + + errors.add(:result_type, "must be #{expected_type} for #{session_type} sessions") + end + + def subject_belongs_to_account + return if subject.nil? || subject.account_id == account_id + + errors.add(:subject, 'must belong to the session account') + end + + def result_belongs_to_account + target_class = result_type.safe_constantize + actual_account_id = target_class && target_class.unscoped.where(id: result_id).pick(:account_id) + return if actual_account_id == account_id + + errors.add(:result, 'must belong to the session account') + end +end diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index d3f6cda8a..b3134e2f2 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -37,6 +37,7 @@ class Captain::Assistant < ApplicationRecord has_many :messages, as: :sender, dependent: :nullify has_many :copilot_threads, dependent: :destroy_async has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async + has_many :agent_sessions, class_name: 'Captain::AgentSession', dependent: :destroy_async store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index 1ef112fb5..1f5376940 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -13,6 +13,7 @@ module Enterprise::Concerns::Account has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse' has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document' has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool' + has_many :captain_agent_sessions, dependent: :destroy_async, class_name: 'Captain::AgentSession' has_many :copilot_threads, dependent: :destroy_async has_many :companies, dependent: :destroy_async diff --git a/spec/enterprise/models/captain/agent_session_spec.rb b/spec/enterprise/models/captain/agent_session_spec.rb new file mode 100644 index 000000000..b4306a11e --- /dev/null +++ b/spec/enterprise/models/captain/agent_session_spec.rb @@ -0,0 +1,160 @@ +require 'rails_helper' + +RSpec.describe Captain::AgentSession, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + + describe 'associations' do + it { is_expected.to belong_to(:account) } + it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') } + it { is_expected.to belong_to(:user).optional } + it { is_expected.to belong_to(:subject) } + it { is_expected.to belong_to(:result).optional } + end + + describe 'enums' do + it { is_expected.to define_enum_for(:session_type).with_values(assistant: 0, copilot: 1).with_prefix(:session) } + end + + describe '#subject' do + it 'returns the conversation for an assistant session' do + conversation = create(:conversation, account: account) + session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation) + + expect(session.subject).to eq(conversation) + end + + it 'returns the copilot thread for a copilot session' do + user = create(:user, account: account) + copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant) + session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user, subject: copilot_thread) + + expect(session.subject).to eq(copilot_thread) + end + + it 'returns nil when the subject record no longer exists' do + conversation = create(:conversation, account: account) + session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation) + conversation.destroy + + expect(session.reload.subject).to be_nil + end + + it 'is not valid when the subject type does not match the session type' do + copilot_thread = create(:captain_copilot_thread, account: account, user: create(:user, account: account), assistant: assistant) + session = build(:captain_agent_session, account: account, assistant: assistant, subject: copilot_thread) + + expect(session).not_to be_valid + expect(session.errors[:subject_type]).to be_present + end + + it 'is not valid when the subject belongs to a different account' do + foreign_conversation = create(:conversation, account: create(:account)) + session = build(:captain_agent_session, account: account, assistant: assistant, subject: foreign_conversation) + + expect(session).not_to be_valid + expect(session.errors[:subject]).to be_present + end + end + + describe '#result' do + it 'returns the message for an assistant session' do + conversation = create(:conversation, account: account) + message = create(:message, account: account, conversation: conversation) + session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: message) + + expect(session.result).to eq(message) + end + + it 'returns the copilot message for a copilot session' do + user = create(:user, account: account) + copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant) + copilot_message = create(:captain_copilot_message, account: account, copilot_thread: copilot_thread) + session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user, + subject: copilot_thread, result: copilot_message) + + expect(session.result).to eq(copilot_message) + end + + it 'returns nil when result_id is nil' do + session = create(:captain_agent_session, account: account, assistant: assistant) + + expect(session.result).to be_nil + end + + it 'is not valid when the result belongs to a different account' do + conversation = create(:conversation, account: account) + foreign_message = create(:message, account: create(:account)) + session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: foreign_message) + + expect(session).not_to be_valid + expect(session.errors[:result]).to be_present + end + + it 'is not valid when result_id/result_type are set directly for a different account' do + conversation = create(:conversation, account: account) + foreign_message = create(:message, account: create(:account)) + session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation, + result_id: foreign_message.id, result_type: 'Message') + + expect(session).not_to be_valid + expect(session.errors[:result]).to be_present + end + + it 'is not valid when result_id/result_type are set directly for a stale id' do + conversation = create(:conversation, account: account) + session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation, + result_id: 0, result_type: 'Message') + + expect(session).not_to be_valid + expect(session.errors[:result]).to be_present + end + end + + describe 'account' do + it 'is derived from the assistant when created via the assistant association' do + conversation = create(:conversation, account: account) + session = assistant.agent_sessions.create!(subject: conversation, session_type: :assistant) + + expect(session.account).to eq(account) + end + + it 'overrides a mismatched explicit account with the assistant account' do + conversation = create(:conversation, account: account) + session = build(:captain_agent_session, account: create(:account), assistant: assistant, subject: conversation) + + expect(session).to be_valid + expect(session.account).to eq(account) + end + end + + describe 'defaults' do + it 'defaults faq_ids, document_ids, scenario_ids and run_context' do + session = create(:captain_agent_session, account: account, assistant: assistant) + + expect(session.faq_ids).to eq([]) + expect(session.document_ids).to eq([]) + expect(session.scenario_ids).to eq([]) + expect(session.run_context).to eq({}) + end + end + + describe 'factory' do + it 'builds a valid assistant session' do + session = create(:captain_agent_session, account: account, assistant: assistant) + + expect(session).to be_valid + expect(session).to be_session_assistant + expect(session.subject).to be_a(Conversation) + end + + it 'builds a valid copilot session' do + session = create(:captain_agent_session, :copilot, account: account, assistant: assistant) + + expect(session).to be_valid + expect(session).to be_session_copilot + expect(session.subject).to be_a(CopilotThread) + expect(session.user).to be_present + end + end +end diff --git a/spec/factories/captain/agent_session.rb b/spec/factories/captain/agent_session.rb new file mode 100644 index 000000000..a7b369b7d --- /dev/null +++ b/spec/factories/captain/agent_session.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + factory :captain_agent_session, class: 'Captain::AgentSession' do + account + association :assistant, factory: :captain_assistant + session_type { :assistant } + subject { create(:conversation, account: account) } + + trait :copilot do + session_type { :copilot } + user + subject { create(:captain_copilot_thread, account: account, user: user) } + end + end +end From 9c444315a6e3325621032c6738d1ea75af482862 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 13 Jul 2026 18:20:36 +0530 Subject: [PATCH 07/19] feat: add `api_and_webhooks` feature flag reconciled from billing plan (#14972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This introduces a new `api_and_webhooks` account feature flag that will control access to the token-authenticated API and account webhooks. The flag is part of the Startup plan features, so paid plans — including trials of paid plans — get it through the billing reconcile, while accounts on the default (Hacker) plan don't, with `manually_managed_features` available as a per-account override. The flag defaults to enabled, and nothing enforces it yet, so this PR is behavior-neutral — enforcement lands in a follow-up. ## What changed - Added `api_and_webhooks` to `features.yml` (first flag on the `feature_flags_ext_1` column, default enabled). - Added the flag to `STARTUP_PLAN_FEATURES` in `Enterprise::Billing::ReconcilePlanFeaturesService`, so all paid tiers get it and the default plan loses it on reconcile. - Added the flag to the manually manageable features list so it can be granted per account via Super Admin. ```rb # Enables the api_and_webhooks feature for all existing accounts and marks it # as manually managed so cloud billing reconciles never strip it. # # NOT committed to source control — run manually on production. # # Usage: # bundle exec rails runner enable_api_and_webhooks.rb # ACCOUNT_ID=123 bundle exec rails runner enable_api_and_webhooks.rb # # Idempotent: accounts already grandfathered are skipped; safe to re-run. probe = Internal::Accounts::InternalAttributesService.new(Account.new) abort 'api_and_webhooks is not in valid_feature_list — deploy the feature flag PR first.' unless probe.valid_feature_list.include?('api_and_webhooks') account_id = ENV.fetch('ACCOUNT_ID', nil) accounts = account_id.present? ? Account.where(id: account_id) : Account.all abort "Account with ID #{account_id} not found" if account_id.present? && accounts.empty? total = accounts.count puts "Grandfathering api_and_webhooks for #{total} account(s)..." puts "Started at: #{Time.current}" updated = 0 skipped = 0 errored = 0 accounts.find_each(batch_size: 500) do |account| service = Internal::Accounts::InternalAttributesService.new(account) features = service.manually_managed_features if features.include?('api_and_webhooks') && account.feature_enabled?('api_and_webhooks') skipped += 1 else service.manually_managed_features = features + ['api_and_webhooks'] unless features.include?('api_and_webhooks') account.enable_features!('api_and_webhooks') updated += 1 end processed = updated + skipped + errored puts "Processed #{processed}/#{total}..." if (processed % 1000).zero? rescue StandardError => e errored += 1 puts "Account #{account.id}: FAILED - #{e.message}" end puts "Done! Updated: #{updated}, Skipped: #{skipped}, Errored: #{errored}, Total: #{total}" ``` --- config/features.yml | 4 ++ .../reconcile_plan_features_service.rb | 1 + .../accounts/internal_attributes_service.rb | 2 +- lib/tasks/feature_defaults.rake | 64 +++++++++++++++++++ .../reconcile_plan_features_service_spec.rb | 53 +++++++++++++++ spec/models/account_spec.rb | 2 + 6 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 lib/tasks/feature_defaults.rake create mode 100644 spec/enterprise/services/enterprise/billing/reconcile_plan_features_service_spec.rb diff --git a/config/features.yml b/config/features.yml index 62bcab2da..39c8a53af 100644 --- a/config/features.yml +++ b/config/features.yml @@ -257,3 +257,7 @@ display_name: Data Import enabled: false column: feature_flags_ext_1 +- name: api_and_webhooks + display_name: API and Webhooks + enabled: true + column: feature_flags_ext_1 diff --git a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb index 205bc348e..435b1f3d1 100644 --- a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb +++ b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb @@ -18,6 +18,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService advanced_search linear_integration channel_voice + api_and_webhooks ].freeze BUSINESS_PLAN_FEATURES = %w[ diff --git a/enterprise/app/services/internal/accounts/internal_attributes_service.rb b/enterprise/app/services/internal/accounts/internal_attributes_service.rb index 593cea799..00c3d3636 100644 --- a/enterprise/app/services/internal/accounts/internal_attributes_service.rb +++ b/enterprise/app/services/internal/accounts/internal_attributes_service.rb @@ -54,7 +54,7 @@ class Internal::Accounts::InternalAttributesService def valid_feature_list Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES + Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES + - %w[inbound_emails] + %w[inbound_emails api_and_webhooks] end # Account notes functionality removed for now diff --git a/lib/tasks/feature_defaults.rake b/lib/tasks/feature_defaults.rake new file mode 100644 index 000000000..6b6e0443a --- /dev/null +++ b/lib/tasks/feature_defaults.rake @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# rubocop:disable Metrics/BlockLength +namespace :feature_defaults do + desc 'Interactively toggle a feature on/off in ACCOUNT_LEVEL_FEATURE_DEFAULTS (affects new account signups only)' + task toggle: :environment do + config = InstallationConfig.find_by!(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS') + + loop do + features = config.value + print_feature_list(features) + + print "\nEnter the number of the feature to toggle (or 'q' to quit): " + input = $stdin.gets.chomp + break if input.casecmp('q').zero? + + feature = select_feature(features, input) + if feature.nil? + puts 'Invalid selection.' + next + end + + toggle_feature(config, features, feature) + end + + puts 'Done.' + end + + def print_feature_list(features) + puts "\n#{'#'.ljust(4)}#{'name'.ljust(35)}#{'display_name'.ljust(30)}enabled" + features.each_with_index do |feature, index| + puts "#{(index + 1).to_s.ljust(4)}#{feature['name'].to_s.ljust(35)}#{feature['display_name'].to_s.ljust(30)}#{feature['enabled']}" + end + end + + def select_feature(features, input) + index = Integer(input, exception: false) + return nil if index.nil? || !index.between?(1, features.length) + + features[index - 1] + end + + def toggle_feature(config, features, feature) + print "#{feature['name']} is currently enabled: #{feature['enabled']}. Type 'true' or 'false' to set (anything else cancels): " + input = $stdin.gets.chomp + + case input + when 'true' + new_state = true + when 'false' + new_state = false + else + puts 'Cancelled.' + return + end + + feature['enabled'] = new_state + config.value = features + config.save! + GlobalConfig.clear_cache + puts "Updated #{feature['name']} to enabled: #{new_state}" + end +end +# rubocop:enable Metrics/BlockLength diff --git a/spec/enterprise/services/enterprise/billing/reconcile_plan_features_service_spec.rb b/spec/enterprise/services/enterprise/billing/reconcile_plan_features_service_spec.rb new file mode 100644 index 000000000..64be87ff4 --- /dev/null +++ b/spec/enterprise/services/enterprise/billing/reconcile_plan_features_service_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +describe Enterprise::Billing::ReconcilePlanFeaturesService do + let(:account) { create(:account) } + + before do + create(:installation_config, { + name: 'CHATWOOT_CLOUD_PLANS', + value: [ + { 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] }, + { 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] } + ] + }) + end + + describe '#perform' do + context 'with api_and_webhooks feature' do + it 'enables the feature for a paid plan with an active subscription' do + account.update!(custom_attributes: { 'plan_name' => 'Startups', 'subscription_status' => 'active' }) + + described_class.new(account: account).perform + + expect(account.reload).to be_feature_enabled('api_and_webhooks') + end + + it 'enables the feature for a paid plan on trial' do + account.update!(custom_attributes: { 'plan_name' => 'Startups', 'subscription_status' => 'trialing' }) + + described_class.new(account: account).perform + + expect(account.reload).to be_feature_enabled('api_and_webhooks') + end + + it 'disables the feature on the default plan' do + account.enable_features!('api_and_webhooks') + account.update!(custom_attributes: { 'plan_name' => 'Hacker', 'subscription_status' => 'active' }) + + described_class.new(account: account).perform + + expect(account.reload).not_to be_feature_enabled('api_and_webhooks') + end + + it 'keeps the feature enabled when manually managed' do + account.update!(custom_attributes: { 'plan_name' => 'Hacker', 'subscription_status' => 'trialing' }) + Internal::Accounts::InternalAttributesService.new(account).manually_managed_features = ['api_and_webhooks'] + + described_class.new(account: account).perform + + expect(account.reload).to be_feature_enabled('api_and_webhooks') + end + end + end +end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index a4932d635..00b464f73 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -108,6 +108,8 @@ RSpec.describe Account do it 'configures the account feature flag extension column' do expect(described_class.flag_columns).to include('feature_flags', 'feature_flags_ext_1') + expect(described_class.flag_mapping['feature_flags_ext_1']).to eq(feature_whatsapp_manual_transfer: 1, feature_data_import: 1 << 1, + feature_api_and_webhooks: 1 << 2) expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_whatsapp_manual_transfer]).to eq(1) expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_data_import]).to eq(2) end From 1b6a80d84d19310217c83c5d8140d08ba9b675f9 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:45:40 +0530 Subject: [PATCH 08/19] fix(captain): improve conversation completion evaluation (#14967) # Pull Request Template ## Description Please include a summary of the change and issue(s) fixed. Also, mention relevant motivation, context, and any dependencies that this change requires. Fixes https://linear.app/chatwoot/issue/AI-136/check-conversation-status-while-auto-resolving - After 60mins of inactivity, we run a job that decides if pending conversations are resolvable or need handoff - the prompt was a bit conservative and didn't have conversation state context ## Type of change Please delete options that are not relevant. - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. locally ran a sample eval ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sony Mathew --- .../conversation_completion_service.rb | 58 +++++++++- .../prompts/conversation_completion.liquid | 25 ++++- .../conversation_completion_service_spec.rb | 104 ++++++++++++++++++ 3 files changed, 179 insertions(+), 8 deletions(-) diff --git a/enterprise/lib/captain/conversation_completion_service.rb b/enterprise/lib/captain/conversation_completion_service.rb index c45559165..37f9add3e 100644 --- a/enterprise/lib/captain/conversation_completion_service.rb +++ b/enterprise/lib/captain/conversation_completion_service.rb @@ -12,7 +12,7 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService pattr_initialize [:account!, :conversation_display_id!] def perform - content = format_messages_as_string + content = format_evaluation_input return default_incomplete_response('No messages found') if content.blank? response = make_api_call( @@ -35,12 +35,58 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService Rails.root.join('enterprise/lib/captain/prompts', "#{file_name}.liquid").read end - def format_messages_as_string - messages = conversation_messages(start_from: 0) - messages.map do |msg| - sender_type = msg[:role] == 'user' ? 'Customer' : 'Assistant' - "#{sender_type}: #{msg[:content]}" + def format_evaluation_input + messages = conversation_message_records(start_from: 0) + return if messages.blank? + + [ + "Conversation status: #{conversation.status}", + format_messages_as_string(messages) + ].join("\n\n") + end + + def conversation_message_records(start_from: 0) + messages = [] + character_count = start_from + + conversation.messages + .where(message_type: [:incoming, :outgoing]) + .where(private: false) + .reorder('id desc') + .each do |message| + content = message.content_for_llm + next if content.blank? + break if character_count + content.length > TOKEN_LIMIT + + messages.prepend({ message: message, content: content }) + character_count += content.length + end + + messages + end + + def format_messages_as_string(messages) + transcript = messages.map do |message_context| + "#{message_sender_label(message_context[:message])}: #{message_context[:content]}" end.join("\n") + + "Conversation transcript:\n#{transcript}" + end + + def message_sender_label(message) + return 'Customer' if message.incoming? + return 'Captain' if captain_reply?(message) + return 'Bot' if bot_reply?(message) + + 'Assistant' + end + + def captain_reply?(message) + message.outgoing? && message.sender_type == 'Captain::Assistant' + end + + def bot_reply?(message) + message.outgoing? && message.sender_type.in?(['AgentBot', 'Captain::Assistant']) end def parse_response(message) diff --git a/enterprise/lib/captain/prompts/conversation_completion.liquid b/enterprise/lib/captain/prompts/conversation_completion.liquid index ed81039af..e039f60b0 100644 --- a/enterprise/lib/captain/prompts/conversation_completion.liquid +++ b/enterprise/lib/captain/prompts/conversation_completion.liquid @@ -2,18 +2,39 @@ You are evaluating whether a customer support conversation is complete and can b The conversation may be in any language. Apply these criteria based on the intent and meaning of messages, regardless of language. +You will receive: +- Conversation status +- Conversation transcript where messages are labeled as Customer, Captain, Bot, or Assistant + +This evaluator runs for inactive pending conversations. Focus on the latest pending exchange or latest unresolved customer request. Older messages may be present only for context. +If the conversation status is "pending", the conversation is still with Captain. Do not assume a handoff happened because Captain mentioned one. + A conversation is INCOMPLETE (keep open) if ANY of these apply: - The assistant asked a question or requested information that the customer hasn't provided - The customer asked a question that wasn't fully answered - The customer asked for something the assistant couldn't do — even if the assistant explained why, the customer's need is unmet - The customer raised multiple questions or issues and not all were addressed +- In the latest pending exchange, Captain, Bot, or Assistant said it handed off, will hand off, escalated, will escalate, or that a human/team/another party will continue the work +- In the latest pending exchange, Captain, Bot, or Assistant promised future action or follow-up instead of resolving the customer's request +- In the latest pending exchange, the customer is waiting for another party's action, response, status update, or investigation result +- The latest customer message is only an attachment placeholder such as "[Attachment]" and there is no later text explaining what it contains or showing the issue was answered +- The customer says they were not helped, asks why nobody replied, repeats the unresolved issue after a previous answer, or otherwise indicates dissatisfaction with the current help + +Do NOT treat these as incomplete by themselves: +- A generic greeting or broad optional offer from Captain/Bot/Assistant, such as "How can I help?", "What would you like to know?", or "Anything else?", when the customer has not made a recognizable request +- A customer greeting, single-word reply, name, phone number, or gibberish with no recognizable question/request, followed only by Captain/Bot/Assistant asking what the customer needs +- An optional invitation for the customer to ask more questions after the assistant already answered the actual request +- Older handoff, escalation, or follow-up messages from a previous exchange when the latest customer message starts a new topic, has no recognizable request, or has already been answered + +Important handoff rule: +- A handoff, escalation, transfer, acknowledgement, or promise of future follow-up is not a resolution by itself +- If conversation status is "pending" and Captain/Bot/Assistant says it handed off, will hand off, or that another party will continue the work in the latest pending exchange, keep the conversation INCOMPLETE. A conversation is COMPLETE only if ALL of these are true: - The assistant's answer fully addressed the customer's question or issue and is self-contained — it requires no further action from the customer - There are no unanswered questions, unmet requests, or outstanding follow-ups from either side - Note: customers often do not explicitly say thanks or confirm resolution. If the assistant gave a complete, self-contained answer and the customer had no follow-up, that is sufficient. Do not require explicit gratitude or confirmation. -- If the customer sent only one or two short messages (single words, names, phone numbers, or gibberish) with no recognizable question or request across the entire conversation, and the - assistant has responded asking for clarification, the conversation is COMPLETE. +- If the customer sent only one or two short text messages (greetings, single words, names, phone numbers, or gibberish) with no recognizable question or request across the entire conversation, and Captain/Bot/Assistant has responded asking what they need or offering help, the conversation is COMPLETE. Analyze the conversation and respond with ONLY a JSON object (no other text): {"complete": true, "reason": "brief explanation"} diff --git a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb index 80b9ab1d8..9cdfc822c 100644 --- a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb +++ b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb @@ -68,6 +68,110 @@ RSpec.describe Captain::ConversationCompletionService do end end + context 'when building evaluation context' do + let(:captain_assistant) { create(:captain_assistant, account: account) } + let(:mock_response) do + instance_double( + RubyLLM::Message, + content: { 'complete' => false, 'reason' => 'Human follow-up is still pending' }, + input_tokens: 100, + output_tokens: 20 + ) + end + + it 'includes conversation status and speaker labels' do + conversation.update!(status: :pending, waiting_since: 2.hours.ago) + create(:message, conversation: conversation, inbox: inbox, account: account, message_type: :incoming, content: 'I need help with a refund') + create( + :message, + conversation: conversation, + inbox: inbox, + account: account, + message_type: :outgoing, + sender: captain_assistant, + content: 'I will transfer this to support for review.' + ) + + expect(mock_chat).to receive(:ask) do |content| + expect(content).to include( + 'Conversation status: pending', + 'Conversation transcript:', + 'Customer: I need help with a refund', + 'Captain: I will transfer this to support for review.' + ) + + mock_response + end + + result = service.perform + + expect(result[:complete]).to be false + end + + it 'includes pending captain handoff evidence in the transcript' do + conversation.update!(status: :pending) + create(:message, conversation: conversation, inbox: inbox, account: account, message_type: :incoming, content: 'Please cancel my order') + create( + :message, + conversation: conversation, + inbox: inbox, + account: account, + message_type: :outgoing, + sender: captain_assistant, + content: 'I will transfer this to a specialist and they will follow up here.' + ) + + expect(mock_chat).to receive(:ask) do |content| + expect(content).to include( + 'Conversation status: pending', + 'Captain: I will transfer this to a specialist and they will follow up here.' + ) + + mock_response + end + + result = service.perform + + expect(result[:complete]).to be false + end + + it 'reuses computed message content while formatting the transcript' do + content_for_llm_calls_by_message_id = Hash.new(0) + allow_any_instance_of(Message).to receive(:content_for_llm).and_wrap_original do |method, *args| # rubocop:disable RSpec/AnyInstance + content_for_llm_calls_by_message_id[method.receiver.id] += 1 + method.call(*args) + end + + incoming_message = create( + :message, + :with_attachment, + conversation: conversation, + inbox: inbox, + account: account, + message_type: :incoming, + content: nil + ) + outgoing_message = create( + :message, + conversation: conversation, + inbox: inbox, + account: account, + message_type: :outgoing, + sender: captain_assistant, + content: 'What do you need help with?' + ) + + allow(mock_chat).to receive(:ask).and_return(mock_response) + + service.perform + + expect(content_for_llm_calls_by_message_id).to include( + incoming_message.id => 1, + outgoing_message.id => 1 + ) + end + end + context 'when conversation has no messages' do it 'returns incomplete with appropriate reason' do result = service.perform From 102f19fe417ff68e49ec60077ff07c2355ec5811 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:31:08 +0530 Subject: [PATCH 09/19] feat(captain): add FAQ suggestion data model (1/3) (#14977) Resolved conversations need a separate suggestion layer so repeated FAQ signals can be grouped without creating untrusted knowledge entries. This PR adds the persistence foundation only; it introduces no user-facing behavior by itself. ## Closes - [CW-7495](https://linear.app/chatwoot/issue/CW-7495/backend-llm-changes-to-make-conversation-faqs-as-signalssuggestions) (stacked PR 1/3; the issue is complete after the full stack lands) ## What changed - Added `captain_faq_suggestions` with question, answer, embedding, source count, and review status. - Added `captain_faq_observations` to retain conversation-level signals. - Added Captain assistant, account, and conversation associations. - Added vector and lookup indexes for semantic grouping. ## How to test This layer has no standalone UI behavior. Apply the migration and confirm Captain assistants can persist open FAQ suggestions with attached conversation observations. --- ...13184351_create_captain_faq_suggestions.rb | 48 +++++++++++++++++ db/schema.rb | 35 ++++++++++++- enterprise/app/models/captain/assistant.rb | 1 + .../app/models/captain/faq_observation.rb | 42 +++++++++++++++ .../app/models/captain/faq_suggestion.rb | 51 +++++++++++++++++++ .../app/models/enterprise/concerns/account.rb | 2 + .../enterprise/concerns/conversation.rb | 1 + 7 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260713184351_create_captain_faq_suggestions.rb create mode 100644 enterprise/app/models/captain/faq_observation.rb create mode 100644 enterprise/app/models/captain/faq_suggestion.rb diff --git a/db/migrate/20260713184351_create_captain_faq_suggestions.rb b/db/migrate/20260713184351_create_captain_faq_suggestions.rb new file mode 100644 index 000000000..6bc03f387 --- /dev/null +++ b/db/migrate/20260713184351_create_captain_faq_suggestions.rb @@ -0,0 +1,48 @@ +class CreateCaptainFaqSuggestions < ActiveRecord::Migration[7.1] + def change + create_faq_suggestions + create_faq_observations + end + + private + + def create_faq_suggestions + create_table :captain_faq_suggestions do |t| + t.string :question, null: false + t.text :answer, null: false + t.vector :embedding, limit: 1536 + t.references :assistant, null: false, index: true + t.references :account, null: false, index: true + t.string :language, null: false, default: 'en' + t.integer :source_count, null: false, default: 0 + t.integer :status, null: false, default: 0 + + t.timestamps + end + + add_index :captain_faq_suggestions, [:account_id, :assistant_id, :status, :language], + name: 'idx_cap_faq_suggestions_on_account_assistant_status_language' + add_index :captain_faq_suggestions, :embedding, using: :ivfflat, + name: 'vector_idx_captain_faq_suggestions_embedding', + opclass: :vector_cosine_ops + end + + def create_faq_observations + create_table :captain_faq_observations do |t| + t.references :account, null: false, index: true + t.references :conversation, null: false, index: true + t.references :faq_suggestion, index: true + t.string :generated_question, null: false + t.text :generated_answer, null: false + t.string :language, null: false, default: 'en' + t.integer :status, null: false, default: 0 + + t.timestamps + end + + add_index :captain_faq_observations, [:conversation_id, :faq_suggestion_id], + unique: true, + where: 'faq_suggestion_id IS NOT NULL', + name: 'idx_captain_faq_observations_on_conversation_and_suggestion' + end +end diff --git a/db/schema.rb b/db/schema.rb index f02b613d9..43e7135b9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do +ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -417,6 +417,39 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do t.index ["status"], name: "index_captain_documents_on_status" end + create_table "captain_faq_observations", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "conversation_id", null: false + t.bigint "faq_suggestion_id" + t.string "generated_question", null: false + t.text "generated_answer", null: false + t.string "language", default: "en", null: false + t.integer "status", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_captain_faq_observations_on_account_id" + t.index ["conversation_id", "faq_suggestion_id"], name: "idx_captain_faq_observations_on_conversation_and_suggestion", unique: true, where: "(faq_suggestion_id IS NOT NULL)" + t.index ["conversation_id"], name: "index_captain_faq_observations_on_conversation_id" + t.index ["faq_suggestion_id"], name: "index_captain_faq_observations_on_faq_suggestion_id" + end + + create_table "captain_faq_suggestions", force: :cascade do |t| + t.string "question", null: false + t.text "answer", null: false + t.vector "embedding", limit: 1536 + t.bigint "assistant_id", null: false + t.bigint "account_id", null: false + t.string "language", default: "en", null: false + t.integer "source_count", default: 0, null: false + t.integer "status", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_captain_faq_suggestions_on_account_id" + t.index ["account_id", "assistant_id", "status", "language"], name: "idx_cap_faq_suggestions_on_account_assistant_status_language" + t.index ["assistant_id"], name: "index_captain_faq_suggestions_on_assistant_id" + t.index ["embedding"], name: "vector_idx_captain_faq_suggestions_embedding", opclass: :vector_cosine_ops, using: :ivfflat + end + create_table "captain_inboxes", force: :cascade do |t| t.bigint "captain_assistant_id", null: false t.bigint "inbox_id", null: false diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index b3134e2f2..bf4691e2c 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -28,6 +28,7 @@ class Captain::Assistant < ApplicationRecord belongs_to :account has_many :documents, class_name: 'Captain::Document', dependent: :destroy_async has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy_async + has_many :faq_suggestions, class_name: 'Captain::FaqSuggestion', dependent: :destroy_async has_many :captain_inboxes, class_name: 'CaptainInbox', foreign_key: :captain_assistant_id, diff --git a/enterprise/app/models/captain/faq_observation.rb b/enterprise/app/models/captain/faq_observation.rb new file mode 100644 index 000000000..15c5e1284 --- /dev/null +++ b/enterprise/app/models/captain/faq_observation.rb @@ -0,0 +1,42 @@ +# == Schema Information +# +# Table name: captain_faq_observations +# +# id :bigint not null, primary key +# generated_answer :text not null +# generated_question :string not null +# language :string default("en"), not null +# status :integer default("attached"), not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# conversation_id :bigint not null +# faq_suggestion_id :bigint +# +class Captain::FaqObservation < ApplicationRecord + self.table_name = 'captain_faq_observations' + + belongs_to :account + belongs_to :conversation, class_name: '::Conversation' + belongs_to :faq_suggestion, class_name: 'Captain::FaqSuggestion', optional: true, inverse_of: :observations + + enum status: { attached: 0, discarded: 1 } + + validates :generated_question, :generated_answer, :language, presence: true + validates :faq_suggestion, presence: true, if: :attached? + validate :faq_suggestion_belongs_to_account + + before_validation :ensure_account + + private + + def ensure_account + self.account = conversation&.account + end + + def faq_suggestion_belongs_to_account + return if faq_suggestion.blank? || faq_suggestion.account_id == account_id + + errors.add(:faq_suggestion, :invalid) + end +end diff --git a/enterprise/app/models/captain/faq_suggestion.rb b/enterprise/app/models/captain/faq_suggestion.rb new file mode 100644 index 000000000..047d5e1fe --- /dev/null +++ b/enterprise/app/models/captain/faq_suggestion.rb @@ -0,0 +1,51 @@ +# == Schema Information +# +# Table name: captain_faq_suggestions +# +# id :bigint not null, primary key +# answer :text not null +# embedding :vector(1536) +# language :string default("en"), not null +# question :string not null +# source_count :integer default(0), not null +# status :integer default("open"), not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# assistant_id :bigint not null +# +class Captain::FaqSuggestion < ApplicationRecord + self.table_name = 'captain_faq_suggestions' + + belongs_to :assistant, class_name: 'Captain::Assistant' + belongs_to :account + has_many :observations, + class_name: 'Captain::FaqObservation', + dependent: :delete_all, + inverse_of: :faq_suggestion + has_neighbors :embedding, normalize: true + + enum status: { open: 0, approved: 1, dismissed: 2 } + + validates :question, :answer, :language, presence: true + + before_validation :ensure_account + after_commit :update_embedding, on: [:create, :update] + + scope :ordered, -> { order(source_count: :desc, updated_at: :desc) } + scope :by_language, ->(language) { where(language: language) } + + private + + def ensure_account + self.account = assistant&.account + end + + def update_embedding + return unless open? + return unless saved_change_to_question? || saved_change_to_answer? || embedding.nil? + return if previously_new_record? && embedding.present? + + Captain::Llm::UpdateEmbeddingJob.perform_later(self, "#{question}: #{answer}") + end +end diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index 1f5376940..427b1e1af 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -11,6 +11,8 @@ module Enterprise::Concerns::Account has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant' has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse' + has_many :captain_faq_observations, dependent: :destroy_async, class_name: 'Captain::FaqObservation' + has_many :captain_faq_suggestions, dependent: :destroy_async, class_name: 'Captain::FaqSuggestion' has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document' has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool' has_many :captain_agent_sessions, dependent: :destroy_async, class_name: 'Captain::AgentSession' diff --git a/enterprise/app/models/enterprise/concerns/conversation.rb b/enterprise/app/models/enterprise/concerns/conversation.rb index a075704d1..c247e01e8 100644 --- a/enterprise/app/models/enterprise/concerns/conversation.rb +++ b/enterprise/app/models/enterprise/concerns/conversation.rb @@ -7,6 +7,7 @@ module Enterprise::Concerns::Conversation has_many :sla_events, dependent: :destroy_async has_many :calls, dependent: :destroy_async has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable + has_many :captain_faq_observations, class_name: 'Captain::FaqObservation', dependent: :delete_all scope :with_sla_applicable_contact, -> { left_joins(:contact).where(contacts: { blocked: [false, nil] }) } before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? } From 280756b483b941d355ab2e09e546c83608fc12d7 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 14 Jul 2026 13:30:19 +0400 Subject: [PATCH 10/19] fix(meta): disable Instagram replies on Cloud during restriction (#15005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents can no longer send replies in Instagram conversations on Chatwoot Cloud while the temporary Meta platform restriction is active. The reply box locks into Private Note mode — the same behavior as an expired 24-hour reply window — so teams can still collaborate internally, with the existing amber restriction banner above the conversation explaining why. Self-hosted installations are unaffected. Follow-up to #14974. ## How to test 1. On a Chatwoot Cloud environment (`isOnChatwootCloud` true), open any Instagram conversation. 2. The composer should be locked to Private Note mode: the Reply/Private Note toggle is disabled, and sending creates a private note — even for conversations within the 24-hour reply window. 3. Switching between conversations should keep the composer in Private Note mode for Instagram conversations. 4. On a self-hosted environment, Instagram conversations should behave as before (reply allowed within the messaging window). Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- .../widgets/conversation/ReplyBox.vue | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 471d10f3c..bd72d45f3 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -146,6 +146,7 @@ export default { currentUser: 'getCurrentUser', lastEmail: 'getLastEmailInSelectedChat', globalConfig: 'globalConfig/get', + isOnChatwootCloud: 'globalConfig/isOnChatwootCloud', }), currentContact() { const senderId = this.currentChat?.meta?.sender?.id; @@ -173,6 +174,9 @@ export default { return this.isATwilioWhatsAppChannel && !this.isPrivate; }, isPrivate() { + if (this.isInstagramReplyRestricted) { + return true; + } if ( this.currentChat.can_reply || this.isAWhatsAppChannel || @@ -197,10 +201,16 @@ export default { ); return !!stripped.trim(); }, + // Instagram replies are disabled on Chatwoot Cloud during the temporary + // Meta platform restriction; private notes remain available. + isInstagramReplyRestricted() { + return this.isOnChatwootCloud && this.isAnInstagramChannel; + }, isReplyRestricted() { return ( - !this.currentChat?.can_reply && - !(this.isAWhatsAppChannel || this.isAPIInbox) + this.isInstagramReplyRestricted || + (!this.currentChat?.can_reply && + !(this.isAWhatsAppChannel || this.isAPIInbox)) ); }, inboxId() { @@ -470,7 +480,10 @@ export default { return; } - if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) { + if ( + !this.isInstagramReplyRestricted && + (canReply || this.isAWhatsAppChannel || this.isAPIInbox) + ) { this.replyType = REPLY_EDITOR_MODES.REPLY; } else { this.replyType = REPLY_EDITOR_MODES.NOTE; @@ -937,7 +950,10 @@ export default { this.$store.dispatch('draftMessages/setReplyEditorMode', { mode, }); - if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) + if ( + !this.isInstagramReplyRestricted && + (canReply || this.isAWhatsAppChannel || this.isAPIInbox) + ) this.replyType = mode; if (this.isRecordingAudio) { this.toggleAudioRecorder(); From 3e03f8da1e8049a5b94d295073cf4763d7d90d7d Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 14 Jul 2026 13:35:10 +0400 Subject: [PATCH 11/19] chore(whatsapp): log warning when Cloud API template sync fails (#15004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp Cloud API template sync currently fails silently — if the Graph API call errors (expired token, rate limit, permission issue), the channel simply keeps its stale templates with no trace in the logs. This adds a warning log when the template fetch fails, so failed syncs are visible and debuggable. ## What changed - `Whatsapp::Providers::WhatsappCloudService#fetch_whatsapp_templates` now logs a warning with the account id, inbox id, HTTP status code, and Meta's error message when the response is not successful. - The inbox id uses safe navigation since sync also runs from the channel's `after_create` callback, before the inbox record exists. - The request URL is intentionally not logged, as it contains the access token as a query param. ## How to reproduce 1. Set up a WhatsApp Cloud inbox with an invalid/expired `api_key`. 2. Trigger a template sync (Inbox settings → sync templates, or wait for the scheduler). 3. Previously nothing was logged; now a `[WHATSAPP] Template sync failed for account ... inbox ...` warning appears in the Rails logs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- app/services/whatsapp/providers/whatsapp_cloud_service.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index 69631c468..373e47b3c 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -40,7 +40,11 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi def fetch_whatsapp_templates(url) response = HTTParty.get(url) - return [] unless response.success? + unless response.success? + Rails.logger.warn "[WHATSAPP] Template sync failed for account #{whatsapp_channel.account_id} " \ + "inbox #{whatsapp_channel.inbox&.id}: #{response.code} #{error_message(response)}" + return [] + end next_url = next_url(response) @@ -155,7 +159,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi def error_message(response) # https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response - response.parsed_response&.dig('error', 'message') + response.parsed_response.dig('error', 'message') if response.parsed_response.is_a?(Hash) end def voice_message?(type, attachment) From 9328f8739ce420bb031550f87d83e5f4bc32b61d Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:05:27 +0530 Subject: [PATCH 12/19] fix: clear whatsapp webhook override when manual cloud inbox is deleted (#15010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a manually-configured WhatsApp Cloud inbox left its phone-number-level webhook override still pointing at Chatwoot on Meta's side. The number kept routing inbound events to us after the inbox was gone, which blocked the customer's own app — subscribed separately on the same WABA — from receiving messages, since the phone-level override takes priority over the app-level subscription. Deleting the inbox now releases the override, as it already did for embedded-signup inboxes. ## What changed The setup and teardown paths gated on opposite halves of the same condition. `Channel::Whatsapp#should_auto_setup_webhooks?` sets the override for `whatsapp_cloud` inboxes where `source != 'embedded_signup'` (i.e. manual ones), while `Whatsapp::WebhookTeardownService#should_teardown_webhook?` only cleared it when `source == 'embedded_signup'`. The two sets are disjoint, so manual inboxes were exactly the ones that set an override on create and never cleared it on destroy. Embedded-signup inboxes were unaffected because `EmbeddedSignupService` calls `setup_webhooks` explicitly. Dropping the `source` check from the teardown guard is the whole fix. Manual `whatsapp_cloud` channels can't persist without `api_key`, `phone_number_id` and `business_account_id` (`validate_provider_config` verifies all three against Meta), so the remaining presence guards and both API calls have everything they need. The WABA-level `DELETE /subscribed_apps` now also fires for manual inboxes when the last one on a WABA is removed, which is symmetric with manual setup subscribing the app in the first place; the token only unsubscribes the app it belongs to, so a customer's separate app subscription is untouched. This fixes the leak going forward. Numbers already stranded still need the override cleared with the customer's own token, since we no longer hold their `api_key` once the inbox is deleted. ## How to reproduce 1. Create a WhatsApp Cloud inbox using manual API keys (not embedded signup). 2. Confirm the override is set: `GET /v22.0/{phone_number_id}?fields=webhook_configuration` shows `phone_number` pointing at your Chatwoot install. 3. Delete the inbox. 4. Before this change, the override still points at Chatwoot. After it, `webhook_configuration` no longer carries the phone-level override and events fall back to the WABA/app-level subscription. --------- Co-authored-by: Muhsin Keloth --- .../whatsapp/webhook_teardown_service.rb | 6 ++-- .../whatsapp/webhook_teardown_service_spec.rb | 31 ++++++++++++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/app/services/whatsapp/webhook_teardown_service.rb b/app/services/whatsapp/webhook_teardown_service.rb index 948d84f04..de794f8e3 100644 --- a/app/services/whatsapp/webhook_teardown_service.rb +++ b/app/services/whatsapp/webhook_teardown_service.rb @@ -23,7 +23,6 @@ class Whatsapp::WebhookTeardownService def should_teardown_webhook? @channel.provider == 'whatsapp_cloud' && - provider_config['source'] == 'embedded_signup' && provider_config['api_key'].present? && (provider_config['phone_number_id'].present? || provider_config['business_account_id'].present?) end @@ -38,8 +37,11 @@ class Whatsapp::WebhookTeardownService Rails.logger.error "[WHATSAPP] Phone-level webhook clear failed for channel #{@channel.id}: #{e.message}" end - # The app subscription is shared by every inbox on the WABA, so only unsubscribe when this is the last one. + # Embedded signup only — a manual token's subscribed app is the customer's, not ours to unsubscribe. + # The subscription is shared across the WABA, so only unsubscribe when this is the last inbox. def unsubscribe_app_if_last_inbox(api_client) + return unless provider_config['source'] == 'embedded_signup' + waba_id = provider_config['business_account_id'] return if waba_id.blank? return if waba_sibling_exists?(waba_id) diff --git a/spec/services/whatsapp/webhook_teardown_service_spec.rb b/spec/services/whatsapp/webhook_teardown_service_spec.rb index be94f3c44..a5bdeef0b 100644 --- a/spec/services/whatsapp/webhook_teardown_service_spec.rb +++ b/spec/services/whatsapp/webhook_teardown_service_spec.rb @@ -51,18 +51,41 @@ RSpec.describe Whatsapp::WebhookTeardownService do end end - context 'when channel is whatsapp_cloud but not embedded_signup' do + context 'when channel is whatsapp_cloud with manual setup' do before do + allow(channel).to receive(:setup_webhooks).and_return(true) + channel.update!( provider: 'whatsapp_cloud', - provider_config: { 'source' => 'manual' } + provider_config: { + 'source' => 'manual', + 'phone_number_id' => 'manual_phone_id', + 'business_account_id' => 'manual_waba_id', + 'api_key' => 'manual_api_key' + } ) end - it 'does not attempt to unsubscribe webhook' do - expect(Whatsapp::FacebookApiClient).not_to receive(:new) + it 'clears the phone number callback override' do + api_client = instance_double(Whatsapp::FacebookApiClient) + allow(Whatsapp::FacebookApiClient).to receive(:new).with('manual_api_key').and_return(api_client) + allow(api_client).to receive(:clear_phone_number_callback_override).with('manual_phone_id') service.perform + + expect(api_client).to have_received(:clear_phone_number_callback_override).with('manual_phone_id') + end + + # The manual token belongs to the customer's own Meta app, so its WABA subscription is not ours to remove. + it 'does not unsubscribe the app from the WABA' do + api_client = instance_double(Whatsapp::FacebookApiClient) + allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client) + allow(api_client).to receive(:clear_phone_number_callback_override) + allow(api_client).to receive(:unsubscribe_app_from_waba) + + service.perform + + expect(api_client).not_to have_received(:unsubscribe_app_from_waba) end end From 2ac55c8728747ba655a0cb2d8d1aee8a0578a70f Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:38:43 +0530 Subject: [PATCH 13/19] fix(captain): honor mandatory handoff guidelines (#15003) Ensures Captain follows explicit mandatory-transfer rules from active Response Guidelines and Guardrails instead of allowing the generic consent-first fallback to override those rules. ## What changed - Made explicit transfer requirements take precedence over generic consent-first handoff defaults only when their condition matches. - Added explicit Response Guideline and Guardrail transfer rules to the human-handoff protocol. - Added focused prompt regression coverage. ## How to reproduce Configure a Response Guideline or Guardrail that requires immediate transfer for a specific condition, then send a request matching that condition. Captain should invoke the human-handoff path without asking the user to consent again. Unmatched requests continue to use the existing consent-first fallback. The assistant prompt renderer, agent prompt context, and focused regression specs pass locally. --- enterprise/lib/captain/prompts/assistant.liquid | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/enterprise/lib/captain/prompts/assistant.liquid b/enterprise/lib/captain/prompts/assistant.liquid index 821d9d472..a8f1dada3 100644 --- a/enterprise/lib/captain/prompts/assistant.liquid +++ b/enterprise/lib/captain/prompts/assistant.liquid @@ -48,6 +48,8 @@ Always respect these boundaries: {% endfor %} {% endif -%} +When a Response Guideline or Guardrail explicitly requires transfer for a matched condition, follow it instead of the generic consent-first handoff defaults below. + # Decision Framework ## 1. Analyze the Request @@ -88,7 +90,8 @@ Handle the request yourself in the following way Transfer to a human agent when: - User explicitly requests human assistance - User accepts an offer to speak with a human +- A Response Guideline or Guardrail explicitly requires transfer for the matched condition - The issue requires specialized knowledge or permissions you don't have - Multiple attempts to help have been unsuccessful -If you cannot find needed information after checking the available information and clarifying context, ask whether the user wants to talk to another support agent. Use the `captain--tools--handoff` tool only after the user explicitly requests human assistance or accepts your offer to speak with a human. When using the tool, provide a clear reason that helps the human agent understand the context. +If you cannot find needed information after checking the available information and clarifying context, ask whether the user wants to talk to another support agent. Use the `captain--tools--handoff` tool only after the user explicitly requests human assistance, accepts your offer to speak with a human, or a Response Guideline or Guardrail explicitly requires transfer for the matched condition. When using the tool, provide a clear reason that helps the human agent understand the context. From 8b4f3e226e7b597434aa8ca4db12ccd4ffea3e3d Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 15 Jul 2026 01:59:41 +0400 Subject: [PATCH 14/19] revert: "fix(meta): disable Instagram replies on Cloud during restriction" (#15020) Reverts chatwoot/chatwoot#15005 --- .../widgets/conversation/ReplyBox.vue | 24 ++++--------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index bd72d45f3..471d10f3c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -146,7 +146,6 @@ export default { currentUser: 'getCurrentUser', lastEmail: 'getLastEmailInSelectedChat', globalConfig: 'globalConfig/get', - isOnChatwootCloud: 'globalConfig/isOnChatwootCloud', }), currentContact() { const senderId = this.currentChat?.meta?.sender?.id; @@ -174,9 +173,6 @@ export default { return this.isATwilioWhatsAppChannel && !this.isPrivate; }, isPrivate() { - if (this.isInstagramReplyRestricted) { - return true; - } if ( this.currentChat.can_reply || this.isAWhatsAppChannel || @@ -201,16 +197,10 @@ export default { ); return !!stripped.trim(); }, - // Instagram replies are disabled on Chatwoot Cloud during the temporary - // Meta platform restriction; private notes remain available. - isInstagramReplyRestricted() { - return this.isOnChatwootCloud && this.isAnInstagramChannel; - }, isReplyRestricted() { return ( - this.isInstagramReplyRestricted || - (!this.currentChat?.can_reply && - !(this.isAWhatsAppChannel || this.isAPIInbox)) + !this.currentChat?.can_reply && + !(this.isAWhatsAppChannel || this.isAPIInbox) ); }, inboxId() { @@ -480,10 +470,7 @@ export default { return; } - if ( - !this.isInstagramReplyRestricted && - (canReply || this.isAWhatsAppChannel || this.isAPIInbox) - ) { + if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) { this.replyType = REPLY_EDITOR_MODES.REPLY; } else { this.replyType = REPLY_EDITOR_MODES.NOTE; @@ -950,10 +937,7 @@ export default { this.$store.dispatch('draftMessages/setReplyEditorMode', { mode, }); - if ( - !this.isInstagramReplyRestricted && - (canReply || this.isAWhatsAppChannel || this.isAPIInbox) - ) + if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) this.replyType = mode; if (this.isRecordingAudio) { this.toggleAudioRecorder(); From 13db36609d77ebbbbe13b180f7cd65d3de31c228 Mon Sep 17 00:00:00 2001 From: Gaurav Singhal Date: Tue, 14 Jul 2026 20:00:39 -0700 Subject: [PATCH 15/19] fix: hide agent bot access tokens from agents (#14830) ## Summary Keeps Agent Bot list and show access available to agents while restricting account bot access tokens to administrators. ## Why Agents need Agent Bot metadata for existing product workflows, but the bot access token can be replayed against bot-authorized APIs and should not be exposed to them. ## What changed - serialize `access_token` only for administrators - verify agents can read Agent Bot metadata without receiving the token - verify administrators still receive the token from index and show responses ## Validation `bundle exec rspec spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb` 27 examples, 0 failures. Related follow-up: [CW-7595](https://linear.app/chatwoot/issue/CW-7595/standardize-one-time-credential-disclosure-across-chatwoot-apis) --------- Co-authored-by: Gaurav Singhal Co-authored-by: Sojan Jose --- .../api/v1/models/_agent_bot.json.jbuilder | 2 +- .../v1/accounts/agent_bots_controller_spec.rb | 30 ++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/views/api/v1/models/_agent_bot.json.jbuilder b/app/views/api/v1/models/_agent_bot.json.jbuilder index d5dbc91b4..2137ca107 100644 --- a/app/views/api/v1/models/_agent_bot.json.jbuilder +++ b/app/views/api/v1/models/_agent_bot.json.jbuilder @@ -6,6 +6,6 @@ json.outgoing_url resource.outgoing_url unless resource.system_bot? json.bot_type resource.bot_type json.bot_config resource.bot_config json.account_id resource.account_id -json.access_token resource.access_token if resource.access_token.present? +json.access_token resource.access_token if resource.access_token.present? && Current.account_user&.administrator? json.secret resource.secret if !resource.system_bot? && Current.account_user&.administrator? json.system_bot resource.system_bot? diff --git a/spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb b/spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb index 61fcf30ac..a98f787e0 100644 --- a/spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'Agent Bot API', type: :request do end end - context 'when it is an authenticated user' do + context 'when it is an authenticated agent' do it 'returns all the agent_bots in account along with global agent bots' do global_bot = create(:agent_bot) get "/api/v1/accounts/#{account.id}/agent_bots", @@ -25,7 +25,7 @@ RSpec.describe 'Agent Bot API', type: :request do expect(response).to have_http_status(:success) expect(response.body).to include(agent_bot.name) expect(response.body).to include(global_bot.name) - expect(response.body).to include(agent_bot.access_token.token) + expect(response.body).not_to include(agent_bot.access_token.token) expect(response.body).not_to include(global_bot.access_token.token) end @@ -54,6 +54,17 @@ RSpec.describe 'Agent Bot API', type: :request do expect(account_bot_response).to include('thumbnail') end end + + context 'when it is an authenticated administrator' do + it 'returns the account bot access token' do + get "/api/v1/accounts/#{account.id}/agent_bots", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.body).to include(agent_bot.access_token.token) + end + end end describe 'GET /api/v1/accounts/{account.id}/agent_bots/:id' do @@ -65,7 +76,7 @@ RSpec.describe 'Agent Bot API', type: :request do end end - context 'when it is an authenticated user' do + context 'when it is an authenticated agent' do it 'shows the agent bot' do get "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}", headers: agent.create_new_auth_token, @@ -73,7 +84,7 @@ RSpec.describe 'Agent Bot API', type: :request do expect(response).to have_http_status(:success) expect(response.body).to include(agent_bot.name) - expect(response.body).to include(agent_bot.access_token.token) + expect(response.body).not_to include(agent_bot.access_token.token) end it 'will show a global agent bot' do @@ -91,6 +102,17 @@ RSpec.describe 'Agent Bot API', type: :request do expect(response.parsed_body).not_to include('outgoing_url') end end + + context 'when it is an authenticated administrator' do + it 'returns the account bot access token' do + get "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.body).to include(agent_bot.access_token.token) + end + end end describe 'POST /api/v1/accounts/{account.id}/agent_bots' do From 49c442751d9c919bf72e65e745dec2955450f61c Mon Sep 17 00:00:00 2001 From: Gaurav Singhal Date: Tue, 14 Jul 2026 23:02:13 -0700 Subject: [PATCH 16/19] fix: require admin for dashboard app mutations (#14831) ## Summary Restricts account-wide Dashboard App creation, updates, and deletion to administrators while keeping read access available to authenticated account users. ## Why Dashboard Apps are account-level integrations displayed in conversation views. Agents should be able to use them, but only administrators should be able to change their configuration. ## What changed - authorize Dashboard App actions through `DashboardAppPolicy` - allow index and show access for authenticated account users - restrict create, update, and destroy actions to administrators - add request coverage for administrator and agent mutation behavior ## Validation `bundle exec rspec spec/controllers/api/v1/accounts/dashboard_apps_controller_spec.rb` 17 examples, 0 failures. `bundle exec rubocop app/controllers/api/v1/accounts/dashboard_apps_controller.rb app/policies/dashboard_app_policy.rb` 2 files inspected, no offenses detected. --------- Co-authored-by: Gaurav Singhal Co-authored-by: Sojan Jose --- .../v1/accounts/dashboard_apps_controller.rb | 1 + app/policies/dashboard_app_policy.rb | 21 ++++++++ .../dashboard_apps_controller_spec.rb | 50 +++++++++++++++++-- 3 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 app/policies/dashboard_app_policy.rb diff --git a/app/controllers/api/v1/accounts/dashboard_apps_controller.rb b/app/controllers/api/v1/accounts/dashboard_apps_controller.rb index a8d7ebcb9..4226db1cc 100644 --- a/app/controllers/api/v1/accounts/dashboard_apps_controller.rb +++ b/app/controllers/api/v1/accounts/dashboard_apps_controller.rb @@ -1,4 +1,5 @@ class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController + before_action :check_authorization before_action :fetch_dashboard_apps, except: [:create] before_action :fetch_dashboard_app, only: [:show, :update, :destroy] diff --git a/app/policies/dashboard_app_policy.rb b/app/policies/dashboard_app_policy.rb new file mode 100644 index 000000000..af7bec82a --- /dev/null +++ b/app/policies/dashboard_app_policy.rb @@ -0,0 +1,21 @@ +class DashboardAppPolicy < ApplicationPolicy + def index? + true + end + + def show? + true + end + + def create? + @account_user.administrator? + end + + def update? + @account_user.administrator? + end + + def destroy? + @account_user.administrator? + end +end diff --git a/spec/controllers/api/v1/accounts/dashboard_apps_controller_spec.rb b/spec/controllers/api/v1/accounts/dashboard_apps_controller_spec.rb index 100f914bb..820010a62 100644 --- a/spec/controllers/api/v1/accounts/dashboard_apps_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/dashboard_apps_controller_spec.rb @@ -70,8 +70,8 @@ RSpec.describe 'DashboardAppsController', type: :request do end end - context 'when it is an authenticated user' do - let(:user) { create(:user, account: account) } + context 'when it is an authenticated administrator' do + let(:user) { create(:user, account: account, role: :administrator) } it 'creates the dashboard app' do expect do @@ -130,11 +130,26 @@ RSpec.describe 'DashboardAppsController', type: :request do expect(response).to have_http_status(:unprocessable_entity) end end + + context 'when it is an authenticated agent' do + let(:agent) { create(:user, account: account, role: :agent) } + + it 'does not create account-wide dashboard apps' do + expect do + post "/api/v1/accounts/#{account.id}/dashboard_apps", + headers: agent.create_new_auth_token, + params: payload, + as: :json + end.not_to change(DashboardApp, :count) + + expect(response).to have_http_status(:unauthorized) + end + end end describe 'PATCH /api/v1/accounts/{account.id}/dashboard_apps/:id' do let(:payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'https://link.com' }] } } } - let(:user) { create(:user, account: account) } + let(:user) { create(:user, account: account, role: :administrator) } let!(:dashboard_app) { create(:dashboard_app, user: user, account: account) } context 'when it is an unauthenticated user' do @@ -160,10 +175,24 @@ RSpec.describe 'DashboardAppsController', type: :request do expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type] end end + + context 'when it is an authenticated agent' do + let(:agent) { create(:user, account: account, role: :agent) } + + it 'does not update account-wide dashboard apps' do + patch "/api/v1/accounts/#{account.id}/dashboard_apps/#{dashboard_app.id}", + headers: agent.create_new_auth_token, + params: payload, + as: :json + + expect(response).to have_http_status(:unauthorized) + expect(dashboard_app.reload.title).not_to eq('CRM Dashboard') + end + end end describe 'DELETE /api/v1/accounts/{account.id}/dashboard_apps/:id' do - let(:user) { create(:user, account: account) } + let(:user) { create(:user, account: account, role: :administrator) } let!(:dashboard_app) { create(:dashboard_app, user: user, account: account) } context 'when it is an unauthenticated user' do @@ -182,5 +211,18 @@ RSpec.describe 'DashboardAppsController', type: :request do expect(user.dashboard_apps.count).to be 0 end end + + context 'when it is an authenticated agent' do + let(:agent) { create(:user, account: account, role: :agent) } + + it 'does not delete account-wide dashboard apps' do + delete "/api/v1/accounts/#{account.id}/dashboard_apps/#{dashboard_app.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + expect(DashboardApp.exists?(dashboard_app.id)).to be(true) + end + end end end From fd625981e96aadc58d0a7991a4ba2bd3e1225106 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:35:57 +0530 Subject: [PATCH 17/19] fix(captain): keep custom tools available in Captain V2 (#15015) Captain V2 assistants can now use every enabled custom tool from their account through the main assistant. The change keeps existing custom tool access when an assistant has no migrated scenarios, so switching from V1 does not remove the capability without warning. ## How to reproduce 1. Create and enable an account custom tool. 2. Use an assistant with no custom instructions and no generated scenarios. 3. Enable Captain V2 for the account. 4. Before this change, the main assistant receives only FAQ lookup and handoff. After this change, it also receives the enabled account custom tool. ## What changed The main V2 assistant now loads enabled custom tools through its account association. Scenario agents still load only the tools named in their scenario instructions. The account custom tool limit keeps the added tool count bounded. Focused model coverage verifies enabled tools, disabled tools, account isolation, FAQ lookup, and handoff. Existing V1 assistant, V2 scenario, and V2 runner coverage passes. RuboCop passes. --- enterprise/app/models/captain/assistant.rb | 3 +- .../models/captain/assistant_spec.rb | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 spec/enterprise/models/captain/assistant_spec.rb diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index bf4691e2c..dc0969cd4 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -98,7 +98,8 @@ class Captain::Assistant < ApplicationRecord def agent_tools [ self.class.resolve_tool_class('faq_lookup').new(self), - self.class.resolve_tool_class('handoff').new(self) + self.class.resolve_tool_class('handoff').new(self), + *account.captain_custom_tools.enabled.map { |custom_tool| custom_tool.tool(self) } ] end diff --git a/spec/enterprise/models/captain/assistant_spec.rb b/spec/enterprise/models/captain/assistant_spec.rb new file mode 100644 index 000000000..e282124ae --- /dev/null +++ b/spec/enterprise/models/captain/assistant_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' + +RSpec.describe Captain::Assistant do + describe '#agent_tools' do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + + it 'includes enabled custom tools from the assistant account' do + custom_tool = create(:captain_custom_tool, account: account) + + tools = assistant.send(:agent_tools) + + expect(tools.map(&:name)).to include(custom_tool.slug) + expect(tools.find { |tool| tool.name == custom_tool.slug }).to be_a(Captain::Tools::HttpTool) + end + + it 'excludes disabled custom tools' do + custom_tool = create(:captain_custom_tool, :disabled, account: account) + + tools = assistant.send(:agent_tools) + + expect(tools.map(&:name)).not_to include(custom_tool.slug) + end + + it 'excludes custom tools from other accounts' do + custom_tool = create(:captain_custom_tool) + + tools = assistant.send(:agent_tools) + + expect(tools.map(&:name)).not_to include(custom_tool.slug) + end + + it 'keeps the built-in FAQ lookup and handoff tools' do + tools = assistant.send(:agent_tools) + + expect(tools).to include( + an_instance_of(Captain::Tools::FaqLookupTool), + an_instance_of(Captain::Tools::HandoffTool) + ) + end + end +end From e32849246300f3040f6c5e5a0e7944b4532f75f9 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Wed, 15 Jul 2026 12:30:46 +0530 Subject: [PATCH 18/19] refactor(automations): fold expiry check into skip_reason_for guard chain --- .../process_pending_execution_job.rb | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/app/jobs/automation_rules/process_pending_execution_job.rb b/app/jobs/automation_rules/process_pending_execution_job.rb index d89c6e3ce..4869b25cb 100644 --- a/app/jobs/automation_rules/process_pending_execution_job.rb +++ b/app/jobs/automation_rules/process_pending_execution_job.rb @@ -8,8 +8,6 @@ class AutomationRules::ProcessPendingExecutionJob < ApplicationJob # Atomic claim: a duplicate enqueue (overlapping sweep or stale reclaim) loses here and returns. return unless pending_execution.claim! - return pending_execution.update!(status: :skipped, skip_reason: 'expired') if expired?(pending_execution) - skip_reason = skip_reason_for(pending_execution) return pending_execution.update!(status: :skipped, skip_reason: skip_reason) if skip_reason @@ -21,15 +19,22 @@ class AutomationRules::ProcessPendingExecutionJob < ApplicationJob private - def expired?(pending_execution) - pending_execution.due_at < AutomationRulePendingExecution::DUE_WINDOW.ago + 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 skip_reason_for(pending_execution) + def structural_skip_reason(pending_execution) rule = pending_execution.automation_rule return 'rule_inactive' if rule.nil? || !rule.active? return 'flag_disabled' unless pending_execution.account.feature_enabled?('delayed_automations') 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 'conditions_changed' unless conditions_still_match?(pending_execution) From 7a73a687538e06e663b685c56df52da45df9a6eb Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Wed, 15 Jul 2026 12:36:58 +0530 Subject: [PATCH 19/19] fix(automations): cancel stale pending runs on delay edit; restrict conversation-level delayed rules to status conditions - Editing a rule's execution_delay (removing or changing it) now cancels any pending executions armed under the old configuration instead of leaving them to fire on a stale schedule. - conversation_created/updated/opened/resolved delayed rules key their episode on status_changed_at alone, so a delayed condition on any other attribute (assignee, team, priority, ...) could collapse distinct qualifying periods into one episode. Restricted to status conditions until episodes track per-attribute change times. --- app/models/automation_rule.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/models/automation_rule.rb b/app/models/automation_rule.rb index 9b7d578b7..01ee19dad 100644 --- a/app/models/automation_rule.rb +++ b/app/models/automation_rule.rb @@ -35,8 +35,11 @@ class AutomationRule < ApplicationRecord 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? } + # Rows already armed under the old delay must not fire on a config the rule no longer has. + after_update :cancel_stale_pending_executions, if: -> { saved_change_to_execution_delay? } scope :active, -> { where(active: true) } @@ -110,6 +113,22 @@ class AutomationRule < ApplicationRecord errors.add(:execution_delay, 'cannot be used with attribute_changed conditions.') end + # Conversation-level events (anything but message_created) key their episode on + # status_changed_at alone. A delayed condition on any other attribute (assignee, team, + # priority, ...) would collapse distinct qualifying periods into one episode and could + # fire on a stale window, so only status conditions are supported until episodes track + # per-attribute change times. + def execution_delay_supported_event + return if execution_delay.blank? || conditions.blank? || event_name == 'message_created' + return if conditions.all? { |obj| obj['attribute_key'] == 'status' } + + errors.add(:execution_delay, 'only supports status conditions for conversation-level events.') + end + + def cancel_stale_pending_executions + pending_executions.pending.find_each { |execution| execution.update!(status: :skipped, skip_reason: 'rule_edited') } + end + def validate_single_condition(condition) query_operator = condition['query_operator']