As faixas novas começam em 0300, com folga de 23 números.
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.
Nove seções, e onde cada resposta mora
São 3.292 linhas. Ninguém lê isso de ponta a ponta — e não precisa. O índice à esquerda acompanha a rolagem, cada bloco de SQL tem botão de copiar e cada tabela rola sozinha, sem levar a página junto.
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.
Três números do briefing mudaram
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.
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.
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:
- 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.
- API oficial da Meta (WhatsApp Cloud API) em tudo. Evolution API não entra.
- 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).
- Todo I/O de WhatsApp acontece em Edge Function. O Worker da Cloudflare não toca em mensagem — nem para receber, nem para enviar.
- 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 privadoanexos,supabase/migrations/0266_anexos_e_comentarios_no_lead.sql:239-322). - 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 recebida | O que o banco diz hoje (2026-08-24) | Consequência |
|---|---|---|
Última migration aplicada é a 0266 | O ledger public._migrations_aplicadas registra até 0276_tipo_de_tarefa_instagram_no_lugar_do_email.sql, aplicada em 2026-08-21 | As 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.164 | 2.140 vivos · 2.046 com telefone · 2.041 canonizáveis (99,76%) · 5 fora do padrão | O 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 pessoa | A 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 vencido | 573 parcelas no total; vencidas e não recebidas hoje: 29 parcelas, R$ 105.246,87 | A 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 pool | 1.407 no pool · 1.973 sem tarefa nenhuma (92% dos vivos) | Confirma o tamanho do problema que a Fase 3 ataca |
| Vault com 7 segredos | 9 segredos | Sem consequência |
| Banco 128 MB | 134 MB de 500 MB | Sem 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-tmpestá atrasado.git ls-tree origin/main supabase/migrations/termina em0266; 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. 0274quebrou o append-only de comentário.public.lead_comentarioshoje temeditado_em,removido_em,removido_por, e existem as RPCseditar_comentario_lead,excluir_comentario_lead,expurgar_comentarios_excluidos. O molde de RLS da0266continua válido (as policies são as mesmas duas, SELECT e INSERT — conferido empg_policies), mas não copie o parágrafo “append-only” da 0266 como se ainda fosse verdade.
0.2 As seis fases
| # | Fase | Horas | Entrega |
|---|---|---|---|
| 0 | Fundação | 33 | Tabelas com RLS no molde 0266, segredos no Vault, provedor de IA próprio, travas de volume e retenção |
| 1 | Aviso de parcela vencida | 26 | Cron lê venda_parcelas vencidas → modelo de utilidade → status de entrega volta |
| 2 | Caixa de entrada no CRM | 118 | Rota /atendimento, aba Conversa no card do lead, relógio da janela de 24h, casamento telefone↔lead |
| 3 | Cérebro de qualificação | 72 | Orquestrador com LLM, agrupamento de rajada, filas, memória por contato, modo sugestão |
| 4 | Disparo e reengajamento | 43 | Construtor de modelo, motor de lote em pg_cron, seleção de público, opt-out |
| 5 | Coach pós-reunião | 24 | Fireflies → 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 porwa_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
insertdireto onde já existe função. Contato virapublic.registrar_contato_lead; comentário virapublic.comentar_lead; aviso virapublic.registrar_notificacao. Detalhe em §5. - Toda migration é aditiva, idempotente e traz bloco de rollback comentado no rodapé, no padrão de
0266e0264. - 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ãovalor),tarefas.descricao(nãotitulo),venda_parcelas(não existeparcelas;vendas.parcelasé uma coluna inteira), coluna de data chamadatimestampeminteracoes/movimentacoes/audit_log, e não existe tabelaalunos(éaluno_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).
create table if not exists— colunas com tipo e nulidade explícita, FK comon deleteescolhido caso a caso, CHECK de conjunto fechado para toda coluna de estado.comment on table+comment on columnem toda coluna não-óbvia +comment on constraintem todo CHECK cuja razão não se lê do nome.- Índices: o de acesso (o
where+order byda tela quente) e o deteam_id— os dois, sempre. Único parcial onde há regra de unicidade condicional. alter table ... enable row level security;seguido dealter table ... force row level security;- Uma policy por operação, com
public.auth_usuario_ativo()eteam_id = public.auth_team_id()como os dois primeiros termos dousing/with check. A visibilidade de lead é herdada porpublic.pode_ver_lead(uuid)(0266:120-138), nunca reescrita. comment on policyexplicando quem passa e por quê.- RPCs de escrita
security invoker(a RLS é a barreira),set search_path = '', comrevoke all ... from public+revoke all ... from anon+grant execute ... to authenticated. Funções que precisam atravessar a RLS por anti-recursão sãosecurity definersem receber identidade por argumento — sempre travadas emauth.uid()/public.auth_team_id(), comopublic.pode_ver_lead(0266:114-138).
Funções de apoio que já existem e que este documento usa sem recriar:
| Função | Onde | O que faz |
|---|---|---|
public.auth_role() | 0050_rls.sql:50 | Papel do JWT (app_metadata, nunca user_metadata) |
public.auth_usuario_ativo() | 0050_rls.sql:93 | Usuário existe em usuarios e está ativo. security definer anti-recursão |
public.auth_team_id() | 0050_rls.sql:131 | team_id do usuário autenticado |
public.auth_tem_papel(text) | 0211_papeis_multiplos_e_movimentacao.sql:213 | Multi-papel: usuarios.papeis CONCEDE, só válido dentro de RPC |
public.team_id_padrao() | 0004_usuarios.sql:32 | Default de team_id |
public.pode_ver_lead(uuid) | 0266:114-138 | Espelha as duas policies de SELECT de leads (0050) |
public.telefone_normalizado_br(text) | 0144_historico_da_pessoa.sql:91 | A 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:629 | Tarefa concluída + linha na timeline, mesma transação |
public.comentar_lead(uuid,text,uuid[],jsonb) | 0266:604-646 | Comentá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) | 0050 | Linha em audit_log |
public.pipeline_texto_normalizado(text) | usada em 0264:283 | Normalizaçã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
| Tabela | Fase | Papel |
|---|---|---|
wa_numeros | 0 | Os números WABA que o CRM opera (hoje 1) |
wa_modelos | 0 | Modelos de mensagem registrados na Meta, com estado de aprovação |
wa_envios | 0 | A fila de envio. Único caminho de saída de mensagem |
wa_webhook_dedupe | 0 | Idempotência do webhook da Meta |
wa_ia_consumo | 0 | Consumo e custo de IA, por chamada — base do alerta de teto |
wa_conversas | 2 | Uma linha por número de contato. Estado, janela de 24h, atendente |
wa_mensagens | 2 | Uma linha por mensagem, entrada e saída |
wa_atribuicoes | 2 | Histórico de quem assumiu a conversa e quando |
wa_ia_execucoes | 3 | Fila e log do orquestrador: rajada, tentativa, resultado |
wa_ia_memoria | 3 | Memória por contato: fatos extraídos, append-only |
wa_ia_sugestoes | 3 | Modo sugestão: o que a IA escreveria, para o humano aprovar |
wa_campanhas | 4 | Um disparo: modelo, público, janela, estado |
wa_campanha_destinatarios | 4 | Um destinatário por linha, com desfecho individual |
wa_reunioes | 5 | Transcriçã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á temevent_id/origem/payloade já é expurgada aos 90 dias porpublic.expurgar_dados_retencao()(0095_lgpd_tecnico.sql:438-470).public.templates_mensagem(existe, 0 linhas, colunasnome/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_mensagemfica 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
-- ============================================================================
-- 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
-- ============================================================================
-- 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):
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.
-- ============================================================================
-- 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:
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
-- ============================================================================
-- 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
-- ============================================================================
-- 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):
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):
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
-- ============================================================================
-- 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):
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
-- ============================================================================
-- 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
-- ============================================================================
-- 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
-- ============================================================================
-- 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
-- ============================================================================
-- 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
-- ============================================================================
-- 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
-- ============================================================================
-- 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.';-- ============================================================================
-- 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
-- ============================================================================
-- 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”.
-- ============================================================================
-- 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:
CHECK (((origem_registro IS NULL) OR (origem_registro = 'primeiro_contato_whatsapp'::text)))-- ============================================================================
-- 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:
-- 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.
-- 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 ewa_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, umupdatede uma linha, e reversível no mesmo movimento.mensagens_dias = nullde 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):
| Item | Estimativa | Base do cálculo |
|---|---|---|
| Invocações de Edge Function | +40k a +90k/mês | 1 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/ano | wa_mensagens domina; ~1 KB por linha, 300/dia = ~110 MB/ano se o volume triplicar |
| Storage | ~0 | Mí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.toml — verify_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ção | verify_jwt | Quem chama | Fase |
|---|---|---|---|
wa-webhook | false | Meta (WhatsApp Cloud API) | 0/1 |
wa-enviar | true | O app Next, com o JWT do vendedor | 1/2 |
wa-fila-drenar | true | pg_cron via pg_net, com service_role | 0/1 |
wa-modelos-sync | true | pg_cron + botão de gestão | 0 |
wa-midia-promover | true | O app Next, com o JWT de quem promove | 2 |
wa-ia-orquestrador | true | pg_cron via pg_net | 3 |
fireflies-webhook | false | Fireflies | 5 |
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.
| Nome | Onde | Para quê |
|---|---|---|
WA_APP_SECRET | env da função | HMAC do X-Hub-Signature-256 |
WA_VERIFY_TOKEN | env da função | Handshake GET da subscription |
WA_SYSTEM_TOKEN | env da função | Bearer da Graph API |
WA_PHONE_NUMBER_ID | env da função | Redundante com wa_numeros; usado só no arranque |
IA_PROVIDER_KEY | env da função | Chave do provedor de IA |
IA_PROVIDER_BASE_URL | env da função | Endpoint do provedor (permite trocar sem deploy) |
FIREFLIES_WEBHOOK_SECRET | env da função | Assinatura do webhook do Fireflies [a confirmar] |
wa_fila_drenar_url / wa_fila_drenar_service_key | Vault | O cron chamar wa-fila-drenar |
wa_ia_orquestrador_url / wa_ia_orquestrador_service_key | Vault | O 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:
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:
{ "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:
const rawBody = await req.text()— corpo cru antes de qualquer parse. A assinatura é sobre os bytes exatos; assinar depois deJSON.parse/stringifyproduz hash diferente.- Gate de assinatura.
verifyHmacSignature(rawBody, req.headers.get('X-Hub-Signature-256'), WA_APP_SECRET). Inválida →401, nada é processado, nada é gravado.WA_APP_SECRETausente →500com log alto (mesmo desenho demeta-leadgen-webhook/index.ts:535-539). JSON.parse— só agora é seguro.- É nosso?
entry[].idtem de bater com algumwa_numeros.waba_idativo. Não bate →200 {ignored:true}e uma linha emwa_webhook_dedupecomresultado='ignorado_nao_nosso'. Devolver 200 aqui é deliberado: a Meta não deve reenviar algo que nunca foi nosso. - Uma única chamada de banco, a RPC
public.wa_aplicar_webhook(p_payload jsonb)— que faz, em uma transação: claim de idempotência emwa_webhook_dedupepor evento, upsert dewa_conversas, insert dewa_mensagensde entrada, e aplicação dos status de saída. Um round-trip, não N. 200 {ok:true, aplicados:N, ignorados:M}.
Códigos de erro:
| Código | Quando | O que a Meta faz |
|---|---|---|
200 | Aplicado, ou ignorado por não ser nosso, ou duplicado | Não reenvia |
401 | Assinatura inválida ou ausente | Não reenvia; log alto do nosso lado |
403 | Handshake com verify_token errado | — |
500 | WA_APP_SECRET ausente (configuração incompleta) | Reenvia |
503 | A 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
awaitde log externo.console.logdo Deno é assíncrono e não bloqueia. - Teto duro: se a RPC passar de 3 segundos, a função devolve
503e 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:
{ "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:
- Sessão e usuário ativo — o gateway já validou o JWT; a função confere
usuarios.ativo. - Visibilidade —
public.pode_ver_lead(lead_id)para o lead da conversa. Quem não vê o lead não fala com ele. Negado devolve404, não403(não confirma a existência). - Opt-out —
leads.wa_optout_em is not nulle o modelo éMARKETING→409 {motivo:'optout'}.UTILITYdentro de conversa aberta pelo próprio lead segue permitido. - Janela de 24h — se
tipo='sessao'enow() - ultima_msg_cliente_em > 24h→409 {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. - Teto —
configuracoes.whatsapp_limitesewa_numeros.limite_diario_meta, o menor dos dois →429 {motivo:'teto_diario', reabre_em:'...'}. - Modelo —
status='aprovado' and ativoe a contagem devariaveisbate com os{{n}}do corpo →422com 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:
- 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 *. Oskip 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. - 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. - 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.
- Chama a Graph API, uma mensagem por vez, respeitando
ritmo_por_horada campanha. - Grava o desfecho —
wa_message_id,estado='enviado',enviado_em, e uminsertemwa_mensagenscomdirecao='saida',status='enviada',envio_idpreenchido.
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 Meta | Classe | O que faz |
|---|---|---|
429, 500, 503, timeout | Transiente | Retenta com backoff |
131047 (fora da janela de 24h) | Definitivo | bloqueado_janela, não retenta |
131026 (não é WhatsApp / não recebe) | Definitivo | falhou + marca o lead para conferência humana |
131049, 368 (bloqueio/limite de qualidade) | Definitivo | falhou + wa_optout_origem='bloqueio_detectado' |
132000-132xxx (template inválido/pausado) | Definitivo | falhou + rebaixa wa_modelos.status |
Token inválido (190) | Transiente e crítico | Retenta 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.
2.5 wa-modelos-sync — o espelho do catálogo
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:
- Teto de custo.
sum(custo_usd)do mês contraconfiguracoes.ia_limites.teto_usd_mes. Estourou → para, notifica admin, devolve200 {parado:'teto'}. Esta é a porta que desliga a IA;wa_ia_verificar_teto()só avisa (§1.6). - Teto de execuções do dia (
max_execucoes_dia). - A conversa quer IA?
wa_conversas.ia_ativa. Falso → descarta a execução (estado='descartada'), sem chamar modelo. - A rajada acabou? Só processa execução com
agendado_para <= now(). Mensagem nova empurrou o horário; a execução espera. - 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:
| Passada | Finalidade | Modelo | Por quê |
|---|---|---|---|
| 1 | classificacao + extracao | Barato | Decide o que fazer e extrai fatos para wa_ia_memoria. Erro aqui gera um roteamento ruim que um humano corrige |
| 2 | resposta | Caro | Só 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}.
- Confere
public.pode_ver_leaddo lead da conversa. Não vê →404. - Confere que
midia_expira_em > now(). Expirou →410 Gonecom a data — a mensagem certa é “esse arquivo não existe mais em lugar nenhum”, não “erro ao baixar”. GET /{midia_meta_id}na Graph API para obter a URL, depois baixa o binário.- Confere o tipo pelos bytes mágicos, não pela extensão nem pelo
mimedeclarado — JPEGFF D8 FF, PNG89 50 4E 47, WebPRIFF....WEBP, PDF%PDF-. Fora da lista →415. É a mesma disciplina de0266, seção 3 do cabeçalho. - Confere o tamanho contra 2 MiB (o
file_size_limitdo bucketanexos,0266:268). - Sobe para
anexosno caminholead/{lead_id}/{uuid}.{ext}— o formato exigido porchk_anexos_caminho(0266:301). - 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. Preenchewa_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.
- Valida assinatura →
401se inválida. - Idempotência por
fireflies_idemwa_reunioes(UNIQUE), e o payload cru vai parapublic.webhook_eventscomorigem='fireflies'(tabela que já existe e já é expurgada aos 90 dias por0095:455-457). - 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. - 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_consumocomfinalidade='resumo'. - Publica por
public.comentar_leade, se houver próximo passo com data, cria a tarefa porpublic.registrar_contato_leadcomorigem_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 ela —
wa_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
| Estado | Significado | Quem enxerga na caixa |
|---|---|---|
nao_atribuida | Chegou mensagem, ninguém pegou | Todo o time (é o pool da caixa) |
com_ia | A Fase 3 está conduzindo | Todo o time, marcada como IA |
com_humano | Alguém assumiu; atendente_id preenchido | Todo o time, com o nome de quem atende |
aguardando_cliente | Respondemos, a bola é deles | Quem atendeu, e a gestão |
encerrada | Assunto fechado | Só por filtro explícito |
Arraste a tabela para o lado
3.3 Transições
| # | De | Para | Quem dispara | Efeito colateral obrigatório |
|---|---|---|---|---|
| T1 | — | nao_atribuida | wa-webhook, 1ª mensagem do contato | Cria a conversa; tenta resolver o lead (§4); nao_lidas=1 |
| T2 | nao_atribuida | com_humano | Vendedor clica em Assumir | wa_atribuicoes motivo='assumiu'; se o lead está no pool, chama public.reivindicar_lead (§5.3) |
| T3 | nao_atribuida | com_humano | Automático, quando o lead já tem dono | wa_atribuicoes motivo='dono_do_lead'; notifica o dono |
| T4 | nao_atribuida | com_ia | wa-ia-orquestrador, com ia_ativa=true | wa_atribuicoes motivo='ia_assumiu' |
| T5 | com_ia | com_humano | IA escala, ou humano intervém | wa_atribuicoes motivo='ia_escalou'; notificação para o dono do lead |
| T6 | com_humano | aguardando_cliente | Mensagem nossa sai com sucesso | ultima_msg_nossa_em; nao_lidas=0 |
| T7 | com_ia | aguardando_cliente | Idem, mandada pela IA | Idem |
| T8 | aguardando_cliente | com_humano | Cliente respondeu e havia atendente | nao_lidas+1; enfileira wa_ia_execucoes se ia_ativa |
| T9 | aguardando_cliente | nao_atribuida | Cliente respondeu e o atendente saiu/está inativo | wa_atribuicoes motivo='devolveu_ao_pool' |
| T10 | com_humano | com_humano | Transferência | wa_atribuicoes motivo='transferiu'; notifica quem recebeu |
| T11 | qualquer | encerrada | Humano clica em Encerrar | encerrada_em/por; wa_atribuicoes motivo='encerrou' |
| T12 | encerrada | nao_atribuida | Cliente escreve de novo | Reabre; encerrada_em=null; mantém todo o histórico |
| T13 | com_humano | com_humano | Vendedor devolve ao pool | atendente_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:
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:
- Na tela, para decidir o que mostrar. Pode estar desatualizada por segundos — é só interface.
- Em
wa-enviar, ao enfileirar. Recusa cedo, com uma mensagem útil. - 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 virabloqueado_janelaem 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ção | Onde | Veredito |
|---|---|---|
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-33 | Abandonar para este caminho |
public.match_lead_por_telefone(text) | 0083_pos_venda_aluno.sql:48-67 | Nã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) vira51134567890— o5do 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: leadac57a489-63c2-498d-adbf-56bf0166c247) vira51938510359. A mesma chave que um55 19 3851-0359brasileiro 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
| Medida | Valor apurado |
|---|---|
| Leads vivos | 2.140 |
| Com telefone preenchido | 2.046 |
Canonizáveis por telefone_normalizado_br | 2.041 (99,76%) |
| Fora do padrão (têm telefone, não canonizam) | 5 |
| Chaves E.164 com mais de um lead vivo | 47 chaves, 94 leads |
Chaves em que o fallback right(...,10) juntaria leads com E.164 diferente | 0 |
Arraste a tabela para o lado
Os cinco fora do padrão, na íntegra:
leads.id | telefone | O que é |
|---|---|---|
1385c237-6530-402c-8049-0c1eddcf9cd1 | 119998288 | 9 dígitos — falta um dígito, não dá para adivinhar qual |
ce320bc5-af1c-4ab5-b446-9eaa8cd99cc7 | 067999502197 | DDD com zero à esquerda (067) — recuperável |
bf9c38f0-f064-4107-a34d-ce9f12157474 | 021970158327 | Idem (021) — recuperável |
b9ded3e2-3bac-42eb-a833-68cfd1c9d182 | 41714117477556181994898171 | 26 dígitos — dois ou três números colados |
ac57a489-63c2-498d-adbf-56bf0166c247 | +351938510359 | Portugal — é 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
-- ============================================================================
-- 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:
| Candidatos | resolucao | lead_id | O que a tela faz |
|---|---|---|---|
| 1 | resolvida | o lead | Abre normal, com a ficha do lado |
| 2 ou mais | ambigua | null | Faixa amarela: “Este número está em 2 cadastros. Escolha qual é.” com nome, dono, data de entrada e se já comprou |
| 0 | sem_lead | null | Faixa: “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:
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:
-- 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:
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
-- ============================================================================
-- 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 novo | Quando | Para quem |
|---|---|---|
wa_conversa_nova | Mensagem de número desconhecido ou lead do pool | Time inteiro |
wa_conversa_atribuida | Conversa roteada para o dono do lead (T3) | O dono |
wa_ia_escalou | A IA desistiu e pediu humano (T5) | O dono do lead, ou o time |
wa_janela_fechando | 1h para fechar, com resposta pendente | Quem atende |
wa_modelo_rejeitado | A Meta rejeitou/pausou um modelo | Gestão |
wa_campanha_concluida | Lote terminou, com o resumo | Quem criou e quem aprovou |
ia_custo_teto | 80% do teto do mês | Admin |
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:
- O índice único não é tocado.
uq_tarefas_primeiro_contato_whatsappindexa 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.
- A ordem natural desarma o duplo registro.
registrar_primeiro_contato_whatsappsó 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 devolvetarefa_id = nullsem 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).
- 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_whatsappprimeiro; só se ela devolvertarefa_id = null(havia tarefa) é que chamawa_registrar_contato_da_conversa. Assim:- o lead que nunca teve contato ganha o
primeiro_contato_whatsappe a atribuição do pool,
- o lead que nunca teve contato ganha o
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ã depipeline,tarefas,alertas. Dentro do grupo(app), que já tem olayout.tsxautenticado. 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_conversasporlead_ide reusa o mesmo componente da caixa de entrada. - O
middleware.tsnão muda. Ele já não importa@supabase/ssre 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)
select count(*) from pg_policies where schemaname='public' and tablename like 'wa\_%'devolve pelo menos uma policy de SELECT por tabela nova, eselect 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 relforcerowsecuritylista todas as tabelas novas.- Toda tabela nova tem
comment on tablenão vazio:select relname from pg_class ... where obj_description(oid) is null and relname like 'wa\_%'devolve zero linhas. select name from vault.secrets where name like 'wa\_%'devolve os quatro nomes de §2.1.select * from public.configuracoes where chave in ('whatsapp_limites','ia_limites','whatsapp_retencao')devolve três linhas.- Uma linha inserida em
wa_ia_consumocomcusto_usde a consulta do teto devolve o valor correto;select public.wa_ia_verificar_teto()roda sem erro e devolve0(nenhum teto estourado). - Teste de isolamento, dentro de
begin ... rollback: autenticado como vendedor A, umselectemwa_enviosde 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. 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 porraise noticeno log, sem erro).
Fase 1 — Aviso de parcela vencida (26h)
select count(*) from public.wa_modelos where finalidade='parcela_vencida' and status='aprovado'devolve 1 — e ometa_template_idestá preenchido (ochk_wa_modelos_aprovado_tem_idjá garante, mas confere-se à vista).- Rodar a função de montagem em
begin ... rollbacke conferir que a contagem de itens emwa_enviosbate comselect count(*) from public.venda_parcelas where vencimento < current_date and coalesce(status,'') <> 'recebido'menos os leads comwa_optout_eme menos os sem telefone canonizável. Hoje esse número é 29 — e o critério é a igualdade, não o número. - Rodar a montagem duas vezes seguidas (na mesma transação revertida) e conferir que a segunda insere zero linhas — a
chk_idempotenciadewa_enviosbarrando. É o teste que prova que ninguém recebe a cobrança duas vezes. - 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ãoenviada → entregue → lidasem nenhum passo para trás. - Um envio para número inválido conhecido termina em
estado='falhou'comerro_codigopreenchido, não empendenteeterno. - Nenhuma parcela
recebidogera envio: consulta de conferência devolve 0.
Fase 2 — Caixa de entrada (118h)
/atendimentoabre 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).- Mensagem enviada de um celular da equipe para o número do CRM aparece na tela em menos de 10 segundos, sem recarregar.
- 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 nulle a faixa amarela na tela com os dois candidatos, nome, dono e data. - Tentar gravar o palpite falha:
update wa_conversas set lead_id='...' where resolucao='ambigua'levanta erro do CHECKchk_wa_conversas_resolucao_coerente. Conferido embegin ... rollback. - Com a janela fechada (conversa cuja
ultima_msg_cliente_emtem mais de 24h), o campo de texto some e o seletor de modelo aparece, com a hora da última mensagem no texto. select public.wa_janela_aberta(id)concorda com o que a tela mostra para 5 conversas escolhidas ao acaso.- Assumir uma conversa de lead do pool: o lead passa a ter
owner_iddo vendedor, existe uma linha emwa_atribuicoescommotivo='assumiu'e uma emaudit_log. Eselect origem_registro from tarefas where lead_id=...mostraprimeiro_contato_whatsappquando não havia tarefa antes — a regra da 0264 preservada. - Promover uma imagem: existe linha em
public.anexoscomcaminhocasandochk_anexos_caminho, o comentário existe emlead_comentarios, e o arquivo abre pela URL assinada.wa_mensagens.anexo_idpreenchido. npm run verify:runtimepassa antes de qualquer publicação (regra 3 do projeto).
Fase 3 — Cérebro de qualificação (72h)
configuracoes.ia_limites.modo = 'sugestao'em produção no dia da estreia — conferido por consulta.- 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. - 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. - A regra de custo é provável:
select finalidade, modelo, count(*) from wa_ia_consumo group by 1,2mostra o modelo barato emclassificacao/extracaoe o caro emresposta/resumo. Se um modelo caro aparecer emclassificacao, a fase não está pronta. - Uma sugestão aprovada sem edição vira
estado='enviada'e um item emwa_envios— a mesma transação. Aprovar sem enfileirar é falha bloqueante. - Desligar a IA de uma conversa (
ia_ativa=false) faz a execução seguinte terminar emestado='descartada'sem nenhuma linha emwa_ia_consumo— a checagem antes da chamada, provada. - Fatos extraídos aparecem em
wa_ia_memoriacommensagem_idpreenchido, e o teste de conflito (dois valores paraqtd_saloes) resulta em um vigente e umsuperado_empreenchido — ouq_wa_ia_memoria_vigentefuncionando.
Fase 4 — Disparo e reengajamento (43h)
- Uma campanha criada por A não pode ser aprovada por A:
chk_wa_campanhas_aprovador_distintolevanta erro. Conferido em transação revertida. - Público montado em
begin ... rollback:total_publicobate com a contagem da consulta equivalente, e todo lead comwa_optout_emaparece comobloqueado_optoutcommotivopreenchido —select count(*) ... where estado='bloqueado_optout' and motivo is nulldevolve 0. - 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. - Pausar no meio e retomar: nenhum destinatário com
estado='enviado'volta parapendente(consulta de conferência devolve 0), e o total enviado não regride. - Um lote de 20 destinatários com
ritmo_por_hora=60leva pelo menos 19 minutos para esvaziar — o ritmo respeitado, medido pormin(enviado_em)emax(enviado_em). - 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. - A palavra
PARARnuma conversa gravaleads.wa_optout_emewa_optout_origem='palavra_chave', e a próxima campanha marca aquele lead comobloqueado_optout.
Fase 5 — Coach pós-reunião (24h)
- Um evento real do Fireflies produz uma linha em
wa_reunioese, no reenvio do mesmo evento, nenhuma linha nova (uq_wa_reunioes_fireflies). - 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'ecomentario_idpreenchido. - 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 nulldevolve 0 — nada é publicado no lead errado. - Próximo passo com data vira tarefa com
origem_registro='coach_pos_reuniao',vence_emna data indicada eowner_iddo vendedor da reunião. 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.
| Fase | Faixa | Primeiros arquivos previstos |
|---|---|---|
| Corrente do CRM (correções em paralelo) | 0277–0299 | Não pertencem a este projeto |
| Fase 0 — Fundação | 0300–0309 | 0300_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 vencida | 0310–0319 | 0310_wa_aviso_parcela_vencida.sql · 0311_wa_cron_parcelas.sql |
| Fase 2 — Caixa de entrada | 0320–0339 | 0320_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ção | 0340–0354 | 0340_wa_ia_execucoes.sql · 0341_wa_ia_memoria.sql · 0342_wa_ia_sugestoes.sql · 0343_wa_ia_cron.sql |
| Fase 4 — Disparo | 0355–0369 | 0355_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ão | 0370–0379 | 0370_wa_reunioes.sql · 0371_wa_reuniao_publicar.sql |
| Reserva — correções das seis fases | 0380–0389 | Só 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
0380–0389e 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 fora | Por quê | Custo se entrar |
|---|---|---|---|
| 1 | Grupos de WhatsApp | A API oficial não permite. A Cloud API não envia nem recebe mensagem de grupo. Não é decisão nossa, é ausência de recurso | Impossível com a decisão 2 (API oficial). Só com biblioteca não-oficial, que está descartada |
| 2 | Editar e apagar mensagem enviada | A 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 |
| 3 | Raspagem de contatos do WhatsApp | Decisã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 ativo | Não entra. Não é questão de horas |
| 4 | Coaching 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 |
| 5 | Editor 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 |
| 6 | Camada 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 |
| 7 | Tickets (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 |
| 8 | Múltiplos números de WhatsApp | Fora 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 |
| 9 | Mesclar leads duplicados automaticamente | Os 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 |
| 10 | Migration de saneamento de telefone | Sã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 |
| 11 | Chatbot 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 |
| 12 | Ligação por WhatsApp / chamada de voz | A Cloud API não oferece. Fora de alcance pela decisão 2 | Impossível hoje |
Arraste a tabela para o lado
09Riscos abertos e o que confirmar
| # | Item | Como confirmar | Bloqueia |
|---|---|---|---|
| 1 | Erro 1102 da Cloudflare — CPU ou memória | wrangler 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 carga | Fase 2. Fases 0 e 1 foram desenhadas sem rota nova justamente para não depender disso |
| 2 | Número WABA, phone_number_id, waba_id, tier e qualidade | Business Manager da Meta, conta do cliente | Fase 0 |
| 3 | Se o número WhatsApp está sob o mesmo App do Lead Ads | Business Manager. Se sim, META_APP_SECRET é reusado; se não, segredo novo | Fase 0 |
| 4 | Prazo real de retenção do media_id na v21.0 | Documentação oficial + um teste com mídia real, medindo quando o download passa a falhar | Texto da tela na Fase 2 |
| 5 | Limiar de desativação de webhook por lentidão | Documentação oficial; não há número público estável | Ajuste do teto de 3s em wa-webhook |
| 6 | Esquema de assinatura do webhook do Fireflies | Capturar headers de um evento real e conferir contra _shared/hmac.ts:20-27. Se não assinar, decidir alternativa antes da Fase 5 | Fase 5 |
| 7 | Texto de aceite do formulário de captação (para o backfill de consentimento) | Ler o formulário vivo + docs/integracao-wordpress-fluentforms.md | Backfill da Fase 0; sem ele, público menor na Fase 4 |
| 8 | Provedor de IA, modelo barato e modelo caro, e o teto mensal em dólar | Decisão do Victor. O valor de 30 em ia_limites.teto_usd_mes é um lugar-comum, não uma decisão | Fase 3 |
| 9 | Tempo de aprovação de modelo na Meta | Submeter o modelo de parcela vencida cedo, na Fase 0, e medir | Fase 1 |
| 10 | Se algo entre 0267 e 0276 alterou objeto citado aqui | Ler 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 0274 | Redação final das migrations |
Arraste a tabela para o lado