simpla.fydocs
Hub APIProduto

Conversas

Threads cross-channel no Hub: como conversas são formadas a partir de adapters de canal, identidade do contato, inbox operacional e auditoria.

Conversas

Conversas são a unidade central de relacionamento no Hub. Cada thread agrupa mensagens trocadas entre um contato e um agente, independentemente do canal de origem (WhatsApp, email, web). O módulo de conversas é provider-agnostic: adapters de canal traduzem payloads externos em uma CanonicalMessage, e um único endpoint de ingestão persiste a mensagem, resolve a identidade do contato e devolve um conversationId estável.

Esta página descreve a anatomia da conversa, o contrato dos adapters, o inbox operacional, a identidade do contato e as garantias de auditoria.

Anatomia de uma conversa

Uma conversa no Hub é uma linha em Conversation com a chave única (organizationId, agentId, crmLeadId). Isso garante exatamente uma thread por triplo organização + agente + lead, independente de quantos canais o contato use.

Cada mensagem persistida em Message carrega:

  • provider e externalId para idempotência via @@unique([provider, externalId]).
  • direction (INBOUND ou OUTBOUND) e authorType (AGENT, OPERATOR, SYSTEM).
  • contentType (texto, mídia, etc.) e rawProviderPayload para auditoria.
  • conversationId opcional, permitindo late-binding de mensagens órfãs antes da resolução do lead.
  • sentAt quando o payload provê timestamp; nunca usar o relógio local como fallback.

A ordenação dentro da thread é determinística: sentAt ASC, provider ASC, id ASC. Esse critério é usado tanto pelo context-builder.ts (snippet de contexto para o LLM) quanto pelo endpoint GET /api/conversations/[id]/messages que alimenta o inbox.

O write path canônico é único. Adapters NUNCA escrevem direto em Message ou Conversation. Eles produzem CanonicalMessage e fazem POST para /api/internal/conversations/ingest. Isso preserva a idempotência, a resolução de identidade e a auditoria.

Canais (WhatsApp, email, web)

O Hub trata canais via o pattern de adapter declarado em src/lib/channels/adapters/. Cada adapter exporta uma função pura:

function <provider>ToCanonical(
  payload: unknown,
  channelId: string,
): CanonicalMessage | null

Contrato obrigatório:

Sem I/O. O adapter não pode chamar banco, HTTP nem usar o relógio. Se o payload não tem timestamp, sentAt fica null. Testes rodam só com fixtures empíricas.

Identidade vem da foundation. Para WhatsApp, a classificação de sufixo JID/LID/PHONE precisa passar por extractContactHint(key) e identifierTriplet({ jid, lid }) de src/lib/crm/followize-contact-mapper.ts. Reimplementar isso causa drift e quebra a deduplicação de ContactIdentifier.

Idempotência. externalId deve ser o id nativo do provider (no Evolution: key.id). A ingestão deduplica em (provider, externalId). Sintetizar ids quebra dedup.

Retornar null para tipos não suportados. Status updates, reações soltas e payloads malformados retornam null. O ingester ignora silenciosamente. Nunca lançar exceção.

Self-message e grupo. fromMe=true mapeia para direction='OUTBOUND' e authorType='AGENT'. Grupos retornam canonical com rawProviderPayload.isGroup=true e quem decide skip é o caller. Self-message em grupo: identifiers vazio, rawSenderType='SYSTEM', mas rawSenderId preservado para rastreabilidade.

O adapter de referência é src/lib/channels/adapters/evolution.ts (WhatsApp via Evolution API). Novos canais (Twilio, Telegram, email transacional) seguem o mesmo template, com switch exaustivo messageType para contentType, extração de body e mídia por tipo e default null.

Fixtures de cada provider vivem versionadas em apps/web/__tests__/fixtures/<provider>/*.json e nunca em tmp/, garantindo reprodutibilidade entre worktrees e CI.

A coluna ChannelType do Prisma exige cuidado ao adicionar valores novos: o route de listagem do inbox faz cast para never por causa de mismatch de enum. Após prisma generate, revisar o cast em Prisma.ChannelWhereInput.

Inbox

O Inbox é a UI operacional cross-channel, montada em /inbox com layout de 2 panes (filtros à esquerda, lista virtualizada à direita) e thread view em /inbox/[id].

Listagem

A fonte de dados é a tabela Conversation joinada a CrmLead, lastUsedChannel e a última Message. Como @@unique([organizationId, agentId, crmLeadId]) garante unicidade, cada linha é um triplo distinto.

Endpoint:

GET /api/conversations
  ?channel=EVOLUTION,WABA
  &agent=<agentId>,<agentId2>
  &status=unread|all
  &search=<termo>
  &cursor=<opaque>

Resposta:

{
  "items": [
    {
      "id": "conv_...",
      "lead": { "id": "...", "name": "...", "identifiers": [] },
      "lastMessageAt": "2026-05-29T12:34:56.000Z",
      "lastDirection": "INBOUND",
      "channels": ["EVOLUTION"]
    }
  ],
  "nextCursor": null
}

A query é escopada por organização via cookie x-current-org-id. O hook useConversations(filters) (src/hooks/use-conversations.ts) usa useInfiniteQuery e dispara o fetch da próxima página quando o virtualizer chega a 3 linhas do fim. Filtros persistem na URL via nuqs (arrays serializados como CSV).

Thread view

A página /inbox/[id] carrega meta da conversa, mensagens paginadas via cursor e um painel de contexto do lead à direita.

GET /api/conversations/[id]
GET /api/conversations/[id]/messages?limit=50&cursor=<msg-id>
POST /api/conversations/[id]/mark-read

Comportamento:

  • O cursor é resolvido lendo o createdAt do id informado e aplicando createdAt > cursor.createdAt. Usar createdAt (NOT NULL) ao invés de sentAt mantém a desigualdade total.
  • mark-read faz updateMany em mensagens INBOUND com readAt: null. A contagem de não lidas é derivada (COUNT(*) WHERE direction='INBOUND' AND readAt IS NULL), não denormalizada.
  • O response inclui Cache-Control: no-store e X-Invalidate: conversations,conversation:<id> para invalidação de cache no cliente.
  • Mismatch de organização retorna 404 (não 403), evitando enumeration por id.
  • Mensagens com conversationId IS NULL (órfãs) não aparecem no thread porque o filtro do endpoint exige o id da conversa.

A flag unreadCount exposta no listing hoje deriva de lastDirection === 'INBOUND' (0 ou 1). Per-message read tracking entra com a próxima onda de inbox.

Identidade do contato

Toda mensagem ingerida passa por findOrLinkLeadByContact (@/lib/crm/contact-resolver). Esse é o único caminho legítimo para resolver lead a partir de identidade — o módulo de ingestão não reimplementa triplet logic.

O resolver implementa 8 etapas:

Monta o triplet de identificadores a partir do CanonicalMessage (PHONE, WHATSAPP_JID, WHATSAPP_LID, EMAIL, etc.). Retorna null se vazio.

Lookup em ContactIdentifier pelo triplet.

Fallback legado em CrmLead.contactPhone IN phoneVariantsBr(raw) para cobrir Followize antigo, importações Pipefy e bulk imports sem rows em ContactIdentifier.

Multi-match aciona merge oldest-wins via mergeLeadsRuntime.

Single-match reutiliza o lead.

No-match cria o lead com externalId sintético via crypto.randomUUID().

Upsert aditivo dos identificadores (idempotente).

Retorna { lead, created, identifiersAdded }.

A conversa é então upserted em @@unique([organizationId, agentId, crmLeadId]). Quando o resolver retorna null (mensagem sem identificadores resolvíveis), a mensagem é persistida com conversationId=null e uma chamada posterior pode fazer o late-binding.

Calls diretos a prisma.crmLead.upsert ou prisma.crmLead.create a partir de adapters, webhooks ou rotas de ingestão são proibidos. Qualquer write-site novo deve passar pelo resolver e ser coberto por testes E2E com pelo menos 3 formatos de telefone. A reimplementação de canonicalização ou parse de JID/LID em call-sites causa lead duplicado e thread fragmentada.

LeadContext

O builder de contexto (format-lead.ts) deriva phone_known: boolean exclusivamente de rows ContactIdentifier do tipo PHONE. O legado lead.contactPhone não conta. Essa convenção mantém o flag alinhado com a flag crm_phone_known consumida pelo loop agentic do consumer.

Transcripts e auditoria

Cada conversa tem dois consumidores principais de transcript:

Garantias de auditoria

  • Idempotência. Toda mensagem é deduplicada em (provider, externalId). Reentregas de webhooks são absorvidas sem duplicação.
  • Raw payload. O payload original é persistido em rawProviderPayload, permitindo reanálise mesmo se o adapter mudar.
  • Histórico de identidade. Cada criação, link ou merge de lead gera linha em CrmLeadHistory. Merges são também marcados em CrmLeadMerge (self-relation com mergedIntoId).
  • Cross-org isolation. Endpoints retornam 404 ao invés de 403 em mismatch de org, evitando que ids sejam usados para descobrir conversas de outra organização.
  • Ingestão internal-only. POST /api/internal/conversations/ingest exige Authorization: Bearer ${MCP_INTERNAL_SECRET}. O header legado x-internal-admin: true foi removido.

Smoke test

Para validar o caminho completo de ingestão e contexto:

MCP_INTERNAL_SECRET=... \
ORG_ID=... \
AGENT_ID=... \
PIPELINE_ID=... \
./apps/web/scripts/smoke-conversations.sh

O script exercita ingestão, reingestão idempotente e leitura de contexto, retornando 201 no primeiro POST, 200 no segundo e 200 no GET.

A persistência canônica de conversas depende da flag ENABLE_CANONICAL_HISTORY=true nos pods simplafy-hub-langgraph e simplafy-hub-consumer. As duas devem estar alinhadas — desalinhamento gera estado inconsistente entre o agente e o middleware de histórico.

On this page