Skip to content

Módulo Chatwoot

Localização: backend/src/modules/integrations/whatsapp/chatwoot/Serviço auxiliar: backend/src/common/services/chatwoot-db.service.ts

O Chatwoot é a plataforma de atendimento onde os agentes respondem às conversas dos clientes. O CRM se integra com o Chatwoot por dois caminhos: via API REST (operações convencionais de mensagens, contatos e conversas) e via banco de dados direto com PostgreSQL (operações privilegiadas sem suporte na API, como controle de sessão e invalidação de tokens).


Arquivos e Responsabilidades

ArquivoClasseResponsabilidade
chatwoot-api.service.tsChatwootApiServiceHTTP client para a API REST do Chatwoot — todos os métodos de contato, conversa, mensagem, agente e inbox
chatwoot-config.service.tsChatwootConfigServiceGerenciamento da configuração por organização: salva, recupera, valida e resolve a API key com suporte a Azure Key Vault
chatwoot-kv.service.tsChatwootKvServiceClient Azure Key Vault para armazenamento seguro das API keys da organização, com cache in-memory e retry
conversation-reassign.service.tsConversationReassignServiceCron a cada 5 minutos: detecta conversas aguardando agente além do timeout e reatribui para a caixa e-commerce
common/services/chatwoot-db.service.tsChatwootDbServiceAcesso direto ao banco PostgreSQL do Chatwoot via pool de conexões (sem Prisma) para operações que a API REST não expõe

Configuração

Cada organização armazena sua própria configuração do Chatwoot na tabela ChatwootConfig:

typescript
{
  instanceUrl: string;   // ex: "https://chatwoot.empresa.com.br"
  apiKey: string;        // ponteiro KV ("kv://chatwoot-apikey-{orgId}") ou AES-256-GCM criptografado
  accountId: number;     // ID da conta no Chatwoot
  whatsappMode: 'evolution' | 'waba_official';
  waitTimeoutEnabled: boolean;
  waitTimeoutMinutes: number;  // 1–1440
  reassignmentMessage: string | null;
}

Endpoints de configuração (permissão: app:integrations):

MétodoPathDescrição
GET/whatsapp/chatwoot/configRetorna configuração atual (sem expor a API key)
POST/whatsapp/chatwoot/configSalva ou atualiza a configuração
GET/whatsapp/chatwoot/healthVerifica conectividade com o Chatwoot
GET/whatsapp/chatwoot/modeRetorna o modo WhatsApp atual
POST/whatsapp/chatwoot/modeDefine o modo (evolution ou waba_official)
GET/whatsapp/chatwoot/wait-timeoutRetorna configuração de timeout de reatribuição
POST/whatsapp/chatwoot/wait-timeoutDefine timeout e mensagem de reatribuição

ChatwootConfigService

Classe: ChatwootConfigServiceImplements: OnApplicationBootstrap

Responsável por gerenciar o ciclo de vida da API key de cada organização. Suporta dois modelos de armazenamento: ponteiro para Azure Key Vault (preferido) ou criptografia AES-256-GCM no banco (fallback).

Inicialização (onApplicationBootstrap)

Executado na subida do backend. Percorre todas as ChatwootConfig existentes e:

  1. Para cada organização, chama healConfig() para detectar e corrigir API keys em formato inválido.
  2. Identifica quais organizações usam ponteiros KV.
  3. Aquece o cache KV em paralelo com warmAll() — elimina cold-start de 2–3s na primeira requisição pós-deploy.

healConfig(orgId, storedValue): void

Corrige automaticamente a API key se estiver em formato inválido:

  • Se já for ponteiro KV (kv://...) → ignora.
  • Se for plain text (sem dois-pontos duplos) → persiste via persistApiKey() (grava no KV se disponível, senão criptografa).
  • Se for criptografado mas com KV disponível → migra para KV e atualiza o ponteiro no banco.
  • Se estiver corrompido (descriptografia falha) → loga erro crítico com instrução de ação manual.

saveConfig(organizationId, dto): Promise<ChatwootConfig>

Salva a configuração completa de uma organização. A API key é processada via persistApiKey() antes de ser gravada.

Parâmetros:

  • organizationId: string
  • dto: SaveChatwootConfigDto{ instanceUrl, apiKey, accountId }

getConfig(organizationId): Promise<object | null>

Retorna a configuração pública (sem API key) para exibição no frontend.

getConfigInternal(organizationId): Promise<object | null>

Retorna a configuração completa com a API key em texto claro. Usado internamente pelo ChatwootApiService. Resolve o valor via resolveApiKey().

resolveApiKey(orgId, stored): Promise<string>

Resolve o valor real da API key a partir do valor armazenado:

  1. Se for ponteiro KV → busca via ChatwootKvService.get(). Se retornar null → lança erro crítico.
  2. Se for plain text → lança erro (instrução de reiniciar backend).
  3. Se for criptografado → decripta com EncryptionService. Se falhar → lança erro (instrução de re-salvar).

persistApiKey(orgId, plainText): Promise<string>

Persiste a API key no formato mais seguro disponível:

  1. Se KV disponível → escreve no Key Vault, atualiza DB com ponteiro, retorna ponteiro.
  2. Se KV indisponível → criptografa com AES-256-GCM, retorna o blob criptografado.

getWhatsappMode / setWhatsappMode

Lê e escreve o campo whatsappMode (evolution | waba_official) na ChatwootConfig.

getWaitTimeout / setWaitTimeout

Lê e escreve os campos waitTimeoutEnabled, waitTimeoutMinutes (clamp 1–1440) e reassignmentMessage.


ChatwootKvService

Classe: ChatwootKvService

Client para o Azure Key Vault responsável por armazenar as API keys das organizações fora do banco de dados. Mantém cache in-memory para evitar latência em requisições recorrentes.

Constantes e Convenções

typescript
const KV_PREFIX = 'chatwoot-apikey-';
const POINTER_PREFIX = 'kv://';
// Nome do secret no KV: "chatwoot-apikey-{orgId}"
// Ponteiro no DB: "kv://chatwoot-apikey-{orgId}"

Inicialização

AZURE_KEYVAULT_URL do ambiente. Se presente, instancia SecretClient com DefaultAzureCredential. Se ausente ou se a inicialização falhar → client = null (fallback para DB-encrypted).

isAvailable(): boolean

Retorna true se o client KV foi inicializado com sucesso.

isPointer(value): boolean

Retorna true se o valor armazenado no DB começa com kv://.

pointer(orgId): string

Retorna o valor de ponteiro a ser gravado no banco: kv://chatwoot-apikey-{orgId}.

get(orgId): Promise<string | null>

Busca a API key do KV com cache in-memory e retry com backoff:

  1. Verifica cache in-memory (Map<string, { value }>). Se presente → retorna imediatamente.
  2. Tenta secretClient.getSecret(secretName) com delays [0ms, 500ms, 1500ms].
  3. Se bem-sucedido → popula cache e retorna valor.
  4. Se todas as tentativas falharem → loga [CRITICAL] e retorna null.

set(orgId, apiKey): Promise<boolean>

Grava a API key no KV e invalida o cache para forçar re-fetch na próxima leitura.

warmAll(orgIds): Promise<void>

Aquece o cache para uma lista de organizações em paralelo (Promise.allSettled). Chamado no bootstrap do ChatwootConfigService.


ChatwootApiService

Classe: ChatwootApiService

HTTP client completo para a API REST do Chatwoot. Cada método resolve internamente a configuração da organização via ChatwootConfigService.getConfigInternal(). Todos os erros são capturados e logados sem propagar exceção — retornam null, false ou [] conforme o tipo de retorno.

Header de autenticação: api_access_token: {apiKey} (header específico do Chatwoot, não Bearer).

Timeout padrão: CRM_CONFIG.HTTP.TIMEOUT_SHORT_MS para a maioria das operações. TIMEOUT_UPLOAD_MS para uploads com FormData.

Gestão de Contatos

findOrCreateContact(organizationId, phone, name?): Promise<ChatwootContact | null>

Busca contato por telefone e cria se não existir.

Fluxo:

  1. GET /api/v1/accounts/{accountId}/contacts/search?q={phone}
  2. Busca match exato em phone_number (com ou sem +).
  3. Se encontrado e name fornecido → chama updateContact() em fire-and-forget.
  4. Se não encontrado → POST /api/v1/accounts/{accountId}/contacts com { phone_number: "+{phone}", name }.
  5. Retorna ChatwootContact | null.

updateContact(organizationId, contactId, name): Promise<void>

Atualiza o nome de um contato. Fire-and-forget — erros são logados como warning, nunca propagados.

PATCH /api/v1/accounts/{accountId}/contacts/{contactId} com { name }.

syncContactName(organizationId, phone, name): Promise<boolean>

Busca contato por telefone e atualiza o nome se encontrado. Não cria novo contato. Retorna true se atualizado, false se não encontrado.

Gestão de Conversas

findOrCreateConversation(organizationId, contactId, inboxId, initialMessage?, attributionWindowDays?): Promise<{ conversation, isNew } | null>

Busca ou cria conversa para um contato em uma inbox. Suporta reabrir conversa dentro da janela de atribuição.

Fluxo:

  1. GET /api/v1/accounts/{accountId}/contacts/{contactId}/conversations
  2. Procura conversa open ou pending na inbox especificada → retorna { isNew: false }.
  3. Se attributionWindowDays > 0 → procura a conversa resolved mais recente na janela de tempo. Se encontrada → reabre via updateConversationStatus('open') → retorna { isNew: false }.
  4. Se nenhuma encontrada → POST /api/v1/accounts/{accountId}/conversations com { source_id: "crm_campaign_{timestamp}", inbox_id, contact_id, status: "open", message? } → retorna { isNew: true }.

findOpenConversation(organizationId, contactId, inboxId): Promise<ChatwootConversation | null>

Busca conversa open ou pending de um contato em uma inbox. Não cria. Retorna null se não existir.

findAnyOpenConversation(organizationId, contactId, excludeInboxId?): Promise<ChatwootConversation | null>

Busca qualquer conversa open de um contato em qualquer inbox. Usado para decidir se deve abrir seletor de loja. Pode excluir uma inbox específica.

findConversationWithLabel(organizationId, contactId, inboxId, label): Promise<ChatwootConversation | null>

Busca qualquer conversa (qualquer status) de um contato em uma inbox que possua o label especificado. Usado para detectar cliente_em_atendimento mesmo em conversas resolvidas.

updateConversationStatus(organizationId, conversationId, status): Promise<boolean>

Muda o status de uma conversa. Usa withRetry com as configurações de CRM_CONFIG.CHATWOOT.

POST /api/v1/accounts/{accountId}/conversations/{conversationId}/toggle_status com { status }.

Status válidos: 'open' | 'pending' | 'resolved'.

createConversation(organizationId, inboxId, contactId): Promise<number | null>

Cria uma nova conversa vazia para um contato em uma inbox. Usa withRetry. Retorna o ID da conversa criada.

POST /api/v1/accounts/{accountId}/conversations com { inbox_id, contact_id }.

getConversationsByLabel(organizationId, label): Promise<Array<...>>

Retorna todas as conversas abertas com um label específico, paginando automaticamente (máximo 20 páginas = 500 conversas).

GET /api/v1/accounts/{accountId}/conversations?status=open&labels[]={label}&page={n}

Retorna array com: { id, createdAt, lastActivityAt, inboxId, contactId, customerName, customerPhone }.

markConversationUnread(organizationId, conversationId): Promise<boolean>

Marca todas as mensagens de uma conversa como não lidas (dispara badge de não lido para o agente atribuído).

POST /api/v1/accounts/{accountId}/conversations/{conversationId}/unread

getInboxOpenConversationIds(organizationId, inboxId): Promise<number[]>

Retorna IDs de todas as conversas abertas de uma inbox, percorrendo todas as páginas (25 por página).

GET /api/v1/accounts/{accountId}/conversations?inbox_id={inboxId}&status=open&page={n}

getConversationContact(organizationId, conversationId): Promise<{ id, phone, name } | null>

Retorna dados do contato de uma conversa via meta.sender do payload.

GET /api/v1/accounts/{accountId}/conversations/{conversationId}

getConversationPhone(organizationId, conversationId): Promise<string | null>

Atalho para getConversationContact().phone.

Mensagens

sendOutboundMessage(organizationId, conversationId, content, messageType?, agentId?, contentAttributes?): Promise<number | null>

Envia mensagem visível ao cliente. Usa withRetry. Retorna o ID da mensagem criada.

POST /api/v1/accounts/{accountId}/conversations/{conversationId}/messages

typescript
// Payload enviado
{
  content: string,
  message_type: 'outgoing' | 'activity',  // padrão: 'outgoing'
  private: false,
  sender_id?: number,          // agentId se fornecido
  content_attributes?: object  // ex: { automated: true }
}

sendIncomingMessage(organizationId, conversationId, content): Promise<number | null>

Envia mensagem como se fosse o cliente (tipo incoming). Aparece no Chatwoot como mensagem recebida do cliente. Usado em canais API para espelhar mensagens WhatsApp.

sendIncomingMessageWithAttachment(organizationId, conversationId, content, buffer, mimeType, fileName): Promise<number | null>

Envia mensagem com anexo via multipart/form-data. Usa timeout extendido (TIMEOUT_UPLOAD_MS) e maxBodyLength: Infinity para suportar arquivos grandes.

sendPrivateNote(organizationId, conversationId, content, important?): Promise<number | null>

Envia nota privada visível apenas para agentes (private: true).

typescript
// Payload enviado
{ content, message_type: 'outgoing', private: true }

Se important = true → chama toggleMessageImportant() após criação para marcar a mensagem como importante no Chatwoot.

toggleMessageImportant(organizationId, conversationId, messageId): Promise<void>

Marca/desmarca uma mensagem como importante.

POST /api/v1/accounts/{accountId}/conversations/{conversationId}/messages/{messageId}/toggle_important

getConversationMessages(organizationId, conversationId, limit?): Promise<Array<...>>

Retorna as últimas N mensagens de uma conversa (padrão: 4). Filtra mensagens de atividade (message_type = 2) e vazias.

GET /api/v1/accounts/{accountId}/conversations/{conversationId}/messages

Retorna: { senderName, content, type: 'in' | 'out' }[].

Labels de Conversas

setConversationLabels(organizationId, conversationId, labels): Promise<boolean>

Substitui todos os labels de uma conversa pela lista fornecida.

POST /api/v1/accounts/{accountId}/conversations/{conversationId}/labels com { labels }.

addConversationLabel(organizationId, conversationId, label): Promise<boolean>

Adiciona um label sem remover os existentes. Primeiro faz GET para buscar os labels atuais, depois POST com a lista atualizada. Idempotente: não duplica se o label já existir.

swapConversationLabel(organizationId, conversationId, remove, add): Promise<boolean>

Troca um label por outro preservando os demais. Combina GET para buscar lista atual + POST com lista modificada.

Agentes

findOrCreateAgent(organizationId, email, name): Promise<number | null>

Busca agente por email no Chatwoot e cria se não existir.

  1. GET /api/v1/accounts/{accountId}/agents — lista completa de agentes.
  2. Filtra por email (case-insensitive).
  3. Se não encontrado → POST /api/v1/accounts/{accountId}/agents com { name, email, role: 'agent' }.

findAgentByEmail(organizationId, email): Promise<number | null>

Busca agente por email sem criar. Retorna apenas o ID.

assignConversation(organizationId, conversationId, agentId): Promise<boolean>

Atribui uma conversa a um agente. Usa withRetry.

POST /api/v1/accounts/{accountId}/conversations/{conversationId}/assignments com { assignee_id: agentId }.

listInboxAgents(organizationId, inboxId): Promise<Array<{ id, name, email }>>

Lista agentes atribuídos a uma inbox.

GET /api/v1/accounts/{accountId}/inbox_members/{inboxId}

addAgentToInbox(organizationId, inboxId, agentIds): Promise<boolean>

Adiciona um ou mais agentes a uma inbox.

POST /api/v1/accounts/{accountId}/inbox_members com { inbox_id, user_ids }.

removeAgentFromInbox(organizationId, inboxId, agentIds): Promise<boolean>

Remove agentes de uma inbox.

DELETE /api/v1/accounts/{accountId}/inbox_members com body { inbox_id, user_ids }.

setAgentAvailability(organizationId, agentId, availability): Promise<boolean>

Define o status de disponibilidade de um agente.

PUT /api/v1/accounts/{accountId}/agents/{agentId} com { availability }.

Status válidos: 'online' | 'offline' | 'busy'.

deleteAgent(organizationId, agentId): Promise<boolean>

Remove um agente da conta do Chatwoot permanentemente.

DELETE /api/v1/accounts/{accountId}/agents/{agentId}

hasOpenConversations(organizationId, contactId): Promise<boolean>

Retorna true se o contato tiver pelo menos uma conversa open ou pending.

Gestão de Inboxes

createApiChannelInbox(organizationId, name): Promise<{ id, webhookUrl } | null>

Cria uma inbox do tipo api (canal customizado) no Chatwoot.

POST /api/v1/accounts/{accountId}/inboxes com { name, channel: { type: 'api', webhook_url: '' } }.

deleteInbox(organizationId, inboxId): Promise<boolean>

Remove uma inbox.

DELETE /api/v1/accounts/{accountId}/inboxes/{inboxId}

listInboxes(organizationId): Promise<Array<{ id, name, channel_type }>>

Lista todas as inboxes da conta.

GET /api/v1/accounts/{accountId}/inboxes

findInboxByName(organizationId, name): Promise<{ id, name } | null>

Busca inbox por nome exato. Atalho para listInboxes() com filtro em memória.

updateInboxWebhookUrl(organizationId, inboxId, webhookUrl): Promise<boolean>

Atualiza a URL de webhook de uma inbox do tipo API Channel. Usado ao configurar o modo WABA oficial.

PATCH /api/v1/accounts/{accountId}/inboxes/{inboxId} com { channel: { webhook_url } }.


ChatwootDbService

Classe: ChatwootDbServiceImplements: OnModuleDestroy

Acesso direto ao banco PostgreSQL do Chatwoot via pg.Pool. Conecta ao database chatwoot_primicia derivando a string de conexão de DATABASE_URL. Opera sem Prisma — queries SQL nativas.

Atenção

Este serviço acessa diretamente o schema interno do Chatwoot. Upgrades do Chatwoot podem renomear colunas ou tabelas e quebrar silenciosamente estas queries. Sempre testar após upgrade.

Gestão de Sessão de Agentes

blockAgentLogins(emails): Promise<number>

Bloqueia o login de agentes para as próximas 24h. Escreve login_blocked_until no campo JSONB custom_attributes dos usuários. O initializer Ruby login_blocker.rb do Chatwoot lê este campo e rejeita sign-in enquanto o timestamp estiver no futuro.

sql
UPDATE users
SET custom_attributes = custom_attributes || jsonb_build_object('login_blocked_until', $2::text)
WHERE email = ANY($1)

unblockAgentLogins(emails): Promise<number>

Remove a flag de bloqueio. Idempotente — seguro chamar quando o agente não está bloqueado.

sql
UPDATE users
SET custom_attributes = custom_attributes - 'login_blocked_until'
WHERE email = ANY($1)

unblockInboxAgents(inboxId): Promise<number>

Desbloqueia todos os agentes de uma inbox pelo ID da inbox (sem precisar de emails).

sql
UPDATE users u
SET custom_attributes = custom_attributes - 'login_blocked_until'
FROM inbox_members im
WHERE im.user_id = u.id AND im.inbox_id = $1
  AND u.custom_attributes ? 'login_blocked_until'

rotateAgentsTokens(emails): Promise<number>

Invalida todas as sessões ativas dos agentes zerando o campo tokens (DeviseTokenAuth). Agentes com aba aberta perdem a sessão imediatamente via ActionCable/Redis pubsub (se combinado com getAgentPubsubTokens).

sql
UPDATE users SET tokens = NULL WHERE email = ANY($1)

getAgentPubsubTokens(emails): Promise<string[]>

Retorna os pubsub_token de agentes pelo email. Usado para fazer broadcast de user:logout via Redis ActionCable e expulsar abas abertas.

sql
SELECT pubsub_token FROM users WHERE email = ANY($1) AND pubsub_token IS NOT NULL

confirmAgentWithPassword(email, password): Promise<boolean>

Confirma o cadastro de um agente no Chatwoot e define sua senha. Gera hash bcrypt (custo 11) e atualiza encrypted_password, confirmed_at, confirmation_token e confirmation_sent_at.

Gestão de Conta e Inbox

removeFromAccount(chatwootUserId, accountId): Promise<boolean>

Remove o vínculo de um usuário com uma conta Chatwoot (account_users). Preserva histórico de mensagens (referenciadas por users.id, não account_users.id).

sql
DELETE FROM account_users WHERE user_id = $1 AND account_id = $2

addAgentToInbox(userId, inboxId): Promise<boolean>

Adiciona agente a uma inbox. ON CONFLICT DO NOTHING — idempotente.

removeAgentFromAllInboxes(chatwootUserId): Promise<number>

Remove agente de todas as inboxes de uma vez. Usado ao desativar vendedor no CRM.

getInboxAgentIds(inboxId): Promise<number[]>

Retorna user_id de todos os agentes de uma inbox.

sql
SELECT user_id FROM inbox_members WHERE inbox_id = $1

Busca de Dados

findChatwootUserId(email, name): Promise<number | null>

Busca o ID de usuário do Chatwoot por email (preferencial) ou nome (fallback, ILIKE com suporte ao padrão NOME - LOJA).

findAgentInInbox(sellerName, inboxId): Promise<number | null>

Busca agente em uma inbox por nome do vendedor. Aceita nome exato ou no padrão NOME - SUFIXO_LOJA (convenção de nomenclatura do Chatwoot).

sql
SELECT u.id FROM users u
JOIN inbox_members im ON im.user_id = u.id
WHERE im.inbox_id = $1
  AND (LOWER(u.name) = $2 OR LOWER(u.name) LIKE $3)
LIMIT 1

getContactPhone(contactId): Promise<string | null>

Busca phone_number de um contato Chatwoot pelo ID.

getOpenConversationsForAgent(agentId, inboxIds): Promise<Array<...>>

Retorna conversas abertas (status = 0) atribuídas a um agente em um conjunto de inboxes.

sql
SELECT id, contact_id, inbox_id, account_id
FROM conversations
WHERE assignee_id = $1 AND status = 0 AND inbox_id = ANY($2)

Auditoria de Ações

getLastReopenActor(conversationId): Promise<'agent' | 'administrator' | null>

Determina o papel do usuário que reabriu uma conversa pela última vez. Extrai o nome do ator a partir do texto da mensagem de atividade (message_type = 2) e faz JOIN com account_users para obter o role.

Retorna null quando a ação foi feita pelo sistema/API (cron, campanha) — nesses casos a reabertura é permitida.

Usado para aplicar a regra: apenas administradores podem reabrir conversas resolvidas.

getLastResolveActor(conversationId): Promise<'agent' | 'administrator' | null>

Mesmo padrão de getLastReopenActor, mas para resoluções (content LIKE '%resolvida por%').

Operações de Banco Especializado

assignAndNoteConversation(convId, accountId, assigneeId, note): Promise<void>

Atribui conversa e insere nota privada em uma única operação. Usado pelo sistema de reatribuição de conversas. Faz dois UPDATEs SQL sequenciais.

setMessageSourceId(messageId, sourceId): Promise<void>

Grava o source_id (= wamId do Meta) em uma mensagem do Chatwoot. Permite correlacionar notificações de status da Meta Graph API com mensagens no Chatwoot.

sql
UPDATE messages SET source_id = $1 WHERE id = $2

updateMessageStatusBySourceId(sourceId, status): Promise<boolean>

Atualiza o status de entrega de uma mensagem identificada pelo source_id. Prefere o caminho via API REST (que aciona ActionCable/broadcast em tempo real para atualizar os checkmarks na tela); faz fallback para SQL direto se a API falhar.

Mapeamento de status:

  • sent → 0
  • delivered → 1
  • read → 2
  • failed → 3

Fluxo:

  1. SELECT m.id, c.display_id FROM messages m JOIN conversations c WHERE m.source_id = $1
  2. Tenta PATCH {chatwootUrl}/api/v1/accounts/1/conversations/{displayId}/messages/{messageId} com timeout 5s.
  3. Se API retornar não-OK ou falhar → UPDATE messages SET status = $1 WHERE id = $2.

getWaitingLabelTimestamps(convIds): Promise<Map<number, number>>

Retorna o timestamp Unix (segundos) de quando o label cliente_aguardando foi adicionado pela última vez a cada conversa. Usado pelo ConversationReassignService para determinar desde quando o cliente aguarda. Conversas sem registro de atividade são omitidas (caller usa lastActivityAt como fallback).

sql
SELECT conversation_id, EXTRACT(EPOCH FROM MAX(created_at))::bigint AS ts
FROM messages
WHERE conversation_id = ANY($1)
  AND message_type = 2
  AND content LIKE '%adicionou cliente_aguardando%'
GROUP BY conversation_id

getExpiringConversations(inboxIds, minutesBefore): Promise<Array<...>>

Retorna conversas abertas com assignee cuja última mensagem inbound (message_type = 0) foi recebida há pelo menos (24*60 - minutesBefore) minutos. Usado para alertar sobre a janela de 24h do Meta expirando.


ConversationReassignService

Classe: ConversationReassignService

Cron que roda a cada 5 minutos (@Cron(CronExpression.EVERY_5_MINUTES)). Detecta conversas com o label cliente_aguardando que ultrapassaram o timeout configurado e as reatribui automaticamente para a inbox da loja e-commerce.

Guard anti-sobreposição: flag isRunning previne que duas execuções simultâneas do cron se sobreponham.

Labels gerenciados:

  • cliente_aguardando — label que indica cliente aguardando atendimento
  • atendimento_redirecionado — label aplicado à conversa nova e à original após reatribuição

checkWaitingConversations(): Promise<void>

Ponto de entrada do cron. Verifica isRunning, delega para run().

run(): Promise<void>

Busca todas as organizações com whatsappMode = 'waba_official' e waitTimeoutEnabled = true. Para cada uma, chama processOrg() com tratamento de erro isolado.

processOrg(org): Promise<void>

Processa uma organização:

  1. Busca loja e-commerce (isEcommerce = true) da organização.
  2. Busca chatwootInboxId do número WhatsApp da loja e-commerce.
  3. Busca agentes da inbox e-commerce + conversas com label cliente_aguardando em paralelo.
  4. Busca timestamps do label via ChatwootDbService.getWaitingLabelTimestamps().
  5. Para cada conversa que ultrapassou o cutoff de tempo:
    • Ignora se já está na inbox e-commerce.
    • Ignora se a loja e-commerce está fechada (via BusinessHoursService.isOpen()).
    • Chama reassign().

Se não houver agentes disponíveis → aplica label sem_agente_disponivel nas conversas aguardando além do timeout.

reassign(organizationId, oldConversationId, contactId, inboxId, agents, ...): Promise<void>

Reatribui uma conversa para a inbox e-commerce:

  1. Seleciona agente aleatoriamente da lista disponível.
  2. Busca o primeiro nome do agente e do cliente (para personalização da mensagem).
  3. Aplica template de mensagem com variáveis , , , .
  4. Conversa original:
    • Aplica labels [atendimento_redirecionado, cliente_aguardando].
    • Muda status para resolved.
  5. Nova conversa:
    • Cria conversa vazia na inbox e-commerce para o contato.
    • Envia nota privada com contexto do cliente (CustomerContextService) + últimas 4 mensagens da conversa anterior.
    • Atribui o agente selecionado.
    • Envia mensagem outbound (tipo outgoing, content_attributes: { automated: true }).
    • Aplica labels [atendimento_redirecionado, cliente_aguardando].
  6. Persiste evento ConversationRedirect no banco para monitoramento.

Template de mensagem padrão:

Olá! Seu atendimento foi transferido para nossa equipe. Em breve entraremos em contato.

KV Store (Armazenamento de API Keys)

O armazenamento da API key segue uma hierarquia:

Azure Key Vault (preferido)
  ↓ fallback se KV indisponível
AES-256-GCM criptografado no banco (EncryptionService)
  ↓ detectado e curado automaticamente
Plain text (estado temporário — auto-heal no bootstrap)

Fluxo de resolução em runtime:

ChatwootApiService.getConfig(orgId)
  → ChatwootConfigService.getConfigInternal(orgId)
    → prisma.chatwootConfig.findUnique()
    → resolveApiKey(orgId, stored)
      → se "kv://..." → ChatwootKvService.get(orgId) → SecretClient.getSecret()
      → se criptografado → EncryptionService.decrypt()
      → se plain text → throw (instrução de ação manual)

Tabelas do Banco

TabelaUso
ChatwootConfigConfiguração por organização (instanceUrl, apiKey pointer/encrypted, accountId, whatsappMode, waitTimeout)
StoreWhatsappNumberVínculo entre loja e chatwootInboxId
ConversationRedirectLog de reatribuições automáticas (para monitoramento)

Tabelas do banco do Chatwoot (acesso direto via ChatwootDbService):

TabelaOperações realizadas
usersblockAgentLogins, unblockAgentLogins, rotateAgentsTokens, confirmAgentWithPassword, getAgentPubsubTokens, findChatwootUserId
account_usersremoveFromAccount, getLastReopenActor, getLastResolveActor
inbox_membersaddAgentToInbox, removeAgentFromAllInboxes, getInboxAgentIds, findAgentInInbox, unblockInboxAgents
conversationsgetOpenConversationsForAgent, updateMessageStatusBySourceId (lookup), getExpiringConversations
messagessetMessageSourceId, updateMessageStatusBySourceId, getWaitingLabelTimestamps, assignAndNoteConversation, getLastReopenActor, getLastResolveActor
contactsgetContactPhone

Integração com Outros Módulos

MóduloTipo de Integração
EvolutionApiServiceConversationReassignService usa para encaminhar mensagens de saudação no modo Evolution
MetaGraphServiceMensagens outbound via WABA oficial
CustomerContextServiceConstrói nota de contexto do cliente na reatribuição
BusinessHoursServiceVerifica se loja e-commerce está aberta antes de reatribuir
WhatsappRouterServiceCria conversa Chatwoot assíncrona no fluxo de redirect WABA
EncryptionServiceAES-256-GCM para API keys sem KV disponível
PrismaServiceBanco principal do CRM

Documentação interna — Galdix CRM