Appearance
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 → TicketO 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 oContacté criado peloflow-ai-webchat-gatewayna 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:
fromaqui é o IGSID de quem escreveu, não oigUserIdda conta Instagram do router. OigUserIdidentifica a conta dona do canal (entidadeInstagramAccount) e viaja comophoneNumberId— 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 destream:summarynoflow-ai-core(summary/handler.ts), ao encerrar um chat ou ticket — mesmo resumo persistido emChat.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:
| Tela | Filtro aplicado |
|---|---|
| Desk → histórico do contato | chat.routerId = routerFilter (query) ∩ escopo RBAC do agente |
| Core-UI → histórico de conversas | routerId = 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
contactVariables | session.variables | |
|---|---|---|
| Escopo | Contato (todos os routers) | Sessão ativa |
| Persistência | Permanente no PostgreSQL | TTL no Redis |
| Gravação | action setContactVariable ou API | action 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õecontactVariablesvia{{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 decontactVariablesno contexto do turno (ctx.contactVars), logo a leitura é síncrona e sem I/O extra; a actionsetContactVariableatualiza 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 exatamentename/phoneNumber/variablesfica 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 semnameLocked(todos os legados) mantêm o comportamento anterior (a sessão espelha o perfil vivo). - Valor vazio/whitespace (ex.:
{{...}}não resolvido) é ignorado —Contact.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
| Rota | Papel |
|---|---|
GET /contacts/:contactId/opt-in?routerId= | status atual do contato naquele router |
GET /contacts/:contactId/opt-ins | histórico completo (todos os registros) |
GET /routers/:routerId/opt-ins | lista paginada do router, com filtro status e busca |
POST /routers/:routerId/opt-ins/bulk | importação em massa (source: "import") |
8. Arquivos relevantes
| Arquivo | Papel |
|---|---|
services/flow-ai-orchestrator/src/handlers/incoming.ts | find-or-create Contact e Chat com routerId (WhatsApp + Instagram) |
services/flow-ai-webchat-gateway/src/auth.ts | negotiateIdentity — resolução de WebChatIdentity e criação do Contact sintético WebChat |
services/flow-ai-core/src/http/routes/contact.routes.ts | PATCH variables, GET detalhe/insights, criação multi-canal |
services/flow-ai-core/src/http/routes/contact-opt-in.routes.ts | rotas de opt-in (status atual, histórico, lista por router, bulk) |
services/flow-ai-core/src/services/contact-opt-in.service.ts | log append-only de opt-in; getCurrentStatus deriva o status atual |
services/flow-ai-core/src/summary/handler.ts | escreve Contact.lastSessionSummary (consumer de stream:summary) |
services/flow-ai-engine/src/runtime/actions.ts | actions setContactVariable / setContextVariable |
services/flow-ai-core/src/repositories/prisma/ticket.repository.ts | listHistoryByContact(contactId, routerFilter?, whereScope?) — filtro por router |
packages/flow-ai-database/prisma/schema.prisma | Modelos Contact, Chat, WebChatIdentity, ContactOptIn |