Skip to content

Ciclo de vida do contato

Um Contact é a representação persistente de uma pessoa que interage com a plataforma. Este guia descreve como um contato é criado, como ele se relaciona com chats, tickets e routers, e como os dados são isolados em cenários multi-router.


Visão geral

primeira mensagem


orchestrator
  resolve/garante Contact
    ├─ WhatsApp  → find-or-create por phoneNumber (E.164)
    ├─ Instagram → find-or-create por "ig-{from}"
    └─ WebChat   → Contact já criado pelo gateway; orchestrator só o lê


  find-or-create Chat (por { contactId, routerId, status: "open" })

     ├─ mode="flow"  → stream:flow  → engine
     ├─ mode="agent" → stream:agent → flow-ai-agent (IA)
     └─ mode="human" → stream:helpdesk → Ticket

O Contact é compartilhado entre todos os routers — ele representa a identidade física da pessoa. Os Chats e Tickets, porém, são escopados por router.

Nota: a criação do Contact é assimétrica por canal. Em WhatsApp e Instagram o próprio orchestrator faz o find-or-create ao receber a primeira mensagem. Em WebChat o Contact é criado pelo flow-ai-webchat-gateway na negociação de identidade — o orchestrator apenas o busca e aborta o evento se não encontrar (incoming.ts).


1. Criação do contato

WhatsApp

Quando o orchestrator recebe a primeira mensagem de um número desconhecido:

typescript
// find-or-create por phoneNumber (normalizado E.164)
const contact =
  (await prisma.contact.findUnique({ where: { phoneNumber: from } })) ??
  (await prisma.contact.create({
    data: { phoneNumber: from, name: contactName, origin: "whatsapp" },
  }))

O name inicial vem do nome de perfil do WhatsApp (contactName). O phoneNumber é o identificador único e imutável. Em mensagens seguintes o Contact não é reescrito — a atualização do nome de perfil acontece apenas no SessionState (session.whatsApp.contact.name), não na linha do Contact.

Instagram

Para Instagram o identificador também é sintético — o IGSID do remetente (from) prefixado:

phoneNumber = "ig-{from}"

O orchestrator faz o mesmo find-or-create, com origin: "instagram":

typescript
const syntheticPhone = `ig-${from}`
const contact =
  (await prisma.contact.findUnique({ where: { phoneNumber: syntheticPhone } })) ??
  (await prisma.contact.create({
    data: { phoneNumber: syntheticPhone, name: contactName, origin: "instagram" },
  }))

Nota: from aqui é o IGSID de quem escreveu, não o igUserId da conta Instagram do router. O igUserId identifica a conta dona do canal (entidade InstagramAccount) e viaja como phoneNumberId — ele nunca é o identificador do contato.

WebChat

Para WebChat o identificador não é um número real — é um identificador sintético:

phoneNumber = "wc-{channelId}-{userId}"

Diferente de WhatsApp e Instagram, o Contact de WebChat é criado pelo gateway (flow-ai-webchat-gateway) durante a negociação de identidade, não pelo orchestrator. O userId é gerado no momento da conexão Socket.io. A entidade WebChatIdentity (com channelId + contactId) garante que o mesmo userId de um canal sempre mapeia para o mesmo Contact.


2. Campos do Contact

prisma
Contact {
  id                 String   // CUID
  phoneNumber        String   @unique
  name               String
  origin             String?  // canal de origem ("whatsapp" | "instagram" | "webchat")
  contactVariables   Json?    // variáveis persistentes — ver seção 5
  context            String?  @db.Text // resumo histórico acumulado — injetado no snapshot do agente de IA
  lastSessionSummary String?  @db.Text // resumo da sessão mais recente (gerado por LLM)
}

O context e o lastSessionSummary alimentam o agente de IA (mode: "agent"): ao iniciar um AgentBlock, o executor lê ambos do Contact e injeta no system prompt (Contexto do contato: / Resumo da última sessão:).

  • lastSessionSummary é escrito pelo consumer de stream:summary no flow-ai-core (summary/handler.ts), ao encerrar um chat ou ticket — mesmo resumo persistido em Chat.summary / Ticket.summary.
  • context é apenas lido pelo agente. Nenhum caminho atual (summarizer ou API de contato) o escreve — ele é populado fora de banda (ex: migração/ferramenta administrativa).

3. Multi-router: um contato, múltiplos chats

O mesmo contato pode conversar com dois Routers diferentes simultaneamente. Cada router tem seu próprio Chat aberto — as conversas são completamente independentes.

Contact (phoneNumber: +5511...)
  ├── Chat (routerId: router-A, status: open)   ← conversa com Bot A
  │     ├── Message ...
  │     └── Ticket (queueId: queue-A)

  └── Chat (routerId: router-B, status: open)   ← conversa com Bot B
        ├── Message ...
        └── Ticket (queueId: queue-B)

Regra de find-or-create

O orchestrator mantém no máximo um Chat aberto por par (contactId, routerId):

typescript
// find-or-create Chat
prisma.chat.findFirst({
  where: { contactId, routerId, status: "open" },
})
// se não existe:
prisma.chat.create({ data: { contactId, routerId } })

Isso significa que:

  • Dois routers distintos → dois chats distintos ✓
  • Mesmo router, mesmo contato → reutiliza o chat aberto existente ✓

Isolamento de histórico

Agentes e administradores sempre visualizam histórico filtrado por router:

TelaFiltro aplicado
Desk → histórico do contatochat.routerId = routerFilter (query) ∩ escopo RBAC do agente
Core-UI → histórico de conversasrouterId = params.id (router da página)

O listHistoryByContact sempre filtra por chat.routerId: cruza o routerFilter da query com o routerScope (routers permitidos ao principal). A interseção vazia retorna [] sem consultar o banco. Um agente atendendo pelo Router A nunca vê tickets gerados pelo Router B, mesmo que o contato seja o mesmo.


4. Chats históricos (sem routerId)

Chats criados antes do isolamento por router têm routerId = null. Eles não aparecem em queries filtradas por router, mas o dado permanece no banco — nada foi deletado. Se necessário, é possível recuperar dados históricos com uma query sem filtro de routerId.


5. Variáveis do contato (contactVariables)

contactVariables é um JSON livre — pares chave: valor persistentes que sobrevivem ao fim da sessão e são compartilhados entre todos os routers do contato.

json
{
  "cpf": "123.456.789-00",
  "plano": "premium",
  "ultima_compra": "2025-03-10"
}

Diferença em relação a session.variables

contactVariablessession.variables
EscopoContato (todos os routers)Sessão ativa
PersistênciaPermanente no PostgreSQLTTL no Redis
Gravaçãoaction setContactVariable ou APIaction setContextVariable
Leitura no engine{{contact.chave}} na interpolação ou condição com source type: "contact"{{variavel}} ou condição source type: "context"

O interpolate ({{...}}) expõe contactVariables via {{contact.<chave>}} (ou a forma explícita {{contact.variables.<chave>}}), aceitando path aninhado — ex.: {{contact.customer.nome}}. O engine e o agent pré-carregam um snapshot de contactVariables no contexto do turno (ctx.contactVars), logo a leitura é síncrona e sem I/O extra; a action setContactVariable atualiza esse snapshot para que a gravação valha já no mesmo turno.

Palavras reservadas do prefixo (resolvem o perfil do canal, não uma variável de contato): {{contact.name}} e {{contact.phoneNumber}} (alias legado), além das formas explícitas {{contact.whatsapp.name}} / {{contact.whatsapp.phoneNumber}}. Uma variável de contato chamada exatamente name/phoneNumber/whatsapp/variables fica sombreada — leia-a por {{contact.variables.name}}.

Sobrescrever o nome do contato

A action setContactName grava a coluna Contact.name (não uma variável) e marca Contact.nameLocked = true. Efeitos:

  • Reflete no desk, tickets, relatórios e quick-replies (todos leem Contact.name).
  • O engine atualiza também session.whatsApp.contact.name, então {{contact.name}} passa a devolver o nome definido no mesmo turno.
  • Com nameLocked = true, o orchestrator para de reescrever o nome com o perfil da Meta a cada inbound (incoming.ts) — o nome permanece cross-turno e cross-sessão. Contatos sem nameLocked (todos os legados) mantêm o comportamento anterior (a sessão espelha o perfil vivo).
  • Valor vazio/whitespace (ex.: {{...}} não resolvido) é ignoradoContact.name é NOT NULL e alimenta o header do desk.

A identidade do contato é o phoneNumber (@unique); o name é só exibição, então sobrescrever é seguro para roteamento/lookup.

Como gravar

Via engine (action setContactVariable, em runtime/actions.ts — faz merge com o JSON atual):

typescript
// Engine persiste diretamente no Contact
await prisma.contact.update({
  where: { id: contactId },
  data: { contactVariables: { ...current, [key]: value } },
})

Via API (patch de variáveis pelo admin ou desk):

http
PATCH /contacts/{contactId}/variables
{ "contactVariables": { "chave": "valor" } }

6. WebChatIdentity

Para WebChat, a entidade WebChatIdentity faz o vínculo entre canal e contato:

prisma
WebChatIdentity {
  channelId  → WebChatChannel
  contactId  → Contact
  sessionKeyHash  // bcrypt da sessionKey persistida no browser
  @@unique([channelId, contactId])
}

Na primeira conexão, negotiateIdentity (em flow-ai-webchat-gateway/src/auth.ts) gera userId + sessionKey, cria um Contact com phoneNumber = "wc-{channelId}-{userId}" e nome fixo "Flow Chat", e grava uma WebChatIdentity com o hash bcrypt da sessionKey.

Nas conexões seguintes, o cliente reenvia { channelId, userId, sessionKey }. O gateway busca a identidade por channelId + o phoneNumber sintético e valida a sessionKey com bcrypt.compare contra o sessionKeyHash — só então reidrata a identidade, sem criar duplicatas.


7. ContactOptIn

Controla se o contato pode receber mensagens ativas (templates HSM fora da janela de 24h) em um router específico. O modelo é um log append-only — cada mudança de consentimento gera uma nova linha, não há atualização in-place:

prisma
ContactOptIn {
  contactId  → Contact
  routerId   → Router
  status     String    // "accepted" | "revoked"
  chatId?    → Chat     // chat que gerou o registro
  source     String     @default("flow")  // "flow" | "import"
  recordedAt DateTime   @default(now())
  @@index([contactId, routerId, recordedAt(sort: Desc)])
}

Não existe @@unique([contactId, routerId]): podem coexistir vários registros para o mesmo par. O status atual é derivado pegando o registro mais recente (findFirst orderBy recordedAt desc) — ver ContactOptInService.getCurrentStatus.

Opt-in é por router — um contato pode estar accepted no Router A e revoked (ou sem registro) no Router B.

Rotas de opt-in

RotaPapel
GET /contacts/:contactId/opt-in?routerId=status atual do contato naquele router
GET /contacts/:contactId/opt-inshistórico completo (todos os registros)
GET /routers/:routerId/opt-inslista paginada do router, com filtro status e busca
POST /routers/:routerId/opt-ins/bulkimportação em massa (source: "import")

8. Arquivos relevantes

ArquivoPapel
services/flow-ai-orchestrator/src/handlers/incoming.tsfind-or-create Contact e Chat com routerId (WhatsApp + Instagram)
services/flow-ai-webchat-gateway/src/auth.tsnegotiateIdentity — resolução de WebChatIdentity e criação do Contact sintético WebChat
services/flow-ai-core/src/http/routes/contact.routes.tsPATCH variables, GET detalhe/insights, criação multi-canal
services/flow-ai-core/src/http/routes/contact-opt-in.routes.tsrotas de opt-in (status atual, histórico, lista por router, bulk)
services/flow-ai-core/src/services/contact-opt-in.service.tslog append-only de opt-in; getCurrentStatus deriva o status atual
services/flow-ai-core/src/summary/handler.tsescreve Contact.lastSessionSummary (consumer de stream:summary)
services/flow-ai-engine/src/runtime/actions.tsactions setContactVariable / setContextVariable
services/flow-ai-core/src/repositories/prisma/ticket.repository.tslistHistoryByContact(contactId, routerFilter?, whereScope?) — filtro por router
packages/flow-ai-database/prisma/schema.prismaModelos Contact, Chat, WebChatIdentity, ContactOptIn

flow-ai — plataforma proprietária de atendimento via WhatsApp