Especificação de construção · 24 de agosto de 2026

Como isso vai ser construído, linha por linha

Seis fases, catorze tabelas novas, sete funções de borda e o SQL de cada uma. Escrito para quem vai aplicar — o documento comercial e o de produto vivem nas outras duas abas.

6 fases · 316 hEscopo especificado
14 tabelasNovas, com proteção no molde
29 blocosDe SQL prontos para copiar
O escopo

Seis fases, 316 horas

As fases 0, 1 e 2 são encadeadas: fundação sem entrega é custo sem retorno, e caixa de entrada sem fundação não é segura. Da 3 em diante cada uma se decide sozinha.

Cada fase fecha com um critério conferível na tela ou numa consulta — nunca por impressão de quem testou. A lista está na seção 06.

0
FundaçãoTabelas com proteção, segredos no cofre, provedor de IA, travas de volume
33 h
1
Aviso de parcela vencidaCron lê a parcela vencida, dispara o modelo e recebe o status de entrega
26 h
2
Caixa de entrada no CRMRota de atendimento, aba de conversa no lead, relógio da janela de 24h
118 h
3
Cérebro de qualificaçãoOrquestrador com modelo de linguagem, filas, memória e modo sugestão
72 h
4
Disparo e reengajamentoConstrutor de modelo, motor de lote, seleção de público e opt-out
43 h
5
Coach pós-reuniãoFireflies vira resumo, objeção e próximo passo como comentário no lead
24 h
Medido no banco em 24 de agosto

Três números do briefing mudaram

Em uma linha

A base é viva. Um desses três muda decisão de projeto — a cobrança da Fase 1 nasce com público de dezenas por dia, não de centenas.

Última migration aplicada
02660276

As faixas novas começam em 0300, com folga de 23 números.

Parcelas vencidas e não recebidas
573 parcelas · R$ 75.33929 parcelas · R$ 105.246,87

As 573 são o total de parcelas. Vencidas e não recebidas hoje são 29 — a Fase 1 nasce com público de dezenas por dia, não de centenas.

Telefones a sanear
89 registros5 registros

Vira tela de pendência dentro da Fase 2, não migration de massa sobre telefone.

Projeto Supabase: xjrlrugbafetdtokbuoa (plano gratuito, região São Paulo) App: Next.js 14 App Router + @cloudflare/next-on-pages, edge, Cloudflare Pages gratuito Repositório inspecionado: C:\dev\hb-main-tmp Data da apuração: 2026-08-24

00O que este documento é, e o que ele assume como fechado

Especificação de construção de seis fases (316 horas) que colocam atendimento de WhatsApp, disparo em lote e IA de qualificação dentro do CRM que já existe e já está em produção com o time usando todo dia.

Seis decisões chegaram fechadas e não são reabertas aqui:

  1. A tela mora dentro do app Next do CRM, não em subdomínio — o cookie de sessão do Supabase é host-only, e subdomínio exigiria segundo login.
  2. API oficial da Meta (WhatsApp Cloud API) em tudo. Evolution API não entra.
  3. Um único projeto Supabase. Importação zero dos quatro projetos Lovable de origem — deles se aproveita lógica de produto, e se descarta a camada de segurança inteira (eram cascas sem banco, sem RLS, sem multi-tenancy).
  4. Todo I/O de WhatsApp acontece em Edge Function. O Worker da Cloudflare não toca em mensagem — nem para receber, nem para enviar.
  5. Mídia não é baixada. Guarda-se o id da Meta. Só vira arquivo nosso o que um humano promover, e a promoção usa o caminho de anexo que já existe (public.anexos + bucket privado anexos, supabase/migrations/0266_anexos_e_comentarios_no_lead.sql:239-322).
  6. IA: modelo caro só onde o erro sai da tela. Classificação e roteamento em modelo barato; redação de resposta que uma pessoa vai ler, em modelo caro.

0.1 Correções de fato apuradas nesta rodada (leia antes de planejar)

Três números do briefing mudaram porque a base é viva, e um deles muda decisão de projeto.

Afirmação recebidaO que o banco diz hoje (2026-08-24)Consequência
Última migration aplicada é a 0266O ledger public._migrations_aplicadas registra até 0276_tipo_de_tarefa_instagram_no_lugar_do_email.sql, aplicada em 2026-08-21As faixas de migration reservadas começam em 0300, não em 0267. Ver §7
2.032 leads vivos · 1.938 com telefone · 95,4% em E.1642.140 vivos · 2.046 com telefone · 2.041 canonizáveis (99,76%) · 5 fora do padrãoO trabalho de saneamento de telefone é de 5 registros, não de 89. Ver §4.4
26 chaves ambíguas entre 52 leads pelo fallback right(...,10)0 chaves em que o fallback junta leads com E.164 diferente; as 47 chaves colidentes (94 leads) são duplicatas verdadeiras da mesma pessoaA armadilha do fallback é estrutural, não incidental — hoje não morde, mas morde no primeiro número internacional. Ver §4.3
573 parcelas com R$ 75.339 vencido573 parcelas no total; vencidas e não recebidas hoje: 29 parcelas, R$ 105.246,87A Fase 1 nasce com público de dezenas por dia, não centenas. Ver §6, Fase 1
1.384 leads no pool sem tarefa · 68% dos vivos no pool1.407 no pool · 1.973 sem tarefa nenhuma (92% dos vivos)Confirma o tamanho do problema que a Fase 3 ataca
Vault com 7 segredos9 segredosSem consequência
Banco 128 MB134 MB de 500 MBSem consequência; o orçamento de crescimento de §1.10 continua válido

Arraste a tabela para o lado

Duas verificações adicionais que importam para o desenho:

  • O clone local C:\dev\hb-main-tmp está atrasado. git ls-tree origin/main supabase/migrations/ termina em 0266; produção tem até 0276. Toda citação de arquivo:linha deste documento é do clone (0266 e anteriores) e foi conferida contra o banco de produção quando o objeto ainda existe lá. O que mudou depois do clone está marcado no texto.
  • 0274 quebrou o append-only de comentário. public.lead_comentarios hoje tem editado_em, removido_em, removido_por, e existem as RPCs editar_comentario_lead, excluir_comentario_lead, expurgar_comentarios_excluidos. O molde de RLS da 0266 continua válido (as policies são as mesmas duas, SELECT e INSERT — conferido em pg_policies), mas não copie o parágrafo “append-only” da 0266 como se ainda fosse verdade.

0.2 As seis fases

#FaseHorasEntrega
0Fundação33Tabelas com RLS no molde 0266, segredos no Vault, provedor de IA próprio, travas de volume e retenção
1Aviso de parcela vencida26Cron lê venda_parcelas vencidas → modelo de utilidade → status de entrega volta
2Caixa de entrada no CRM118Rota /atendimento, aba Conversa no card do lead, relógio da janela de 24h, casamento telefone↔lead
3Cérebro de qualificação72Orquestrador com LLM, agrupamento de rajada, filas, memória por contato, modo sugestão
4Disparo e reengajamento43Construtor de modelo, motor de lote em pg_cron, seleção de público, opt-out
5Coach pós-reunião24Fireflies → resumo, objeções e próximos passos como comentário no lead

Arraste a tabela para o lado

0.3 Convenções que valem no documento inteiro

  • Prefixo wa_ em toda tabela, função e Edge Function desta entrega. Uma busca por wa_ devolve a superfície inteira do recurso — foi o que faltou no ClickUp e no Meta, cujos objetos estão espalhados por nome.
  • Nada de insert direto onde já existe função. Contato vira public.registrar_contato_lead; comentário vira public.comentar_lead; aviso vira public.registrar_notificacao. Detalhe em §5.
  • Toda migration é aditiva, idempotente e traz bloco de rollback comentado no rodapé, no padrão de 0266 e 0264.
  • Nenhum agente aplica migration, publica ou faz push. Este documento é escrito para ser aplicado pelo orquestrador.
  • Nomes que enganam e que aparecem aqui: vendas.ticket (não valor), tarefas.descricao (não titulo), venda_parcelas (não existe parcelas; vendas.parcelas é uma coluna inteira), coluna de data chamada timestamp em interacoes/movimentacoes/audit_log, e não existe tabela alunosaluno_historico + status_aluno).

01Modelo de dados

1.0 O molde, em sete passos

Todo create table desta especificação segue a 0266 na ordem abaixo. O molde não é estilo: é o que faz a tabela nova herdar a mesma barreira que as 65 tabelas atuais já têm (235 policies, RLS em todas — conferido em pg_policies).

  1. create table if not exists — colunas com tipo e nulidade explícita, FK com on delete escolhido caso a caso, CHECK de conjunto fechado para toda coluna de estado.
  2. comment on table + comment on column em toda coluna não-óbvia + comment on constraint em todo CHECK cuja razão não se lê do nome.
  3. Índices: o de acesso (o where+order by da tela quente) e o de team_id — os dois, sempre. Único parcial onde há regra de unicidade condicional.
  4. alter table ... enable row level security; seguido de alter table ... force row level security;
  5. Uma policy por operação, com public.auth_usuario_ativo() e team_id = public.auth_team_id() como os dois primeiros termos do using/with check. A visibilidade de lead é herdada por public.pode_ver_lead(uuid) (0266:120-138), nunca reescrita.
  6. comment on policy explicando quem passa e por quê.
  7. RPCs de escrita security invoker (a RLS é a barreira), set search_path = '', com revoke all ... from public + revoke all ... from anon + grant execute ... to authenticated. Funções que precisam atravessar a RLS por anti-recursão são security definer sem receber identidade por argumento — sempre travadas em auth.uid()/public.auth_team_id(), como public.pode_ver_lead (0266:114-138).

Funções de apoio que já existem e que este documento usa sem recriar:

FunçãoOndeO que faz
public.auth_role()0050_rls.sql:50Papel do JWT (app_metadata, nunca user_metadata)
public.auth_usuario_ativo()0050_rls.sql:93Usuário existe em usuarios e está ativo. security definer anti-recursão
public.auth_team_id()0050_rls.sql:131team_id do usuário autenticado
public.auth_tem_papel(text)0211_papeis_multiplos_e_movimentacao.sql:213Multi-papel: usuarios.papeis CONCEDE, só válido dentro de RPC
public.team_id_padrao()0004_usuarios.sql:32Default de team_id
public.pode_ver_lead(uuid)0266:114-138Espelha as duas policies de SELECT de leads (0050)
public.telefone_normalizado_br(text)0144_historico_da_pessoa.sql:91A canônica. E.164-BR sem +, null quando não é BR reconhecível
public.registrar_contato_lead(uuid,uuid,text,text,timestamptz)0250_telas_com_teto.sql:629Tarefa concluída + linha na timeline, mesma transação
public.comentar_lead(uuid,text,uuid[],jsonb)0266:604-646Comentário na ficha com menção e anexo
public.registrar_notificacao(uuid,text,text,text,uuid,uuid)0066Único escritor de notificacoes; sem grant para authenticated
public.registrar_auditoria(text,uuid,text,text,text)0050Linha em audit_log
public.pipeline_texto_normalizado(text)usada em 0264:283Normalização de texto para casar nome de lookup

Arraste a tabela para o lado

Papel na RLS vem de auth.users.raw_app_meta_data->>'role' via auth_role(), não de usuarios.papel. usuarios.papel RESTRINGE; usuarios.papeis CONCEDE e só vale dentro de RPC, via auth_tem_papel. usuarios.id é o id do Auth.

1.1 Mapa das tabelas novas, por fase

TabelaFasePapel
wa_numeros0Os números WABA que o CRM opera (hoje 1)
wa_modelos0Modelos de mensagem registrados na Meta, com estado de aprovação
wa_envios0A fila de envio. Único caminho de saída de mensagem
wa_webhook_dedupe0Idempotência do webhook da Meta
wa_ia_consumo0Consumo e custo de IA, por chamada — base do alerta de teto
wa_conversas2Uma linha por número de contato. Estado, janela de 24h, atendente
wa_mensagens2Uma linha por mensagem, entrada e saída
wa_atribuicoes2Histórico de quem assumiu a conversa e quando
wa_ia_execucoes3Fila e log do orquestrador: rajada, tentativa, resultado
wa_ia_memoria3Memória por contato: fatos extraídos, append-only
wa_ia_sugestoes3Modo sugestão: o que a IA escreveria, para o humano aprovar
wa_campanhas4Um disparo: modelo, público, janela, estado
wa_campanha_destinatarios4Um destinatário por linha, com desfecho individual
wa_reunioes5Transcrição/resumo vindo do Fireflies

Arraste a tabela para o lado

Colunas acrescentadas a tabelas existentes: leads (consentimento e opt-out) e tarefas (origem_registro ganha valores). Ver §1.11.

Reuso deliberado, para não criar tabela à toa:

  • public.webhook_events (0016, 4.449 linhas hoje) recebe o payload cru do Fireflies na Fase 5 — ela já existe, já tem event_id/origem/payload e já é expurgada aos 90 dias por public.expurgar_dados_retencao() (0095_lgpd_tecnico.sql:438-470).
  • public.templates_mensagem (existe, 0 linhas, colunas nome/corpo/variaveis/etapa_id/ativo) não é reaproveitada para modelo da Meta. Motivo: modelo da Meta tem nome canônico, idioma, categoria, id remoto, estado de aprovação e componentes — nada disso cabe nas colunas dela, e forçar cabimento produziria uma tabela que mente sobre o que está aprovado do lado da Meta. templates_mensagem fica como está, para resposta rápida interna.
  • public.configuracoes (chave/valor jsonb por team) recebe os tetos operacionais em vez de uma tabela nova de parâmetros. Ver §1.10.

1.2 public.wa_numeros — Fase 0

SQL
-- ============================================================================
-- FASE 0 · public.wa_numeros — os números WABA que o CRM opera
-- ============================================================================
-- Hoje é UM número. A tabela existe assim mesmo por dois motivos concretos:
--   (a) o phone_number_id da Meta precisa morar em algum lugar consultável pela
--       tela ("de qual número esta conversa é?") sem virar variável de ambiente
--       que só a Edge Function enxerga;
--   (b) a Fase 2 grava numero_id em cada conversa. Se essa coluna nascer depois,
--       a retrofit exige backfill sobre a tabela mais quente do recurso.
-- O TOKEN NÃO MORA AQUI. Ele mora no Vault (§2.1). Aqui fica só identificação.
create table if not exists public.wa_numeros (
  id                 uuid primary key default extensions.gen_random_uuid(),
  -- Identificadores da Meta. phone_number_id é o que vai na URL do Graph;
  -- waba_id é a conta, e é o que a Meta usa no campo `entry[].id` do webhook.
  phone_number_id    text not null,
  waba_id            text not null,
  -- E.164 SEM "+" — mesmo formato de public.telefone_normalizado_br (0144:91).
  telefone_e164      text not null,
  rotulo             text not null,
  ativo              boolean not null default true,
  -- Teto de mensagens por dia que a Meta concede a este número (tier). É lido
  -- do Graph pela wa-modelos-sync e serve de trava local em wa_envios.
  limite_diario_meta integer,
  qualidade          text,
  team_id            uuid not null default public.team_id_padrao(),
  created_at         timestamptz not null default now(),
  updated_at         timestamptz not null default now(),

  constraint uq_wa_numeros_phone_number_id unique (phone_number_id),
  constraint chk_wa_numeros_e164
    check (telefone_e164 ~ '^55(1[1-9]|[2-9][0-9])[0-9]{8,9}$'),
  constraint chk_wa_numeros_rotulo
    check (length(btrim(rotulo)) between 1 and 60),
  constraint chk_wa_numeros_qualidade
    check (qualidade is null or qualidade in ('GREEN', 'YELLOW', 'RED', 'UNKNOWN')),
  constraint chk_wa_numeros_limite
    check (limite_diario_meta is null or limite_diario_meta > 0)
);

comment on table public.wa_numeros is
  'Numeros WABA (WhatsApp Business Account) que este CRM opera. Hoje um so. O TOKEN de acesso NAO fica aqui -- fica no Vault, lido pela Edge Function; esta tabela guarda identificacao e estado operacional (tier de envio e qualidade), que a tela precisa mostrar. Multiplos numeros estao FORA do escopo das 6 fases (ver secao 8) -- a tabela suporta, o roteamento nao.';
comment on column public.wa_numeros.phone_number_id is
  'Id do numero na Graph API da Meta -- e o que vai no caminho /v21.0/{phone_number_id}/messages. Nao e o telefone. UNIQUE: um numero da Meta, uma linha.';
comment on column public.wa_numeros.waba_id is
  'Id da conta WhatsApp Business. E o valor que a Meta manda em entry[].id no webhook -- e por ele que wa-webhook decide se o evento e nosso antes de gravar qualquer coisa.';
comment on column public.wa_numeros.limite_diario_meta is
  'Teto de conversas iniciadas por nos em 24h que a Meta concede a este numero (messaging tier: 250 / 1k / 10k / ilimitado). Sincronizado pela wa-modelos-sync. NULL = ainda nao lido. E o teto DELES; o teto NOSSO fica em configuracoes.whatsapp_limites (menor dos dois vence).';
comment on column public.wa_numeros.qualidade is
  'Quality rating do numero na Meta (GREEN/YELLOW/RED). RED e o estado que precede a suspensao de envio -- a tela de campanha (Fase 4) recusa disparo com o numero em RED.';
comment on constraint chk_wa_numeros_e164 on public.wa_numeros is
  'Mesmo formato canonico de public.telefone_normalizado_br (0144:91): 55 + DDD valido (11-99) + 8 ou 9 digitos, sem "+". Uma segunda grafia do proprio numero quebraria o casamento de conversa.';

create index if not exists idx_wa_numeros_ativo
  on public.wa_numeros (ativo) where ativo;
create index if not exists idx_wa_numeros_team
  on public.wa_numeros (team_id);

drop trigger if exists trg_wa_numeros_updated_at on public.wa_numeros;
create trigger trg_wa_numeros_updated_at
  before update on public.wa_numeros
  for each row execute function public.set_updated_at();

alter table public.wa_numeros enable row level security;
alter table public.wa_numeros force row level security;

-- SELECT: todo mundo do time enxerga (a tela mostra "enviado de: Comercial HB").
drop policy if exists wa_numeros_select on public.wa_numeros;
create policy wa_numeros_select
  on public.wa_numeros for select to authenticated
  using (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
  );

comment on policy wa_numeros_select on public.wa_numeros is
  'Qualquer pessoa ativa do time le a identificacao do numero -- e rotulo de tela, nao segredo. O token nunca esteve nesta tabela.';

-- INSERT/UPDATE: gestão. Cadastrar número é ato de configuração, não de operação.
drop policy if exists wa_numeros_insert_gestao on public.wa_numeros;
create policy wa_numeros_insert_gestao
  on public.wa_numeros for insert to authenticated
  with check (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and public.auth_role() in ('admin', 'gerente')
  );

drop policy if exists wa_numeros_update_gestao on public.wa_numeros;
create policy wa_numeros_update_gestao
  on public.wa_numeros for update to authenticated
  using (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and public.auth_role() in ('admin', 'gerente')
  )
  with check (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and public.auth_role() in ('admin', 'gerente')
  );

comment on policy wa_numeros_update_gestao on public.wa_numeros is
  'So admin/gerente cadastra ou altera numero. Nao ha policy de DELETE: numero se desativa (ativo=false), porque conversas e mensagens antigas apontam para ele e um DELETE arrastaria historico ou deixaria FK orfa.';

Nota de operação: qualidade e limite_diario_meta são escritos pela Edge Function wa-modelos-sync com service_role (bypass nativo de RLS), não pela tela.

1.3 public.wa_modelos — Fase 0

SQL
-- ============================================================================
-- FASE 0 · public.wa_modelos — o catálogo de modelos aprovados na Meta
-- ============================================================================
-- Fora da janela de 24h, a API oficial só aceita MODELO previamente aprovado.
-- Esta tabela é o espelho local desse catálogo, e ela existe para responder uma
-- pergunta que a Meta responde devagar e a tela precisa responder rápido:
-- "posso disparar isto agora?".
--
-- ESTADO É ESPELHO, NUNCA OPINIÃO. status vem do Graph (wa-modelos-sync). A tela
-- não pode marcar um modelo como aprovado; se pudesse, a Fase 4 enfileiraria
-- disparo que a Meta recusa em massa, e recusa em massa derruba o quality rating
-- do número (wa_numeros.qualidade) — o custo do erro não é a mensagem perdida, é
-- o número.
create table if not exists public.wa_modelos (
  id                 uuid primary key default extensions.gen_random_uuid(),
  numero_id          uuid not null references public.wa_numeros (id) on delete restrict,
  -- Nome canônico na Meta: minúsculas, dígitos e underscore. É a chave de envio.
  nome_meta          text not null,
  idioma             text not null default 'pt_BR',
  categoria          text not null,
  -- Id remoto do template. NULL enquanto o registro ainda não foi submetido.
  meta_template_id   text,
  status             text not null default 'rascunho',
  motivo_rejeicao    text,
  -- O corpo COM os marcadores {{1}}, {{2}}... exatamente como submetido. É o que
  -- a tela mostra na prévia e o que o construtor da Fase 4 usa para saber quantas
  -- variáveis preencher.
  corpo              text not null,
  cabecalho_tipo     text,
  cabecalho_texto    text,
  rodape             text,
  botoes             jsonb not null default '[]'::jsonb,
  -- Descrição humana de cada variável, na ordem: ["nome do lead","valor","data"].
  -- Serve à tela; a contagem é conferida contra o corpo pelo CHECK abaixo.
  variaveis          jsonb not null default '[]'::jsonb,
  -- Para que serve, do nosso lado. Amarra o modelo ao caso de uso e é o que a
  -- Fase 1 usa para achar "o modelo de parcela vencida" sem hardcode de nome.
  finalidade         text,
  ativo              boolean not null default true,
  criado_por         uuid references public.usuarios (id),
  team_id            uuid not null default public.team_id_padrao(),
  created_at         timestamptz not null default now(),
  updated_at         timestamptz not null default now(),

  constraint uq_wa_modelos_nome
    unique (numero_id, nome_meta, idioma),
  constraint chk_wa_modelos_nome_meta
    check (nome_meta ~ '^[a-z0-9_]{1,512}$'),
  constraint chk_wa_modelos_idioma
    check (idioma ~ '^[a-z]{2}(_[A-Z]{2})?$'),
  constraint chk_wa_modelos_categoria
    check (categoria in ('UTILITY', 'MARKETING', 'AUTHENTICATION')),
  constraint chk_wa_modelos_status
    check (status in ('rascunho', 'em_analise', 'aprovado', 'rejeitado', 'pausado', 'desativado')),
  constraint chk_wa_modelos_corpo
    check (length(btrim(corpo)) between 1 and 1024),
  constraint chk_wa_modelos_cabecalho_tipo
    check (cabecalho_tipo is null or cabecalho_tipo in ('texto', 'imagem', 'documento', 'video')),
  constraint chk_wa_modelos_variaveis_array
    check (jsonb_typeof(variaveis) = 'array' and jsonb_array_length(variaveis) <= 20),
  constraint chk_wa_modelos_botoes_array
    check (jsonb_typeof(botoes) = 'array' and jsonb_array_length(botoes) <= 10),
  constraint chk_wa_modelos_finalidade
    check (finalidade is null or finalidade in (
      'parcela_vencida', 'reengajamento', 'convite_evento',
      'confirmacao_reuniao', 'retomada_conversa', 'avulso')),
  -- Aprovado exige id remoto: sem meta_template_id não há o que enviar.
  constraint chk_wa_modelos_aprovado_tem_id
    check (status <> 'aprovado' or meta_template_id is not null),
  -- Rejeitado exige motivo: "rejeitado e não sei por quê" é o estado que faz
  -- alguém resubmeter a mesma coisa três vezes.
  constraint chk_wa_modelos_rejeitado_tem_motivo
    check (status <> 'rejeitado' or motivo_rejeicao is not null)
);

comment on table public.wa_modelos is
  'Espelho local do catalogo de MODELOS (message templates) aprovados na Meta para um numero. Fora da janela de 24h a API oficial so aceita modelo aprovado -- e por isso que esta tabela decide o que a Fase 1 e a Fase 4 podem disparar. status e ESPELHO do Graph, escrito so pela edge wa-modelos-sync com service_role: a tela nao promove modelo a aprovado, porque disparo recusado em massa derruba o quality rating do numero.';
comment on column public.wa_modelos.nome_meta is
  'Nome canonico do template na Meta (minusculas/digitos/underscore). E a chave de envio no payload. UNIQUE por (numero, nome, idioma) -- a Meta versiona template por idioma.';
comment on column public.wa_modelos.corpo is
  'Corpo do modelo COM os marcadores {{1}},{{2}}..., exatamente como submetido a Meta. Fonte unica da previa na tela e da contagem de variaveis. Divergir daqui e enviar payload que a Meta recusa por numero de parametros.';
comment on column public.wa_modelos.finalidade is
  'Para que ESTE modelo serve do nosso lado. E o que permite a Fase 1 achar o modelo de parcela vencida por finalidade, sem hardcode de nome_meta em codigo -- trocar o texto aprovado nao exige deploy.';
comment on column public.wa_modelos.categoria is
  'Categoria da Meta. UTILITY (transacional, ex.: parcela vencida) tem aprovacao mais facil e nao entra nas regras de marketing; MARKETING exige opt-in e respeita opt-out (leads.wa_optout_em). A categoria e escolhida na submissao e a Meta pode RECATEGORIZAR -- por isso ela e espelho, nao decisao nossa.';
comment on constraint chk_wa_modelos_aprovado_tem_id on public.wa_modelos is
  'Aprovado sem meta_template_id e um estado impossivel que a fila trataria como enviavel, gerando falha por linha em todo um lote.';

create index if not exists idx_wa_modelos_envio
  on public.wa_modelos (numero_id, status, ativo) where status = 'aprovado' and ativo;
create index if not exists idx_wa_modelos_finalidade
  on public.wa_modelos (finalidade) where finalidade is not null;
create index if not exists idx_wa_modelos_team
  on public.wa_modelos (team_id);

drop trigger if exists trg_wa_modelos_updated_at on public.wa_modelos;
create trigger trg_wa_modelos_updated_at
  before update on public.wa_modelos
  for each row execute function public.set_updated_at();

alter table public.wa_modelos enable row level security;
alter table public.wa_modelos force row level security;

drop policy if exists wa_modelos_select on public.wa_modelos;
create policy wa_modelos_select
  on public.wa_modelos for select to authenticated
  using (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
  );

comment on policy wa_modelos_select on public.wa_modelos is
  'Todo mundo do time LE o catalogo: o vendedor precisa escolher um modelo quando a janela de 24h fechou (secao 3). Ler modelo nao e enviar modelo -- o envio passa por wa_envios, que tem policy propria.';

drop policy if exists wa_modelos_insert_gestao on public.wa_modelos;
create policy wa_modelos_insert_gestao
  on public.wa_modelos for insert to authenticated
  with check (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and public.auth_role() in ('admin', 'gerente')
    and criado_por = auth.uid()
    -- Nasce SEMPRE como rascunho. Promover a aprovado é ato do sincronizador.
    and status = 'rascunho'
    and meta_template_id is null
  );

comment on policy wa_modelos_insert_gestao on public.wa_modelos is
  'So gestao redige modelo, e o modelo NASCE como rascunho sem id remoto -- a promocao a em_analise/aprovado/rejeitado e do wa-modelos-sync (service_role), que le o Graph. Sem isso, a tela poderia declarar aprovado o que a Meta ainda vai recusar.';

drop policy if exists wa_modelos_update_gestao on public.wa_modelos;
create policy wa_modelos_update_gestao
  on public.wa_modelos for update to authenticated
  using (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and public.auth_role() in ('admin', 'gerente')
  )
  with check (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and public.auth_role() in ('admin', 'gerente')
  );

comment on policy wa_modelos_update_gestao on public.wa_modelos is
  'Gestao edita rascunho e pode desativar (ativo=false). A RLS nao restringe COLUNA -- a trava de "a tela nao promove a aprovado" e o gatilho trg_wa_modelos_status_so_do_sync abaixo, nao esta policy.';

Trava de coluna (a RLS não sabe restringir coluna — mesmo motivo de remover_anexo, 0266:474-486):

SQL
create or replace function public.trg_wa_modelos_status_so_do_sync()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
  -- auth.uid() null = service_role/cron (o sincronizador). Esse pode tudo.
  if auth.uid() is null then
    return new;
  end if;
  -- Sessão de usuário: status, meta_template_id e motivo_rejeicao são espelho
  -- da Meta e ficam congelados. Alterar pela tela é declarar aprovado o que a
  -- Meta não aprovou — e o preço disso é o quality rating do número.
  if new.status is distinct from old.status
     or new.meta_template_id is distinct from old.meta_template_id
     or new.motivo_rejeicao is distinct from old.motivo_rejeicao then
    raise exception 'Status do modelo e espelho da Meta: use "Enviar para analise" e aguarde a sincronizacao.'
      using errcode = '42501';
  end if;
  return new;
end;
$$;

comment on function public.trg_wa_modelos_status_so_do_sync() is
  'Congela status/meta_template_id/motivo_rejeicao para sessoes de usuario (auth.uid() nao nulo). O sincronizador roda com service_role (auth.uid() null) e escreve livremente. Existe porque RLS nao restringe coluna -- mesmo desenho de remover_anexo (0266) e atualizar_meu_avatar (0129).';

drop trigger if exists trg_wa_modelos_status on public.wa_modelos;
create trigger trg_wa_modelos_status
  before update on public.wa_modelos
  for each row execute function public.trg_wa_modelos_status_so_do_sync();

1.4 public.wa_envios — Fase 0 (a fila de envio)

Toda mensagem que sai do CRM passa por aqui. Não existe “envio direto”: a Edge Function de envio enfileira e retorna; quem fala com a Meta é o drenador (§2.4). O motivo é o teto de CPU do Worker e o incidente 1102 em aberto — nenhuma requisição de usuário pode ficar esperando a Graph API.

SQL
-- ============================================================================
-- FASE 0 · public.wa_envios — a fila. Único caminho de saída de mensagem.
-- ============================================================================
-- POR QUE FILA, E NÃO ENVIO SÍNCRONO:
--   1. A Graph API leva de 200ms a vários segundos. Prender a requisição do
--      vendedor nisso é gastar tempo de parede do Worker num app que já tem
--      1102 em aberto (docs/04-PROBLEMAS-ABERTOS.md).
--   2. Retentativa precisa de estado durável. Um retry em memória morre com o
--      isolado, e isolado morre a cada publicação.
--   3. O teto de volume (24h da Meta, tier do número, teto nosso) só é
--      aplicável olhando o CONJUNTO do que está para sair — não dá para decidir
--      isso dentro de uma requisição que só conhece a própria mensagem.
--
-- IDEMPOTÊNCIA: chave_idempotencia é UNIQUE e é construída por quem enfileira
-- (ex.: 'parcela:{venda_parcela_id}:{competencia}'). Um cron que roda duas vezes
-- no mesmo dia colide na segunda e não duplica aviso. Sem isso, a Fase 1
-- mandaria a mesma cobrança duas vezes para a mesma cliente — o erro mais caro
-- do recurso inteiro, porque é o que o dono ouve de volta pelo telefone.
create table if not exists public.wa_envios (
  id                   uuid primary key default extensions.gen_random_uuid(),
  numero_id            uuid not null references public.wa_numeros (id) on delete restrict,
  -- Destino em E.164-BR sem "+", já canonizado por quem enfileirou.
  destino_e164         text not null,
  -- Vínculos, todos opcionais e todos com on delete set null: a fila é
  -- plumbing e não pode segurar nem arrastar dado de negócio.
  lead_id              uuid references public.leads (id) on delete set null,
  conversa_id          uuid,   -- FK adicionada na Fase 2 (ver §1.11.3)
  campanha_id          uuid,   -- FK adicionada na Fase 4 (ver §1.11.3)
  modelo_id            uuid references public.wa_modelos (id) on delete restrict,

  tipo                 text not null,
  -- Texto livre (só válido em tipo='sessao', dentro da janela de 24h).
  corpo                text,
  -- Parâmetros do modelo, na ordem: ["Ana","R$ 1.200,00","10/09"].
  variaveis            jsonb not null default '[]'::jsonb,

  origem               text not null,
  prioridade           smallint not null default 5,
  agendado_para        timestamptz not null default now(),

  estado               text not null default 'pendente',
  tentativas           smallint not null default 0,
  max_tentativas       smallint not null default 5,
  proxima_tentativa_em timestamptz,
  -- Lock de drenagem: quem pega marca até quando é dono do item. Item com lock
  -- vencido volta para a fila — é o que sobrevive a um drenador morto no meio.
  lock_ate             timestamptz,

  wa_message_id        text,
  erro_codigo          text,
  erro_detalhe         text,
  enviado_em           timestamptz,

  chave_idempotencia   text not null,
  criado_por           uuid references public.usuarios (id),
  team_id              uuid not null default public.team_id_padrao(),
  created_at           timestamptz not null default now(),
  updated_at           timestamptz not null default now(),

  constraint uq_wa_envios_idempotencia unique (chave_idempotencia),
  constraint chk_wa_envios_destino
    check (destino_e164 ~ '^[0-9]{10,15}$'),
  constraint chk_wa_envios_tipo
    check (tipo in ('sessao', 'modelo')),
  constraint chk_wa_envios_origem
    check (origem in ('atendimento', 'parcela_vencida', 'campanha', 'ia', 'teste')),
  constraint chk_wa_envios_estado
    check (estado in ('pendente', 'processando', 'enviado', 'falhou',
                      'cancelado', 'bloqueado_optout', 'bloqueado_janela',
                      'bloqueado_teto')),
  constraint chk_wa_envios_prioridade
    check (prioridade between 1 and 9),
  constraint chk_wa_envios_tentativas
    check (tentativas >= 0 and max_tentativas between 1 and 10),
  -- Modelo exige modelo_id; sessão exige corpo. Um envio sem conteúdo é uma
  -- falha silenciosa que só aparece como "não chegou" três dias depois.
  constraint chk_wa_envios_conteudo
    check (
      (tipo = 'modelo' and modelo_id is not null)
      or (tipo = 'sessao' and corpo is not null and length(btrim(corpo)) between 1 and 4096)
    ),
  constraint chk_wa_envios_variaveis
    check (jsonb_typeof(variaveis) = 'array' and jsonb_array_length(variaveis) <= 20),
  -- Enviado é um par: ou tem instante e id da Meta, ou não está enviado.
  constraint chk_wa_envios_enviado
    check (estado <> 'enviado' or (enviado_em is not null and wa_message_id is not null)),
  constraint chk_wa_envios_falhou
    check (estado <> 'falhou' or erro_codigo is not null)
);

comment on table public.wa_envios is
  'FILA DE ENVIO -- unico caminho de saida de mensagem do CRM. Ninguem chama a Graph API dentro da requisicao do usuario: a edge wa-enviar valida e ENFILEIRA aqui; quem fala com a Meta e a edge wa-fila-drenar, disparada por pg_cron via pg_net (mesmo mecanismo de disparar_meta_insights_sync, 0089). chave_idempotencia UNIQUE e a garantia de que um cron reexecutado nao manda a mesma cobranca duas vezes.';
comment on column public.wa_envios.chave_idempotencia is
  'Chave construida por QUEM enfileira, deterministica a partir do fato que motivou a mensagem (ex.: parcela:{venda_parcela_id}:2026-08, campanha:{campanha_id}:{lead_id}, atendimento:{conversa_id}:{uuid do cliente}). UNIQUE: a segunda tentativa de enfileirar o MESMO fato colide e nao duplica. E a trava contra o pior erro do recurso -- a mesma cobranca chegando duas vezes na mesma cliente.';
comment on column public.wa_envios.tipo is
  'sessao = texto livre, so valido DENTRO da janela de 24h (secao 3). modelo = template aprovado (wa_modelos), unico caminho valido com a janela fechada. O drenador reconfere a janela no instante do envio -- a janela pode ter fechado entre o enfileiramento e a drenagem.';
comment on column public.wa_envios.lock_ate is
  'Ate quando o drenador que pegou este item e dono dele. Item com lock_ate < now() e estado=processando volta para pendente na proxima passada -- e o que faz um drenador morto no meio do lote (isolado reciclado, deploy) nao travar a fila para sempre.';
comment on column public.wa_envios.estado is
  'pendente -> processando -> enviado | falhou. Os tres estados bloqueado_* sao DESFECHOS, nao erros: bloqueado_optout (o lead pediu para nao receber), bloqueado_janela (era sessao e a janela fechou antes da drenagem), bloqueado_teto (o teto diario nosso ou o tier da Meta ja estourou). Sao separados de falhou de proposito: falha se retenta, bloqueio nao.';
comment on column public.wa_envios.prioridade is
  'Menor sai primeiro. 1-3 = resposta de atendimento (tem gente esperando do outro lado), 5 = padrao/transacional, 7-9 = campanha em lote. Sem isso, um disparo de 500 pessoas colocaria a resposta do vendedor no fim da fila.';
comment on constraint chk_wa_envios_conteudo on public.wa_envios is
  'Modelo sem modelo_id e sessao sem corpo sao envios vazios -- a Meta recusa, o item vai para falhou e a pessoa so descobre pelo silencio. Barrado na escrita.';

-- Índice de drenagem: é a consulta quente, roda a cada minuto.
create index if not exists idx_wa_envios_drenagem
  on public.wa_envios (agendado_para, prioridade, created_at)
  where estado = 'pendente';
create index if not exists idx_wa_envios_travados
  on public.wa_envios (lock_ate)
  where estado = 'processando';
create index if not exists idx_wa_envios_conversa
  on public.wa_envios (conversa_id) where conversa_id is not null;
create index if not exists idx_wa_envios_campanha
  on public.wa_envios (campanha_id) where campanha_id is not null;
create index if not exists idx_wa_envios_lead
  on public.wa_envios (lead_id, created_at desc) where lead_id is not null;
create index if not exists idx_wa_envios_team
  on public.wa_envios (team_id);

drop trigger if exists trg_wa_envios_updated_at on public.wa_envios;
create trigger trg_wa_envios_updated_at
  before update on public.wa_envios
  for each row execute function public.set_updated_at();

alter table public.wa_envios enable row level security;
alter table public.wa_envios force row level security;

-- SELECT: herda a visibilidade do LEAD. Sem lead (campanha para número solto,
-- teste), só gestão. É a mesma regra de anexos_select (0266:404-425): não há
-- verdade paralela sobre quem enxerga o quê.
drop policy if exists wa_envios_select on public.wa_envios;
create policy wa_envios_select
  on public.wa_envios for select to authenticated
  using (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and (
      (lead_id is not null and public.pode_ver_lead(lead_id))
      or (lead_id is null and public.auth_role() in ('admin', 'gerente'))
    )
  );

comment on policy wa_envios_select on public.wa_envios is
  'Quem enxerga o lead enxerga o que saiu para ele -- visibilidade HERDADA de public.pode_ver_lead (0266), nunca uma regra propria. Envio sem lead vinculado (teste, numero solto) e so da gestao, porque nao ha titular por quem herdar.';

-- NÃO HÁ policy de INSERT/UPDATE/DELETE para authenticated, e é deliberado: a
-- tela não enfileira direto. Quem escreve é a edge wa-enviar (service_role),
-- depois de conferir janela de 24h, opt-out e teto diário — três checagens que
-- uma policy não consegue fazer e que, feitas no cliente, seriam contornáveis
-- por qualquer um com o anon key. Sem policy de escrita, a fila não tem porta
-- lateral. O único ato do usuário sobre a fila é cancelar, e é RPC (abaixo).

Cancelamento (único ato do usuário sobre a fila) é RPC, não policy de UPDATE:

SQL
create or replace function public.wa_cancelar_envio(p_envio_id uuid)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
  v_uid   uuid := auth.uid();
  v_linha public.wa_envios%rowtype;
begin
  if v_uid is null or not public.auth_usuario_ativo() then
    raise exception 'Sessao invalida ou usuario inativo.' using errcode = '42501';
  end if;

  select * into v_linha from public.wa_envios e where e.id = p_envio_id;
  if not found then
    raise exception 'Envio nao encontrado.' using errcode = '42501';
  end if;

  -- Mesma barreira da leitura: quem não vê, não mexe. Mensagem de negado igual
  -- à de inexistente, para não confirmar a existência do item (0266:512-517).
  if not (
    v_linha.team_id = public.auth_team_id()
    and (
      (v_linha.lead_id is not null and public.pode_ver_lead(v_linha.lead_id))
      or (v_linha.lead_id is null and public.auth_role() in ('admin', 'gerente'))
    )
  ) then
    raise exception 'Envio nao encontrado.' using errcode = '42501';
  end if;

  -- Só cancela o que ainda não saiu. 'processando' NÃO é cancelável: o drenador
  -- pode já ter chamado a Meta, e cancelar aqui criaria um registro que diz
  -- "não enviado" para uma mensagem que a cliente recebeu.
  update public.wa_envios e
     set estado = 'cancelado'
   where e.id = p_envio_id
     and e.estado = 'pendente';

  return found;
end;
$$;

comment on function public.wa_cancelar_envio(uuid) is
  'Cancela um envio que AINDA esta pendente. Retorna false se ja saiu, ja falhou ou esta sendo processado -- "processando" nao e cancelavel porque o drenador pode ja ter chamado a Meta, e marcar como cancelado o que a cliente recebeu e pior do que nao cancelar. SECURITY DEFINER restrito a UMA coluna e a um id, porque nao existe policy de UPDATE em wa_envios e RLS nao restringe coluna (mesmo desenho de remover_anexo, 0266). Autorizacao herda public.pode_ver_lead.';

revoke all on function public.wa_cancelar_envio(uuid) from public;
revoke all on function public.wa_cancelar_envio(uuid) from anon;
grant execute on function public.wa_cancelar_envio(uuid) to authenticated;

1.5 public.wa_webhook_dedupe — Fase 0

SQL
-- ============================================================================
-- FASE 0 · public.wa_webhook_dedupe — idempotência do webhook da Meta
-- ============================================================================
-- Gêmeo de public.meta_leadgen_dedupe (0087), pelo mesmo motivo e com o mesmo
-- desenho: acesso EXCLUSIVO de service_role, nenhuma policy para authenticated.
--
-- POR QUE NÃO REUSAR public.webhook_events (0016): aquela tabela guarda o
-- PAYLOAD inteiro em jsonb. Ela tem 4.449 linhas hoje, com volume de webhook de
-- ClickUp. O volume de WhatsApp é de outra ordem — cada mensagem gera um evento
-- de entrada e de dois a três de status (enviada/entregue/lida). Guardar payload
-- de tudo isso em 500 MB de banco gratuito é gastar o teto com plumbing. Aqui
-- guarda-se só a CHAVE.
--
-- A CHAVE NÃO É O ID DA MENSAGEM SOZINHO. A Meta manda o mesmo wa_message_id
-- várias vezes, uma por transição de status. Deduplicar por id da mensagem
-- descartaria o "entregue" porque o "enviada" já tinha passado. A chave é
-- {tipo}:{id}:{estado} — ver o comment.
create table if not exists public.wa_webhook_dedupe (
  chave        text primary key,
  recebido_em  timestamptz not null default now(),
  resultado    text
);

comment on table public.wa_webhook_dedupe is
  'Dedup de idempotencia do webhook da WhatsApp Cloud API. Gemeo de public.meta_leadgen_dedupe (0087): PK unica, sem payload, acesso exclusivo de service_role e NENHUMA policy para authenticated. Guarda so a chave porque o volume de eventos de WhatsApp (1 de entrada + 2-3 de status por mensagem) tornaria proibitivo guardar payload em 500 MB de plano gratuito.';
comment on column public.wa_webhook_dedupe.chave is
  'Formato {tipo}:{id}:{estado}. Exemplos: msg:wamid.HBgN...:recebida | status:wamid.HBgN...:delivered. NAO e o wa_message_id sozinho: a Meta reenvia o MESMO id a cada transicao de status (sent, delivered, read), e deduplicar por id descartaria o "entregue" porque o "enviada" ja tinha passado.';
comment on column public.wa_webhook_dedupe.resultado is
  'O que o processamento decidiu: aplicado | ignorado_nao_nosso | ignorado_sem_conversa | erro. Existe para o diagnostico de "a mensagem chegou na Meta e nao apareceu na tela" -- sem isso a investigacao comeca sem nenhum rastro do lado de ca.';

create index if not exists idx_wa_webhook_dedupe_recebido
  on public.wa_webhook_dedupe (recebido_em);

alter table public.wa_webhook_dedupe enable row level security;
alter table public.wa_webhook_dedupe force row level security;
-- Nenhuma policy. service_role bypassa RLS nativamente; qualquer outro role
-- lê zero linhas. Mesma postura de meta_leadgen_dedupe (0087:45-46).

Retenção: 30 dias, acoplada ao cron de expurgo que já existe. Ver §1.10.

1.6 public.wa_ia_consumo — Fase 0

SQL
-- ============================================================================
-- FASE 0 · public.wa_ia_consumo — o que a IA gastou, por chamada
-- ============================================================================
-- Nasce na Fase 0, antes de existir IA nenhuma, por uma razão só: o alerta de
-- teto precisa de série histórica no dia em que a Fase 3 subir. Uma tabela de
-- consumo criada junto com o consumidor produz um primeiro alerta sem base de
-- comparação, e alerta sem base vira alarme ignorado.
--
-- CUSTO É GRAVADO, NÃO CALCULADO NA LEITURA. O preço por token muda; um
-- dashboard que multiplica tokens pelo preço de hoje reescreve o passado toda
-- vez que o fornecedor muda a tabela. Aqui o custo do instante fica congelado.
create table if not exists public.wa_ia_consumo (
  id              uuid primary key default extensions.gen_random_uuid(),
  conversa_id     uuid,   -- FK adicionada na Fase 2 (§1.11.3)
  lead_id         uuid references public.leads (id) on delete set null,
  provedor        text not null,
  modelo          text not null,
  finalidade      text not null,
  tokens_entrada  integer not null default 0,
  tokens_saida    integer not null default 0,
  custo_usd       numeric(12, 6) not null default 0,
  latencia_ms     integer,
  sucesso         boolean not null,
  erro            text,
  team_id         uuid not null default public.team_id_padrao(),
  created_at      timestamptz not null default now(),

  constraint chk_wa_ia_consumo_finalidade
    check (finalidade in ('classificacao', 'resposta', 'resumo', 'extracao', 'roteamento')),
  constraint chk_wa_ia_consumo_tokens
    check (tokens_entrada >= 0 and tokens_saida >= 0),
  constraint chk_wa_ia_consumo_custo
    check (custo_usd >= 0),
  constraint chk_wa_ia_consumo_erro
    check (sucesso or erro is not null)
);

comment on table public.wa_ia_consumo is
  'Uma linha por chamada de LLM: provedor, modelo, finalidade, tokens e CUSTO congelado no instante. Nasce na Fase 0, antes da Fase 3, para que o alerta de teto tenha serie historica no dia em que a IA subir. custo_usd e GRAVADO e nao recalculado na leitura -- preco por token muda, e um painel que multiplica pelo preco de hoje reescreve o passado a cada mudanca de tabela do fornecedor.';
comment on column public.wa_ia_consumo.finalidade is
  'Para que a chamada serviu. E o eixo da decisao de custo do projeto ("modelo caro so onde o erro sai da tela"): classificacao/roteamento/extracao usam modelo barato; resposta e resumo, que uma pessoa le, usam o caro. Sem esta coluna nao da para provar que a regra esta sendo seguida.';
comment on column public.wa_ia_consumo.sucesso is
  'false = a chamada falhou (timeout, recusa, cota). A linha e gravada mesmo assim, e o custo costuma ser > 0: falha de LLM que ja consumiu tokens de entrada e cobrada. Ignorar as falhas subestima o gasto exatamente no dia ruim.';

create index if not exists idx_wa_ia_consumo_dia
  on public.wa_ia_consumo (created_at desc);
create index if not exists idx_wa_ia_consumo_conversa
  on public.wa_ia_consumo (conversa_id) where conversa_id is not null;
create index if not exists idx_wa_ia_consumo_team
  on public.wa_ia_consumo (team_id);

alter table public.wa_ia_consumo enable row level security;
alter table public.wa_ia_consumo force row level security;

-- SELECT: só gestão. Custo de IA é dado de operação, não de carteira — mostrar
-- para vendedor gera a pergunta errada ("essa conversa custou caro?") sobre a
-- pessoa errada.
drop policy if exists wa_ia_consumo_select_gestao on public.wa_ia_consumo;
create policy wa_ia_consumo_select_gestao
  on public.wa_ia_consumo for select to authenticated
  using (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and public.auth_role() in ('admin', 'gerente')
  );

comment on policy wa_ia_consumo_select_gestao on public.wa_ia_consumo is
  'So admin/gerente le o consumo de IA. E numero de operacao, nao de carteira: exibido ao vendedor, produz a pergunta errada sobre a pessoa errada. Escrita e exclusiva de service_role (a edge que chama o LLM) -- nao ha policy de INSERT para authenticated.';

O alerta de teto (Fase 0, roda no cron diário que já existe):

SQL
create or replace function public.wa_ia_verificar_teto()
returns integer
language plpgsql
security definer
set search_path = ''
as $$
declare
  v_teto     numeric;
  v_gasto    numeric;
  v_team     uuid;
  v_alvo     uuid;
  v_avisados integer := 0;
begin
  for v_team, v_teto in
    select c.team_id, (c.valor ->> 'teto_usd_mes')::numeric
      from public.configuracoes c
     where c.chave = 'ia_limites'
       and (c.valor ->> 'teto_usd_mes') is not null
  loop
    select coalesce(sum(k.custo_usd), 0) into v_gasto
      from public.wa_ia_consumo k
     where k.team_id = v_team
       and k.created_at >= date_trunc('month', now() at time zone 'America/Sao_Paulo');

    -- 80% do teto: avisa uma vez por dia, e o cron é diário, então não há
    -- laço de repetição por hora. Passar de 100% NÃO desliga a IA aqui — quem
    -- desliga é a própria edge, na porta de entrada (§2.6). Uma função de
    -- alerta que também desliga é uma função que erra duas coisas de uma vez.
    if v_gasto >= v_teto * 0.8 then
      for v_alvo in
        select u.id from public.usuarios u
         where u.team_id = v_team and u.ativo
           and (u.papel::text = 'admin' or u.papeis @> array['admin'])
      loop
        perform public.registrar_notificacao(
          v_alvo,
          'ia_custo_teto',
          'Consumo de IA em ' || round(100 * v_gasto / nullif(v_teto, 0)) || '% do teto do mês',
          'Gasto no mês: US$ ' || to_char(v_gasto, 'FM999990.00') ||
            ' de US$ ' || to_char(v_teto, 'FM999990.00') || '.',
          null,
          v_team
        );
        v_avisados := v_avisados + 1;
      end loop;
    end if;
  end loop;

  return v_avisados;
end;
$$;

comment on function public.wa_ia_verificar_teto() is
  'Avisa admin quando o consumo de IA do mes passa de 80% do teto configurado em public.configuracoes chave ia_limites. NAO desliga a IA -- quem desliga e a propria edge, na porta de entrada, para que "parou de responder" e "avisou que esta caro" nunca sejam a mesma decisao tomada em dois lugares. Usa public.registrar_notificacao (0066), o unico escritor de notificacoes -- nao ha insert direto.';

revoke all on function public.wa_ia_verificar_teto() from public;

Agendamento (idempotente, molde de 0089:92-100):

SQL
do $$
begin
  perform cron.unschedule('wa_ia_teto_hb')
    where exists (select 1 from cron.job where jobname = 'wa_ia_teto_hb');
  perform cron.schedule('wa_ia_teto_hb', '0 12 * * *', 'select public.wa_ia_verificar_teto();');
end;
$$;

'0 12 * * *' = 09:00 em Brasília — junto do horário dos outros avisos diários já existentes (leads_frios_hb às 30 11, sla_leads_parados_hb às 0 9).

1.7 public.wa_conversas — Fase 2

SQL
-- ============================================================================
-- FASE 2 · public.wa_conversas — uma linha por número de contato
-- ============================================================================
-- A CONVERSA É DO NÚMERO, NÃO DO LEAD. Decisão que organiza a tabela inteira.
-- Motivo: a mensagem chega antes de sabermos de quem é. Se a conversa exigisse
-- lead_id, o webhook teria de RESOLVER o lead para conseguir gravar — e resolver
-- sob pressão de 5 segundos é exatamente onde nasce o "chuta o mais recente" que
-- esta especificação existe para eliminar (§4).
--
-- Então: a conversa nasce sempre, com lead_id NULL se preciso, e a resolução é
-- um estado explícito (coluna resolucao) que a tela sabe exibir e cobrar.
--
-- A JANELA DE 24H NÃO É COLUNA DE ESTADO. É derivada de ultima_msg_cliente_em.
-- Uma coluna booleana "janela_aberta" precisaria de um cron para virar de hora
-- em hora e estaria errada entre uma passada e outra — e estar errada aqui
-- significa oferecer ao vendedor um campo de texto livre que a Meta vai recusar.
create table if not exists public.wa_conversas (
  id                     uuid primary key default extensions.gen_random_uuid(),
  numero_id              uuid not null references public.wa_numeros (id) on delete restrict,
  -- O identificador do contato como a Meta manda (E.164 sem "+").
  contato_e164           text not null,
  -- Chave canônica de casamento: o E.164-BR normalizado por
  -- public.telefone_normalizado_br. NULL quando o número não é BR reconhecível
  -- (internacional) — e NULL não casa com nada, de propósito (0144:100-108).
  contato_chave          text,
  contato_nome_wa        text,

  lead_id                uuid references public.leads (id) on delete set null,
  resolucao              text not null default 'pendente',
  -- Quando resolucao='ambigua': os leads que casaram. A tela mostra a lista e
  -- pede que um humano escolha. Nunca se escolhe sozinho (§4).
  candidatos             uuid[] not null default '{}'::uuid[],
  resolvido_por          uuid references public.usuarios (id),
  resolvido_em           timestamptz,

  estado                 text not null default 'nao_atribuida',
  atendente_id           uuid references public.usuarios (id) on delete set null,
  ia_ativa               boolean not null default false,

  ultima_msg_cliente_em  timestamptz,
  ultima_msg_nossa_em    timestamptz,
  ultima_msg_previa      text,
  nao_lidas              integer not null default 0,
  encerrada_em           timestamptz,
  encerrada_por          uuid references public.usuarios (id),

  team_id                uuid not null default public.team_id_padrao(),
  created_at             timestamptz not null default now(),
  updated_at             timestamptz not null default now(),

  constraint uq_wa_conversas_contato unique (numero_id, contato_e164),
  constraint chk_wa_conversas_contato
    check (contato_e164 ~ '^[0-9]{10,15}$'),
  constraint chk_wa_conversas_resolucao
    check (resolucao in ('pendente', 'resolvida', 'ambigua', 'sem_lead', 'ignorada')),
  constraint chk_wa_conversas_estado
    check (estado in ('nao_atribuida', 'com_ia', 'com_humano', 'aguardando_cliente', 'encerrada')),
  constraint chk_wa_conversas_nao_lidas
    check (nao_lidas >= 0),
  -- Resolvida exige lead. Ambígua exige 2+ candidatos e NÃO pode ter lead
  -- escolhido — é o CHECK que impede o "chutou e gravou".
  constraint chk_wa_conversas_resolucao_coerente
    check (
      (resolucao = 'resolvida'  and lead_id is not null)
      or (resolucao = 'ambigua' and lead_id is null and array_length(candidatos, 1) >= 2)
      or (resolucao in ('pendente', 'sem_lead', 'ignorada') and lead_id is null)
    ),
  constraint chk_wa_conversas_encerrada
    check (estado <> 'encerrada' or encerrada_em is not null),
  constraint chk_wa_conversas_atendente
    check (estado <> 'com_humano' or atendente_id is not null),
  constraint chk_wa_conversas_previa
    check (ultima_msg_previa is null or length(ultima_msg_previa) <= 160)
);

comment on table public.wa_conversas is
  'Uma linha por numero de contato neste numero WABA. A conversa e do NUMERO, nao do lead: a mensagem chega antes de sabermos de quem e, e exigir lead_id obrigaria o webhook a resolver sob pressao de 5 segundos -- que e exatamente onde nasce o "chuta o mais recente" que a secao 4 existe para eliminar. A resolucao e um ESTADO explicito (pendente/resolvida/ambigua/sem_lead), nao um palpite. A janela de 24h NAO e coluna: e derivada de ultima_msg_cliente_em, porque uma coluna booleana estaria errada entre duas passadas de cron e ofereceria ao vendedor um campo de texto que a Meta recusaria.';
comment on column public.wa_conversas.contato_chave is
  'E.164-BR canonizado por public.telefone_normalizado_br (0144:91) -- a MESMA funcao que ja indexa public.leads (idx_leads_telefone_normalizado, 0144:136). NULL quando nao e telefone BR reconhecivel (internacional): NULL nao casa com nada, e e o que impede um numero de Portugal de ser costurado a um lead brasileiro (secao 4.3).';
comment on column public.wa_conversas.resolucao is
  'pendente = ainda nao se tentou casar. resolvida = ha exatamente UM lead. ambigua = ha 2+ e um humano precisa escolher (candidatos). sem_lead = nao existe lead com este numero (a tela oferece "criar lead"). ignorada = alguem marcou como nao-lead (fornecedor, engano, spam) -- some da caixa sem apagar historico.';
comment on column public.wa_conversas.candidatos is
  'Os lead_id que casaram quando resolucao=ambigua. A tela lista nome, dono e data de entrada de cada um e pede a escolha. NUNCA se escolhe sozinho -- e o requisito central da secao 4, e o CHECK chk_wa_conversas_resolucao_coerente impede que ambigua tenha lead_id preenchido.';
comment on column public.wa_conversas.estado is
  'nao_atribuida (chegou e ninguem pegou) -> com_ia (a Fase 3 esta conduzindo) | com_humano (alguem assumiu) -> aguardando_cliente (respondemos, a bola e deles) -> encerrada. Ver a maquina de estados completa na secao 3.';
comment on column public.wa_conversas.ia_ativa is
  'Se a Fase 3 pode agir NESTA conversa. Desligado por padrao e ligado por decisao explicita -- e a chave que permite subir a Fase 3 em modo sugestao para 3 conversas antes de ligar para 1.400.';
comment on column public.wa_conversas.ultima_msg_previa is
  'Primeiros 160 caracteres da ultima mensagem, para a lista da caixa de entrada nao precisar de um LATERAL em wa_mensagens por linha. Denormalizacao deliberada: a lista e a tela mais aberta do recurso.';
comment on constraint chk_wa_conversas_resolucao_coerente on public.wa_conversas is
  'Os tres estados possiveis, e nenhum outro: resolvida TEM lead; ambigua NAO tem lead e tem 2+ candidatos; pendente/sem_lead/ignorada nao tem lead. E o CHECK que torna impossivel, no schema, gravar um palpite como se fosse resolucao.';

-- Índice de acesso: a caixa de entrada é "as conversas do time, mais recentes
-- primeiro, as não lidas em cima".
create index if not exists idx_wa_conversas_caixa
  on public.wa_conversas (team_id, estado, ultima_msg_cliente_em desc nulls last);
create index if not exists idx_wa_conversas_lead
  on public.wa_conversas (lead_id) where lead_id is not null;
create index if not exists idx_wa_conversas_chave
  on public.wa_conversas (contato_chave) where contato_chave is not null;
create index if not exists idx_wa_conversas_atendente
  on public.wa_conversas (atendente_id, ultima_msg_cliente_em desc) where atendente_id is not null;
create index if not exists idx_wa_conversas_pendentes
  on public.wa_conversas (created_at) where resolucao in ('pendente', 'ambigua', 'sem_lead');
create index if not exists idx_wa_conversas_team
  on public.wa_conversas (team_id);

drop trigger if exists trg_wa_conversas_updated_at on public.wa_conversas;
create trigger trg_wa_conversas_updated_at
  before update on public.wa_conversas
  for each row execute function public.set_updated_at();

alter table public.wa_conversas enable row level security;
alter table public.wa_conversas force row level security;

-- SELECT: herda pode_ver_lead quando há lead. Conversa SEM lead resolvido é
-- visível a todo o time ativo — é o equivalente ao pool: alguém precisa poder
-- pegar. Sem isso, uma mensagem de número desconhecido ficaria invisível para
-- todos menos a gestão, e o cliente esperaria resposta que ninguém veria.
drop policy if exists wa_conversas_select on public.wa_conversas;
create policy wa_conversas_select
  on public.wa_conversas for select to authenticated
  using (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and (
      lead_id is null
      or public.pode_ver_lead(lead_id)
    )
  );

comment on policy wa_conversas_select on public.wa_conversas is
  'Com lead resolvido, a visibilidade e HERDADA de public.pode_ver_lead (0266) -- vendedor le a conversa do lead dele e a do pool, gestao le todas do time. SEM lead resolvido, todo o time ativo enxerga: e o equivalente ao pool. A alternativa (so gestao) deixaria a mensagem de um numero desconhecido invisivel para quem poderia responder, e o cliente esperando.';

-- Sem policy de INSERT: conversa nasce pelo webhook (service_role). Não existe
-- "criar conversa" pela tela — para falar com alguém novo, o caminho é a Fase 4
-- (modelo) ou o botão de WhatsApp do lead, que abre o app externo.

-- UPDATE: as duas colunas que a tela mexe (estado/atendente e leitura) passam
-- por RPC. Esta policy existe só para o marcar-como-lida, que é alto volume e
-- não merece uma RPC por rolagem de tela.
drop policy if exists wa_conversas_update on public.wa_conversas;
create policy wa_conversas_update
  on public.wa_conversas for update to authenticated
  using (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and (lead_id is null or public.pode_ver_lead(lead_id))
  )
  with check (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and (lead_id is null or public.pode_ver_lead(lead_id))
  );

comment on policy wa_conversas_update on public.wa_conversas is
  'Existe para o marcar-como-lida (nao_lidas=0), que e alto volume e nao merece uma RPC por rolagem. As mudancas que IMPORTAM -- assumir (estado/atendente_id), resolver o lead, encerrar -- passam por RPC com regra propria, e o gatilho trg_wa_conversas_colunas_travadas abaixo recusa altera-las por UPDATE direto, porque RLS nao restringe coluna.';

Trava de coluna (as colunas de decisão só mudam por RPC):

SQL
create or replace function public.trg_wa_conversas_colunas_travadas()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
  -- service_role / cron / RPC security definer: passa.
  if auth.uid() is null
     or current_setting('highbeauty.wa_rpc_autorizado', true) = 'on' then
    return new;
  end if;

  if new.lead_id      is distinct from old.lead_id
     or new.resolucao is distinct from old.resolucao
     or new.candidatos is distinct from old.candidatos
     or new.estado    is distinct from old.estado
     or new.atendente_id is distinct from old.atendente_id
     or new.ia_ativa  is distinct from old.ia_ativa
     or new.contato_e164 is distinct from old.contato_e164
     or new.numero_id is distinct from old.numero_id then
    raise exception 'Use as acoes da tela (assumir, resolver, encerrar) para mudar esta conversa.'
      using errcode = '42501';
  end if;
  return new;
end;
$$;

comment on function public.trg_wa_conversas_colunas_travadas() is
  'Congela as colunas de DECISAO da conversa (lead_id, resolucao, candidatos, estado, atendente_id, ia_ativa, identidade do contato) para UPDATE vindo de sessao de usuario. Elas so mudam dentro das RPCs, que setam o flag de sessao highbeauty.wa_rpc_autorizado -- mesmo mecanismo do flag de expurgo LGPD (0095:453). A policy de UPDATE segue existindo para o marcar-como-lida; RLS nao restringe coluna, entao a restricao de coluna e aqui.';

drop trigger if exists trg_wa_conversas_travadas on public.wa_conversas;
create trigger trg_wa_conversas_travadas
  before update on public.wa_conversas
  for each row execute function public.trg_wa_conversas_colunas_travadas();

1.8 public.wa_mensagens — Fase 2

SQL
-- ============================================================================
-- FASE 2 · public.wa_mensagens — uma linha por mensagem, entrada e saída
-- ============================================================================
-- MÍDIA NÃO É BAIXADA. Guarda-se midia_meta_id (o id do objeto na Meta) e os
-- metadados que vieram no webhook. O binário só entra no nosso Storage quando um
-- humano PROMOVE o arquivo, e a promoção grava em public.anexos pelo caminho que
-- já existe (0266), preenchendo anexo_id aqui.
--
-- Por quê: 1 GB de Storage no plano gratuito, hoje com 421 KB usados. Baixar
-- tudo que chega por WhatsApp — áudio de 2 minutos, print de conversa, PDF de
-- contrato — consome o teto em semanas e ninguém percebe até o upload de
-- comprovante parar de funcionar. E a maior parte da mídia nunca é reaberta.
--
-- CONSEQUÊNCIA ACEITA E DOCUMENTADA: o id da mídia na Meta expira (a URL de
-- download tem validade curta e o objeto é retido por tempo limitado
-- [a confirmar: a documentação da Cloud API fala em 30 dias para o media id;
-- confirmar contra a versão v21.0 antes de escrever o texto da tela]). Passado
-- esse prazo, a mídia não promovida não é mais recuperável. A tela diz isso, com
-- a data limite, ao lado de todo anexo não promovido.
create table if not exists public.wa_mensagens (
  id              uuid primary key default extensions.gen_random_uuid(),
  conversa_id     uuid not null references public.wa_conversas (id) on delete cascade,
  direcao         text not null,
  -- Id da mensagem na Meta (wamid...). NULL só em mensagem de sistema nossa.
  wa_message_id   text,
  tipo            text not null,
  corpo           text,

  -- Mídia: referência, não arquivo.
  midia_meta_id   text,
  midia_mime      text,
  midia_nome      text,
  midia_bytes     bigint,
  midia_expira_em timestamptz,
  anexo_id        uuid references public.anexos (id) on delete set null,

  -- Quem escreveu, do nosso lado. NULL = veio do cliente, ou foi a IA.
  autor_id        uuid references public.usuarios (id),
  autor_tipo      text not null default 'cliente',
  modelo_id       uuid references public.wa_modelos (id) on delete set null,
  envio_id        uuid references public.wa_envios (id) on delete set null,

  status          text not null default 'recebida',
  erro_codigo     text,
  erro_detalhe    text,
  entregue_em     timestamptz,
  lida_em         timestamptz,

  -- Instante que a META carimbou. Não é created_at: mensagem pode chegar com
  -- atraso, e ordenar a conversa por chegada mostraria o diálogo fora de ordem.
  ocorreu_em      timestamptz not null,
  team_id         uuid not null default public.team_id_padrao(),
  created_at      timestamptz not null default now(),

  constraint chk_wa_mensagens_direcao
    check (direcao in ('entrada', 'saida')),
  constraint chk_wa_mensagens_tipo
    check (tipo in ('texto', 'imagem', 'audio', 'video', 'documento', 'sticker',
                    'localizacao', 'contato', 'modelo', 'interativo',
                    'reacao', 'sistema', 'nao_suportado')),
  constraint chk_wa_mensagens_autor_tipo
    check (autor_tipo in ('cliente', 'humano', 'ia', 'sistema')),
  constraint chk_wa_mensagens_status
    check (status in ('recebida', 'fila', 'enviada', 'entregue', 'lida', 'falhou')),
  constraint chk_wa_mensagens_corpo
    check (corpo is null or length(corpo) <= 8000),
  -- Autor humano exige autor_id; cliente e IA não podem ter.
  constraint chk_wa_mensagens_autor_coerente
    check (
      (autor_tipo = 'humano' and autor_id is not null)
      or (autor_tipo in ('cliente', 'ia', 'sistema') and autor_id is null)
    ),
  -- Entrada é sempre do cliente; saída nunca é.
  constraint chk_wa_mensagens_direcao_autor
    check (
      (direcao = 'entrada' and autor_tipo in ('cliente', 'sistema'))
      or (direcao = 'saida' and autor_tipo in ('humano', 'ia', 'sistema'))
    ),
  constraint chk_wa_mensagens_midia
    check (midia_bytes is null or midia_bytes >= 0),
  constraint chk_wa_mensagens_falhou
    check (status <> 'falhou' or erro_codigo is not null)
);

comment on table public.wa_mensagens is
  'Uma linha por mensagem, entrada e saida. MIDIA NAO E BAIXADA: guarda-se midia_meta_id e os metadados do webhook; o binario so entra no nosso Storage quando um humano PROMOVE, e a promocao usa o caminho de anexo que ja existe (public.anexos + bucket privado anexos, 0266), preenchendo anexo_id. Motivo: 1 GB de Storage gratuito (421 KB usados hoje) sumiria em semanas com audio e print, e a maior parte da midia nunca e reaberta. Consequencia aceita: midia nao promovida expira no lado da Meta e some -- a tela avisa, com a data.';
comment on column public.wa_mensagens.ocorreu_em is
  'Instante carimbado pela META, nao o de gravacao. E por ele que a conversa e ordenada: webhook pode chegar atrasado ou fora de ordem, e ordenar por created_at exibiria o dialogo trocado -- o defeito que faz a tela parecer quebrada mesmo com o dado certo.';
comment on column public.wa_mensagens.midia_expira_em is
  'Ate quando a midia ainda pode ser baixada da Meta. A tela mostra esta data ao lado de todo anexo NAO promovido, com o botao de promover. Depois disso, o arquivo nao existe mais em lugar nenhum -- e a consequencia aceita de nao baixar tudo.';
comment on column public.wa_mensagens.autor_tipo is
  'cliente | humano | ia | sistema. Separado de autor_id porque IA nao e usuario e nao pode virar um usuario de mentira em public.usuarios -- se virasse, apareceria em ranking de vendedor, em atribuicao de lead e em comissao. sistema = eventos nossos (conversa encerrada, janela fechou).';
comment on column public.wa_mensagens.envio_id is
  'Item da fila (wa_envios) que originou esta mensagem de saida. E o que liga o status de entrega que volta pelo webhook ao pedido original -- sem ele, "entregue" chega e nao se sabe de qual disparo.';
comment on column public.wa_mensagens.status is
  'recebida (entrada) | fila -> enviada -> entregue -> lida (saida) | falhou. A transicao NUNCA volta atras: o webhook da Meta pode entregar "sent" depois de "delivered", e aplicar em ordem de chegada rebaixaria o status. A RPC de aplicacao compara a ordem antes de escrever (secao 2.2).';

create unique index if not exists uq_wa_mensagens_wa_message_id
  on public.wa_mensagens (wa_message_id) where wa_message_id is not null;
-- Índice de acesso: a thread aberta, em ordem cronológica.
create index if not exists idx_wa_mensagens_thread
  on public.wa_mensagens (conversa_id, ocorreu_em asc);
create index if not exists idx_wa_mensagens_envio
  on public.wa_mensagens (envio_id) where envio_id is not null;
create index if not exists idx_wa_mensagens_midia_pendente
  on public.wa_mensagens (midia_expira_em)
  where midia_meta_id is not null and anexo_id is null;
create index if not exists idx_wa_mensagens_team
  on public.wa_mensagens (team_id);

alter table public.wa_mensagens enable row level security;
alter table public.wa_mensagens force row level security;

-- SELECT: quem enxerga a conversa enxerga as mensagens. Herdado, sem regra
-- própria — mesma disciplina de anexos_select (0266:404-425).
drop policy if exists wa_mensagens_select on public.wa_mensagens;
create policy wa_mensagens_select
  on public.wa_mensagens for select to authenticated
  using (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and exists (
      select 1 from public.wa_conversas c
       where c.id = conversa_id
         and c.team_id = public.auth_team_id()
         and (c.lead_id is null or public.pode_ver_lead(c.lead_id))
    )
  );

comment on policy wa_mensagens_select on public.wa_mensagens is
  'Quem enxerga a conversa le as mensagens dela. Visibilidade HERDADA, sem regra propria -- duas verdades sobre quem ve o que e como o multi-papel ficou meses errado (banco tinha, tela ignorava).';

-- Sem policy de INSERT/UPDATE/DELETE para authenticated: mensagem de entrada é
-- escrita pelo webhook (service_role); mensagem de saída nasce do drenador da
-- fila (service_role). A tela nunca escreve aqui — se escrevesse, existiria um
-- caminho de gravar "mandei" sem ter mandado.

1.9 public.wa_atribuicoes — Fase 2

SQL
-- ============================================================================
-- FASE 2 · public.wa_atribuicoes — quem assumiu a conversa, e quando
-- ============================================================================
-- Existe porque wa_conversas.atendente_id guarda só o ESTADO ATUAL, e a pergunta
-- que aparece em toda briga operacional é histórica: "quem estava atendendo às
-- 14h?". A auditoria genérica (public.audit_log) não serve: ela guarda
-- campo/antes/depois, sem o motivo, e a conversa troca de mão por quatro motivos
-- diferentes que precisam ser distinguíveis.
create table if not exists public.wa_atribuicoes (
  id           uuid primary key default extensions.gen_random_uuid(),
  conversa_id  uuid not null references public.wa_conversas (id) on delete cascade,
  de_usuario   uuid references public.usuarios (id),
  para_usuario uuid references public.usuarios (id),
  motivo       text not null,
  por_usuario  uuid references public.usuarios (id),
  observacao   text,
  team_id      uuid not null default public.team_id_padrao(),
  created_at   timestamptz not null default now(),

  constraint chk_wa_atribuicoes_motivo
    check (motivo in ('assumiu', 'transferiu', 'devolveu_ao_pool',
                      'ia_assumiu', 'ia_escalou', 'dono_do_lead', 'encerrou')),
  constraint chk_wa_atribuicoes_observacao
    check (observacao is null or length(btrim(observacao)) between 1 and 500),
  -- Não é troca se não muda nada.
  constraint chk_wa_atribuicoes_mudou
    check (de_usuario is distinct from para_usuario or motivo = 'encerrou')
);

comment on table public.wa_atribuicoes is
  'Historico de posse da conversa. wa_conversas.atendente_id guarda so o AGORA; a pergunta que aparece em briga operacional e "quem estava atendendo as 14h". public.audit_log nao serve porque guarda campo/antes/depois sem MOTIVO, e a conversa troca de mao por motivos que precisam ser distinguiveis -- assumir espontaneo, transferencia, devolucao ao pool e escalonamento da IA nao sao a mesma coisa.';
comment on column public.wa_atribuicoes.motivo is
  'assumiu (pegou sozinho) | transferiu (passou para outro) | devolveu_ao_pool | ia_assumiu (a Fase 3 pegou) | ia_escalou (a IA desistiu e chamou humano) | dono_do_lead (roteamento automatico para quem ja e dono) | encerrou. ia_escalou e o motivo mais importante do conjunto: e o numero que diz se a IA esta ajudando ou empurrando trabalho.';

create index if not exists idx_wa_atribuicoes_conversa
  on public.wa_atribuicoes (conversa_id, created_at desc);
create index if not exists idx_wa_atribuicoes_para
  on public.wa_atribuicoes (para_usuario, created_at desc) where para_usuario is not null;
create index if not exists idx_wa_atribuicoes_team
  on public.wa_atribuicoes (team_id);

alter table public.wa_atribuicoes enable row level security;
alter table public.wa_atribuicoes force row level security;

drop policy if exists wa_atribuicoes_select on public.wa_atribuicoes;
create policy wa_atribuicoes_select
  on public.wa_atribuicoes for select to authenticated
  using (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and exists (
      select 1 from public.wa_conversas c
       where c.id = conversa_id
         and c.team_id = public.auth_team_id()
         and (c.lead_id is null or public.pode_ver_lead(c.lead_id))
    )
  );

comment on policy wa_atribuicoes_select on public.wa_atribuicoes is
  'Quem enxerga a conversa le o historico de posse dela. Sem policy de INSERT: a linha nasce dentro das RPCs de assumir/transferir/encerrar, na mesma transacao da mudanca -- historico gravavel por fora e historico que nao vale como prova.';

1.10 Fase 3 — as três tabelas da IA

public.wa_ia_execucoes — a fila e o log do orquestrador

SQL
-- ============================================================================
-- FASE 3 · public.wa_ia_execucoes — fila do orquestrador e log do que ele fez
-- ============================================================================
-- UMA TABELA, NÃO DUAS. Fila e log são o mesmo objeto em momentos diferentes, e
-- separá-los obrigaria a copiar a linha de uma para a outra no fim — uma cópia
-- que, quando falha, produz execução que rodou e não aparece em lugar nenhum.
--
-- AGRUPAMENTO DE RAJADA: a pessoa manda "oi", "tudo bem?", "vi o anúncio" em
-- oito segundos. Responder cada uma é responder três vezes a mesma pergunta e
-- parecer um robô. A execução é AGENDADA para agora + janela_rajada (padrão 25s,
-- em configuracoes) e, enquanto pendente, uma mensagem nova da mesma conversa
-- EMPURRA agendado_para para frente em vez de criar execução nova. O índice
-- único parcial abaixo é o que garante "no máximo uma execução pendente por
-- conversa" — sem ele, três mensagens em rajada viram três respostas.
create table if not exists public.wa_ia_execucoes (
  id                 uuid primary key default extensions.gen_random_uuid(),
  conversa_id        uuid not null references public.wa_conversas (id) on delete cascade,
  gatilho            text not null,
  estado             text not null default 'pendente',
  agendado_para      timestamptz not null default now(),
  lock_ate           timestamptz,
  tentativas         smallint not null default 0,
  -- A partir de qual mensagem a execução deve ler. Evita reprocessar a thread
  -- inteira a cada rajada e é o que mantém o custo previsível.
  desde_mensagem_id  uuid references public.wa_mensagens (id) on delete set null,
  mensagens_lidas    integer,

  -- O que a IA decidiu.
  decisao            text,
  motivo             text,
  confianca          numeric(4, 3),
  sugestao_id        uuid,   -- FK adicionada logo abaixo, depois de wa_ia_sugestoes
  envio_id           uuid references public.wa_envios (id) on delete set null,

  erro               text,
  team_id            uuid not null default public.team_id_padrao(),
  created_at         timestamptz not null default now(),
  concluido_em       timestamptz,

  constraint chk_wa_ia_execucoes_gatilho
    check (gatilho in ('mensagem_cliente', 'reprocessar', 'manual', 'cron_retomada')),
  constraint chk_wa_ia_execucoes_estado
    check (estado in ('pendente', 'processando', 'concluida', 'falhou', 'descartada')),
  constraint chk_wa_ia_execucoes_decisao
    check (decisao is null or decisao in (
      'responder', 'sugerir', 'escalar_humano', 'qualificar',
      'desqualificar', 'agendar_tarefa', 'nao_fazer_nada')),
  constraint chk_wa_ia_execucoes_confianca
    check (confianca is null or (confianca >= 0 and confianca <= 1)),
  constraint chk_wa_ia_execucoes_tentativas
    check (tentativas between 0 and 5),
  constraint chk_wa_ia_execucoes_concluida
    check (estado not in ('concluida', 'falhou') or concluido_em is not null),
  constraint chk_wa_ia_execucoes_falhou
    check (estado <> 'falhou' or erro is not null)
);

comment on table public.wa_ia_execucoes is
  'Fila E log do orquestrador de IA -- uma tabela so, porque sao o mesmo objeto em momentos diferentes e separa-los exigiria copiar a linha no fim, copia que quando falha produz execucao que rodou e nao aparece. Agrupamento de rajada: a execucao e agendada para agora + janela (configuracoes.ia_limites.janela_rajada_seg, padrao 25) e mensagem nova EMPURRA agendado_para em vez de criar execucao nova -- o indice unico parcial uq_wa_ia_execucoes_pendente e o que garante uma so pendente por conversa.';
comment on column public.wa_ia_execucoes.decisao is
  'O que a IA concluiu que se deve fazer. responder = mandou (so com ia_ativa e modo automatico). sugerir = escreveu e deixou para o humano aprovar (wa_ia_sugestoes) -- e o modo padrao de estreia. escalar_humano = desistiu e chamou gente. qualificar/desqualificar = mexeu em leads.qualificacao pela RPC que ja existe. nao_fazer_nada = decisao legitima e a mais comum em conversa ja atendida por humano.';
comment on column public.wa_ia_execucoes.desde_mensagem_id is
  'A partir de qual mensagem esta execucao le a thread. Sem isso, cada rajada reprocessaria a conversa inteira e o custo cresceria com o quadrado do tamanho da conversa -- o jeito mais silencioso de estourar o teto de IA.';
comment on column public.wa_ia_execucoes.confianca is
  'Autoavaliacao do modelo, 0 a 1. NAO e usada como trava sozinha (modelo e mal calibrado e mente com seguranca); e usada para ORDENAR a fila de revisao humana no modo sugestao: o que ele acha duvidoso aparece em cima.';

-- A trava do agrupamento de rajada: no máximo UMA execução pendente por conversa.
create unique index if not exists uq_wa_ia_execucoes_pendente
  on public.wa_ia_execucoes (conversa_id)
  where estado in ('pendente', 'processando');

comment on index public.uq_wa_ia_execucoes_pendente is
  'No maximo UMA execucao viva por conversa. E a garantia de banco do agrupamento de rajada: tres mensagens em oito segundos nao viram tres respostas. Mesmo papel do uq_tarefas_primeiro_contato_whatsapp (0264:183-186) -- o indice resolve o que o lock nao alcanca.';

create index if not exists idx_wa_ia_execucoes_fila
  on public.wa_ia_execucoes (agendado_para) where estado = 'pendente';
create index if not exists idx_wa_ia_execucoes_conversa
  on public.wa_ia_execucoes (conversa_id, created_at desc);
create index if not exists idx_wa_ia_execucoes_team
  on public.wa_ia_execucoes (team_id);

alter table public.wa_ia_execucoes enable row level security;
alter table public.wa_ia_execucoes force row level security;

drop policy if exists wa_ia_execucoes_select on public.wa_ia_execucoes;
create policy wa_ia_execucoes_select
  on public.wa_ia_execucoes for select to authenticated
  using (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and exists (
      select 1 from public.wa_conversas c
       where c.id = conversa_id
         and c.team_id = public.auth_team_id()
         and (c.lead_id is null or public.pode_ver_lead(c.lead_id))
    )
  );

comment on policy wa_ia_execucoes_select on public.wa_ia_execucoes is
  'Quem enxerga a conversa enxerga o que a IA decidiu nela, e o motivo. Deliberado: IA cuja decisao o operador nao consegue ler e IA que o operador nao consegue corrigir. Escrita e exclusiva de service_role.';

public.wa_ia_memoria — memória por contato

SQL
-- ============================================================================
-- FASE 3 · public.wa_ia_memoria — o que já se sabe sobre este contato
-- ============================================================================
-- FATO, NÃO RESUMO EM PROSA. Cada linha é uma afirmação curta com origem e
-- instante. Um resumo em prosa reescrito a cada rodada perde informação de forma
-- invisível e não dá para corrigir pedaço — a terceira reescrita já não tem o que
-- a cliente disse na primeira.
--
-- APPEND-ONLY COM INVALIDAÇÃO. Fato não se edita: cria-se um novo e marca-se o
-- antigo como superado. "Ela tem 2 salões" vira "ela tem 3 salões" sem apagar
-- que um dia teve 2 — que é a informação que interessa a quem for vender.
create table if not exists public.wa_ia_memoria (
  id             uuid primary key default extensions.gen_random_uuid(),
  conversa_id    uuid not null references public.wa_conversas (id) on delete cascade,
  lead_id        uuid references public.leads (id) on delete cascade,
  chave          text not null,
  valor          text not null,
  confianca      numeric(4, 3),
  origem         text not null,
  -- A mensagem de onde o fato saiu. É o que permite ao humano conferir a fonte
  -- em um clique, e é o que impede a IA de "lembrar" do que ninguém disse.
  mensagem_id    uuid references public.wa_mensagens (id) on delete set null,
  superado_por   uuid references public.wa_ia_memoria (id) on delete set null,
  superado_em    timestamptz,
  team_id        uuid not null default public.team_id_padrao(),
  created_at     timestamptz not null default now(),

  constraint chk_wa_ia_memoria_chave
    check (chave in (
      'nome_preferido', 'cidade', 'qtd_saloes', 'qtd_colaboradores',
      'faturamento_declarado', 'tempo_de_mercado', 'dor_principal',
      'objecao', 'interesse_produto', 'disponibilidade', 'ja_e_aluno',
      'canal_preferido', 'observacao_livre')),
  constraint chk_wa_ia_memoria_valor
    check (length(btrim(valor)) between 1 and 500),
  constraint chk_wa_ia_memoria_origem
    check (origem in ('mensagem_cliente', 'extracao_ia', 'humano', 'crm')),
  constraint chk_wa_ia_memoria_confianca
    check (confianca is null or (confianca >= 0 and confianca <= 1)),
  constraint chk_wa_ia_memoria_superado
    check (num_nonnulls(superado_por, superado_em) in (0, 2))
);

comment on table public.wa_ia_memoria is
  'Memoria por contato: FATOS curtos com origem e instante, nunca resumo em prosa. Resumo reescrito a cada rodada perde informacao de forma invisivel e nao da para corrigir pedaco -- na terceira reescrita ja nao tem o que a cliente disse na primeira. Append-only com invalidacao: o fato novo marca o antigo como superado, e o historico fica ("tinha 2 saloes, hoje tem 3" e o que interessa a quem vai vender). chave e conjunto FECHADO: memoria de chave livre vira lixo em duas semanas.';
comment on column public.wa_ia_memoria.mensagem_id is
  'A mensagem de onde o fato saiu. Permite conferir a fonte em um clique e e a barreira contra a IA "lembrar" do que ninguem disse. NULL so quando origem=crm (o fato veio do proprio cadastro) ou humano.';
comment on column public.wa_ia_memoria.origem is
  'De onde veio: mensagem_cliente (ela escreveu literalmente) | extracao_ia (o modelo inferiu -- o menos confiavel) | humano (o vendedor anotou) | crm (veio de leads/custom_fields). A tela mostra a origem junto do fato, porque "ela disse" e "o modelo achou" tem pesos diferentes numa negociacao.';

create unique index if not exists uq_wa_ia_memoria_vigente
  on public.wa_ia_memoria (conversa_id, chave)
  where superado_em is null and chave <> 'observacao_livre';

comment on index public.uq_wa_ia_memoria_vigente is
  'No maximo UM fato vigente por chave e por conversa (exceto observacao_livre, que e lista). E o que impede a memoria de acumular tres respostas contraditorias para "quantos saloes" e o modelo escolher a errada.';

create index if not exists idx_wa_ia_memoria_conversa
  on public.wa_ia_memoria (conversa_id) where superado_em is null;
create index if not exists idx_wa_ia_memoria_lead
  on public.wa_ia_memoria (lead_id) where lead_id is not null and superado_em is null;
create index if not exists idx_wa_ia_memoria_team
  on public.wa_ia_memoria (team_id);

alter table public.wa_ia_memoria enable row level security;
alter table public.wa_ia_memoria force row level security;

drop policy if exists wa_ia_memoria_select on public.wa_ia_memoria;
create policy wa_ia_memoria_select
  on public.wa_ia_memoria for select to authenticated
  using (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and exists (
      select 1 from public.wa_conversas c
       where c.id = conversa_id
         and c.team_id = public.auth_team_id()
         and (c.lead_id is null or public.pode_ver_lead(c.lead_id))
    )
  );

comment on policy wa_ia_memoria_select on public.wa_ia_memoria is
  'Quem enxerga a conversa le a memoria dela -- e o painel lateral que faz o vendedor entrar na conversa sabendo o que ja foi dito. Escrita so por service_role e pela RPC wa_ia_anotar_fato (origem=humano).';

public.wa_ia_sugestoes — o modo sugestão

SQL
-- ============================================================================
-- FASE 3 · public.wa_ia_sugestoes — o que a IA escreveria, para o humano decidir
-- ============================================================================
-- O MODO DE ESTREIA É ESTE, e não o automático. Motivo operacional, não de
-- prudência abstrata: a primeira versão do prompt vai errar tom, e o erro de tom
-- num mercado onde as donas de salão se conhecem volta como reputação, não como
-- ticket de suporte.
create table if not exists public.wa_ia_sugestoes (
  id             uuid primary key default extensions.gen_random_uuid(),
  conversa_id    uuid not null references public.wa_conversas (id) on delete cascade,
  execucao_id    uuid not null references public.wa_ia_execucoes (id) on delete cascade,
  texto          text not null,
  justificativa  text,
  estado         text not null default 'aberta',
  -- O texto que a pessoa REALMENTE mandou, quando editou antes de mandar. É a
  -- matéria-prima de melhoria do prompt: a diferença entre texto e texto_final
  -- é a correção humana, medida.
  texto_final    text,
  decidido_por   uuid references public.usuarios (id),
  decidido_em    timestamptz,
  envio_id       uuid references public.wa_envios (id) on delete set null,
  team_id        uuid not null default public.team_id_padrao(),
  created_at     timestamptz not null default now(),

  constraint chk_wa_ia_sugestoes_texto
    check (length(btrim(texto)) between 1 and 4096),
  constraint chk_wa_ia_sugestoes_estado
    check (estado in ('aberta', 'enviada', 'editada_e_enviada', 'descartada', 'expirada')),
  constraint chk_wa_ia_sugestoes_decidida
    check (estado = 'aberta' or (decidido_em is not null)),
  constraint chk_wa_ia_sugestoes_editada
    check (estado <> 'editada_e_enviada' or texto_final is not null)
);

comment on table public.wa_ia_sugestoes is
  'O que a IA escreveria, para um humano aprovar, editar ou descartar. E o MODO DE ESTREIA da Fase 3, nao o automatico: a primeira versao do prompt erra tom, e erro de tom num mercado onde as donas de salao se conhecem volta como reputacao. texto_final guarda o que a pessoa realmente mandou quando editou -- a diferenca entre texto e texto_final e a correcao humana, medida, e e a materia-prima para melhorar o prompt sem achismo.';
comment on column public.wa_ia_sugestoes.estado is
  'aberta -> enviada (aprovou como estava) | editada_e_enviada (mudou antes de mandar) | descartada (nao serviu) | expirada (ninguem decidiu e a janela de 24h fechou -- sugestao velha nao pode ser enviada como se fosse nova).';

create index if not exists idx_wa_ia_sugestoes_abertas
  on public.wa_ia_sugestoes (conversa_id, created_at desc) where estado = 'aberta';
create index if not exists idx_wa_ia_sugestoes_team
  on public.wa_ia_sugestoes (team_id);

alter table public.wa_ia_sugestoes enable row level security;
alter table public.wa_ia_sugestoes force row level security;

drop policy if exists wa_ia_sugestoes_select on public.wa_ia_sugestoes;
create policy wa_ia_sugestoes_select
  on public.wa_ia_sugestoes for select to authenticated
  using (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and exists (
      select 1 from public.wa_conversas c
       where c.id = conversa_id
         and c.team_id = public.auth_team_id()
         and (c.lead_id is null or public.pode_ver_lead(c.lead_id))
    )
  );

comment on policy wa_ia_sugestoes_select on public.wa_ia_sugestoes is
  'Quem enxerga a conversa le as sugestoes dela. A DECISAO (aprovar/editar/descartar) e RPC, nao policy de UPDATE: aprovar tambem enfileira o envio, e as duas coisas precisam acontecer na mesma transacao -- aprovada sem enfileirar e a falha que faz o vendedor achar que mandou.';

-- FK adiada de wa_ia_execucoes.sugestao_id (a tabela alvo nasce depois).
alter table public.wa_ia_execucoes
  drop constraint if exists fk_wa_ia_execucoes_sugestao;
alter table public.wa_ia_execucoes
  add constraint fk_wa_ia_execucoes_sugestao
  foreign key (sugestao_id) references public.wa_ia_sugestoes (id) on delete set null;

1.11 Fase 4 — campanhas

SQL
-- ============================================================================
-- FASE 4 · public.wa_campanhas — um disparo
-- ============================================================================
-- A CAMPANHA NÃO ENVIA. Ela produz linhas em wa_campanha_destinatarios, que
-- viram itens em wa_envios, que o drenador manda. Três passos em vez de um, de
-- propósito: é o que permite pausar no meio, ver quem já recebeu e não mandar
-- duas vezes para ninguém depois de uma pausa.
create table if not exists public.wa_campanhas (
  id                uuid primary key default extensions.gen_random_uuid(),
  nome              text not null,
  numero_id         uuid not null references public.wa_numeros (id) on delete restrict,
  modelo_id         uuid not null references public.wa_modelos (id) on delete restrict,
  -- O critério do público, guardado como foi escolhido na tela. Não é SQL livre:
  -- é um objeto com campos conhecidos (funil, etapa, dono, faixa, sem contato há
  -- N dias...). SQL livre vindo da tela seria injeção com outro nome.
  publico_criterio  jsonb not null default '{}'::jsonb,
  estado            text not null default 'rascunho',
  -- Janela de disparo em horário de Brasília. Fora dela o drenador não manda.
  janela_inicio     time not null default '09:00',
  janela_fim        time not null default '19:00',
  dias_semana       smallint[] not null default '{1,2,3,4,5}'::smallint[],
  ritmo_por_hora    integer not null default 60,
  agendada_para     timestamptz,

  total_publico     integer not null default 0,
  total_enfileirado integer not null default 0,
  total_enviado     integer not null default 0,
  total_falhou      integer not null default 0,
  total_bloqueado   integer not null default 0,

  criada_por        uuid not null references public.usuarios (id),
  aprovada_por      uuid references public.usuarios (id),
  aprovada_em       timestamptz,
  iniciada_em       timestamptz,
  concluida_em      timestamptz,
  team_id           uuid not null default public.team_id_padrao(),
  created_at        timestamptz not null default now(),
  updated_at        timestamptz not null default now(),

  constraint chk_wa_campanhas_nome
    check (length(btrim(nome)) between 3 and 120),
  constraint chk_wa_campanhas_estado
    check (estado in ('rascunho', 'aguardando_aprovacao', 'aprovada',
                      'em_andamento', 'pausada', 'concluida', 'cancelada')),
  constraint chk_wa_campanhas_janela
    check (janela_fim > janela_inicio),
  constraint chk_wa_campanhas_ritmo
    check (ritmo_por_hora between 1 and 600),
  constraint chk_wa_campanhas_dias
    check (array_length(dias_semana, 1) between 1 and 7),
  constraint chk_wa_campanhas_criterio
    check (jsonb_typeof(publico_criterio) = 'object'),
  constraint chk_wa_campanhas_totais
    check (total_publico >= 0 and total_enfileirado >= 0 and total_enviado >= 0
           and total_falhou >= 0 and total_bloqueado >= 0),
  -- Aprovação é par, e ninguém aprova a própria campanha.
  constraint chk_wa_campanhas_aprovada
    check (num_nonnulls(aprovada_por, aprovada_em) in (0, 2)),
  constraint chk_wa_campanhas_aprovador_distinto
    check (aprovada_por is null or aprovada_por is distinct from criada_por),
  constraint chk_wa_campanhas_estado_aprovada
    check (estado not in ('aprovada', 'em_andamento', 'pausada', 'concluida')
           or aprovada_em is not null)
);

comment on table public.wa_campanhas is
  'Um disparo em lote. A campanha NAO envia: produz linhas em wa_campanha_destinatarios, que viram itens em wa_envios, que o drenador manda -- tres passos de proposito, porque e o que permite pausar no meio, ver quem ja recebeu e nao repetir para ninguem depois da pausa. Aprovacao por PESSOA DIFERENTE de quem criou (chk_wa_campanhas_aprovador_distinto): disparo para centenas de clientes reais e a acao mais irreversivel do CRM inteiro, e a segunda leitura custa dois minutos.';
comment on column public.wa_campanhas.publico_criterio is
  'O criterio do publico como foi escolhido na tela -- objeto com campos CONHECIDOS (funil_id, etapa_id, owner_id, faixa, sem_contato_dias, qualificacao, tem_venda...), nunca SQL. SQL livre vindo da tela e injecao com outro nome. A traducao para consulta e da funcao public.wa_campanha_publico, que so entende esses campos.';
comment on column public.wa_campanhas.ritmo_por_hora is
  'Quantas mensagens por hora o drenador pode tirar desta campanha. Existe por duas razoes: a Meta rebaixa o quality rating de numero que dispara em rajada, e o time nao consegue atender 300 respostas que chegam no mesmo minuto. O segundo motivo e o que costuma ser esquecido.';
comment on column public.wa_campanhas.janela_inicio is
  'Horario de Brasilia. Fora da janela o drenador nao manda -- mensagem comercial as 23h e reclamacao garantida e denuncia provavel.';

create index if not exists idx_wa_campanhas_ativas
  on public.wa_campanhas (estado, agendada_para) where estado in ('aprovada', 'em_andamento');
create index if not exists idx_wa_campanhas_team
  on public.wa_campanhas (team_id);

drop trigger if exists trg_wa_campanhas_updated_at on public.wa_campanhas;
create trigger trg_wa_campanhas_updated_at
  before update on public.wa_campanhas
  for each row execute function public.set_updated_at();

alter table public.wa_campanhas enable row level security;
alter table public.wa_campanhas force row level security;

drop policy if exists wa_campanhas_select on public.wa_campanhas;
create policy wa_campanhas_select
  on public.wa_campanhas for select to authenticated
  using (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
  );

comment on policy wa_campanhas_select on public.wa_campanhas is
  'Todo o time LE as campanhas -- o vendedor precisa saber que a cliente dele recebeu um disparo hoje antes de ligar para ela. Esconder isso do vendedor produz a ligacao que comeca com "nao sei do que voce esta falando".';

drop policy if exists wa_campanhas_insert_gestao on public.wa_campanhas;
create policy wa_campanhas_insert_gestao
  on public.wa_campanhas for insert to authenticated
  with check (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and public.auth_role() in ('admin', 'gerente')
    and criada_por = auth.uid()
    and estado = 'rascunho'
    and aprovada_por is null
    and aprovada_em is null
  );

drop policy if exists wa_campanhas_update_gestao on public.wa_campanhas;
create policy wa_campanhas_update_gestao
  on public.wa_campanhas for update to authenticated
  using (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and public.auth_role() in ('admin', 'gerente')
    and estado in ('rascunho', 'aguardando_aprovacao')
  )
  with check (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and public.auth_role() in ('admin', 'gerente')
  );

comment on policy wa_campanhas_update_gestao on public.wa_campanhas is
  'Gestao edita RASCUNHO e campanha aguardando aprovacao. Depois de aprovada, a campanha so muda por RPC (aprovar, pausar, retomar, cancelar) -- editar publico ou modelo de campanha ja em andamento produziria um lote onde metade recebeu uma coisa e metade outra, sem registro de qual foi qual.';
SQL
-- ============================================================================
-- FASE 4 · public.wa_campanha_destinatarios — um destinatário por linha
-- ============================================================================
create table if not exists public.wa_campanha_destinatarios (
  id           uuid primary key default extensions.gen_random_uuid(),
  campanha_id  uuid not null references public.wa_campanhas (id) on delete cascade,
  lead_id      uuid not null references public.leads (id) on delete cascade,
  destino_e164 text not null,
  variaveis    jsonb not null default '[]'::jsonb,
  estado       text not null default 'pendente',
  motivo       text,
  envio_id     uuid references public.wa_envios (id) on delete set null,
  team_id      uuid not null default public.team_id_padrao(),
  created_at   timestamptz not null default now(),
  processado_em timestamptz,

  constraint uq_wa_campanha_destinatarios unique (campanha_id, lead_id),
  constraint chk_wa_campanha_dest_destino
    check (destino_e164 ~ '^[0-9]{10,15}$'),
  constraint chk_wa_campanha_dest_estado
    check (estado in ('pendente', 'enfileirado', 'enviado', 'falhou',
                      'bloqueado_optout', 'bloqueado_duplicado',
                      'bloqueado_sem_telefone', 'cancelado')),
  constraint chk_wa_campanha_dest_bloqueado
    check (estado not like 'bloqueado%' or motivo is not null)
);

comment on table public.wa_campanha_destinatarios is
  'Um destinatario por linha, com desfecho INDIVIDUAL. UNIQUE (campanha, lead): a mesma pessoa nao recebe a mesma campanha duas vezes, nem quando o lote e recalculado depois de uma pausa. Os estados bloqueado_* sao desfechos legitimos e ficam gravados COM motivo -- "por que a Fulana nao recebeu?" e a pergunta que sempre vem, e sem esta linha a resposta e um encolher de ombros.';
comment on column public.wa_campanha_destinatarios.motivo is
  'Por que este destinatario nao saiu, em texto legivel a quem opera: "lead pediu para nao receber em 12/07", "mesmo telefone de outro lead ja incluido no lote", "lead sem telefone canonizavel".';

create index if not exists idx_wa_campanha_dest_fila
  on public.wa_campanha_destinatarios (campanha_id, estado) where estado = 'pendente';
create index if not exists idx_wa_campanha_dest_lead
  on public.wa_campanha_destinatarios (lead_id, created_at desc);
create index if not exists idx_wa_campanha_dest_team
  on public.wa_campanha_destinatarios (team_id);

alter table public.wa_campanha_destinatarios enable row level security;
alter table public.wa_campanha_destinatarios force row level security;

drop policy if exists wa_campanha_dest_select on public.wa_campanha_destinatarios;
create policy wa_campanha_dest_select
  on public.wa_campanha_destinatarios for select to authenticated
  using (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and public.pode_ver_lead(lead_id)
  );

comment on policy wa_campanha_dest_select on public.wa_campanha_destinatarios is
  'Visibilidade HERDADA do lead (public.pode_ver_lead, 0266): o vendedor ve as linhas dos leads dele e do pool; a gestao ve o lote inteiro. Escrita e exclusiva de service_role (a edge que monta e drena o lote).';

-- FKs adiadas em wa_envios (as tabelas alvo nascem depois da Fase 0).
alter table public.wa_envios drop constraint if exists fk_wa_envios_campanha;
alter table public.wa_envios
  add constraint fk_wa_envios_campanha
  foreign key (campanha_id) references public.wa_campanhas (id) on delete set null;

1.12 Fase 5 — public.wa_reunioes

SQL
-- ============================================================================
-- FASE 5 · public.wa_reunioes — o que o Fireflies trouxe
-- ============================================================================
-- A TRANSCRIÇÃO INTEIRA NÃO É GUARDADA. Guarda-se o resumo, as objeções, os
-- próximos passos e o LINK para a gravação no Fireflies. Motivo: 500 MB de banco
-- gratuito, e transcrição de reunião de uma hora tem dezenas de milhares de
-- caracteres. O texto integral já existe no Fireflies, que é onde ele deve ficar.
--
-- O ENTREGÁVEL NÃO É ESTA TABELA. É o COMENTÁRIO no lead, escrito por
-- public.comentar_lead (0266:604-646) -- que é onde o vendedor de fato lê. Esta
-- tabela é o rastro e a chave de idempotência.
create table if not exists public.wa_reunioes (
  id                uuid primary key default extensions.gen_random_uuid(),
  -- Id do encontro no Fireflies. É a chave de idempotência do webhook.
  fireflies_id      text not null,
  lead_id           uuid references public.leads (id) on delete cascade,
  vendedor_id       uuid references public.usuarios (id) on delete set null,
  titulo            text,
  realizada_em      timestamptz,
  duracao_seg       integer,
  url_gravacao      text,
  participantes     jsonb not null default '[]'::jsonb,

  resumo            text,
  objecoes          jsonb not null default '[]'::jsonb,
  proximos_passos   jsonb not null default '[]'::jsonb,

  estado            text not null default 'recebida',
  comentario_id     uuid references public.lead_comentarios (id) on delete set null,
  erro              text,
  team_id           uuid not null default public.team_id_padrao(),
  created_at        timestamptz not null default now(),
  processada_em     timestamptz,

  constraint uq_wa_reunioes_fireflies unique (fireflies_id),
  constraint chk_wa_reunioes_estado
    check (estado in ('recebida', 'processando', 'publicada',
                      'sem_lead', 'falhou', 'descartada')),
  constraint chk_wa_reunioes_duracao
    check (duracao_seg is null or duracao_seg >= 0),
  constraint chk_wa_reunioes_resumo
    check (resumo is null or length(resumo) <= 6000),
  constraint chk_wa_reunioes_listas
    check (jsonb_typeof(objecoes) = 'array' and jsonb_typeof(proximos_passos) = 'array'
           and jsonb_typeof(participantes) = 'array'),
  constraint chk_wa_reunioes_publicada
    check (estado <> 'publicada' or comentario_id is not null),
  constraint chk_wa_reunioes_falhou
    check (estado <> 'falhou' or erro is not null)
);

comment on table public.wa_reunioes is
  'Resumo de reuniao vindo do Fireflies. A TRANSCRICAO INTEGRAL NAO E GUARDADA -- so resumo, objecoes, proximos passos e o link da gravacao; o texto completo ja existe no Fireflies, e transcricao de uma hora tem dezenas de milhares de caracteres num banco de 500 MB. O ENTREGAVEL nao e esta tabela: e o COMENTARIO no lead, escrito por public.comentar_lead (0266), que e onde o vendedor de fato le. Aqui fica o rastro e a chave de idempotencia (fireflies_id UNIQUE).';
comment on column public.wa_reunioes.estado is
  'recebida -> processando -> publicada (virou comentario no lead) | sem_lead (nao deu para casar participante com lead -- fica na tela de pendencias, NUNCA se chuta) | falhou | descartada (reuniao interna, sem cliente).';
comment on column public.wa_reunioes.objecoes is
  'Array de objetos {texto, momento_seg}. momento_seg permite a tela linkar direto para o ponto da gravacao -- e a diferenca entre um resumo que se le e um resumo que se confere.';

create index if not exists idx_wa_reunioes_lead
  on public.wa_reunioes (lead_id, realizada_em desc) where lead_id is not null;
create index if not exists idx_wa_reunioes_pendentes
  on public.wa_reunioes (created_at) where estado in ('recebida', 'sem_lead', 'falhou');
create index if not exists idx_wa_reunioes_team
  on public.wa_reunioes (team_id);

alter table public.wa_reunioes enable row level security;
alter table public.wa_reunioes force row level security;

drop policy if exists wa_reunioes_select on public.wa_reunioes;
create policy wa_reunioes_select
  on public.wa_reunioes for select to authenticated
  using (
    public.auth_usuario_ativo()
    and team_id = public.auth_team_id()
    and (
      (lead_id is not null and public.pode_ver_lead(lead_id))
      or (lead_id is null and public.auth_role() in ('admin', 'gerente'))
    )
  );

comment on policy wa_reunioes_select on public.wa_reunioes is
  'Com lead casado, a visibilidade e herdada de public.pode_ver_lead. Sem lead (estado=sem_lead), so gestao -- e a fila de pendencia de quem vai casar a mao, nao um mural.';

1.13 O que muda em tabela que já existe

Três alterações, todas aditivas. Migrations reais, não menções.

1.13.1 public.leads — consentimento e opt-out (Fase 0)

Nasce na Fase 0, e não na Fase 4, por um motivo específico: a Fase 1 já manda mensagem, e não existe “mandar antes de ter onde registrar que a pessoa pediu para parar”.

SQL
-- ============================================================================
-- FASE 0 · migration 0301 — consentimento e opt-out de WhatsApp em public.leads
-- ============================================================================
-- QUATRO COLUNAS, E CADA UMA RESPONDE UMA PERGUNTA DIFERENTE:
--   · quando ela consentiu, e por onde  -> defesa em fiscalização e em briga
--   · quando ela pediu para parar, e por onde -> a trava operacional
--
-- POR QUE NÃO UMA COLUNA BOOLEANA "aceita_whatsapp": booleano não diz quando nem
-- por onde, e é exatamente isso que se precisa provar. Um "false" sem data é
-- indefensável tanto para o cliente quanto para nós.
--
-- OPT-OUT NÃO TEM DESFAZER PELA TELA. Reverter opt-out é RPC de gestão com
-- justificativa (public.wa_reverter_optout), porque o caso legítimo existe
-- (a pessoa escreveu "para" por engano e pediu para voltar) e o ilegítimo
-- também (alguém limpando a lista antes de um disparo grande).
alter table public.leads
  add column if not exists wa_consentimento_em     timestamptz,
  add column if not exists wa_consentimento_origem text,
  add column if not exists wa_optout_em            timestamptz,
  add column if not exists wa_optout_origem        text;

alter table public.leads drop constraint if exists chk_leads_wa_consentimento_origem;
alter table public.leads
  add constraint chk_leads_wa_consentimento_origem
  check (wa_consentimento_origem is null or wa_consentimento_origem in (
    'formulario', 'importacao', 'conversa_iniciada_pelo_lead',
    'declarado_pelo_vendedor', 'evento_presencial'));

alter table public.leads drop constraint if exists chk_leads_wa_optout_origem;
alter table public.leads
  add constraint chk_leads_wa_optout_origem
  check (wa_optout_origem is null or wa_optout_origem in (
    'palavra_chave', 'botao_da_mensagem', 'pedido_verbal',
    'gestao', 'bloqueio_detectado'));

-- Consentimento e opt-out são pares (instante + origem), sempre.
alter table public.leads drop constraint if exists chk_leads_wa_consentimento_par;
alter table public.leads
  add constraint chk_leads_wa_consentimento_par
  check (num_nonnulls(wa_consentimento_em, wa_consentimento_origem) in (0, 2));

alter table public.leads drop constraint if exists chk_leads_wa_optout_par;
alter table public.leads
  add constraint chk_leads_wa_optout_par
  check (num_nonnulls(wa_optout_em, wa_optout_origem) in (0, 2));

comment on column public.leads.wa_consentimento_em is
  'Instante em que este lead consentiu em receber WhatsApp nosso. NULL = nao ha registro de consentimento -- o que NAO impede resposta dentro de conversa que ela mesma iniciou (a janela de 24h e consentimento por definicao), mas impede disparo de MARKETING (Fase 4). Anda em par com wa_consentimento_origem.';
comment on column public.leads.wa_optout_em is
  'Instante em que este lead pediu para parar de receber. E a trava operacional: wa-enviar recusa qualquer envio de categoria MARKETING para lead com opt-out, e a montagem de campanha (Fase 4) marca o destinatario como bloqueado_optout COM motivo. Mensagem de UTILITY em conversa aberta pelo proprio lead segue permitida -- opt-out de marketing nao e recusa de atendimento.';
comment on column public.leads.wa_optout_origem is
  'Como o pedido chegou: palavra_chave (ela escreveu PARAR/SAIR/DESCADASTRAR na conversa -- deteccao automatica), botao_da_mensagem (clicou no botao de opt-out do modelo), pedido_verbal (disse ao vendedor, que registrou), gestao (decisao interna), bloqueio_detectado (a Meta devolveu erro de bloqueio no envio -- ela nos bloqueou, o que e um opt-out de fato).';

-- Índice de acesso: a montagem de público da Fase 4 filtra por isto em todo lote.
create index if not exists idx_leads_wa_optout
  on public.leads (wa_optout_em) where wa_optout_em is not null;

-- ROLLBACK MANUAL (não executado por esta migration)
--   alter table public.leads drop constraint if exists chk_leads_wa_optout_par;
--   alter table public.leads drop constraint if exists chk_leads_wa_consentimento_par;
--   alter table public.leads drop constraint if exists chk_leads_wa_optout_origem;
--   alter table public.leads drop constraint if exists chk_leads_wa_consentimento_origem;
--   drop index if exists public.idx_leads_wa_optout;
--   alter table public.leads
--     drop column if exists wa_optout_origem,
--     drop column if exists wa_optout_em,
--     drop column if exists wa_consentimento_origem,
--     drop column if exists wa_consentimento_em;

Backfill (não faz parte da migration, roda depois e é reversível): os leads que entraram por formulário com aceite explícito recebem wa_consentimento_em = created_at, wa_consentimento_origem = 'formulario'[a confirmar] que o formulário de captação de fato contém a frase de aceite de contato por WhatsApp; conferir o texto vivo em docs/integracao-wordpress-fluentforms.md e na página antes de escrever a data. Sem essa confirmação, o backfill não roda e a Fase 4 começa com público menor, o que é o erro barato.

1.13.2 public.tarefas.origem_registro — os valores novos (Fase 2)

O CHECK vivo hoje, conferido em pg_constraint, é exatamente este:

Trecho
CHECK (((origem_registro IS NULL) OR (origem_registro = 'primeiro_contato_whatsapp'::text)))
SQL
-- ============================================================================
-- FASE 2 · migration 0320 — origem_registro ganha os valores do atendimento
-- ============================================================================
-- Mesmo padrão de drop+add da 0264:171-175 (que por sua vez segue o de
-- chk_tarefas_desfecho/0221): reaplicar é seguro, e o conjunto continua FECHADO.
--
-- OS QUATRO VALORES NOVOS, E O QUE CADA UM PRECISA SIGNIFICAR:
--   atendimento_whatsapp  a conversa na caixa de entrada gerou um contato
--                         registrado. NÃO é o clique no botão (esse continua
--                         sendo primeiro_contato_whatsapp, da 0264) — é a
--                         mensagem trocada DENTRO do CRM.
--   qualificacao_ia       a Fase 3 registrou um contato/tarefa por conta própria.
--                         Separado de atendimento_whatsapp porque a pergunta
--                         "quanto do funil a IA está movendo sozinha" precisa de
--                         resposta, e ela não sai de um valor compartilhado.
--   campanha_whatsapp     a pessoa RESPONDEU a um disparo e isso virou tarefa
--                         para o dono do lead.
--   coach_pos_reuniao     a Fase 5 criou a tarefa de próximo passo que saiu da
--                         reunião.
alter table public.tarefas
  drop constraint if exists chk_tarefas_origem_registro;
alter table public.tarefas
  add constraint chk_tarefas_origem_registro
  check (origem_registro is null or origem_registro in (
    'primeiro_contato_whatsapp',
    'atendimento_whatsapp',
    'qualificacao_ia',
    'campanha_whatsapp',
    'coach_pos_reuniao'));

comment on column public.tarefas.origem_registro is
  'Como a tarefa NASCEU quando nao nasceu de alguem preenchendo a tela. NULL = o caso normal. primeiro_contato_whatsapp = registrada por public.registrar_primeiro_contato_whatsapp quando o vendedor abriu o WhatsApp de um lead sem tarefa nenhuma (0264). atendimento_whatsapp = a conversa trocada DENTRO do CRM virou contato registrado (Fase 2) -- e caminho PARALELO ao do clique, nao uma extensao dele. qualificacao_ia = a IA registrou sozinha (Fase 3), separado de proposito para responder "quanto do funil a IA move sem humano". campanha_whatsapp = resposta a disparo virou tarefa para o dono (Fase 4). coach_pos_reuniao = proximo passo saido da reuniao (Fase 5). Conjunto fechado por chk_tarefas_origem_registro.';

O que NÃO muda, e é a parte importante: uq_tarefas_primeiro_contato_whatsapp (0264:183-186) indexa apenas where origem_registro = 'primeiro_contato_whatsapp'. Os valores novos não entram nesse índice parcial e por isso não competem com a regra do clique. Ver §5.2.

1.13.3 FKs adiadas

wa_envios.conversa_id e wa_ia_consumo.conversa_id nascem sem FK na Fase 0 (a tabela alvo só existe na Fase 2) e ganham a restrição no início da Fase 2:

SQL
-- FASE 2 · migration 0320
alter table public.wa_envios drop constraint if exists fk_wa_envios_conversa;
alter table public.wa_envios
  add constraint fk_wa_envios_conversa
  foreign key (conversa_id) references public.wa_conversas (id) on delete set null;

alter table public.wa_ia_consumo drop constraint if exists fk_wa_ia_consumo_conversa;
alter table public.wa_ia_consumo
  add constraint fk_wa_ia_consumo_conversa
  foreign key (conversa_id) references public.wa_conversas (id) on delete set null;

on delete set null nos dois: apagar uma conversa por expurgo de LGPD não pode arrastar o registro de custo (que é contábil) nem o item de fila já enviado (que é histórico de entrega).

1.13.4 Travas de volume e retenção (Fase 0)

Ficam em public.configuracoes (chave/valor jsonb, já existe, 4 linhas hoje) e não em tabela nova — mudar um teto vira um update de uma linha, sem migration.

SQL
-- FASE 0 · migration 0302
insert into public.configuracoes (chave, valor, team_id)
values
  ('whatsapp_limites', jsonb_build_object(
      'envios_por_dia',              500,
      'envios_por_hora',             120,
      'campanha_max_destinatarios',  1000,
      'sessao_max_por_conversa_dia', 30,
      'janela_horas',                24,
      'janela_aviso_minutos',        60
   ), public.team_id_padrao()),
  ('ia_limites', jsonb_build_object(
      'teto_usd_mes',        30,
      'janela_rajada_seg',   25,
      'max_execucoes_dia',   800,
      'max_tokens_contexto', 8000,
      'modo',                'sugestao'
   ), public.team_id_padrao()),
  ('whatsapp_retencao', jsonb_build_object(
      'dedupe_dias',        30,
      'envios_dias',        180,
      'ia_execucoes_dias',  90,
      'mensagens_dias',     null
   ), public.team_id_padrao())
on conflict (chave, team_id) do nothing;
  • envios_por_dia = 500 é teto nosso, e o efetivo é o menor entre ele e wa_numeros.limite_diario_meta (o tier da Meta). Um número novo começa em 250 conversas iniciadas por 24h — [a confirmar] o tier atual do número no Business Manager antes da primeira campanha.
  • modo = 'sugestao' é o padrão de estreia da Fase 3. Trocar para 'automatico' é decisão do Victor, um update de uma linha, e reversível no mesmo movimento.
  • mensagens_dias = null de propósito: histórico de conversa não é expurgado por tempo. Ele sai pelo expurgo de titular (LGPD), que já existe e é por pessoa, não por idade. Apagar conversa de seis meses atrás por regra de calendário destruiria justamente o histórico que faz o vendedor entrar na conversa sabendo o que já foi dito.

O expurgo entra na função que já existe, public.expurgar_dados_retencao() (0095_lgpd_tecnico.sql:438-470), acrescentando os delete de wa_webhook_dedupe, wa_envios concluídos e wa_ia_execucoes concluídas — sem criar cron novo: o job expurgo_retencao_hb já roda às 30 3 * * *.

Consumo estimado no plano gratuito (números de hoje: banco 134 MB de 500, Storage 421 KB de 1 GB, Edge Functions ~18,4k/mês de 500k):

ItemEstimativaBase do cálculo
Invocações de Edge Function+40k a +90k/mês1 webhook por mensagem de entrada + 2-3 de status por saída, com 200-400 mensagens/dia; drenador a cada minuto = 43,2k/mês sozinho
Banco+30 a +60 MB/anowa_mensagens domina; ~1 KB por linha, 300/dia = ~110 MB/ano se o volume triplicar
Storage~0Mídia não é baixada (§1.8). Só cresce o que humano promove, e vai para o bucket anexos que já tem controle de 2 MiB por arquivo

Arraste a tabela para o lado

O drenador a cada minuto é o maior consumidor isolado. Mitigação desenhada: ele roda a cada minuto apenas dentro da janela comercial (9-19h em dias úteis, via expressão cron * 12-22 * * 1-5) e a cada 5 minutos fora dela — 18k/mês em vez de 43k. Continua folgado dentro dos 500k.

02Contratos das Edge Functions

Sete funções novas. Todas em supabase/functions/, todas com bloco declarativo em supabase/config.tomlverify_jwt declarado, nunca implícito (a lição está escrita no próprio arquivo: supabase/config.toml, bloco [functions], “auditoria Leva 6, fix H1”).

Deploy é automático pelo workflow .github/workflows/deploy-functions.yml, que dispara em push na main que toque supabase/functions/** ou supabase/config.toml (docs/deploy-edge-functions.md). Mudar _shared/** redeploya todas — vale lembrar antes de mexer no hmac.ts.

Funçãoverify_jwtQuem chamaFase
wa-webhookfalseMeta (WhatsApp Cloud API)0/1
wa-enviartrueO app Next, com o JWT do vendedor1/2
wa-fila-drenartruepg_cron via pg_net, com service_role0/1
wa-modelos-synctruepg_cron + botão de gestão0
wa-midia-promovertrueO app Next, com o JWT de quem promove2
wa-ia-orquestradortruepg_cron via pg_net3
fireflies-webhookfalseFireflies5

Arraste a tabela para o lado

2.1 Segredos (Vault e env)

Os segredos operacionais das Edge Functions ficam em Deno.env.get (secrets do projeto Supabase); os que o banco precisa para disparar por pg_net ficam no Vault, no mesmo mecanismo de disparar_meta_insights_sync (0089:57-59). O Vault hoje tem 9 segredos.

NomeOndePara quê
WA_APP_SECRETenv da funçãoHMAC do X-Hub-Signature-256
WA_VERIFY_TOKENenv da funçãoHandshake GET da subscription
WA_SYSTEM_TOKENenv da funçãoBearer da Graph API
WA_PHONE_NUMBER_IDenv da funçãoRedundante com wa_numeros; usado só no arranque
IA_PROVIDER_KEYenv da funçãoChave do provedor de IA
IA_PROVIDER_BASE_URLenv da funçãoEndpoint do provedor (permite trocar sem deploy)
FIREFLIES_WEBHOOK_SECRETenv da funçãoAssinatura do webhook do Fireflies [a confirmar]
wa_fila_drenar_url / wa_fila_drenar_service_keyVaultO cron chamar wa-fila-drenar
wa_ia_orquestrador_url / wa_ia_orquestrador_service_keyVaultO cron chamar wa-ia-orquestrador

Arraste a tabela para o lado

WA_APP_SECRET pode ser o mesmo META_APP_SECRET que já existe se o número WhatsApp estiver sob o mesmo App da Meta que já recebe o Lead Ads — [a confirmar] no Business Manager. Se for o mesmo App, reusar o segredo é o certo: dois segredos para o mesmo App é uma rotação que alguém vai fazer pela metade.

2.2 wa-webhook — recebe tudo da Meta

verify_jwt = false. Obrigatório: a Meta não manda JWT. A barreira é a assinatura.

Reaproveita integralmente o padrão que já roda com a própria Meta em supabase/functions/meta-leadgen-webhook/index.ts:3 (import de _shared/hmac.ts) e :534-542 (o gate de assinatura).

Entrada GET — handshake da subscription:

Trecho
GET ?hub.mode=subscribe&hub.verify_token=<WA_VERIFY_TOKEN>&hub.challenge=<n>

Saída: 200 com o hub.challenge em texto puro (não JSON). Token errado ou hub.mode diferente de subscribe: 403, corpo vazio.

Entrada POST — o envelope da Cloud API:

JSON
{ "object": "whatsapp_business_account",
  "entry": [{ "id": "<waba_id>", "changes": [{ "field": "messages",
    "value": { "metadata": { "phone_number_id": "..." },
               "contacts": [{ "wa_id": "5511...", "profile": { "name": "..." } }],
               "messages": [ /* entrada */ ],
               "statuses": [ /* sent|delivered|read|failed */ ] } }] }] }

Ordem de execução, e ela é o contrato:

  1. const rawBody = await req.text()corpo cru antes de qualquer parse. A assinatura é sobre os bytes exatos; assinar depois de JSON.parse/stringify produz hash diferente.
  2. Gate de assinatura. verifyHmacSignature(rawBody, req.headers.get('X-Hub-Signature-256'), WA_APP_SECRET). Inválida → 401, nada é processado, nada é gravado. WA_APP_SECRET ausente → 500 com log alto (mesmo desenho de meta-leadgen-webhook/index.ts:535-539).
  3. JSON.parse — só agora é seguro.
  4. É nosso? entry[].id tem de bater com algum wa_numeros.waba_id ativo. Não bate → 200 {ignored:true} e uma linha em wa_webhook_dedupe com resultado='ignorado_nao_nosso'. Devolver 200 aqui é deliberado: a Meta não deve reenviar algo que nunca foi nosso.
  5. Uma única chamada de banco, a RPC public.wa_aplicar_webhook(p_payload jsonb) — que faz, em uma transação: claim de idempotência em wa_webhook_dedupe por evento, upsert de wa_conversas, insert de wa_mensagens de entrada, e aplicação dos status de saída. Um round-trip, não N.
  6. 200 {ok:true, aplicados:N, ignorados:M}.

Códigos de erro:

CódigoQuandoO que a Meta faz
200Aplicado, ou ignorado por não ser nosso, ou duplicadoNão reenvia
401Assinatura inválida ou ausenteNão reenvia; log alto do nosso lado
403Handshake com verify_token errado
500WA_APP_SECRET ausente (configuração incompleta)Reenvia
503A RPC falhou por causa transiente (timeout, conexão)Reenvia com backoff

Arraste a tabela para o lado

Idempotência. A chave é {tipo}:{id}:{estado} (§1.5), não o wa_message_id sozinho — porque a Meta reenvia o mesmo id a cada transição de status. O claim é o próprio insert ... on conflict (chave) do nothing returning chave: quem recebeu a chave de volta processa; quem não recebeu, pula.

Status nunca anda para trás. A Meta pode entregar sent depois de delivered. A RPC compara a ordem (recebida < fila < enviada < entregue < lida) antes de escrever e ignora o rebaixamento. Sem isso, uma mensagem lida volta a aparecer como “enviada” na tela por reordenação de rede.

O 200 em menos de 5 segundos — o que garante:

  • Nenhuma chamada à Graph API dentro do webhook. Mídia não é baixada (§1.8); nome de perfil vem no próprio payload (contacts[].profile.name).
  • Nenhuma chamada de IA. A Fase 3 é acionada por wa_ia_execucoes (inserida pela mesma RPC, custo de um insert) e drenada por cron. O webhook não espera o modelo.
  • Um round-trip de banco, não um por mensagem.
  • Sem await de log externo. console.log do Deno é assíncrono e não bloqueia.
  • Teto duro: se a RPC passar de 3 segundos, a função devolve 503 e deixa a Meta reenviar — 200 tardio é pior que 503 rápido, porque a Meta corta a subscription depois de repetidos atrasos. [a confirmar] o limiar exato de desativação de webhook por lentidão na versão v21.0 da Cloud API; a documentação fala em falhas repetidas, sem número público estável.

Em falha: nada é gravado pela metade — a RPC é uma transação. Falha transiente libera o claim (delete da chave dentro da mesma transação que aborta, o que acontece de graça no rollback) e devolve 503. Falha definitiva (payload malformado, tipo desconhecido) grava a mensagem com tipo='nao_suportado' e devolve 200: payload-veneno não deve ser reenviado para sempre — a mesma decisão já tomada em meta-leadgen-webhook/index.ts:500-506.

2.3 wa-enviar — a porta única de saída

verify_jwt = true. Chamada pelo app Next com o JWT do vendedor.

Entrada:

JSON
{ "conversa_id": "uuid",           // ou destino_e164 + lead_id, para o 1º contato
  "tipo": "sessao" | "modelo",
  "corpo": "texto...",             // tipo=sessao
  "modelo_id": "uuid",             // tipo=modelo
  "variaveis": ["Ana", "R$ 1.200,00"],
  "sugestao_id": "uuid"            // opcional: veio do modo sugestão da Fase 3
}

Saída: 202 {envio_id, estado:'pendente', posicao_na_fila:N}202, não 200. A mensagem foi aceita, não enviada. A tela mostra “enviando” e o estado real chega pelo webhook de status. Dizer 200 aqui seria a tela afirmar entrega que ninguém confirmou.

O que valida antes de qualquer I/O, nesta ordem:

  1. Sessão e usuário ativo — o gateway já validou o JWT; a função confere usuarios.ativo.
  2. Visibilidadepublic.pode_ver_lead(lead_id) para o lead da conversa. Quem não vê o lead não fala com ele. Negado devolve 404, não 403 (não confirma a existência).
  3. Opt-outleads.wa_optout_em is not null e o modelo é MARKETING409 {motivo:'optout'}. UTILITY dentro de conversa aberta pelo próprio lead segue permitido.
  4. Janela de 24h — se tipo='sessao' e now() - ultima_msg_cliente_em > 24h409 {motivo:'janela_fechada', modelos_disponiveis:[...]}. A resposta já traz os modelos aprovados, para a tela poder oferecer a saída em vez de só recusar.
  5. Tetoconfiguracoes.whatsapp_limites e wa_numeros.limite_diario_meta, o menor dos dois → 429 {motivo:'teto_diario', reabre_em:'...'}.
  6. Modelostatus='aprovado' and ativo e a contagem de variaveis bate com os {{n}} do corpo → 422 com a contagem esperada.

Só então enfileira: um insert em wa_envios com chave_idempotencia construída como atendimento:{conversa_id}:{sha1(corpo)}:{minuto} — o minuto no lugar de um uuid é deliberado: dois cliques no botão dentro do mesmo minuto com o mesmo texto são um duplo-clique, não duas mensagens.

Erros: 401 (sem JWT), 404 (conversa/lead invisível), 409 (opt-out, janela), 422 (modelo inválido, variáveis erradas), 429 (teto), 503 (banco indisponível). Nunca 500 genérico — o vendedor precisa saber se refaz ou se espera.

Em falha: nada é enfileirado. A função não tem estado parcial porque só escreve uma linha.

2.4 wa-fila-drenar — quem realmente fala com a Meta

verify_jwt = true — o cron manda service_role no Authorization, o gateway valida, e a função confere internamente que o chamador é service_role (mesma postura de meta-insights-sync, declarada em supabase/config.toml).

Quem chama: pg_cron via pg_net, por public.wa_disparar_fila(), cópia fiel de public.disparar_meta_insights_sync (0089:48-79) — URL e chave lidas do Vault por nome, no-op silencioso com raise notice se os segredos não existirem. É o que permite registrar o cron antes de a função existir.

Entrada: {"trigger":"cron"} ou {"trigger":"manual","envio_id":"uuid"}.

Saída: 200 {processados, enviados, falharam, bloqueados, restantes}.

O laço, e o que cada passo protege:

  1. Claim com lock. update wa_envios set estado='processando', lock_ate = now() + interval '2 minutes' where id in (select id from wa_envios where estado='pendente' and agendado_para <= now() order by prioridade, created_at limit N for update skip locked) returning *. O skip locked é o que permite duas execuções simultâneas sem entregar o mesmo item duas vezes — e duas execuções simultâneas acontecem, porque o cron não espera a anterior terminar.
  2. Devolve os travados. Antes do claim: update ... set estado='pendente', lock_ate=null where estado='processando' and lock_ate < now(). É o que faz um drenador morto no meio (deploy, isolado reciclado) não travar a fila para sempre.
  3. Reconfere a janela e o opt-out no instante do envio. A janela pode ter fechado entre enfileirar e drenar; o lead pode ter pedido para parar. Reconferir aqui não é redundância: é o único ponto que enxerga o estado do mundo no momento em que a mensagem sai.
  4. Chama a Graph API, uma mensagem por vez, respeitando ritmo_por_hora da campanha.
  5. Grava o desfechowa_message_id, estado='enviado', enviado_em, e um insert em wa_mensagens com direcao='saida', status='enviada', envio_id preenchido.

Retentativa: backoff exponencial com teto — proxima_tentativa_em = now() + (2 ^ tentativas) * interval '1 minute', até max_tentativas (padrão 5, teto ~32 minutos). A classificação do erro decide se retenta:

Erro da MetaClasseO que faz
429, 500, 503, timeoutTransienteRetenta com backoff
131047 (fora da janela de 24h)Definitivobloqueado_janela, não retenta
131026 (não é WhatsApp / não recebe)Definitivofalhou + marca o lead para conferência humana
131049, 368 (bloqueio/limite de qualidade)Definitivofalhou + wa_optout_origem='bloqueio_detectado'
132000-132xxx (template inválido/pausado)Definitivofalhou + rebaixa wa_modelos.status
Token inválido (190)Transiente e críticoRetenta e notifica admin — token expirado para a fila inteira

Arraste a tabela para o lado

Teto de tempo: a função processa no máximo N=25 itens por invocação e devolve restantes. Se sobrou fila, o próprio cron pega no minuto seguinte. Uma função que tenta esvaziar 800 itens numa invocação é uma função que estoura o tempo de execução no dia em que a fila cresce.

verify_jwt = true. Chamada por pg_cron (diária) e por botão de gestão.

Entrada: {"trigger":"cron"} ou {"trigger":"manual","modelo_id":"uuid"}. Saída: 200 {sincronizados, promovidos, rejeitados, numero:{qualidade, tier}}.

Faz GET /{waba_id}/message_templates e GET /{phone_number_id}?fields=quality_rating,messaging_limit_tier e escreve com service_role, o único caminho que o gatilho trg_wa_modelos_status_so_do_sync deixa passar (§1.3).

O que valida antes de qualquer I/O: que existe wa_numeros ativo e que WA_SYSTEM_TOKEN está presente. Sem token → 503, não 500: é configuração pendente, e o cron deve tentar de novo depois.

Em falha: nada é escrito. Um sync parcial que promovesse metade dos modelos e deixasse a outra metade em em_analise faria a Fase 4 disparar com catálogo desatualizado.

Efeito colateral desejado: quando um modelo cai para rejeitado ou pausado, a função notifica a gestão por public.registrar_notificacao e cancela os wa_envios pendentes que o usam (estado='cancelado'). Descobrir a rejeição pelo lote inteiro falhando é descobrir tarde.

2.6 wa-ia-orquestrador — o cérebro (Fase 3)

verify_jwt = true. Chamada por pg_cron a cada minuto, via Vault, mesmo mecanismo do drenador.

Entrada: {"trigger":"cron"} ou {"trigger":"manual","execucao_id":"uuid"}. Saída: 200 {processadas, respondidas, sugeridas, escaladas, custo_usd}.

O que valida ANTES de qualquer chamada de LLM — e a ordem importa, porque cada checagem que falha economiza dinheiro:

  1. Teto de custo. sum(custo_usd) do mês contra configuracoes.ia_limites.teto_usd_mes. Estourou → para, notifica admin, devolve 200 {parado:'teto'}. Esta é a porta que desliga a IA; wa_ia_verificar_teto() só avisa (§1.6).
  2. Teto de execuções do dia (max_execucoes_dia).
  3. A conversa quer IA? wa_conversas.ia_ativa. Falso → descarta a execução (estado='descartada'), sem chamar modelo.
  4. A rajada acabou? Só processa execução com agendado_para <= now(). Mensagem nova empurrou o horário; a execução espera.
  5. Ainda há humano na conversa? estado='com_humano' e mensagem nossa nos últimos 10 minutos → decisao='nao_fazer_nada'. A IA não fala por cima de quem está atendendo.

As duas passadas de modelo, e a regra de custo:

PassadaFinalidadeModeloPor quê
1classificacao + extracaoBaratoDecide o que fazer e extrai fatos para wa_ia_memoria. Erro aqui gera um roteamento ruim que um humano corrige
2respostaCaroSó roda se a passada 1 decidiu responder ou sugerir. É texto que uma cliente vai ler — é aqui que o erro sai da tela

Arraste a tabela para o lado

Toda chamada grava uma linha em wa_ia_consumo, inclusive as que falham (§1.6).

Modo. configuracoes.ia_limites.modo: 'sugestao' (padrão) grava em wa_ia_sugestoes e não enfileira nada; 'automatico' chama wa-enviar internamente. A troca é um update numa linha de configuração, sem deploy — é o que permite desligar a IA em trinta segundos numa segunda-feira ruim.

Retentativa: até 3, com backoff. Depois disso estado='falhou' e a conversa é escalada para humano com uma linha em wa_atribuicoes (motivo='ia_escalou'). A IA que não consegue responder chama gente; nunca fica em silêncio.

Em falha: a execução fica marcada, a conversa é escalada, e nenhuma mensagem sai. O modo de falha escolhido é “o humano recebe trabalho”, nunca “o cliente recebe algo estranho”.

2.7 wa-midia-promover — o único caminho de mídia para o Storage

verify_jwt = true. Chamada pelo app com o JWT de quem clicou.

Entrada: {"mensagem_id":"uuid", "comentario":"texto que vai junto"}. Saída: 201 {anexo_id, caminho, comentario_id}.

  1. Confere public.pode_ver_lead do lead da conversa. Não vê → 404.
  2. Confere que midia_expira_em > now(). Expirou → 410 Gone com a data — a mensagem certa é “esse arquivo não existe mais em lugar nenhum”, não “erro ao baixar”.
  3. GET /{midia_meta_id} na Graph API para obter a URL, depois baixa o binário.
  4. Confere o tipo pelos bytes mágicos, não pela extensão nem pelo mime declarado — JPEG FF D8 FF, PNG 89 50 4E 47, WebP RIFF....WEBP, PDF %PDF-. Fora da lista → 415. É a mesma disciplina de 0266, seção 3 do cabeçalho.
  5. Confere o tamanho contra 2 MiB (o file_size_limit do bucket anexos, 0266:268).
  6. Sobe para anexos no caminho lead/{lead_id}/{uuid}.{ext} — o formato exigido por chk_anexos_caminho (0266:301).
  7. Chama public.comentar_lead(lead_id, corpo, '{}', jsonb do anexo) — a RPC que já existe (0266:604-646). Comentário e anexo nascem na mesma transação. Preenche wa_mensagens.anexo_id.

Em falha depois do upload: o binário fica órfão no bucket. É o pior caso aceito, o mesmo já documentado em remover_anexo (0266:469-473), e é achável pela consulta do rodapé da 0266. O caso inverso — comentário dizendo “segue o print” sem print — seria pior.

2.8 fireflies-webhook — Fase 5

verify_jwt = false. A barreira é a assinatura.

Entrada: o evento de transcrição pronta do Fireflies. [a confirmar] o esquema exato de assinatura: a documentação pública não fecha um formato único. Método de confirmação: ligar o webhook contra um endpoint de captura, gravar os headers de um evento real e conferir contra _shared/hmac.ts, que já aceita hex puro, sha256=<hex> e base64 (_shared/hmac.ts:20-27 já documenta essa mesma incerteza para o ClickUp). Se o Fireflies não assinar, a alternativa é um token estático longo no caminho da URL + allowlist de IP — e isso precisa de decisão do Victor antes da Fase 5, não durante.

  1. Valida assinatura → 401 se inválida.
  2. Idempotência por fireflies_id em wa_reunioes (UNIQUE), e o payload cru vai para public.webhook_events com origem='fireflies' (tabela que já existe e já é expurgada aos 90 dias por 0095:455-457).
  3. Casa o participante com um lead, pelo mesmo algoritmo estrito da §4 — por telefone quando houver, por e-mail via public.email_normalizado (0144:118). Ambíguo ou vazio → estado='sem_lead', e a conversa para aí: vai para a fila de pendências da gestão. Não se chuta.
  4. Chama o LLM (modelo caro — o texto vira comentário que o vendedor lê) para produzir resumo, objeções e próximos passos. Grava em wa_ia_consumo com finalidade='resumo'.
  5. Publica por public.comentar_lead e, se houver próximo passo com data, cria a tarefa por public.registrar_contato_lead com origem_registro='coach_pos_reuniao'.

Saída: 200 {estado, comentario_id}. Erros: 401 (assinatura), 503 (LLM indisponível — o Fireflies reenvia), 200 {estado:'sem_lead'} (não é erro: é pendência com registro).

03Máquina de estados da conversa

3.1 Dois eixos, não um

A conversa tem dois estados independentes, e confundi-los é o erro clássico dessa tela:

  • Quem está com elawa_conversas.estado. Muda por ação de pessoa ou da IA.
  • Se dá para escrever livremente — a janela de 24 horas. Muda sozinha, pelo relógio.

Uma conversa pode estar com_humano e com a janela fechada. São coisas diferentes e a tela mostra as duas separadas: o cabeçalho diz quem atende, o rodapé diz o que dá para mandar.

3.2 Os cinco estados

EstadoSignificadoQuem enxerga na caixa
nao_atribuidaChegou mensagem, ninguém pegouTodo o time (é o pool da caixa)
com_iaA Fase 3 está conduzindoTodo o time, marcada como IA
com_humanoAlguém assumiu; atendente_id preenchidoTodo o time, com o nome de quem atende
aguardando_clienteRespondemos, a bola é delesQuem atendeu, e a gestão
encerradaAssunto fechadoSó por filtro explícito

Arraste a tabela para o lado

3.3 Transições

#DeParaQuem disparaEfeito colateral obrigatório
T1nao_atribuidawa-webhook, 1ª mensagem do contatoCria a conversa; tenta resolver o lead (§4); nao_lidas=1
T2nao_atribuidacom_humanoVendedor clica em Assumirwa_atribuicoes motivo='assumiu'; se o lead está no pool, chama public.reivindicar_lead (§5.3)
T3nao_atribuidacom_humanoAutomático, quando o lead já tem donowa_atribuicoes motivo='dono_do_lead'; notifica o dono
T4nao_atribuidacom_iawa-ia-orquestrador, com ia_ativa=truewa_atribuicoes motivo='ia_assumiu'
T5com_iacom_humanoIA escala, ou humano intervémwa_atribuicoes motivo='ia_escalou'; notificação para o dono do lead
T6com_humanoaguardando_clienteMensagem nossa sai com sucessoultima_msg_nossa_em; nao_lidas=0
T7com_iaaguardando_clienteIdem, mandada pela IAIdem
T8aguardando_clientecom_humanoCliente respondeu e havia atendentenao_lidas+1; enfileira wa_ia_execucoes se ia_ativa
T9aguardando_clientenao_atribuidaCliente respondeu e o atendente saiu/está inativowa_atribuicoes motivo='devolveu_ao_pool'
T10com_humanocom_humanoTransferênciawa_atribuicoes motivo='transferiu'; notifica quem recebeu
T11qualquerencerradaHumano clica em Encerrarencerrada_em/por; wa_atribuicoes motivo='encerrou'
T12encerradanao_atribuidaCliente escreve de novoReabre; encerrada_em=null; mantém todo o histórico
T13com_humanocom_humanoVendedor devolve ao poolatendente_id=null + estado='nao_atribuida' (é T9 manual)

Arraste a tabela para o lado

Todas as transições são RPC. Nenhuma é update da tela — o gatilho trg_wa_conversas_colunas_travadas (§1.7) recusa. Cada RPC grava a linha de wa_atribuicoes na mesma transação da mudança de estado: histórico gravado depois é histórico que falta justamente no caso que interessa.

T12 é a que se esquece. Conversa encerrada que recebe mensagem nova reabre, não cria conversa nova — o unique (numero_id, contato_e164) de wa_conversas torna a duplicata impossível, e é de propósito: duas threads do mesmo número é como se perde metade do histórico.

3.4 A janela de 24 horas

A regra da Meta: dentro de 24 horas contadas da última mensagem do cliente, dá para mandar texto livre. Fora dela, só modelo aprovado.

Onde ela vive: wa_conversas.ultima_msg_cliente_em. É derivada, nunca armazenada como booleano (§1.7). A função canônica:

SQL
create or replace function public.wa_janela_aberta(p_conversa_id uuid)
returns boolean
language sql
stable
security invoker
set search_path = ''
as $$
  select c.ultima_msg_cliente_em is not null
     and c.ultima_msg_cliente_em > now() - make_interval(
           hours => coalesce(
             (select (cf.valor ->> 'janela_horas')::int
                from public.configuracoes cf
               where cf.chave = 'whatsapp_limites' and cf.team_id = c.team_id),
             24))
    from public.wa_conversas c
   where c.id = p_conversa_id;
$$;

comment on function public.wa_janela_aberta(uuid) is
  'true se a janela de 24h da Meta ainda esta aberta nesta conversa -- ou seja, se da para mandar TEXTO LIVRE. Derivada de ultima_msg_cliente_em, nunca de uma coluna booleana: coluna precisaria de cron para virar e estaria errada entre duas passadas, e estar errada aqui significa oferecer ao vendedor um campo de texto que a Meta vai recusar. A duracao vem de configuracoes.whatsapp_limites.janela_horas (24 por padrao) e nao esta cravada em codigo, porque a Meta ja mudou essa regra antes. SECURITY INVOKER: a RLS de wa_conversas decide se o chamador enxerga a linha.';

revoke all on function public.wa_janela_aberta(uuid) from public;
revoke all on function public.wa_janela_aberta(uuid) from anon;
grant execute on function public.wa_janela_aberta(uuid) to authenticated;

A janela é conferida em três pontos, e os três são necessários:

  1. Na tela, para decidir o que mostrar. Pode estar desatualizada por segundos — é só interface.
  2. Em wa-enviar, ao enfileirar. Recusa cedo, com uma mensagem útil.
  3. Em wa-fila-drenar, no instante do envio. Este é o que vale. A janela pode ter fechado entre o enfileiramento e a drenagem, e é aqui que o item vira bloqueado_janela em vez de virar erro da Meta.

3.5 O que a interface mostra

Janela aberta, mais de 1 hora restando: campo de texto normal. Um marcador discreto no rodapé: “Janela aberta até 14:32”.

Janela aberta, menos de 1 hora (janela_aviso_minutos, configurável): o marcador fica em destaque — “A janela fecha em 47 min. Depois disso só dá para mandar modelo.” Contador ao vivo, calculado no cliente a partir de ultima_msg_cliente_em (não é uma consulta por segundo).

Janela fechada: o campo de texto é substituído, não desabilitado. No lugar dele:

A janela de 24 horas fechou.A última mensagem de Ana Paula foi ontem às 16:12. O WhatsApp só permite continuar com ummodelo aprovado.[ Escolher modelo ▾ ] · [ Ver por quê ]

A lista do seletor vem de wa_modelos com status='aprovado' and ativo, ordenada por finalidade. Se não houver nenhum modelo aprovado, o texto muda para “Nenhum modelo aprovado ainda — peça à gestão” com link para a tela de modelos. Um campo desabilitado sem explicação é o que faz o vendedor achar que o sistema quebrou e mandar pelo celular dele, fora do CRM — que é a falha que este projeto existe para acabar.

Assim que o modelo é enviado e a cliente responde, a janela reabre e o campo de texto volta sozinho, sem recarregar.

Conversa encerrada: o rodapé mostra “Conversa encerrada por Júlia em 12/08 às 15:40” com o botão Reabrir. Reabrir é T12 manual e não apaga nada.

04O casamento telefone ↔ lead

Requisito central: nunca adivinhar. O sistema resolve, ou declara que não resolveu — jamais escolhe em silêncio.

4.1 Qual função usar, e qual abandonar

FunçãoOndeVeredito
public.telefone_normalizado_br(text)0144_historico_da_pessoa.sql:91-106É a canônica. Usar esta
public.normalizar_telefone(text)0083_pos_venda_aluno.sql:23-33Abandonar para este caminho
public.match_lead_por_telefone(text)0083_pos_venda_aluno.sql:48-67Não usar aqui, e não corrigir

Arraste a tabela para o lado

Por que telefone_normalizado_br é a certa: ela produz E.164-BR (55 + DDD + 8 ou 9 dígitos) e devolve null quando o valor não é telefone BR reconhecível — e null não casa com nada, que é exatamente a propriedade que se quer (0144:100-108). Ela já é a chave do índice funcional idx_leads_telefone_normalizado (0144:136-138) e já é a mesma regra do ingest-lead (supabase/functions/ingest-lead/mapeamento.ts:306-318). Usar outra coisa criaria uma segunda definição de “mesmo telefone” dentro do mesmo banco.

Por que normalizar_telefone é a errada: ela faz right(digitos, 11) — corta os últimos 11 dígitos, sem entender país nem DDD (0083:31). Consequências:

  • Um fixo 55 11 3456-7890 (12 dígitos) vira 51134567890o 5 do país entra no lugar do DDD. É uma chave que não corresponde a telefone nenhum.
  • Um número internacional +351 938 510 359 (existe hoje na base: lead ac57a489-63c2-498d-adbf-56bf0166c247) vira 51938510359. A mesma chave que um 55 19 3851-0359 brasileiro produziria.

Por que match_lead_por_telefone não serve, mesmo com a função certa por baixo: ela tem o fallback right(public.normalizar_telefone(l.telefone), 10) = right(alvo.tel, 10) e resolve o empate com order by l.created_at desc limit 1 (0083:63-66). Ou seja: quando há mais de um candidato, ela devolve o mais recente em silêncio. É precisamente o comportamento proibido.

E não se corrige match_lead_por_telefone. Ela é usada hoje em produção por supabase/functions/webhook-clickup/mentoria.ts:115 e webhook-clickup/posvenda.ts:86. Mudar o critério dela muda o casamento de dois webhooks que funcionam. Caminho paralelo, função nova.

4.2 Medição de hoje (2026-08-24), e o que ela muda

MedidaValor apurado
Leads vivos2.140
Com telefone preenchido2.046
Canonizáveis por telefone_normalizado_br2.041 (99,76%)
Fora do padrão (têm telefone, não canonizam)5
Chaves E.164 com mais de um lead vivo47 chaves, 94 leads
Chaves em que o fallback right(...,10) juntaria leads com E.164 diferente0

Arraste a tabela para o lado

Os cinco fora do padrão, na íntegra:

leads.idtelefoneO que é
1385c237-6530-402c-8049-0c1eddcf9cd11199982889 dígitos — falta um dígito, não dá para adivinhar qual
ce320bc5-af1c-4ab5-b446-9eaa8cd99cc7067999502197DDD com zero à esquerda (067) — recuperável
bf9c38f0-f064-4107-a34d-ce9f12157474021970158327Idem (021) — recuperável
b9ded3e2-3bac-42eb-a833-68cfd1c9d1824171411747755618199489817126 dígitos — dois ou três números colados
ac57a489-63c2-498d-adbf-56bf0166c247+351938510359Portugal — é válido, só não é BR

Arraste a tabela para o lado

O que muda no plano: o item do briefing “sanear ~89 leads fora do padrão” é, hoje, 5 registros, dos quais 2 são consertáveis por regra (zero à esquerda no DDD), 2 exigem alguém olhando, e 1 está certo e só precisa ser aceito como internacional. Isso sai de trabalho de migration e vira uma tela de pendência (§4.5). A migration de saneamento não deve existir: um update em massa sobre telefone é a operação com maior chance de estragar dado real, para ganhar cinco linhas.

E a ambiguidade do fallback é estrutural, não incidental. Hoje ela não morde (0 casos). Ela morde no primeiro número internacional que virar conversa — e já existe um na base. Por isso o algoritmo abaixo não depende do fato de hoje estar limpo.

4.3 O algoritmo

SQL
-- ============================================================================
-- FASE 2 · public.wa_candidatos_por_telefone — casamento ESTRITO
-- ============================================================================
-- DUAS FORMAS CANÔNICAS, NUNCA UM SUFIXO SOLTO.
--
-- O problema real do Brasil: celular pode aparecer com ou sem o nono dígito, e a
-- Meta é inconsistente nisso — o wa_id de um contato antigo pode vir sem o 9 que
-- o cadastro tem. A tentação é comparar sufixos ("os últimos 8 dígitos batem"),
-- e é dela que nasce o bug de 0083: sufixo casa coisa que não é a mesma pessoa.
--
-- O que se faz aqui: gera-se o CONJUNTO de formas canônicas equivalentes do
-- número (com 9 e sem 9, quando o DDD e o prefixo tornam isso legítimo) e
-- compara-se por IGUALDADE contra esse conjunto. Igualdade contra duas formas
-- conhecidas é diferente de igualdade contra um sufixo arbitrário: um número de
-- Portugal nunca gera uma forma canônica brasileira, então nunca entra.
--
-- Devolve TODOS os candidatos. Quem decide o que fazer com dois é quem chama —
-- esta função não escolhe.
create or replace function public.wa_formas_canonicas(p_telefone text)
returns text[]
language sql
immutable
set search_path = ''
as $$
  with base as (
    select public.telefone_normalizado_br(p_telefone) as e164
  ),
  partes as (
    select e164,
           substring(e164 from 3 for 2)  as ddd,
           substring(e164 from 5)        as numero
      from base
     where e164 is not null
  )
  select case
    -- 9 dígitos começando com 9 (celular moderno): a forma sem o 9 também é
    -- legítima para o MESMO assinante. Só vale para 9 seguido de 8 dígitos.
    when length(numero) = 9 and left(numero, 1) = '9'
      then array['55' || ddd || numero, '55' || ddd || substring(numero from 2)]
    -- 8 dígitos começando com 6-9 (celular antigo): a forma com o 9 na frente é
    -- a mesma pessoa. Prefixo 2-5 é FIXO e NÃO ganha 9 — fixo não virou celular.
    when length(numero) = 8 and left(numero, 1) in ('6', '7', '8', '9')
      then array['55' || ddd || numero, '55' || ddd || '9' || numero]
    -- Fixo, ou qualquer outra forma: uma canônica só. Nada de inventar variante.
    else array['55' || ddd || numero]
  end
  from partes;
$$;

comment on function public.wa_formas_canonicas(text) is
  'Conjunto de formas E.164-BR equivalentes de um telefone: celular de 9 digitos gera tambem a forma sem o nono; celular antigo de 8 digitos (prefixo 6-9) gera tambem a forma com o nono. FIXO (prefixo 2-5) gera UMA forma so -- fixo nao virou celular, e dar-lhe um 9 casaria com outro assinante. Numero nao-BR devolve NULL (public.telefone_normalizado_br ja devolve null), e null nao casa com nada. E a alternativa ESTRITA ao sufixo right(...,10) de match_lead_por_telefone (0083:63), que casa numero de Portugal com numero de Sao Paulo. IMMUTABLE: indexavel.';

revoke all on function public.wa_formas_canonicas(text) from public;
grant execute on function public.wa_formas_canonicas(text) to authenticated, service_role;


create or replace function public.wa_candidatos_por_telefone(p_telefone text)
returns table (
  lead_id     uuid,
  nome        text,
  owner_id    uuid,
  created_at  timestamptz,
  tem_venda   boolean
)
language sql
stable
security definer
set search_path = ''
as $$
  select l.id, l.nome, l.owner_id, l.created_at,
         exists (select 1 from public.vendas v where v.lead_id = l.id) as tem_venda
    from public.leads l
   where l.deleted_at is null
     and public.telefone_normalizado_br(l.telefone) = any (public.wa_formas_canonicas(p_telefone))
   order by l.created_at desc;
$$;

comment on function public.wa_candidatos_por_telefone(text) is
  'TODOS os leads vivos cujo telefone canonizado casa por IGUALDADE com alguma forma canonica do numero (public.wa_formas_canonicas). NAO escolhe: devolve o conjunto, com nome, dono, data de entrada e se ja comprou -- que e o que um humano precisa para desempatar. A ordem (mais recente primeiro) e so de exibicao e NAO deve ser usada como criterio de escolha automatica: foi exatamente o "order by created_at desc limit 1" de match_lead_por_telefone (0083:65-66) que produzia a escolha silenciosa. SECURITY DEFINER porque o webhook precisa enxergar leads de qualquer dono para poder dizer que ha dois -- e uma consulta de EXISTENCIA, e o resultado so aparece na tela depois da RLS de wa_conversas.';

revoke all on function public.wa_candidatos_por_telefone(text) from public;
revoke all on function public.wa_candidatos_por_telefone(text) from anon;
grant execute on function public.wa_candidatos_por_telefone(text) to authenticated, service_role;

O índice que faz isso ser barato já existe: idx_leads_telefone_normalizado (0144:136-138), funcional sobre public.telefone_normalizado_br(telefone) e parcial em deleted_at is null. O = any(array) usa esse índice.

4.4 A decisão, por número de candidatos

Aplicada dentro de public.wa_aplicar_webhook, na mesma transação em que a conversa nasce:

Candidatosresolucaolead_idO que a tela faz
1resolvidao leadAbre normal, com a ficha do lado
2 ou maisambiguanullFaixa amarela: “Este número está em 2 cadastros. Escolha qual é.” com nome, dono, data de entrada e se já comprou
0sem_leadnullFaixa: “Número não está no CRM.” com Criar lead e Marcar como não-lead

Arraste a tabela para o lado

O chk_wa_conversas_resolucao_coerente (§1.7) torna impossível gravar ambigua com lead_id preenchido. A regra não é uma convenção de código: é uma restrição de schema.

Resolver a ambiguidade é RPC:

SQL
create or replace function public.wa_resolver_conversa(
  p_conversa_id uuid,
  p_lead_id     uuid
)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
  v_uid  uuid := auth.uid();
  v_conv public.wa_conversas%rowtype;
begin
  if v_uid is null or not public.auth_usuario_ativo() then
    raise exception 'Sessao invalida ou usuario inativo.' using errcode = '42501';
  end if;

  select * into v_conv from public.wa_conversas c where c.id = p_conversa_id;
  if not found or v_conv.team_id <> public.auth_team_id() then
    raise exception 'Conversa nao encontrada.' using errcode = '42501';
  end if;

  -- O lead escolhido tem de ser VISÍVEL para quem escolhe, e tem de estar entre
  -- os candidatos apurados. As duas coisas: sem a primeira, alguém amarra a
  -- conversa a um lead que não pode ver; sem a segunda, a escolha manual vira um
  -- jeito de costurar qualquer conversa a qualquer lead, e o casamento estrito
  -- deixa de valer para quem tem pressa.
  if not public.pode_ver_lead(p_lead_id) then
    raise exception 'Lead nao encontrado ou sem acesso.' using errcode = '42501';
  end if;
  if v_conv.resolucao = 'ambigua'
     and not (p_lead_id = any (v_conv.candidatos)) then
    raise exception 'Este lead nao esta entre os candidatos deste numero.' using errcode = '22023';
  end if;

  perform set_config('highbeauty.wa_rpc_autorizado', 'on', true);

  update public.wa_conversas c
     set lead_id       = p_lead_id,
         resolucao     = 'resolvida',
         candidatos    = '{}'::uuid[],
         resolvido_por = v_uid,
         resolvido_em  = now()
   where c.id = p_conversa_id;

  perform set_config('highbeauty.wa_rpc_autorizado', 'off', true);

  perform public.registrar_auditoria(
    'wa_conversas', p_conversa_id, 'lead_id', null, p_lead_id::text
  );

  return true;
end;
$$;

comment on function public.wa_resolver_conversa(uuid, uuid) is
  'Amarra uma conversa a UM lead, por escolha humana. Duas barreiras: o lead tem de ser visivel a quem escolhe (public.pode_ver_lead) E, quando a conversa esta ambigua, tem de estar entre os candidatos apurados -- sem a segunda, a escolha manual viraria um jeito de costurar qualquer conversa a qualquer lead e o casamento estrito deixaria de valer para quem tem pressa. Deixa rastro em audit_log. SECURITY DEFINER porque escreve colunas que o gatilho trg_wa_conversas_colunas_travadas congela para sessao de usuario.';

revoke all on function public.wa_resolver_conversa(uuid, uuid) from public;
revoke all on function public.wa_resolver_conversa(uuid, uuid) from anon;
grant execute on function public.wa_resolver_conversa(uuid, uuid) to authenticated;

4.5 As duplicatas: 47 chaves, 94 leads

Estes não são um problema de casamento — são um problema de cadastro que o casamento expõe. O algoritmo devolve 2 candidatos e a tela pergunta. Correto e suficiente.

O que NÃO se faz: mesclar leads automaticamente. Cada um dos 94 pode ter dono diferente, venda, comissão e histórico. Mesclar é decisão de negócio com impacto em comissão — fora do escopo destas seis fases (§8).

O que se faz: a tela de ambiguidade mostra os dados que permitem decidir em cinco segundos (nome, dono, quando entrou, se já comprou) e oferece Marcar como duplicado de — que grava leads.duplicado_de (coluna que já existe, 0008_leads.sql:63) sem apagar nada. A partir daí, a conversa resolve sozinha, porque o duplicado sai do conjunto de candidatos:

SQL
-- Acréscimo ao where de wa_candidatos_por_telefone, aplicado junto da Fase 2:
--     and l.duplicado_de is null
-- Lead marcado como duplicata para de aparecer como candidato. É o que faz o
-- trabalho de limpeza render: cada par resolvido uma vez nunca mais pergunta.

Os 5 fora do padrão vão para a mesma tela de pendências, com o telefone cru à vista e um campo para corrigir — corrigido pela pessoa, no lead, com o histórico de auditoria que já existe (trg_leads_auditoria). Zero migration.

4.6 Número desconhecido vira lead novo

Quando resolucao='sem_lead', a conversa existe e é atendível — ninguém precisa criar um lead antes de responder “oi”. A criação é um botão, e é RPC:

SQL
create or replace function public.wa_criar_lead_da_conversa(
  p_conversa_id uuid,
  p_nome        text
)
returns uuid
language plpgsql
security definer
set search_path = ''
as $$
declare
  v_uid    uuid := auth.uid();
  v_conv   public.wa_conversas%rowtype;
  v_origem uuid;
  v_lead   uuid;
begin
  if v_uid is null or not public.auth_usuario_ativo() then
    raise exception 'Sessao invalida ou usuario inativo.' using errcode = '42501';
  end if;

  select * into v_conv from public.wa_conversas c where c.id = p_conversa_id;
  if not found or v_conv.team_id <> public.auth_team_id() then
    raise exception 'Conversa nao encontrada.' using errcode = '42501';
  end if;
  if v_conv.lead_id is not null then
    return v_conv.lead_id;   -- idempotente: dois cliques não criam dois leads
  end if;

  -- Serializa criações concorrentes para o MESMO número. Mesmo mecanismo da
  -- 0264:264 (advisory lock por lead), com namespace desta migration.
  perform pg_catalog.pg_advisory_xact_lock(8320, pg_catalog.hashtext(v_conv.contato_e164));

  -- Recheca DEPOIS do lock: outra aba pode ter criado enquanto esta esperava.
  if exists (select 1 from public.wa_candidatos_por_telefone(v_conv.contato_e164)) then
    raise exception 'Este numero ja tem lead. Recarregue a conversa.' using errcode = '22023';
  end if;

  select o.id into v_origem from public.origens o
   where public.pipeline_texto_normalizado(o.nome) like '%whats%' and o.ativo
   order by o.nome limit 1;

  insert into public.leads (nome, telefone, origem_id, formulario_origem,
                            owner_id, team_id,
                            wa_consentimento_em, wa_consentimento_origem)
  values (
    btrim(p_nome),
    v_conv.contato_e164,
    v_origem,
    'whatsapp_inbound',
    -- Nasce COM DONO: quem está atendendo. Um lead criado no pool a partir de
    -- uma conversa em andamento seria um lead que outro vendedor pode pegar no
    -- meio do atendimento -- e dois vendedores na mesma pessoa é o pior desfecho
    -- num mercado onde elas se conhecem (mesmo raciocínio da 0264, seção 4).
    v_uid,
    public.auth_team_id(),
    -- A pessoa escreveu para nós. É consentimento por definição, e é o único
    -- caso em que ele pode ser afirmado com data e origem sem depender de nada.
    now(),
    'conversa_iniciada_pelo_lead'
  )
  returning id into v_lead;

  perform set_config('highbeauty.wa_rpc_autorizado', 'on', true);
  update public.wa_conversas c
     set lead_id = v_lead, resolucao = 'resolvida',
         resolvido_por = v_uid, resolvido_em = now()
   where c.id = p_conversa_id;
  perform set_config('highbeauty.wa_rpc_autorizado', 'off', true);

  return v_lead;
end;
$$;

comment on function public.wa_criar_lead_da_conversa(uuid, text) is
  'Cria um lead a partir de uma conversa de numero desconhecido e amarra os dois. O lead nasce COM DONO (quem esta atendendo), nao no pool: lead no pool durante atendimento e lead que outro vendedor pega no meio da conversa, e dois vendedores em cima da mesma dona de salao e o pior desfecho possivel (mesmo raciocinio da 0264, secao 4). Nasce tambem com consentimento registrado (conversa_iniciada_pelo_lead) -- e o unico caso em que consentimento pode ser afirmado com data e origem sem depender de nada. Idempotente por advisory lock no numero + recheca depois do lock: dois cliques nao criam dois leads.';

revoke all on function public.wa_criar_lead_da_conversa(uuid, text) from public;
revoke all on function public.wa_criar_lead_da_conversa(uuid, text) from anon;
grant execute on function public.wa_criar_lead_da_conversa(uuid, text) to authenticated;

Criação automática, sem humano? Não. Deliberado. A caixa de entrada receberia lead de fornecedor, de engano e de robô, e 1.973 leads já estão sem tarefa nenhuma (medido hoje) — somar lixo a esse número piora o problema que a Fase 3 vai atacar. O botão Marcar como não-lead (resolucao='ignorada') resolve esses casos em um clique e mantém o histórico.

05Integração com o que já existe

Regra que vale para a seção inteira: nada de insert direto em tarefas, interacoes, notificacoes ou lead_comentarios. Cada uma dessas tabelas tem um escritor único, e ele existe porque a escrita direta já produziu inconsistência antes.

5.1 Conversa qualificada vira tarefa e comentário

SQL
-- ============================================================================
-- FASE 2/3 · public.wa_registrar_contato_da_conversa
-- ============================================================================
-- É uma CASCA — a mesma figura de public.registrar_primeiro_contato_whatsapp
-- (0264:213-372), e pelo mesmo motivo declarado lá: "a função nova decide SE é
-- hora de registrar e delega o COMO para registrar_contato_lead".
--
-- Consequência de graça, que é o ponto: o registro nasce contando na métrica
-- certa. public.contatos_classificados (0222) o enxerga sem nenhuma regra nova,
-- e a Performance do Time (0265) soma sem alteração.
create or replace function public.wa_registrar_contato_da_conversa(
  p_conversa_id uuid,
  p_resumo      text,
  p_origem      text default 'atendimento_whatsapp'
)
returns uuid
language plpgsql
security invoker          -- a RLS decide, igual a registrar_contato_lead (0250)
set search_path = ''
as $$
declare
  v_uid       uuid := auth.uid();
  v_conv      public.wa_conversas%rowtype;
  v_tipo_id   uuid;
  v_tarefa_id uuid;
begin
  if v_uid is null or not public.auth_usuario_ativo() then
    raise exception 'Sessao invalida ou usuario inativo.' using errcode = '42501';
  end if;
  if p_origem not in ('atendimento_whatsapp', 'qualificacao_ia', 'campanha_whatsapp') then
    raise exception 'Origem de registro invalida.' using errcode = '22023';
  end if;

  select * into v_conv from public.wa_conversas c where c.id = p_conversa_id;
  if not found or v_conv.lead_id is null then
    raise exception 'Conversa sem lead resolvido -- resolva o lead antes de registrar contato.'
      using errcode = '22023';
  end if;
  if not public.pode_ver_lead(v_conv.lead_id) then
    raise exception 'Lead nao encontrado ou sem acesso.' using errcode = '42501';
  end if;

  -- O MESMO tipo de tarefa "WhatsApp" que a 0264 acha, pelo MESMO caminho
  -- (lookup editável pelo admin, casado por nome normalizado — 0264:280-289).
  -- Não se inventa tipo novo: um segundo "WhatsApp" na lista de tipos partiria a
  -- métrica de contatos por canal em duas, e ninguém notaria por meses.
  select t.id into v_tipo_id
    from public.tipos_tarefa t
   where t.ativo
     and public.pipeline_texto_normalizado(t.nome) like '%whats%'
   order by t.ordem, t.nome
   limit 1;

  if v_tipo_id is null then
    raise exception 'Nao ha tipo de tarefa WhatsApp ativo.' using errcode = '22023';
  end if;

  -- O COMO é de quem já sabia: tarefa concluída + linha em public.interacoes,
  -- numa transação só, com a regra de desfecho de ligação respeitada (0221).
  -- p_desfecho null porque WhatsApp não é ligação — igual à 0264:311-317.
  v_tarefa_id := public.registrar_contato_lead(
    v_conv.lead_id,
    v_tipo_id,
    left(btrim(coalesce(p_resumo, 'Atendimento por WhatsApp no CRM.')), 500),
    null,
    null
  );

  -- A marca de origem. NÃO é 'primeiro_contato_whatsapp' — ver §5.2.
  update public.tarefas set origem_registro = p_origem where id = v_tarefa_id;

  return v_tarefa_id;
end;
$$;

comment on function public.wa_registrar_contato_da_conversa(uuid, text, text) is
  'Registra um contato a partir de uma conversa de WhatsApp atendida DENTRO do CRM. E uma CASCA sobre public.registrar_contato_lead (0250) -- mesma figura e mesmo motivo de public.registrar_primeiro_contato_whatsapp (0264): decide SE registra e delega o COMO. Consequencia de graca: o contato nasce contando em public.contatos_classificados (0222) e na Performance do Time (0265) sem nenhuma regra nova. Reusa o MESMO tipo de tarefa WhatsApp da 0264, achado pelo mesmo caminho -- um segundo tipo partiria a metrica de canal em duas. Exige lead RESOLVIDO: conversa ambigua nao vira contato, porque contato no lead errado e pior que contato ausente.';

revoke all on function public.wa_registrar_contato_da_conversa(uuid, text, text) from public;
revoke all on function public.wa_registrar_contato_da_conversa(uuid, text, text) from anon;
grant execute on function public.wa_registrar_contato_da_conversa(uuid, text, text) to authenticated;

Comentário no lead: o resumo do atendimento e a extração da IA viram comentário por public.comentar_lead(p_lead_id, p_corpo, p_mencionados, p_anexos) (0266:604-646) — a RPC que já grava comentário e anexos na mesma transação, e cuja menção dispara notificação por gatilho, não por parâmetro (0266, seção 8). A Fase 5 usa exatamente esta função para publicar o resumo da reunião. Nunca insert into lead_comentarios — o insert direto pularia a validação de corpo e não amarraria o anexo à mesma transação.

Tipo novoQuandoPara quem
wa_conversa_novaMensagem de número desconhecido ou lead do poolTime inteiro
wa_conversa_atribuidaConversa roteada para o dono do lead (T3)O dono
wa_ia_escalouA IA desistiu e pediu humano (T5)O dono do lead, ou o time
wa_janela_fechando1h para fechar, com resposta pendenteQuem atende
wa_modelo_rejeitadoA Meta rejeitou/pausou um modeloGestão
wa_campanha_concluidaLote terminou, com o resumoQuem criou e quem aprovou
ia_custo_teto80% do teto do mêsAdmin

Arraste a tabela para o lado

5.2 Como o caminho novo convive com a regra do clique, sem competir

Esta é a parte que exige cuidado, e o desenho é o seguinte.

O que a 0264 faz hoje: quando o vendedor clica no botão de WhatsApp de um lead que não tem tarefa nenhuma viva, public.registrar_primeiro_contato_whatsapp cria uma tarefa concluída marcada origem_registro='primeiro_contato_whatsapp' e, se o lead estava no pool, o atribui por public.reivindicar_lead (0264:213-372). Duas travas garantem unicidade: advisory lock por lead (0264:264) e o índice único parcial uq_tarefas_primeiro_contato_whatsapp (0264:183-186).

Por que não estender essa função: ela é a definição operacional de “primeiro contato” que alimenta contatos_classificados (0222) e os três cartões da Performance do Time (0265). Mexer nela mexe em número que o dono já leu e aprovou. Caminho paralelo.

As três garantias de não-competição:

  1. O índice único não é tocado. uq_tarefas_primeiro_contato_whatsapp indexa só where origem_registro = 'primeiro_contato_whatsapp'. Os valores novos (atendimento_whatsapp, qualificacao_ia, campanha_whatsapp, coach_pos_reuniao) ficam fora do índice. Um lead pode ter a tarefa do clique e tarefas de atendimento sem violação. Nenhuma alteração no índice, nenhum risco de conflito.
  1. A ordem natural desarma o duplo registro. registrar_primeiro_contato_whatsapp só cria tarefa quando o lead não tem tarefa nenhuma viva (0264:270-283). Se a conversa no CRM registrou contato primeiro, o lead já tem tarefa — e o clique posterior devolve tarefa_id = null sem escrever nada. Nenhum código novo precisa impedir isso: a regra que já existe impede. É o mesmo motivo pelo qual a 0264 escolheu o critério (B) em vez do (A) (0264:36-56).
  1. Quando a conversa é o primeiro contato de verdade, quem registra é a 0264. O botão Assumir da caixa de entrada (T2) chama registrar_primeiro_contato_whatsapp primeiro; só se ela devolver tarefa_id = null (havia tarefa) é que chama wa_registrar_contato_da_conversa. Assim:
    • o lead que nunca teve contato ganha o primeiro_contato_whatsapp e a atribuição do pool,

pelo caminho canônico, com a linha de auditoria owner_id_origem que a 0264 grava (0264:349-357);

- o lead que já tinha histórico ganha o registro novo, marcado como atendimento.

Uma chamada, dois desfechos, zero regra nova de classificação — que era a condição.

O que muda em contatos_classificados (0222) e na Performance do Time (0265): nada precisa mudar para os números continuarem certos. As tarefas novas são tarefas concluídas presas a um lead, que é a definição de contato desde a 0099, e entram em contatos_whatsapp porque o tipo é o mesmo. O que se deve acrescentar depois, e é opcional: um recorte por origem_registro no painel, para separar “contato registrado na tela” de “contato que a IA registrou sozinha”. Sem esse recorte a soma continua verdadeira — só menos informativa.

5.3 Atribuição de lead: reusar, nunca copiar

Quando alguém Assume uma conversa cujo lead está no pool, a posse é transferida por public.reivindicar_lead (0050, autorização revista em 0211) — nunca por update leads set owner_id. É a mesma decisão, com as mesmas palavras, da 0264:110-127: mesma autorização (só quem tem o papel de vendedor), mesma atomicidade (where owner_id is null, então quem chega depois recebe false) e o mesmo rastro de auditoria.

A recusa não desfaz o atendimento. reivindicar_lead levanta exceção para quem não tem o papel de vendedor (hoje, admins puros). A chamada vai dentro de um bloco exception com savepoint próprio, exatamente como 0264:331-347 — o vendedor assume a conversa, o lead segue no pool, e a tela diz a verdade sobre as duas coisas.

5.4 Onde a tela encaixa

  • Rota nova: app/(app)/atendimento/ — irmã de pipeline, tarefas, alertas. Dentro do grupo (app), que já tem o layout.tsx autenticado. Sem rota de API nova — as fases 0 e 1 foram desenhadas assim de propósito, por causa do 1102 em aberto (CRM/documentacao/04-PROBLEMAS-ABERTOS.md). A tela conversa direto com o PostgREST e com as Edge Functions, ambos fora do Worker.
  • Aba nova no card do lead: Conversa, ao lado das que já existem. Lê wa_conversas por lead_id e reusa o mesmo componente da caixa de entrada.
  • O middleware.ts não muda. Ele já não importa @supabase/ssr e decodifica o JWT localmente (middleware.ts:26-40) — acrescentar rota não acrescenta peso.
  • Fase 2 depende do veredito do 1102. Se o diagnóstico for memória, a tela precisa entrar com carregamento por rota e sem bibliotecas novas pesadas no bundle compartilhado. Se for CPU, é decisão do Victor sobre plano pago (regra 5 do projeto: pare e pergunte).

06Critério de pronto por fase

Cada item é conferível na tela ou por uma consulta. Nada de “funcionando bem”.

Fase 0 — Fundação (33h)

  1. select count(*) from pg_policies where schemaname='public' and tablename like 'wa\_%' devolve pelo menos uma policy de SELECT por tabela nova, e select relname from pg_class c join pg_namespace n on n.oid=c.relnamespace where n.nspname='public' and relname like 'wa\_%' and relrowsecurity and relforcerowsecurity lista todas as tabelas novas.
  2. Toda tabela nova tem comment on table não vazio: select relname from pg_class ... where obj_description(oid) is null and relname like 'wa\_%' devolve zero linhas.
  3. select name from vault.secrets where name like 'wa\_%' devolve os quatro nomes de §2.1.
  4. select * from public.configuracoes where chave in ('whatsapp_limites','ia_limites','whatsapp_retencao') devolve três linhas.
  5. Uma linha inserida em wa_ia_consumo com custo_usd e a consulta do teto devolve o valor correto; select public.wa_ia_verificar_teto() roda sem erro e devolve 0 (nenhum teto estourado).
  6. Teste de isolamento, dentro de begin ... rollback: autenticado como vendedor A, um select em wa_envios de um lead do vendedor B devolve 0 linhas; como admin, devolve a linha. É o teste que a regra 7 do projeto exige — verificar na tela e no banco.
  7. select jobname from cron.job where jobname like 'wa\_%' lista os jobs registrados, e cada um é no-op silencioso enquanto os segredos não existirem (comprovado por raise notice no log, sem erro).

Fase 1 — Aviso de parcela vencida (26h)

  1. select count(*) from public.wa_modelos where finalidade='parcela_vencida' and status='aprovado' devolve 1 — e o meta_template_id está preenchido (o chk_wa_modelos_aprovado_tem_id já garante, mas confere-se à vista).
  2. Rodar a função de montagem em begin ... rollback e conferir que a contagem de itens em wa_envios bate com select count(*) from public.venda_parcelas where vencimento < current_date and coalesce(status,'') <> 'recebido' menos os leads com wa_optout_em e menos os sem telefone canonizável. Hoje esse número é 29 — e o critério é a igualdade, não o número.
  3. Rodar a montagem duas vezes seguidas (na mesma transação revertida) e conferir que a segunda insere zero linhas — a chk_idempotencia de wa_envios barrando. É o teste que prova que ninguém recebe a cobrança duas vezes.
  4. Um envio real para um número da equipe (não de cliente) chega no aparelho, e select status, entregue_em, lida_em from public.wa_mensagens where wa_message_id = '...' mostra a progressão enviada → entregue → lida sem nenhum passo para trás.
  5. Um envio para número inválido conhecido termina em estado='falhou' com erro_codigo preenchido, não em pendente eterno.
  6. Nenhuma parcela recebido gera envio: consulta de conferência devolve 0.

Fase 2 — Caixa de entrada (118h)

  1. /atendimento abre para vendedor, gerente e admin, e a lista mostra as conversas que cada um pode ver — conferido na tela, com dois logins diferentes, não só por consulta (regra 7).
  2. Mensagem enviada de um celular da equipe para o número do CRM aparece na tela em menos de 10 segundos, sem recarregar.
  3. Um número ambíguo de propósito (usar um dos 47 pares reais, em transação revertida, ou dois leads de teste) produz resolucao='ambigua', lead_id is null e a faixa amarela na tela com os dois candidatos, nome, dono e data.
  4. Tentar gravar o palpite falha: update wa_conversas set lead_id='...' where resolucao='ambigua' levanta erro do CHECK chk_wa_conversas_resolucao_coerente. Conferido em begin ... rollback.
  5. Com a janela fechada (conversa cuja ultima_msg_cliente_em tem mais de 24h), o campo de texto some e o seletor de modelo aparece, com a hora da última mensagem no texto.
  6. select public.wa_janela_aberta(id) concorda com o que a tela mostra para 5 conversas escolhidas ao acaso.
  7. Assumir uma conversa de lead do pool: o lead passa a ter owner_id do vendedor, existe uma linha em wa_atribuicoes com motivo='assumiu' e uma em audit_log. E select origem_registro from tarefas where lead_id=... mostra primeiro_contato_whatsapp quando não havia tarefa antes — a regra da 0264 preservada.
  8. Promover uma imagem: existe linha em public.anexos com caminho casando chk_anexos_caminho, o comentário existe em lead_comentarios, e o arquivo abre pela URL assinada. wa_mensagens.anexo_id preenchido.
  9. npm run verify:runtime passa antes de qualquer publicação (regra 3 do projeto).

Fase 3 — Cérebro de qualificação (72h)

  1. configuracoes.ia_limites.modo = 'sugestao' em produção no dia da estreia — conferido por consulta.
  2. Três mensagens enviadas em 10 segundos produzem uma linha em wa_ia_execucoes (select count(*) ... where conversa_id=...), não três. O agrupamento de rajada provado.
  3. Toda execução concluída tem pelo menos uma linha correspondente em wa_ia_consumo: select count(*) from wa_ia_execucoes e where e.estado='concluida' and not exists (select 1 from wa_ia_consumo k where k.conversa_id = e.conversa_id and k.created_at between e.created_at and e.concluido_em) devolve 0.
  4. A regra de custo é provável: select finalidade, modelo, count(*) from wa_ia_consumo group by 1,2 mostra o modelo barato em classificacao/extracao e o caro em resposta/resumo. Se um modelo caro aparecer em classificacao, a fase não está pronta.
  5. Uma sugestão aprovada sem edição vira estado='enviada' e um item em wa_envios — a mesma transação. Aprovar sem enfileirar é falha bloqueante.
  6. Desligar a IA de uma conversa (ia_ativa=false) faz a execução seguinte terminar em estado='descartada' sem nenhuma linha em wa_ia_consumo — a checagem antes da chamada, provada.
  7. Fatos extraídos aparecem em wa_ia_memoria com mensagem_id preenchido, e o teste de conflito (dois valores para qtd_saloes) resulta em um vigente e um superado_em preenchido — o uq_wa_ia_memoria_vigente funcionando.

Fase 4 — Disparo e reengajamento (43h)

  1. Uma campanha criada por A não pode ser aprovada por A: chk_wa_campanhas_aprovador_distinto levanta erro. Conferido em transação revertida.
  2. Público montado em begin ... rollback: total_publico bate com a contagem da consulta equivalente, e todo lead com wa_optout_em aparece como bloqueado_optout com motivo preenchidoselect count(*) ... where estado='bloqueado_optout' and motivo is null devolve 0.
  3. Dois leads com o mesmo telefone canônico no mesmo lote: um sai, o outro fica bloqueado_duplicado. A mesma pessoa não recebe duas vezes.
  4. Pausar no meio e retomar: nenhum destinatário com estado='enviado' volta para pendente (consulta de conferência devolve 0), e o total enviado não regride.
  5. Um lote de 20 destinatários com ritmo_por_hora=60 leva pelo menos 19 minutos para esvaziar — o ritmo respeitado, medido por min(enviado_em) e max(enviado_em).
  6. Fora da janela (janela_inicio/janela_fim), o drenador não envia: teste com janela fechada deixa a fila parada e o log registra o motivo.
  7. A palavra PARAR numa conversa grava leads.wa_optout_em e wa_optout_origem='palavra_chave', e a próxima campanha marca aquele lead como bloqueado_optout.

Fase 5 — Coach pós-reunião (24h)

  1. Um evento real do Fireflies produz uma linha em wa_reunioes e, no reenvio do mesmo evento, nenhuma linha nova (uq_wa_reunioes_fireflies).
  2. Reunião com participante casável vira comentário visível na ficha do lead — conferido na tela, com o texto de resumo, a lista de objeções e os próximos passos. estado='publicada' e comentario_id preenchido.
  3. Reunião sem casamento fica em estado='sem_lead' e aparece na tela de pendências da gestão. select count(*) from wa_reunioes where estado='publicada' and lead_id is null devolve 0 — nada é publicado no lead errado.
  4. Próximo passo com data vira tarefa com origem_registro='coach_pos_reuniao', vence_em na data indicada e owner_id do vendedor da reunião.
  5. select sum(custo_usd) from wa_ia_consumo where finalidade='resumo' mostra custo por reunião dentro do esperado — e o número é conferido contra o teto do mês antes de ligar para todas as reuniões.

07Faixas de migration reservadas

A base é 0276, não 0266. O ledger public._migrations_aplicadas registra até 0276_tipo_de_tarefa_instagram_no_lugar_do_email.sql (2026-08-21). O clone local C:\dev\hb-main-tmp só tem até 0266 — ele está atrasado, e é ele que engana.

E o ledger também não deve ser usado para decidir o que falta: ele tem 200 linhas para um diretório com mais arquivos que isso, e o briefing registra defasagem de 11 arquivos. O ledger serve para uma coisa só, e é o que se usa aqui: descobrir o maior número já aplicado, para não reutilizar. Para saber o que existe de fato, a fonte é o banco (pg_proc, pg_policies, information_schema.columns), não a lista de arquivos.

As faixas começam em 0300 — folga deliberada de 23 números entre a última aplicada e a primeira nova, porque o CRM continua recebendo correções durante estas seis fases e as duas frentes não podem colidir.

FaseFaixaPrimeiros arquivos previstos
Corrente do CRM (correções em paralelo)02770299Não pertencem a este projeto
Fase 0 — Fundação030003090300_wa_fundacao_numeros_e_modelos.sql · 0301_leads_consentimento_e_optout.sql · 0302_wa_configuracoes_e_tetos.sql · 0303_wa_fila_de_envio.sql · 0304_wa_dedupe_e_consumo_ia.sql · 0305_wa_crons_e_vault.sql
Fase 1 — Parcela vencida031003190310_wa_aviso_parcela_vencida.sql · 0311_wa_cron_parcelas.sql
Fase 2 — Caixa de entrada032003390320_wa_conversas_e_mensagens.sql · 0321_wa_atribuicoes.sql · 0322_wa_casamento_telefone.sql · 0323_wa_aplicar_webhook.sql · 0324_tarefas_origem_registro_atendimento.sql · 0325_wa_promover_midia.sql
Fase 3 — Cérebro de qualificação034003540340_wa_ia_execucoes.sql · 0341_wa_ia_memoria.sql · 0342_wa_ia_sugestoes.sql · 0343_wa_ia_cron.sql
Fase 4 — Disparo035503690355_wa_campanhas.sql · 0356_wa_campanha_destinatarios.sql · 0357_wa_publico_e_optout.sql · 0358_wa_campanha_cron.sql
Fase 5 — Coach pós-reunião037003790370_wa_reunioes.sql · 0371_wa_reuniao_publicar.sql
Reserva — correções das seis fases03800389Só o que corrigir algo destas fases

Arraste a tabela para o lado

Regras da faixa:

  • Uma frente nunca usa número de outra, mesmo com a faixa vazia. Número livre em faixa alheia é convite para dois arquivos com o mesmo nome em branches diferentes.
  • Se uma faixa esgotar, não invade a seguinte: usa a reserva 03800389 e registra o desvio no cabeçalho do arquivo.
  • Toda migration abre com o cabeçalho no molde da 0266/0264 (o que faz, por que, o que é aceito como consequência) e fecha com bloco de rollback comentado, não executado.
  • Nenhum agente aplica. O agente escreve o arquivo e reporta; quem aplica é o orquestrador.

08O que NÃO será construído

Lista fechada, com o motivo e — quando cabível — o custo se for reincorporado depois.

#O que fica de foraPor quêCusto se entrar
1Grupos de WhatsAppA API oficial não permite. A Cloud API não envia nem recebe mensagem de grupo. Não é decisão nossa, é ausência de recursoImpossível com a decisão 2 (API oficial). Só com biblioteca não-oficial, que está descartada
2Editar e apagar mensagem enviadaA API oficial não permite apagar mensagem já entregue, e a edição não existe para mensagem enviada por negócio [a confirmar: a Meta anunciou edição em janelas curtas para alguns casos; confirmar na v21.0 antes de prometer qualquer coisa ao cliente]Impossível hoje
3Raspagem de contatos do WhatsAppDecisão. Exigiria acesso não-oficial ao aparelho ou ao Web, o que contraria a decisão 2 e cria risco de banimento do número — e o número é o ativoNão entra. Não é questão de horas
4Coaching ao vivo (a IA soprando durante a conversa, em tempo real)Exige transcrição em streaming e latência de segundos, o que multiplica o custo de IA e força chamada de modelo caro em toda mensagem. A Fase 3 já dá o essencial (sugestão) sem esse preço~40h + custo recorrente de IA de 3 a 5 vezes o da Fase 3
5Editor visual de fluxo (arrastar caixinhas de automação)O CRM já tem reguas_cadencia (7 linhas em uso) e regras_roteamento. Um segundo motor de fluxo, visual, seria a segunda verdade sobre “o que acontece depois” — e a primeira já está em produção~60h, e exigiria antes decidir qual dos dois motores morre
6Camada de sentimento (classificar o humor da conversa)Custo de IA por mensagem para produzir um número que ninguém age. Enquanto não houver uma decisão que dependa dele, é métrica bonita~16h — barato, mas só depois de existir a decisão que ele alimenta
7Tickets (SLA de atendimento, filas por assunto, reabertura formal)O CRM já tem tarefas com SLA (sla_primeiro_contato_minutos, cron a cada 5 min) e notificacoes. Ticket seria uma segunda fila de trabalho ao lado da que o time já usa~50h, e cria o risco real de o time passar a olhar duas caixas
8Múltiplos números de WhatsAppFora do escopo destas seis fases. wa_numeros já suporta no schema (a conversa aponta para o número); o que falta é o roteamento — de qual número sai a resposta, quem atende o quê, como a caixa separa~28h (roteamento + filtro na caixa + seleção no envio + teste). O schema não precisa mudar
9Mesclar leads duplicados automaticamenteOs 94 leads em 47 chaves duplicadas têm dono, venda e comissão possivelmente diferentes. Mesclar é decisão de negócio com impacto em comissão~24h, e precisa de decisão do dono antes de uma linha de código
10Migration de saneamento de telefoneSão 5 registros, medidos hoje. update em massa sobre telefone é a operação com maior chance de estragar dado real. Vira tela de pendência (§4.5)0h — a tela de pendência já está dentro da Fase 2
11Chatbot com menus/botões interativos (fluxo de árvore antes do humano)A Fase 3 responde com linguagem, que é melhor para esse público. Menu de botões seria um segundo caminho de conversa para manter~30h
12Ligação por WhatsApp / chamada de vozA Cloud API não oferece. Fora de alcance pela decisão 2Impossível hoje

Arraste a tabela para o lado

09Riscos abertos e o que confirmar

#ItemComo confirmarBloqueia
1Erro 1102 da Cloudflare — CPU ou memóriawrangler pages deployment tail <id> --project-name=highbeauty-crm --format=json com o tail aberto no momento da publicação, lendo outcome (exceededCpu vs exceededMemory). Cuidado: o próprio tail adiciona cargaFase 2. Fases 0 e 1 foram desenhadas sem rota nova justamente para não depender disso
2Número WABA, phone_number_id, waba_id, tier e qualidadeBusiness Manager da Meta, conta do clienteFase 0
3Se o número WhatsApp está sob o mesmo App do Lead AdsBusiness Manager. Se sim, META_APP_SECRET é reusado; se não, segredo novoFase 0
4Prazo real de retenção do media_id na v21.0Documentação oficial + um teste com mídia real, medindo quando o download passa a falharTexto da tela na Fase 2
5Limiar de desativação de webhook por lentidãoDocumentação oficial; não há número público estávelAjuste do teto de 3s em wa-webhook
6Esquema de assinatura do webhook do FirefliesCapturar headers de um evento real e conferir contra _shared/hmac.ts:20-27. Se não assinar, decidir alternativa antes da Fase 5Fase 5
7Texto de aceite do formulário de captação (para o backfill de consentimento)Ler o formulário vivo + docs/integracao-wordpress-fluentforms.mdBackfill da Fase 0; sem ele, público menor na Fase 4
8Provedor de IA, modelo barato e modelo caro, e o teto mensal em dólarDecisão do Victor. O valor de 30 em ia_limites.teto_usd_mes é um lugar-comum, não uma decisãoFase 3
9Tempo de aprovação de modelo na MetaSubmeter o modelo de parcela vencida cedo, na Fase 0, e medirFase 1
10Se algo entre 0267 e 0276 alterou objeto citado aquiLer os arquivos no origin/main atualizado (o clone local está atrasado). Já conferido no banco: pode_ver_lead, comentar_lead, registrar_contato_lead, telefone_normalizado_br e o CHECK de origem_registro continuam como descritos; lead_comentarios ganhou editado_em/removido_em/removido_por na 0274Redação final das migrations

Arraste a tabela para o lado