notification-flow
# Notification Flow
> Nota de arquitetura: este fluxo descreve o contrato atual baseado em
> `POST /internal/notifications` e persistência no PocketBase. A direção-alvo
> documentada em
> [`ADR-002`](../adr/ADR-002-communications-domain-and-postgres.md) e
> [`communications-domain-roadmap.md`](communications-domain-roadmap.md)
> mantém esse contrato por compatibilidade, mas move o domínio canônico de
> comunicação para o Communications Service com Postgres.
> Essa evolução não remove o uso atual do PocketBase/realtime nem troca o
> domínio da Ailian para notificações comuns. Sender identity de cliente entra
> principalmente para envios em nome do cliente, como campanha, bulk e mensagens
> comerciais customer-visible.
## Objetivo
Explicar o fluxo canônico de criação e execução de notificações no Communications Service.
## Contrato recomendado
Quem produz notificações não deve escrever diretamente em:
- `notifications`
- `notification_recipients`
- `notification_deliveries`
O contrato recomendado agora é:
1. um produtor confiável, como `n8n`, chama `POST /internal/notifications`
2. o Communications Service valida o payload
3. aplica `notification_preferences`
4. cria `notifications`
5. cria `notification_recipients`
6. cria `notification_deliveries`
7. enfileira imediatamente o que já estiver elegível
## Navegação
`navigationKey` passa a ser a identidade estável de navegação da notificação.
Regras:
- o produtor pode enviar `navigationKey`
- se não enviar, o backend gera automaticamente
- `route` deixa de ser a origem da navegação
- o backend sempre persiste `route` como:
- `/dashboard/notifications/r/<navigationKey>`
- se `payload.ui.copyOnOpen` vier com:
- `message`
- `body`
- `phone`
o backend anexa `?copy=<valor>` na rota derivada
Na prática:
- `navigationKey` identifica a notificação no front
- `route` vira apenas a URL resolvedora persistida
- `dedupeKey` continua sendo só deduplicação
- `groupKey` continua sendo só agrupamento
- para WhatsApp, envie `payload.links.whatsAppShareUrl`
- para os botões do push, use `actions` com `action` e `title`
Exemplo de lead handoff com WhatsApp:
```json
{
"actions": [
{ "action": "copy-handoff", "title": "Copiar mensagem" },
{ "action": "open-whatsapp", "title": "Abrir WhatsApp" },
{ "action": "open-sheet", "title": "Abrir planilha" }
],
"payload": {
"kind": "lead_handoff",
"ui": {
"copyOnOpen": "body"
},
"links": {
"whatsAppShareUrl": "https://wa.me/5511999999999?text=...",
"sheetUrl": "https://docs.google.com/spreadsheets/d/..."
},
"push": {
"body": "Novo lead de Taboao da Serra - SP"
}
}
}
```
O front continua consumindo o PocketBase como antes.
## Quem chama essa API
Chamadores esperados:
- `n8n`
- backend interno
- workflows trusted
- serviços internos
Não é uma API pensada para o browser chamar diretamente, porque ela exige:
- `Authorization: Bearer <SERVICE_AUTH_TOKEN>`
## O que a API faz
Quando recebe uma requisição válida, o serviço:
- trata a criação como uma única operação
- usa `dedupeKey` ou `idempotencyKey` quando enviados
- cria a notificação inicialmente como `draft`
- cria recipients e deliveries necessários
- faz compensação em caso de falha durante a montagem
- muda a notificação para `queued` quando houver deliveries
- muda a notificação para `cancelled` quando tudo for suprimido por preferência
Na prática, isso elimina a necessidade de o consumidor conhecer 3 tabelas.
## Audiência e scopes
O serviço suporta os 3 scopes persistidos em `notifications`:
- `user`: notifica usuários explícitos em `recipients`
- `instance`: notifica usuários de uma instância, com opção de filtrar por role
- `application`: notifica usuários ligados a instâncias na plataforma, com opção de filtrar por role
Mesmo para `scope=instance` e `scope=application`, o Communications Service cria `notification_recipients` para cada usuário resolvido. Isso é necessário porque as regras de leitura do PocketBase dependem de `notification_recipients`.
Para resolver audiência automaticamente, use `audience.roles`:
```json
{
"scope": "instance",
"instanceId": "4f0kfmefs6x09lo",
"audience": {
"roles": ["super_admin", "gestor", "notificacoes"]
}
}
```
Regras:
- se `recipients` vier preenchido, os usuários explícitos são mantidos
- se `audience` vier junto, usuários resolvidos são mesclados e duplicados são removidos
- `scope=instance` exige `instanceId` e busca em `user_instance_roles.instance`
- `scope=application` busca em `user_instance_roles` sem filtrar instância
- `scope=user` continua exigindo `recipients`
- para e-mail 1:1 externo, `recipients` pode usar `{ "email": "...", "name": "..." }` sem `userId`
- destinatário externo é aceito somente com canal `email`; `push`, `realtime`, `whatsapp` e `webhook` exigem `userId`
- como o destinatário externo não tem usuário PocketBase, preferências por usuário não são aplicadas nesse caso
- o schema externo do PocketBase precisa permitir recipient/delivery sem `user` e persistir `external_email`/`external_name`
### Writeback para CRM/e-mail 1:1
Quando uma notificação de e-mail traz `payload.crm.subject` e
`payload.crm.correlation_id`, o worker de dispatch publica o evento
`communications.delivery.status_changed` no Redis Stream configurado por
`EVENT_BUS_REDIS_STREAM`. O evento carrega `correlation_id`, subject, status,
canal, provider e ids de delivery/recipient para o CRM reconciliar timeline e
status.
Replies entram por `POST /internal/email/messages/received`. O endpoint aceita
`correlationId`/`crmSubject` diretos ou os headers carimbados pelo envio:
- `X-Ailian-Correlation-Id`
- `X-Ailian-Reply-To-Token`
- `X-Ailian-Subject-Type`
- `X-Ailian-Subject-Id`
Com correlation + subject, o serviço publica
`communications.message.received`. Sem um desses dois campos, o reply fica
`orphaned` e não é publicado como evento resolvido para o CRM.
## Como `notification_preferences` entra no fluxo
`notification_preferences` é interpretado no próprio Communications Service, não no front.
O modelo atual por flags de canal continua documentado abaixo. A evolucao
aprovada para preferencias granulares por topico/canal e dominios de envio por
tenant esta documentada em:
- `docs/adr/ADR-002-email-domains-and-notification-preferences.md`
- `docs/concepts/email-sender-domains-and-notification-preferences.md`
Em ambientes com os hooks externos do PocketBase habilitados, preferencias basicas sao provisionadas automaticamente quando usuarios e vinculos em instancias sao criados. Esse provisionamento vive fora deste repositorio e esta documentado em:
- `docs/concepts/pocketbase-notification-preferences-hooks.md`
Regras suportadas na ingestão:
- `in_app_enabled` controla a disponibilidade do item na experiência in-app
- `toast_enable` controla especificamente o canal `realtime` usado para toast/sinalização imediata
- `push_enabled` controla o canal `push`
- `email_enabled` controla o canal `email`
- `whatsapp_enabled` controla o canal `whatsapp`
- `mute_low_priority` suprime notificações `low`
- `require_action_push` permite push apenas quando `requireAction = true`
- `mute_until` suprime envios até a data configurada
O que ainda não está sendo interpretado na ingestão:
- `quiet_hours`
Esse ponto continua documentado como evolução futura.
Observação: para segurança operacional, `whatsapp` exige `whatsapp_enabled=true`. Quando não há preferência provisionada para o usuário/escopo, o canal `whatsapp` é suprimido.
## Trigger operacional
O serviço tem 2 modos de disparo:
### 1. Imediato
Ao criar pela API interna, deliveries sem agendamento futuro já são enfileiradas na hora.
### 2. Scan periódico
Mesmo sem trigger imediato, os workers continuam consultando o PocketBase em intervalo fixo.
Padrão atual:
- `NOTIFICATION_SCAN_EVERY_MS = 30000`
Ou seja:
- até 30 segundos para deliveries `queued`
- retries respeitam `next_retry_at`
## Fluxo resumido
1. `n8n` chama `POST /internal/notifications`
2. o serviço persiste tudo no PocketBase
3. o serviço já enfileira deliveries elegíveis
4. workers processam por canal/provider
5. `notification_deliveries`, `notification_recipients` e `notifications` são atualizadas
## Providers externos
### Email com Resend
Para usar Resend, envie `channel=email` e `provider=resend`.
Quando `RESEND_API_KEY` estiver configurado, `provider=resend` é assumido automaticamente para deliveries criadas pela API canônica com `channel=email`.
O provider tenta resolver o email pelo usuário do PocketBase usando `PB_USER_EMAIL_FIELD` e fallback para `email`. O payload pode sobrescrever o destino com `payload.email.to`.
Exemplo:
```json
{
"channels": ["email"],
"providers": {
"email": "resend"
},
"payload": {
"email": {
"from": "Ailian <[email protected]>",
"subject": "Novo lead recebido",
"html": "<p>Um novo lead chegou.</p>"
}
}
}
```
Para templates de email versionados neste repositório, use `payload.email.template`.
O Communications Service renderiza `subject`, `html` e `text` localmente antes
de chamar o Resend. Não use `templateId` para esses templates.
Emails transacionais obrigatórios, como confirmação de pagamento e nota fiscal,
podem enviar `bypassPreferences: true`. Use esse campo apenas para comunicações
legais/operacionais que não devem ser bloqueadas por preferências de marketing
ou notificações do app.
Templates disponíveis:
- `payment-approved`: confirmação de pagamento aprovado
- `invoice-issued`: aviso de nota fiscal emitida
- `commercial-offer-sent`: envio de proposta comercial com link de aceite
Exemplo de pagamento aprovado:
```json
{
"channels": ["email"],
"providers": {
"email": "resend"
},
"bypassPreferences": true,
"payload": {
"email": {
"template": "payment-approved",
"subject": "Pagamento aprovado",
"variables": {
"appName": "Ailian",
"customerName": "Marina Costa",
"orderId": "PED-2026-0001",
"productName": "Ghostwriter Starter",
"paymentAmount": "R$ 199,90",
"paymentMethod": "Cartão de crédito",
"paidAt": "11/05/2026 18:30",
"appUrl": "https://app.ailian.com.br/billing"
}
}
}
}
```
Exemplo de nota fiscal emitida:
```json
{
"channels": ["email"],
"providers": {
"email": "resend"
},
"bypassPreferences": true,
"payload": {
"email": {
"template": "invoice-issued",
"subject": "Nota fiscal emitida",
"variables": {
"appName": "Ailian",
"customerName": "Marina Costa",
"invoiceNumber": "NF-e 1024",
"productName": "Créditos Ailian 20K",
"invoiceAmount": "R$ 199,90",
"issuedAt": "11/05/2026 18:45",
"invoiceUrl": "https://app.ailian.com.br/invoices/nf-001",
"appUrl": "https://app.ailian.com.br/billing"
}
}
}
}
```
Exemplo de proposta comercial enviada:
```json
{
"channels": ["email"],
"providers": {
"email": "resend"
},
"payload": {
"email": {
"template": "commercial-offer-sent",
"subject": "Sua proposta comercial está pronta",
"variables": {
"appName": "Ailian",
"appUrl": "https://app.ailian.com.br/billing",
"customerName": "Marina Costa",
"customerEmail": "[email protected]",
"planName": "Ghostwriter Growth",
"planSlug": "ghostwriter-growth",
"proposalAmount": "R$ 899,00",
"proposalAmountCents": 89900,
"proposalCurrency": "BRL",
"interval": "month",
"intervalLabel": "Mensal",
"activationPolicy": "on_accept",
"activationPolicyLabel": "Ativação após aceite",
"offerId": "offer_20260513_001",
"offerUrl": "https://app.ailian.com.br/billing/offers/offer_20260513_001",
"expiresAt": "2026-05-20T18:30:00.000-03:00"
}
}
}
}
```
A lista atual também fica disponível em:
- `/docs/email-templates`
- `/docs/email-templates.json`
### WhatsApp com Chatwoot
Para usar WhatsApp em notifications, envie `channel=whatsapp` e `provider=chatwoot`.
Para campanhas ou disparos que não devem criar conversa/mensagem no Chatwoot,
use `providers.whatsapp=chatwoot_silent`. Esse provider chama o endpoint interno
`POST /connectai/internal/whatsapp/template_messages` do ConnectAI Chatwoot com
HMAC `X-Integration-*`, recebe o `wamid` e registra a delivery como `sent`.
O status final (`delivered`, `read`, `failed`) deve vir depois por writeback da
Meta via `chatwoot-wa-capture`, chamando
`POST /internal/whatsapp/delivery-statuses` com HMAC `X-Integration-*`. O status
Meta `read` fica preservado em `provider_response`, mas o ledger atual registra
a delivery como `delivered` para evitar mudanca de schema.
`provider=chatwoot` é assumido automaticamente para deliveries criadas pela API canônica com `channel=whatsapp`.
Regras:
- `whatsapp_enabled` precisa estar `true` na preferência escolhida do usuário
- sem preferência provisionada, `whatsapp` é suprimido
- o provider tenta resolver o telefone em `profiles` usando `PB_PROFILE_CALLING_CODE_FIELD` + `PB_PROFILE_PHONE_FIELD`
- se não houver profile/telefone, tenta fallback no usuário do PocketBase usando `PB_USER_WHATSAPP_FIELD`
- também aceita override por `payload.whatsapp.phoneNumber` ou `payload.targets.whatsapp.phoneNumber`
Exemplo com template WhatsApp:
```json
{
"channels": ["realtime", "push", "whatsapp"],
"providers": {
"push": "webpush",
"whatsapp": "chatwoot"
},
"payload": {
"whatsapp": {
"templateName": "novo_lead_recebido",
"language": "pt_BR",
"processedParams": {
"lead_name": "Fulano",
"city": "Sao Paulo"
}
}
}
}
```
Se já existir uma conversa ou contato no Chatwoot, o payload pode informar:
```json
{
"payload": {
"whatsapp": {
"conversationId": 123,
"contactId": 456
}
}
}
```
Exemplo de template silencioso para campanha:
```json
{
"channels": ["whatsapp"],
"providers": {
"whatsapp": "chatwoot_silent"
},
"payload": {
"crm": {
"subject": { "type": "campaign_run", "id": "run-1" },
"correlation_id": "campaign-run-1"
},
"whatsapp": {
"accountId": 1,
"inboxId": 2,
"phoneNumber": "+5511999999999",
"template_params": {
"name": "campaign_template",
"language": "pt_BR",
"processed_params": {
"body": {
"1": "Rodrigo"
}
}
},
"metadata": {
"crm_campaign_id": "campaign-1",
"campaign_run_id": "run-1"
}
}
}
}
```
## Exemplo real usado nas docs
Os exemplos interativos do Scalar usam hoje:
- `instanceId = 4f0kfmefs6x09lo`
- `userId = y1412767403q7jx`
Services usados nos exemplos:
- `z97bsc1rlv9qbk3` para chatbot/ConectAi
- `51x9x66y6z86h8w` para prospecção ativa
- `x4q5cd5zj5qtwi3` para geração de conteúdo