Devocional Diário via WhatsApp
Devocionais diários em texto e áudio entregues direto no WhatsApp do assinante.
39,174 lines335,489 words46 sectionsgenerated in 3h 38mAug 25, 2026
Palavra Diária — Especificação Técnica Completa #
Devocional cristão diário em texto e áudio, entregue no WhatsApp. Modelo freemium, cobrança recorrente pela Asaas, painel próprio do assinante e painel administrativo editorial. Mercado brasileiro, português do Brasil.
Versão 1.0 — Final
Visão Geral do Documento #
Este documento especifica, de ponta a ponta, um sistema que entrega um devocional cristão todos os dias às 06:00 (America/Sao_Paulo) no WhatsApp do assinante, em texto e em áudio narrado. O plano gratuito recebe o devocional em texto uma vez por semana, aos domingos. O plano pago recebe texto e áudio todos os dias.
Ele foi escrito para ser executado por um agente de IA trabalhando sozinho, sem poder fazer perguntas. Toda decisão de produto, de arquitetura, de dados, de segurança e de operação já está tomada e registrada aqui. Onde havia mais de um caminho razoável, a escolha foi feita e justificada; onde havia restrição externa — as regras de mensagem do WhatsApp, o modelo de cobrança da Asaas, a LGPD — a restrição é descrita antes da solução que a atende.
Três decisões estruturam o resto do documento e vale conhecê-las antes de ler:
- A entrega do áudio contorna uma restrição real da plataforma. A API do WhatsApp não permite enviar áudio como primeira mensagem do dia, e não permite texto com parágrafos dentro de um template. O sistema resolve isso com o desenho "Template + Janela": um convite curto por template às 06:00 abre a janela de atendimento de 24 horas, e o devocional completo e o áudio seguem como mensagens livres dentro dessa janela. A Seção 17 descreve o desenho e o que aconteceria em cada alternativa descartada.
- Falha de pagamento encerra o acesso pago na hora, sem carência. Essa é uma regra de negócio explícita do produto, não um efeito colateral de implementação. Ela é diferente do cancelamento voluntário, em que o assinante mantém o acesso até o fim do período que já pagou. A Seção 13 separa os dois casos com linhas do tempo.
- Cada assunto tem uma seção dona. O schema do banco é da Seção 6; o contrato de API é da Seção 7; a matriz de planos é da Seção 13; as variáveis de ambiente são da Seção 26. As demais seções referenciam a dona em vez de repetir a definição, para que nada divirja.
Como ler. Um executor deve começar pela Seção 30, que explica a ordem de leitura e as regras invioláveis, e depois seguir o plano de milestones da Seção 28. Um leitor de negócio encontra o essencial nas Seções 1, 2 e 13. A Seção 1 lista as decisões que podem ser trocadas antes da execução, cada uma já com um padrão que vale caso ninguém responda.
Índice #
- Antes de Começar — Decisões Configuráveis
- Visão Geral do Produto e Objetivos
- Personas, Papéis e Matriz de Permissões
- Arquitetura e Stack Tecnológica
- Convenções de Código e Padrões de Projeto
- Modelo de Dados e Schema do Banco
- Design de API — Contratos, Erros e Padrões
- Autenticação, Sessões e Autorização
- Landing Page e Página de Vendas — Especificação Funcional
- Copy Aprovado — Página de Vendas e Mensagens ao Assinante
- Cadastro, Opt-in e Onboarding do Assinante
- Integração de Pagamentos — Asaas
- Assinaturas: Ciclo de Vida, Inadimplência e Entitlements
- Painel do Assinante
- Painel Administrativo e Calendário Editorial
- Geração de Áudio (TTS) e Pipeline de Mídia
- Integração WhatsApp Business Cloud API
- Motor de Envio Diário
- Catálogo de Mensagens e Fluxos Conversacionais
- Cancelamento, Opt-out e Retenção
- Dashboard de Métricas e Analytics
- Segurança, Privacidade e LGPD
- Observabilidade, Logs, Métricas e Alertas
- Estratégia de Testes e QA
- Infraestrutura, Deploy e CI/CD
- Registro de Configuração e Variáveis de Ambiente
- Tratamento de Erros, Resiliência e Runbooks Operacionais
- Plano de Execução e Milestones
- Critérios de Aceitação
- Instruções para o Agente Executor
- Glossário e Referências Externas
1. Antes de Começar — Decisões Configuráveis #
Esta seção existe para uma finalidade única: permitir que o dono do produto ajuste o serviço antes da primeira linha de código, sem abrir nenhuma outra seção do documento. Cada item abaixo é uma pergunta fechada, com um padrão já decidido. Nada aqui bloqueia a execução.
1.1 Como usar esta seção #
- Leia as perguntas de 1.3 a 1.30. Responda apenas as que você quer mudar.
- O que não for respondido entra em produção com o padrão indicado.
- O executor registra cada resposta no local indicado em "Onde impacta". Há três locais
possíveis, e a escolha do local não é livre:
- Variável de ambiente — valores de infraestrutura, credenciais e chaves de operação. Catálogo normativo na Seção 26.
- Tabela
settings— valores de negócio que o administrador pode mudar em produção sem novo deploy (preço exibido, dia do envio gratuito, limites de reenvio, texto de consentimento). Estrutura da tabela na Seção 6. - Código versionado — identidade visual, textos de interface, tokens de tema.
- Nenhuma resposta desta seção altera o schema do banco, os contratos de API ou a máquina de estados da assinatura. Se uma resposta parecer exigir isso, ela está fora do escopo do MVP e deve ser tratada como pedido de mudança, não como configuração.
1.2 Regra de não bloqueio #
Ausência de resposta nunca interrompe a execução. O executor aplica o padrão listado, anota a decisão no
README.mddo repositório sob o título "Decisões configuráveis aplicadas" e segue para a próxima tarefa. Perguntar ao dono do produto e esperar resposta é comportamento proibido nesta fase.
1.3 Q01 — Nome comercial do serviço #
- Pergunta: qual é o nome comercial exibido ao público?
- Por que importa: aparece na landing, nos e-mails, no nome de exibição do WhatsApp, nos termos de uso e no cabeçalho dos painéis. Trocar depois exige revisar textos aprovados e reenviar templates para aprovação da Meta.
- Onde impacta: Seção 9 (landing), Seção 10 (copy aprovado), Seção 17 (nome de exibição
do remetente). Código:
apps/web/src/i18n/pt-BR.ts, chavebrand.name. - Padrão: Palavra Diária. O identificador técnico do monorepo e dos artefatos
permanece
palavra-diariamesmo que o nome comercial mude, para evitar renomeação de imagens Docker, buckets e filas.
1.4 Q02 — Domínio e subdomínios #
- Pergunta: qual domínio de produção e qual a divisão de subdomínios?
- Por que importa: define emissão de certificado TLS, cookies de sessão, URLs de webhook
registradas na Asaas e na Meta, e o
canonicalde SEO. Mudar depois exige reconfigurar os dois provedores externos e invalidar sessões. - Onde impacta: Seção 9 (SEO e rotas públicas), Seção 12 (URL do webhook da Asaas),
Seção 17 (URL do webhook da Meta), Seção 25 (Caddy e TLS), Seção 26 (
APP_URL). - Padrão:
palavradiaria.com.brpara o site público,app.palavradiaria.com.brpara os painéis do assinante e administrativo,api.palavradiaria.com.brpara webhooks e API pública. Cookie de sessão com prefixo__Host-, portanto semDomaine restrito ao host que o emitiu (ver Seção 8).
1.5 Q03 — Paleta de cores #
- Pergunta: quais são as cores da marca?
- Por que importa: todo o tema é gerado a partir de tokens; contraste insuficiente reprova os critérios de acessibilidade WCAG 2.2 AA exigidos na Seção 9.
- Onde impacta: Seção 9 e Seção 14. Código:
apps/web/src/styles/theme.css(tokens CSS consumidos pelo Tailwind) eapps/web/src/components/ui/*. - Padrão: primária
#1F4E5F(azul-petróleo), secundária#C8873B(âmbar), fundo claro#FAF8F4, texto#1A1A1A, sucesso#2E7D5B, erro#B3261E, aviso#8A6100. Todos os pares texto/fundo validados com contraste mínimo de 4,5:1 para texto normal e 3:1 para texto grande e elementos de interface. Tema escuro derivado por inversão de luminância, não por cores novas.
1.6 Q04 — Tipografia #
- Pergunta: quais famílias tipográficas, e elas são auto-hospedadas?
- Por que importa: fonte externa adiciona uma requisição de terceiro na rota crítica da landing e conflita com a meta de desempenho da página de vendas (Seção 9). Fonte de leitura ruim prejudica o texto do devocional no painel.
- Onde impacta: Seção 9 (desempenho e SEO). Código:
apps/web/app/layout.tsxeapps/web/src/styles/theme.css. - Padrão: títulos em Fraunces (serifada, peso 600), corpo em Inter (peso 400/600),
ambas auto-hospedadas em
apps/web/public/fonts/comfont-display: swape subset latin-ext. Tamanho base de 17 px no corpo do devocional, altura de linha 1,7.
1.7 Q05 — Logo, favicon e imagem de compartilhamento #
- Pergunta: existe logo pronto? Em quais formatos?
- Por que importa: ausência de logo trava a landing, o cabeçalho dos painéis, o
og:imagee o perfil comercial do WhatsApp. - Onde impacta: Seção 9 (Open Graph), Seção 17 (perfil comercial). Código:
apps/web/public/brand/logo.svg,logo-mark.svg,favicon.ico,og-default.png. - Padrão: marca tipográfica gerada com a tipografia de Q04 sobre a cor primária de Q03,
mais um símbolo de sol nascente em traço único. Entregues em SVG, PNG 512×512 (perfil do
WhatsApp exige imagem quadrada) e PNG 1200×630 para
og:image.
1.8 Q06 — Preço do plano mensal #
- Pergunta: qual o valor mensal em reais?
- Por que importa: define o
valueenviado à Asaas na criação da assinatura, o MRR e a viabilidade do custo por assinante. Alterar o preço não altera assinaturas já ativas. - Onde impacta: Seção 12 (criação da assinatura na Asaas), Seção 13 (planos vigentes),
Seção 21 (MRR). Dados: registro
plan_monthlyna tabelaplans(Seção 6), semeado empackages/db/prisma/seed.ts. - Padrão: R$ 19,90/mês, cobrança recorrente, sem período de teste gratuito.
1.9 Q07 — Preço do plano anual #
- Pergunta: qual o valor anual e qual o desconto implícito?
- Por que importa: o plano anual reduz o efeito de cartão negado e de churn involuntário, que é alto no Brasil, mas antecipa receita e aumenta o custo de reembolso.
- Onde impacta: Seção 12, Seção 13, Seção 21. Dados: registro
plan_annualemplans. - Padrão: R$ 199,00/ano, equivalente a R$ 16,58/mês, desconto de 16,7% sobre doze mensalidades. O número 16,7% é interno: o rótulo exibido ao público é sempre arredondado para baixo, portanto "economize 16%", para nunca prometer mais do que se entrega (regra de arredondamento na Seção 9.5). A página de planos usa "economize 16%" e "economize 2 meses" como chamada secundária.
1.10 Q08 — Formas de pagamento aceitas #
- Pergunta: aceitar cartão de crédito, PIX, ambos?
- Por que importa: cartão permite recorrência automática; PIX exige gerar uma cobrança nova a cada ciclo e depende de ação do assinante, o que muda o desenho de lembretes.
- Onde impacta: Seção 12 (fluxos de checkout), Seção 13 (renovação e inadimplência), Seção 19 (mensagens de lembrete).
- Padrão: CREDIT_CARD e PIX. Boleto fica fora do MVP por causa do prazo de compensação, que é incompatível com a regra de revogação imediata da Seção 13.
1.11 Q09 — Dia da semana do envio gratuito #
- Pergunta: em que dia o assinante do plano gratuito recebe seu devocional semanal?
- Por que importa: define o pico de volume semanal, o custo de mensagens do dia e a janela em que a conversão para o plano pago é mais provável.
- Onde impacta: Seção 13 (entitlements), Seção 18 (cálculo do lote diário). Dados:
chave
send.free_tier_weekdayna tabelasettings(catálogo na Seção 26.8.1). Não existe variável de ambiente equivalente: o dia do envio gratuito é decisão de negócio e vive exclusivamente emsettings. - Padrão: domingo (
0, no padrão em que domingo é 0 e sábado é 6). Exemplo: em um cenário de 7.000 assinantes gratuitos e 3.000 pagos, o domingo dispara 10.000 envios e os demais dias, 3.000.
1.12 Q10 — Horário do envio diário #
- Pergunta: a que horas o devocional chega?
- Por que importa: é a promessa central do produto. Também define o horário do planejamento do lote e a janela de manutenção, que não pode colidir com o envio.
- Onde impacta: Seção 18 (motor de envio), Seção 23 (alertas de atraso). Dados: chaves
send.daily_hour_localesend.plan_offset_minutesemsettings(Seção 26.8.1). - Padrão: 06:00 no fuso America/Sao_Paulo, com o job de planejamento do lote às
05:40 local. O fuso é sempre o nomeado
America/Sao_Paulo; deslocamento numérico fixo é proibido (Seção 30.3, regra 13). O sistema não oferece horário personalizado por assinante — isso é explicitamente fora de escopo (ver Seção 2.7).
1.13 Q11 — Versão bíblica utilizada #
- Pergunta: qual tradução da Bíblia será citada nos devocionais?
- Por que importa: traduções modernas amplamente conhecidas são obras licenciadas. Publicar versículos delas em um produto pago, sem contrato, é risco jurídico real.
- Onde impacta: Seção 15 (editor de conteúdo, campo
bible_version), Seção 22 (risco jurídico e direitos autorais). Dados: chavecontent.bible_version_defaultemsettings(Seção 26.8.1). - Padrão:
ALMEIDA_1911— a Almeida Revista e Corrigida na edição de 1911, em domínio público — comBIBLIA_LIVRE(Almeida Livre / Bíblia Livre) como alternativa aceita. A siglaARCé proibida como código e como atribuição: no mercado brasileiro ela identifica a Almeida Revista e Corrigida em edição revisada por editora ativa, que é obra protegida, e não a edição de 1911. A atribuição impressa em toda entrega éJoão 3:16 (Almeida 1911)ouJoão 3:16 (Bíblia Livre), nunca uma sigla ambígua. O campobible_versioné obrigatório em todo devocional, aceita apenas os valores da lista fechada e o painel administrativo bloqueia a publicação se estiver vazio. Adotar uma tradução licenciada exige contrato assinado antes da publicação, e essa decisão não é configuração — é mudança de escopo.
1.14 Q12 — Provedor de TTS e voz #
- Pergunta: qual provedor de síntese de voz e qual voz específica?
- Por que importa: a voz é a identidade sonora do produto. Trocar depois de o acervo estar publicado gera inconsistência entre devocionais antigos e novos.
- Onde impacta: Seção 16 (pipeline de áudio). Configuração:
TTS_PROVIDER,TTS_VOICE_ID,TTS_MODEL_ID(Seção 26). - Padrão: ElevenLabs como provedor primário, modelo multilíngue corrente, voz
feminina brasileira de timbre grave e ritmo pausado,
stability=0.45,similarity_boost=0.75, saídamp3_44100_128. Google Cloud TTS como fallback automático após três falhas consecutivas do primário. O provedor efetivamente usado é gravado em cada ativo de áudio, para auditoria de custo e de timbre.
1.15 Q13 — Número e nome de exibição do WhatsApp #
- Pergunta: qual número de telefone e qual nome de exibição comercial?
- Por que importa: o nome de exibição passa por aprovação da Meta e não pode ser trocado livremente. O número é a identidade do remetente; perder acesso a ele é perder o canal.
- Onde impacta: Seção 17 (operação do canal), Seção 27 (runbook de bloqueio de número).
Configuração:
WHATSAPP_PHONE_NUMBER_ID,WHATSAPP_BUSINESS_ACCOUNT_ID. - Padrão: um número novo, dedicado e exclusivo do serviço, nunca um número já usado em aplicativo pessoal ou WhatsApp Business comum. Nome de exibição igual ao nome comercial de Q01. O número não recebe ligações e a mensagem de ausência informa o canal de suporte.
1.16 Q14 — Política de reenvio manual pelo painel #
- Pergunta: quantas vezes por dia o assinante pode pedir o reenvio do devocional do dia?
- Por que importa: cada reenvio é uma mensagem paga se estiver fora da janela de 24 horas. Limite alto abre espaço para abuso e para custo imprevisível.
- Onde impacta: Seção 13 (entitlements) e Seção 14 (painel do assinante). Dados: chaves
resend.free_daily_limiteresend.paid_daily_limitemsettings(Seção 26.8.1). - Padrão: 1 reenvio por dia no plano gratuito e 3 por dia no plano pago, contados
por dia de calendário em America/Sao_Paulo e reiniciados à meia-noite local. Exemplo: um
assinante pago que pediu reenvio às 07:10, 12:30 e 19:45 recebe
RESEND_LIMIT_REACHEDna quarta tentativa do mesmo dia e volta a ter três créditos às 00:00 do dia seguinte.
1.17 Q15 — Limite do acervo no plano gratuito #
- Pergunta: quantos dias de histórico o assinante gratuito vê no painel web?
- Por que importa: o acervo é um dos dois principais motivos de conversão, junto com o áudio. Um limite generoso demais remove o incentivo de assinar.
- Onde impacta: Seção 13 (entitlements) e Seção 14 (listagem do acervo). Dados: chave
archive.free_daysemsettings(Seção 26.8.1). - Padrão: últimos 7 dias para o plano gratuito; acervo completo desde a primeira assinatura paga do assinante para o plano pago (regra detalhada na Seção 13.6.1: quem foi pago, deixou de ser e voltou, vê tudo desde a primeira vez). O limite é imposto no servidor, não apenas na interface. Exemplo: em 25/08, o gratuito enxerga devocionais de 19/08 a 25/08; itens anteriores aparecem bloqueados, com chamada para assinar, e não desaparecem da lista — o bloqueio visível converte melhor do que a ausência.
1.18 Q16 — Texto do consentimento LGPD #
- Pergunta: qual o texto exato do aceite exibido no cadastro?
- Por que importa: o consentimento é a base legal do envio de mensagens. Ele é gravado em formato imutável, com versão, e precisa ser exibível anos depois exatamente como foi aceito.
- Onde impacta: Seção 11 (formulário de cadastro), Seção 22 (LGPD e prova de
consentimento). Dados: chave
privacy.policy_versionemsettings(Seção 26.8.1); o texto de cada versão fica emapps/web/src/legal/consent/, versionado no repositório. - Padrão: versão
v1, com o texto: "Autorizo o envio de mensagens no meu WhatsApp com o devocional diário e avisos sobre a minha assinatura. Posso cancelar a qualquer momento respondendo SAIR. Li e aceito os Termos de Uso e a Política de Privacidade." O aceite é registrado com data e hora, endereço IP, agente de usuário, canal e versão do texto. Alterar o texto exige criarv2— nunca editarv1no lugar.
1.19 Q17 — Encarregado de dados (DPO) e canal do titular #
- Pergunta: quem é o encarregado e qual e-mail público?
- Por que importa: a LGPD exige indicação de encarregado com canal de contato divulgado. A ausência é infração autônoma, independente de vazamento.
- Onde impacta: Seção 22 (privacidade e direitos do titular), Seção 9 (rodapé e página
de privacidade). Configuração: chave
privacy.dpo_emailemsettings(Seção 26.8.1). - Padrão:
privacidade@palavradiaria.com.br, com resposta em até 15 dias corridos. Esta seção é a dona do valor padrão (Seção 30.2); o seed grava exatamente este endereço emprivacy.dpo_email. O nome do encarregado é publicado na página de privacidade. Enquanto não houver indicação formal de terceiro, o encarregado é o sócio responsável pela operação.
1.20 Q18 — Razão social, CNPJ e endereço nos termos #
- Pergunta: qual entidade jurídica figura nos termos, na política de privacidade e no recibo de pagamento?
- Por que importa: contrato de adesão sem parte identificada é inexequível, e a Asaas vincula a conta de recebimento a um CNPJ ou CPF específico.
- Onde impacta: Seção 9 (páginas legais), Seção 12 (conta de recebimento). Dados: chave
única
privacy.legal_entityemsettings(Seção 26.8.1), do tipoJSON, com os camposrazaoSocial,cnpjeendereco. - Padrão: os campos são preenchidos no seed com os dados reais da empresa antes do
primeiro deploy de produção. Em ambientes local e de homologação, o padrão é
Palavra Diária Conteúdo Digital LTDA, CNPJ00.000.000/0001-00e endereço fictício, claramente marcados como dados de teste. Regra dura: o deploy de produção falha na verificação de sanidade descrita na Seção 25 se qualquer campo deprivacy.legal_entityainda estiver com valor de teste.
1.21 Q19 — Canal de suporte e SLA #
- Pergunta: qual o e-mail de suporte e qual o prazo prometido?
- Por que importa: o prazo aparece na página de planos como diferencial do plano pago e vira promessa contratual.
- Onde impacta: Seção 9 (comparativo de planos), Seção 13 (entitlements), Seção 19
(respostas automáticas). Dados: chave
support.emailemsettings(Seção 26.8.1). O prazo prometido não é uma chave de configuração: ele é texto aprovado da Seção 10 e valor da matriz de entitlements da Seção 13. - Padrão:
suporte@palavradiaria.com.br. Plano gratuito: FAQ e e-mail sem prazo prometido. Plano pago: resposta em 1 dia útil. O robô do WhatsApp não faz atendimento humano; ele responde com o FAQ e encaminha o contato por e-mail.
1.22 Q20 — Idioma da interface administrativa #
- Pergunta: o painel administrativo fica em português ou em inglês?
- Por que importa: o painel do assinante é obrigatoriamente em português. O painel administrativo é usado por editores brasileiros, mas manter dois idiomas dobra o custo de texto.
- Onde impacta: Seção 15 (painel administrativo), Seção 5.14 (arquivo único de textos).
- Padrão: português do Brasil em toda a interface, incluindo o painel administrativo. Rótulos de dados técnicos que espelham valores do sistema (nomes de estado, códigos de erro da Meta, identificadores) permanecem em inglês, exatamente como são gravados, e ganham um texto explicativo em português ao lado. Não há alternância de idioma no MVP.
1.23 Q21 — Provedor de e-mail transacional e domínio remetente #
- Pergunta: qual serviço envia e-mails e de qual domínio?
- Por que importa: e-mail é canal secundário — recibo, magic link de acesso alternativo, aviso de falha de pagamento. Sem SPF, DKIM e DMARC configurados, esses e-mails vão para spam e o assinante perde o acesso alternativo.
- Onde impacta: Seção 8 (magic link), Seção 13 (avisos de cobrança), Seção 25 (registros
DNS). Configuração:
RESEND_API_KEY,EMAIL_FROM. - Padrão: Resend, remetente
Palavra Diária <nao-responda@palavradiaria.com.br>, com SPF, DKIM e DMARC em políticaquarantineno domínio de envio. Endereço de resposta apontando para o suporte de Q19.
1.24 Q22 — Provedor de armazenamento de mídia #
- Pergunta: onde ficam os arquivos de áudio?
- Por que importa: o áudio é o ativo mais caro de produzir. Perder o acervo significa regerar tudo e pagar TTS de novo.
- Onde impacta: Seção 16 (pipeline de mídia), Seção 25 (backup). Configuração:
S3_ENDPOINT,S3_BUCKET,S3_REGION,S3_ACCESS_KEY_ID,S3_SECRET_ACCESS_KEY. - Padrão: qualquer serviço compatível com S3, acessado pelo SDK oficial da AWS para
S3 (ver Seção 4). Padrão operacional: bucket privado em provedor com egresso gratuito ou
barato, chaves no formato
devotionals/{YYYY}/{MM}/{devotionalId}/{voice}.{ext}, URLs assinadas com validade de 15 minutos para o player web. Armazenamento em disco local do servidor é explicitamente rejeitado, porque impede restaurar o serviço em outra máquina.
1.25 Q23 — Região de hospedagem #
- Pergunta: o servidor fica no Brasil ou fora?
- Por que importa: latência para o assinante, custo do servidor e leitura de conforto regulatório. A LGPD não exige hospedagem no país, mas hospedar no Brasil elimina a necessidade de justificar transferência internacional na política de privacidade.
- Onde impacta: Seção 22 (transferência internacional), Seção 25 (provisionamento).
- Padrão: região no Brasil (São Paulo) para o servidor de aplicação e o banco. O armazenamento de mídia pode ficar fora do país, desde que a política de privacidade declare a transferência e o bucket permaneça privado. Backups replicados para uma segunda região, sempre cifrados.
1.26 Q24 — Janela de manutenção #
- Pergunta: em que horário são aplicadas migrações e reinícios planejados?
- Por que importa: manutenção durante o planejamento ou o disparo do lote diário faz o produto quebrar sua única promessa.
- Onde impacta: Seção 25 (deploy), Seção 23 (silenciamento de alertas), Seção 27 (runbooks).
- Padrão: terças e quintas, das 14:00 às 16:00 (America/Sao_Paulo). Nenhum deploy planejado entre 05:00 e 08:00 em qualquer dia. Correções de emergência são permitidas a qualquer hora, com registro no diário de incidentes.
1.27 Q25 — Canal de alertas operacionais #
- Pergunta: para onde vão os alertas de falha?
- Por que importa: alerta que ninguém lê é alerta inexistente. O produto tem um único momento crítico por dia e precisa de aviso ativo se ele falhar.
- Onde impacta: Seção 23 (observabilidade e alertas), Seção 27 (runbooks). Configuração:
OPS_WEBHOOK_URLeOPS_WEBHOOK_SECRET(Seção 26.3). - Padrão: webhook de entrada em um grupo dedicado do Telegram ou do Slack, com duplicação por e-mail para o endereço de plantão em alertas de severidade crítica. O canal recebe também um resumo diário do lote às 06:30, mesmo quando tudo dá certo — silêncio absoluto impede distinguir "sem problema" de "monitoramento quebrado".
1.28 Q26 — Palavras-chave de saída e de retorno #
- Pergunta: quais palavras o assinante pode enviar para sair, e qual para voltar?
- Por que importa: reconhecer o pedido de saída é obrigação regulatória e de política da Meta. Falhar nisso derruba a qualidade do número.
- Onde impacta: Seção 19 (fluxos conversacionais), Seção 20 (opt-out). Dados: chaves
send.optout_keywordsesend.optin_keywordsemsettings(Seção 26.8.1). - Padrão: saída por sete palavras —
SAIR,PARAR,PARE,CANCELAR,STOP,DESCADASTRARouREMOVER; retorno porVOLTAR,RETORNAR,QUERO VOLTARouREATIVAR. A normalização canônica é a funçãonormalizeKeyword()da Seção 20.3.2, que remove acentos e pontuação, colapsa espaços e compara em maiúsculas; nenhuma seção reimplementa essa regra. A comparação exige correspondência da mensagem inteira, não de um trecho. Exemplo:" sair, por favor "não é reconhecido como saída porque a mensagem inteira não corresponde à palavra, e cai no fluxo de ajuda com um botão explícito de cancelamento;" Sair "é reconhecido. O reconhecimento das palavras de saída tem precedência absoluta sobre qualquer outro processamento de mensagem de entrada (Seção 19.5).
1.29 Q27 — Analytics de produto e cookies #
- Pergunta: haverá ferramenta de análise no site, e ela usa cookies?
- Por que importa: ferramenta com cookies exige banner de consentimento e mais texto legal. Sem cookies, o banner deixa de ser necessário.
- Onde impacta: Seção 21 (analytics), Seção 22 (cookies e consentimento).
- Padrão: análise sem cookies e sem identificador persistente, auto-hospedada, com agregação por página e origem. Nesse desenho o site exibe apenas um aviso informativo, não um banner de consentimento com bloqueio. Se o dono do produto quiser uma ferramenta baseada em cookies, o banner com bloqueio prévio passa a ser obrigatório.
1.30 Q28 — Retenção de registros de mensagem #
- Pergunta: por quanto tempo guardar o histórico de mensagens enviadas e recebidas?
- Por que importa: o volume de registros de mensagem cresce mais rápido que qualquer outra tabela. Retenção longa aumenta custo de banco e de backup; retenção curta destrói a capacidade de investigar disputas de entrega.
- Onde impacta: Seção 6 (particionamento e expurgo), Seção 22 (retenção e anonimização).
Dados: chave
privacy.message_log_retention_monthsemsettings(Seção 26.8.1). - Padrão: 18 meses para registros de mensagem, com partição mensal e expurgo automático da partição mais antiga. Registros de consentimento e de eventos de pagamento têm retenção de 5 anos, por obrigação legal e fiscal, e não seguem esta chave. Backups têm retenção própria de 35 dias.
1.31 Tabela-resumo — Pergunta, Padrão e Onde muda #
| # | Pergunta | Padrão aplicado sem resposta | Onde muda |
|---|---|---|---|
| Q01 | Nome comercial | Palavra Diária | apps/web/src/i18n/pt-BR.ts → brand.name |
| Q02 | Domínio e subdomínios | palavradiaria.com.br, app., api. |
APP_URL (Seção 26) + Caddy (Seção 25) |
| Q03 | Paleta de cores | Azul-petróleo #1F4E5F / âmbar #C8873B |
apps/web/src/styles/theme.css |
| Q04 | Tipografia | Fraunces + Inter, auto-hospedadas | apps/web/app/layout.tsx |
| Q05 | Logo e imagens de marca | Marca tipográfica gerada + símbolo | apps/web/public/brand/ |
| Q06 | Preço mensal | R$ 19,90 | plans.plan_monthly (seed, Seção 6) |
| Q07 | Preço anual | R$ 199,00, rótulo "economize 16%" | plans.plan_annual (seed, Seção 6) |
| Q08 | Formas de pagamento | CREDIT_CARD e PIX | Seção 12 |
| Q09 | Dia do envio gratuito | Domingo | send.free_tier_weekday |
| Q10 | Horário do envio | 06:00 America/Sao_Paulo | send.daily_hour_local e send.plan_offset_minutes |
| Q11 | Versão bíblica | ALMEIDA_1911 (domínio público) |
content.bible_version_default; campo devotionals.bible_version (Seção 15) |
| Q12 | Provedor e voz de TTS | ElevenLabs, voz PT-BR grave; fallback Google | TTS_PRIMARY_PROVIDER, TTS_VOICE_ID |
| Q13 | Número e nome no WhatsApp | Número novo dedicado, nome = Q01 | WHATSAPP_PHONE_NUMBER_ID |
| Q14 | Limite de reenvio manual | 1/dia gratuito, 3/dia pago | resend.free_daily_limit, resend.paid_daily_limit |
| Q15 | Acervo do plano gratuito | Últimos 7 dias | archive.free_days |
| Q16 | Texto de consentimento | Versão v1 descrita em 1.18 |
privacy.policy_version; textos em apps/web/src/legal/consent/ |
| Q17 | Encarregado de dados | privacidade@palavradiaria.com.br |
privacy.dpo_email |
| Q18 | Razão social e CNPJ | Preenchido antes do deploy de produção | privacy.legal_entity (JSON) |
| Q19 | Suporte e SLA | suporte@palavradiaria.com.br, 1 dia útil no pago |
support.email; prazo no copy da Seção 10 |
| Q20 | Idioma do painel administrativo | Português do Brasil | apps/web/src/i18n/pt-BR.ts |
| Q21 | E-mail transacional | Resend, nao-responda@palavradiaria.com.br |
RESEND_API_KEY, EMAIL_FROM |
| Q22 | Storage de mídia | Bucket privado compatível com S3 | S3_* (Seção 26) |
| Q23 | Região de hospedagem | Brasil (São Paulo) | Seção 25 |
| Q24 | Janela de manutenção | Terça e quinta, 14:00–16:00 | Seção 25 |
| Q25 | Canal de alertas | Webhook de grupo + e-mail em crítico | OPS_WEBHOOK_URL |
| Q26 | Palavras de saída e retorno | SAIR/PARAR/PARE/CANCELAR/STOP/DESCADASTRAR/REMOVER; VOLTAR | send.optout_keywords, send.optin_keywords |
| Q27 | Analytics e cookies | Sem cookies, auto-hospedado | Seção 21 |
| Q28 | Retenção de mensagens | 18 meses | privacy.message_log_retention_months |
Toda chave citada na coluna "Onde muda" segue o formato grupo.chave e existe no catálogo
canônico da Seção 26.8.1 e no seed obrigatório da Seção 6.32.4. Nenhuma outra grafia é
aceita: nome em maiúsculas é variável de ambiente (Seção 26.3), não chave de settings.
1.32 O que esta seção não permite mudar #
As decisões abaixo estão travadas e não são configuráveis. Alterá-las é mudança de escopo, com novo planejamento:
- Identidade do assinante é o número de telefone em E.164 (Seção 8).
- Não há período de carência em caso de falha de pagamento (Seção 13).
- Existem exatamente dois níveis de acesso, gratuito e pago, sem plano intermediário e sem teste gratuito (Seção 13).
- O áudio nunca é enviado como primeira mensagem do dia, por restrição da plataforma (Seção 17 e Seção 18).
- O conteúdo é escrito por um editor humano; não há geração de devocional por inteligência artificial no MVP (Seção 2.7).
- Fuso operacional único, sem horário personalizado por assinante (Seção 18).
2. Visão Geral do Produto e Objetivos #
2.1 O problema #
O cristão brasileiro médio quer começar o dia com uma leitura devocional e não consegue manter o hábito. Três causas se repetem:
- Atrito de acesso. O devocional impresso fica na estante, o aplicativo devocional fica na terceira tela do celular e as notificações são silenciadas junto com todas as outras.
- Ausência de horário. Sem um gatilho externo em horário fixo, a leitura compete com o despertador, o trânsito e o trabalho — e perde.
- Formato único. Quem dirige, cozinha ou cuida de filhos às 6h da manhã não consegue ler. Precisa ouvir. A maioria das soluções entrega só texto.
O resultado é um padrão conhecido: a pessoa baixa o aplicativo, usa por quatro dias e abandona. O problema não é falta de conteúdo — conteúdo devocional é abundante e gratuito. O problema é entrega no lugar certo, na hora certa, no formato que cabe na rotina.
2.2 Proposta de valor #
Palavra Diária entrega o devocional dentro do WhatsApp, todos os dias às 6h da manhã, em texto e em áudio narrado. Não há aplicativo para instalar, conta para criar em outro lugar, nem notificação nova para ignorar. A mensagem chega no mesmo lugar onde a pessoa já conversa com a família.
Três afirmações resumem a promessa:
- Chega sozinho. O assinante não precisa lembrar de abrir nada.
- Cabe na rotina. Texto para quem pode ler, áudio de três a seis minutos para quem só pode ouvir.
- Sai quando quiser. Uma palavra no WhatsApp encerra o envio, sem formulário e sem ligação de retenção.
O plano gratuito existe para provar a promessa: um devocional em texto por semana, aos domingos. O plano pago entrega todos os dias, com áudio e acervo completo. A conversão acontece porque a diferença é sentida no cotidiano, não porque um recurso foi bloqueado artificialmente.
2.3 Público-alvo #
O produto atende dois perfis de comprador. Eles têm a mesma fé e rotinas opostas, e por isso justificam os dois formatos de entrega.
2.3.1 Persona 1 — Regina, 52 anos, auxiliar administrativa (Belo Horizonte/MG) #
- Rotina: acorda às 5h30, prepara o café, sai de casa às 6h40. Usa WhatsApp o dia inteiro, principalmente em grupos de família e da igreja. Instala poucos aplicativos e desconfia de cadastro que pede muitos dados.
- Relação com o conteúdo: frequenta a igreja há vinte anos, tem o hábito de "leitura da manhã" e sente culpa quando fica dias sem ler. Prefere ler a ouvir.
- Por que assina: valoriza o acervo e o hábito diário. Encaminha o devocional para o grupo da família quase todo dia, o que torna a persona também um canal de aquisição.
- Como paga: PIX. Tem cartão de crédito, mas evita usar em serviços recorrentes por receio de esquecer de cancelar.
- O que a faz cancelar: mensagens demais, tom sensacionalista ou promessa de prosperidade.
- Consequência de projeto: o texto completo precisa ser legível e encaminhável no WhatsApp, sem depender de link. A cobrança por PIX precisa ser um caminho de primeira classe, com lembretes antes do vencimento (Seção 12).
2.3.2 Persona 2 — Tiago, 34 anos, motorista de aplicativo (Guarulhos/SP) #
- Rotina: começa a rodar às 5h50. Fica com as mãos ocupadas e os olhos na rua até o meio da tarde. Consome quase tudo em áudio e vídeo.
- Relação com o conteúdo: vai à igreja aos domingos, ouve pregação em áudio no carro. Nunca terminou um plano de leitura escrito.
- Por que assina: o áudio. É a única forma de consumo compatível com o trabalho dele.
- Como paga: cartão de crédito, débito automático, sem pensar no assunto de novo.
- O que o faz cancelar: áudio robótico demais, longo demais ou que não abre no player do WhatsApp.
- Consequência de projeto: o áudio precisa chegar como mensagem de voz nativa, com player e forma de onda, e não como arquivo anexo. Isso determina o formato de codificação (Seção 16) e o desenho de entrega em duas etapas (Seção 18).
As duas personas convergem em um ponto decisivo: nenhuma das duas quer instalar mais um aplicativo. Esse é o eixo do produto, tratado em 2.8.
Os perfis internos que operam o sistema — editor de conteúdo, administrador e proprietário — são detalhados na Seção 3, junto com a matriz de permissões.
2.4 A jornada do assinante #
Em texto corrido: a pessoa chega à página de vendas por indicação, busca ou compartilhamento de um devocional encaminhado. Informa nome e telefone, marca o aceite de consentimento e recebe um código de seis dígitos no WhatsApp. Ao confirmar o código, a conta é criada no plano gratuito e o sistema envia uma mensagem de boas-vindas pedindo confirmação explícita de recebimento. Enquanto essa confirmação não vier, nenhum devocional é enviado. Confirmado o opt-in, o assinante entra na lista do próximo domingo.
No domingo às 6h, o sistema envia o convite do dia. O assinante toca no botão para ler e ouvir, e recebe o texto completo. Se estiver no plano gratuito, não recebe áudio, e a mensagem de fechamento informa que o áudio e os envios diários fazem parte do plano pago. Ao decidir assinar, ele abre o painel, escolhe mensal ou anual, escolhe cartão ou PIX e conclui o pagamento. A confirmação do pagamento chega por webhook, o acesso pago é liberado na hora e o envio diário começa no dia seguinte.
A partir daí, o ciclo é diário: convite às 6h, texto completo e áudio quando o assinante interage, e acervo disponível no painel a qualquer momento. Se o assinante já tiver conversado com o número nas últimas 24 horas, o sistema pula o convite e entrega o pacote completo diretamente — caminho mais barato e com menos atrito. Se o pagamento falhar, o acesso pago é revogado no mesmo instante e o assinante volta ao ritmo semanal, sem período de tolerância. Se ele pedir para sair, os envios param imediatamente e a cobrança do ciclo seguinte é suspensa na hora — mas a assinatura não é cancelada de imediato, para que um "SAIR" acidental não destrua a contratação. O painel deixa a distinção explícita: parar mensagens e encerrar a assinatura são ações separadas. Se em 30 dias não houver reativação, a assinatura é encerrada ao fim do período já pago, com aviso. A regra completa está na Seção 13.
[Descoberta] [Cadastro] [Ativação]
indicação, busca, ┌────────────────────────────┐ ┌──────────────────────────┐
devocional │ nome + telefone + aceite │ │ código de 6 dígitos no │
encaminhado ───────► │ LGPD na página de vendas ├──►│ WhatsApp (10 min de TTL) │
└────────────────────────────┘ └────────────┬─────────────┘
│
┌─────────────────────────────────────────────▼─────────────┐
│ boas-vindas no WhatsApp + confirmação ativa de opt-in │
│ nenhum devocional sai antes desta confirmação │
└─────────────────────────────┬────────────────────────────-┘
│
┌────────────▼────────────┐
│ PLANO GRATUITO │
│ domingo 06:00, texto │
│ acervo de 7 dias │
└───┬─────────────────┬───┘
│ │
decide assinar ◄─────┘ └────► segue gratuito
│ (sem prazo)
┌────────────▼─────────────┐
│ checkout: mensal|anual │
│ cartão (recorrente) │
│ ou PIX (ciclo a ciclo) │
└────────────┬─────────────┘
│ webhook de pagamento confirmado
┌────────────▼─────────────┐
│ PLANO PAGO │
│ todo dia 06:00 │
│ texto + áudio narrado │
│ acervo completo │
└────────────┬─────────────┘
│
┌─────────────────────┼─────────────────────┬───────────────────────┐
│ │ │ │
falha de pagamento cancela no painel pede SAIR permanece ativo
│ │ │ │
revogação imediata acesso até o fim do envios param na hora; renovação
→ volta ao gratuito ciclo já pago → cobrança do ciclo automática
(sem carência) depois gratuito seguinte suspensa; a cada ciclo
encerra em 30 dias
sem reativação2.5 O que é o MVP #
Está dentro do MVP, e nada aqui é opcional:
- Página de vendas pública, com amostra de áudio e comparativo de planos.
- Cadastro por telefone com código de acesso entregue no WhatsApp, opt-in em duas etapas e registro imutável de consentimento.
- Dois níveis de acesso: gratuito semanal em texto e pago diário com texto e áudio.
- Painel do assinante: acervo, dados cadastrais, reenvio do devocional do dia, gestão da assinatura, exportação dos próprios dados e exclusão da conta.
- Painel administrativo: calendário editorial, editor de devocional, fluxo de publicação, revisão de áudio, gestão de assinantes, gestão de usuários internos e trilha de auditoria.
- Pipeline de áudio: geração por síntese de voz, transcodificação para mensagem de voz, armazenamento em bucket privado e envio único de mídia ao canal.
- Motor de envio diário com planejamento antecipado, controle de taxa, idempotência, registro de estado por mensagem, reprocessamento seguro e reavaliação do nível de acesso no instante do disparo, e não no planejamento.
- Integração de pagamentos com criação de cliente, assinatura recorrente por cartão, cobrança por PIX, tratamento de webhooks e reconciliação diária.
- Ciclo de vida da assinatura com revogação imediata em caso de falha de pagamento.
- Opt-out por palavra-chave, reativação e a separação explícita entre parar mensagens e cancelar cobrança.
- Dashboard de métricas com as definições da Seção 21.
- Observabilidade: logs estruturados, métricas, alertas e runbooks para os modos de falha conhecidos.
- Conformidade com a LGPD: base legal declarada, direitos do titular atendidos por autoatendimento, retenção definida e encarregado indicado.
2.6 Definição de sucesso #
Metas para os primeiros doze meses de operação. Cada métrica tem definição formal e fórmula na Seção 21; aqui estão apenas os alvos.
| Dimensão | Métrica | Meta em 12 meses | Limite de alerta |
|---|---|---|---|
| Adoção | Assinantes cadastrados | 10.000 | — |
| Adoção | Assinantes pagos | 3.000 | — |
| Ativação | Opt-in confirmado em até 24 h do cadastro | ≥ 70% | < 55% |
| Conversão | Gratuito → pago em até 90 dias do cadastro | ≥ 8% | < 5% |
| Conversão | Escolha do plano anual entre os pagantes | ≥ 25% | — |
| Retenção | Churn mensal de assinaturas pagas | ≤ 6% ao mês | > 9% |
| Retenção | Assinantes pagos ativos após 30 dias | ≥ 85% | < 75% |
| Engajamento | Abertura da janela de conversa no dia do envio | ≥ 45% | < 30% |
| Entrega | Mensagens com status entregue ou lido | ≥ 97% | < 93% |
| Entrega | Lote diário concluído até 06:20 | 100% dos dias | qualquer atraso |
| Operação | Disponibilidade mensal das superfícies web | ≥ 99,5% | < 99,0% |
| Operação | p95 de resposta das rotas de API | < 400 ms | > 700 ms |
| Custo | Custo mensal por assinante pago (mensagens + voz + infraestrutura) | ≤ R$ 3,00 | > R$ 5,00 |
| Reputação | Classificação de qualidade do número no canal | verde | amarelo ou vermelho |
Exemplo numérico do alvo de custo: com preço mensal de R$ 19,90 e custo por assinante pago de R$ 3,00, a margem bruta por assinante é de R$ 16,90, ou 85%. Se a categoria do template diário for reclassificada e o custo por mensagem subir de forma relevante, o gatilho de revisão de preço é o custo mensal por assinante ultrapassar R$ 5,00 — não antes.
2.7 Fora de escopo #
Esta seção é a dona da lista. Nenhum item abaixo é especificado, projetado, prototipado ou "deixado preparado" em qualquer outra seção. Tentativa de implementar qualquer um deles é desvio de escopo e deve ser rejeitada pelo executor.
- Telegram, e-mail ou SMS como canal de entrega do devocional. E-mail existe somente como canal transacional secundário (recibo, acesso alternativo, aviso de cobrança).
- Multi-idioma. O produto é apenas em português do Brasil, interface e conteúdo.
- Recursos sociais entre assinantes. Sem comentários, sem curtidas, sem seguir.
- Horário de envio personalizado por assinante. Fuso e horário são únicos.
- Narração humana gravada. Toda narração é sintetizada.
- Multi-tenant ou white-label para outras igrejas. Uma marca, uma base.
- Geração de devocional por inteligência artificial. O autor é humano, sempre.
- Aplicativo móvel nativo. Nem Android, nem iOS, nem aplicativo web instalável com notificações próprias.
- Pedidos de oração, aconselhamento ou qualquer atendimento pastoral.
- Grupos e comunidades do WhatsApp. A entrega é individual, um a um.
- Boleto bancário como forma de pagamento.
- Emissão automática de nota fiscal.
- Cupons, campanhas promocionais e descontos. Não existe tabela de cupons no modelo de dados, e nenhuma seção pode criá-la.
- Testes A/B de conteúdo devocional.
2.8 Por que WhatsApp e não aplicativo próprio #
A escolha do canal é a decisão de produto mais importante, e é anterior a qualquer decisão técnica. O raciocínio:
A favor do WhatsApp
- Distribuição pronta. As duas personas já usam o aplicativo várias horas por dia. Não há passo de instalação, e o passo de instalação é onde a maioria dos funis morre.
- Entrega ativa e confiável. Mensagem no WhatsApp é lida. Notificação push de aplicativo novo é silenciada, agrupada ou bloqueada pelo sistema de economia de bateria, cenário comum em aparelhos Android de entrada.
- Áudio nativo. O canal já reproduz mensagens de voz com player e forma de onda, em velocidade ajustável, sem que seja preciso escrever um reprodutor.
- Compartilhamento embutido. Encaminhar um devocional para o grupo da família é um gesto de um toque. Isso vira aquisição orgânica sem custo.
- Custo de construção. Um aplicativo nativo em duas plataformas exigiria ciclos de revisão de loja, distribuição de versões e suporte a aparelhos antigos. O canal remove tudo isso.
Contra o WhatsApp, e como o projeto responde
| Limitação | Resposta do projeto |
|---|---|
| Mensagem iniciada pelo negócio fora da janela de conversa exige template aprovado | Entrega em duas etapas: convite por template, conteúdo completo em mensagem livre dentro da janela (Seção 18) |
| Cabeçalho de template não aceita áudio | O áudio nunca é a primeira mensagem do dia; há um caminho alternativo com mídia em vídeo para quem nunca interage (Seção 18) |
| Custo por mensagem iniciada pelo negócio | O caminho preferencial usa a janela já aberta, que não gera custo por mensagem; o custo é monitorado por assinante (Seção 21) |
| Dependência de política de uma única plataforma | Camada de abstração do provedor de mensageria (Seção 4.10), painel web como superfície independente e acervo próprio no banco |
| Interface limitada para gestão de conta | Todo o autoatendimento fica no painel web (Seção 14); o canal serve para entrega e comandos simples |
| Risco de bloqueio ou queda de qualidade do número | Monitoramento ativo de qualidade, respeito estrito a opt-out e runbook de contingência (Seção 27) |
Decisão registrada: o WhatsApp é o canal de entrega; o painel web é a superfície de gestão. Um aplicativo próprio não entra no roteiro enquanto a base for inferior a 50.000 assinantes, porque o custo de aquisição de instalação supera o ganho marginal de controle.
2.9 Contexto de mercado brasileiro #
As afirmações abaixo são premissas de trabalho adotadas no planejamento. Elas orientam prioridade, não substituem pesquisa própria.
- Onipresença do canal. O WhatsApp está instalado em praticamente todos os smartphones brasileiros em uso e é, para boa parte da população, sinônimo de "internet". Para o público das personas, é o primeiro aplicativo aberto de manhã e o último fechado à noite. Nenhum outro canal digital tem esse alcance no país.
- Pagamento instantâneo como padrão. O PIX se consolidou como meio de pagamento cotidiano e é frequentemente preferido ao cartão, sobretudo em faixas de renda média e para valores baixos. Ignorar o PIX em um produto de R$ 19,90 elimina uma parcela relevante de compradores dispostos. Em contrapartida, PIX não tem recorrência automática: cada ciclo exige uma cobrança nova e um lembrete. Isso é custo operacional aceito conscientemente.
- Fragilidade do cartão recorrente. No Brasil, cartões negados, limites baixos e trocas de cartão produzem churn involuntário significativo. O plano anual e o PIX são as duas respostas do produto a esse fato.
- Público cristão numeroso e com hábito de consumo de conteúdo religioso. Uma parcela expressiva da população brasileira se declara evangélica ou católica praticante, e o consumo diário de conteúdo religioso por rádio, vídeo e mensagens já é um comportamento estabelecido. O produto não precisa criar o hábito de consumo — precisa capturar um hábito existente e dar a ele horário e formato.
- Disposição a pagar por conteúdo devocional. Existe precedente de assinatura paga em aplicativos devocionais e em plataformas de conteúdo cristão. O ponto de preço de R$ 19,90/mês fica na faixa de assinaturas de baixo valor com as quais o público já convive, e o plano anual de R$ 199,00 reduz a fricção mensal.
- Sensibilidade de tom. O público reage mal a linguagem de venda agressiva, promessa de prosperidade e uso de culpa. O tom aprovado do produto está na Seção 10 e é uma decisão de produto, não de estilo.
- Custo de mensagens. O preço por mensagem iniciada pelo negócio varia por categoria e é revisado periodicamente pela plataforma. O desenho de entrega em duas etapas existe em boa parte para tornar o custo por assinante previsível mesmo se a categoria mudar. As duas faixas de custo consideradas estão na Seção 17.
2.10 Premissas de negócio #
| # | Premissa | Se for falsa |
|---|---|---|
| P1 | É possível obter aprovação de conta comercial e de templates na plataforma de mensageria em prazo compatível com o cronograma | O lançamento atrasa; o trabalho segue com número de teste (Seção 28) |
| P2 | O template de devocional diário é aceito na categoria de utilidade | O custo por mensagem sobe; o caminho de janela aberta passa a ser essencial e a meta de custo de 2.6 é revista |
| P3 | Existe capacidade editorial para produzir ao menos 30 devocionais por mês com antecedência de 14 dias | O calendário editorial fica exposto no painel com alerta de lacuna (Seção 15) |
| P4 | A qualidade da voz sintetizada é aceita pelo público como narração adequada | Aumenta o churn do plano pago; a resposta é trocar a voz, não gravar narração humana (fora de escopo) |
| P5 | A operadora de pagamentos entrega webhooks de forma confiável | A reconciliação diária corrige divergências (Seção 12) |
| P6 | O uso de tradução bíblica em domínio público é aceito pelo público | Exige contrato de licenciamento antes de trocar a tradução (Seção 22) |
| P7 | Um único servidor suporta a base do primeiro ano | Escala vertical, depois separação de processos, conforme 4.11 |
2.11 Riscos de produto e mitigação #
| # | Risco | Impacto | Probabilidade | Mitigação |
|---|---|---|---|---|
| R1 | Bloqueio ou restrição do número no canal de mensageria | Crítico — produto para de entregar | Baixa | Opt-out respeitado em segundos, monitoramento de qualidade com alerta imediato, número reserva pré-verificado, runbook na Seção 27 |
| R2 | Reclassificação do template diário para categoria de marketing | Alto — custo por mensagem sobe | Média | Preferir o caminho de janela aberta, medir custo por assinante diariamente, registrar a categoria efetiva retornada pela plataforma |
| R3 | Limitação de entrega por saúde do ecossistema, comum no Brasil | Médio — parte da base não recebe no dia | Média | Adiar o envio e reinserir o assinante no lote seguinte sem contar como falha de entrega (Seção 27) |
| R4 | Baixa taxa de abertura da janela de conversa | Médio — assinantes pagos não recebem áudio | Média | Botão de ação direto no convite, caminho alternativo com mídia em vídeo após três dias sem interação, reforço no painel |
| R5 | Conversão de gratuito para pago abaixo da meta | Alto — inviabiliza a operação | Média | Bloqueio visível do acervo, amostra de áudio na página de vendas, mensagem de fechamento dominical com convite explícito |
| R6 | Churn involuntário por cartão negado | Alto — perda de receita sem intenção do cliente | Alta | Lembretes antes do vencimento, aviso imediato de falha, caminho de pagamento por PIX em um toque, incentivo ao plano anual |
| R7 | Lacuna no calendário editorial | Alto — dia sem conteúdo publicado | Média | Alerta com 7 dias de antecedência, exigência de 14 dias de fila, política de reaproveitamento de devocional do acervo com marcação explícita (Seção 15) |
| R8 | Qualidade da narração sintetizada rejeitada pelo público | Médio | Baixa | Amostra pública na página de vendas antes da compra, voz configurável, provedor de fallback |
| R9 | Reclamação de titular de dados ou notificação de autoridade | Alto — exposição legal | Baixa | Consentimento versionado e imutável, exportação e exclusão por autoatendimento, encarregado indicado (Seção 22) |
| R10 | Uso indevido de tradução bíblica licenciada por um editor | Alto — risco jurídico | Baixa | Campo de versão obrigatório, lista de versões permitidas no painel, aviso no editor |
| R11 | Falha do provedor de síntese de voz no dia do envio | Médio — dia sem áudio | Baixa | Áudio gerado com antecedência, no momento em que o devocional fica pronto, e não no momento do envio; provedor de fallback |
| R12 | Custo de infraestrutura crescendo mais rápido que a receita | Médio | Baixa | Métrica de custo por assinante calculada todo dia, com limite de alerta definido em 2.6 |
| R13 | Encaminhamento em massa do conteúdo pago reduzindo a conversão | Baixo | Alta | Aceito como custo de aquisição: o rodapé de cada devocional convida quem recebeu encaminhado a assinar |
3. Personas, Papéis e Matriz de Permissões #
3.1 Enum canônico de papéis #
O sistema tem exatamente quatro papéis. A lista é fechada. Nenhuma outra seção pode introduzir um quinto papel, e nenhuma funcionalidade pode depender de um papel que não esteja aqui.
// packages/core/src/auth/roles.ts
export const ROLES = ['SUBSCRIBER', 'EDITOR', 'ADMIN', 'OWNER'] as const
export type Role = (typeof ROLES)[number]
/** Ordem de precedência. Índice maior nunca é menos poderoso que índice menor. */
export const ROLE_RANK: Record<Role, number> = {
SUBSCRIBER: 0,
EDITOR: 1,
ADMIN: 2,
OWNER: 3,
}
/** Papéis que existem dentro do painel administrativo. */
export const STAFF_ROLES = ['EDITOR', 'ADMIN', 'OWNER'] as const satisfies readonly Role[]
export type StaffRole = (typeof STAFF_ROLES)[number]O tipo enumerado correspondente é declarado no banco na Seção 6, com o mesmo nome e os
mesmos valores. SUBSCRIBER nunca aparece como papel de usuário interno: a restrição é
aplicada por CHECK constraint na tabela de usuários internos (Seção 6).
Nível de acesso é coisa diferente de papel. Todo assinante tem papel SUBSCRIBER; o que
distingue gratuito de pago é o nível de acesso (FREE ou PAID), resolvido pela função
resolveEntitlements(subscriber) descrita na Seção 13. Nenhum código pode inferir
capacidades a partir do papel para assinantes, nem a partir do nível de acesso para usuários
internos.
3.2 Como cada papel nasce e como morre #
| Papel | Como é criado | Como é removido | Onde vive |
|---|---|---|---|
SUBSCRIBER |
Autocadastro na página pública, com telefone verificado por código de acesso (Seção 11) | Autoexclusão no painel, ou exclusão administrativa; em ambos os casos vira exclusão lógica seguida de anonimização em 30 dias (Seção 22) | Tabela subscribers |
EDITOR |
Criado por ADMIN ou OWNER no painel administrativo, com convite por e-mail e definição de senha na primeira entrada |
Desativado por ADMIN ou OWNER (exclusão lógica); sessões revogadas na hora |
Tabela admin_users |
ADMIN |
Criado ou promovido por OWNER |
Rebaixado ou desativado por OWNER |
Tabela admin_users |
OWNER |
Primeiro registro criado pelo seed inicial do banco; demais promovidos por outro OWNER |
Rebaixado ou desativado por outro OWNER, respeitando a regra do último proprietário em 3.10 |
Tabela admin_users |
Não existe autocadastro de usuário interno. Não existe rota pública que crie um registro em
admin_users. A única exceção é o seed de instalação, que cria um único OWNER a partir de
variáveis de ambiente e exige troca de senha no primeiro acesso.
3.3 Persona operacional — Assinante gratuito #
- Quem é: pessoa cadastrada, com opt-in confirmado, sem assinatura paga ativa.
- O que faz: recebe o devocional semanal aos domingos às 06:00; abre o painel para ler o acervo dos últimos 7 dias; pede um reenvio por dia; edita nome e e-mail; exporta seus próprios dados; exclui a própria conta; assina um plano pago.
- O que não pode fazer: receber áudio; receber devocional em dias que não sejam o dia do envio gratuito; abrir devocionais fora da janela de acervo; pedir mais de um reenvio no mesmo dia; ver dados de qualquer outro assinante; acessar qualquer rota do painel administrativo.
- Cenário concreto: em uma quarta-feira, Regina abre o painel e clica em "reenviar
devocional de hoje". O sistema responde
NO_DEVOTIONAL_FOR_TODAY_ON_FREE_TIER, porque no nível gratuito o dia corrente não tem devocional atribuído a ela. O painel exibe o convite para assinar, com o texto aprovado na Seção 10.
3.4 Persona operacional — Assinante pago #
- Quem é: assinante com assinatura em estado ativo e nível de acesso
PAID. - O que faz: tudo do gratuito, mais: recebe devocional todos os dias; recebe áudio narrado; acessa o acervo completo desde a primeira assinatura paga (Seção 13.6.1); pede até três reenvios por dia; troca de plano mensal para anual; troca a forma de pagamento; consulta recibos; cancela a assinatura.
- O que não pode fazer: pausar a assinatura mantendo o acesso; escolher outro horário de envio; escolher outra voz de narração; baixar o arquivo de áudio fora do player do painel por URL permanente — as URLs são assinadas e expiram em 15 minutos (Seção 16).
- Cenário concreto: Tiago cancela no dia 10, com ciclo pago até o dia 28. Ele continua recebendo áudio até o dia 28 e é rebaixado para gratuito na virada do dia 29. Isso não é período de carência; é o período que ele já pagou. A distinção é normativa e está na Seção 13.
3.5 Persona interna — Editor de conteúdo (EDITOR) #
- Quem é: responsável pela produção do devocional. Perfil editorial, não técnico.
- O que faz: cria devocionais; edita rascunhos; preenche título, referência bíblica, texto do versículo, versão da tradução, reflexão, oração e teaser; agenda a data de publicação no calendário editorial; marca o devocional como pronto, o que dispara a geração de áudio; ouve o áudio gerado; solicita nova geração de áudio; publica o devocional; consulta o histórico de revisões e restaura uma revisão anterior.
- O que não pode fazer: ver telefone, CPF, e-mail ou qualquer dado pessoal de assinante; ver dados de pagamento; alterar preços; criar, editar ou desativar usuários internos; alterar configurações do sistema; ligar ou desligar feature flags; exportar bases de assinantes; disparar reenvio para um assinante específico; alterar templates do canal de mensageria; acessar a trilha de auditoria.
- Cenário concreto: o editor abre a lista de assinantes por curiosidade. A rota retorna
403comcodeFORBIDDEN_ROLE, e o evento é registrado com o identificador do usuário, a rota e o papel exigido. Tentativas repetidas geram alerta operacional (Seção 23).
3.6 Persona interna — Administrador (ADMIN) #
- Quem é: responsável pela operação diária do serviço.
- O que faz: tudo do editor, mais: consulta e edita cadastro de assinantes; consulta
assinaturas, pagamentos e eventos de pagamento; força a reconciliação de uma assinatura
específica contra a operadora de pagamentos; dispara reenvio manual para um assinante;
consulta lotes de envio, tentativas de entrega e registros de mensagem; reprocessa um lote
com falha; consulta mensagens recebidas; gerencia templates do canal de mensageria;
consulta o dashboard de métricas; exporta relatórios; cria e desativa
EDITOR; consulta a trilha de auditoria; consulta execuções de job e entregas de webhook. - O que não pode fazer: criar, promover, rebaixar ou desativar
ADMINouOWNER; alterar preços dos planos; alterar chaves de integração; excluir permanentemente assinantes ou devocionais; alterar registros de consentimento; alterar a trilha de auditoria; alterar configurações marcadas como sensíveis (lista em 3.9). - Cenário concreto: um assinante liga dizendo que não recebeu o devocional. O administrador abre a ficha, vê que a última tentativa de entrega falhou com um código de limitação de entrega do canal, e usa o botão de reenvio manual. A ação é registrada na trilha de auditoria com o identificador do assinante e o motivo informado.
3.7 Persona interna — Proprietário (OWNER) #
- Quem é: responsável final pelo negócio e pelos dados. Tipicamente uma ou duas pessoas.
- O que faz: tudo do administrador, mais: cria, promove, rebaixa e desativa usuários internos de qualquer papel; altera preços e cria novas versões de plano; altera configurações sensíveis; aciona a exclusão definitiva de um assinante a pedido do titular; aprova e executa exportações completas de base; visualiza custos e chaves de integração em forma mascarada; encerra sessões de qualquer usuário.
- O que não pode fazer: nada além do sistema — mas continua sujeito a três limites invioláveis: (a) não pode remover ou rebaixar o último proprietário ativo (3.10); (b) não pode editar nem apagar registros de consentimento e a trilha de auditoria, que são somente-inserção; (c) não pode ler valores em texto claro de segredos de integração, que nunca são devolvidos pela API.
- Cenário concreto: o proprietário decide promover um editor a administrador. A operação exige reautenticação com o segundo fator, grava um registro de auditoria com papel anterior e novo papel, e revoga todas as sessões ativas do usuário afetado, forçando novo login com o papel atualizado.
3.8 Matriz de permissões — papel × recurso × ação #
Legenda: L ler · C criar · E editar · A apagar · X exportar · — nenhuma permissão · p apenas os próprios registros do usuário autenticado.
Onde consta A para assinantes e devocionais, entenda-se exclusão lógica; a exclusão física é feita apenas por rotina de retenção (Seção 22).
| Recurso | SUBSCRIBER | EDITOR | ADMIN | OWNER |
|---|---|---|---|---|
| Devocionais (rascunho) | — | L C E A | L C E A | L C E A |
| Devocionais (publicados) | L (limitado ao acervo do nível) | L C E | L C E | L C E A |
| Revisões de devocional | — | L C | L C | L C |
| Ativos de áudio | L (do próprio acervo, via URL assinada) | L C | L C E | L C E A |
| Templates do canal de mensageria | — | L | L C E | L C E A |
| Assinantes | L p, E p, A p, X p | — | L C E A X | L C E A X |
| Perfil do assinante (nome, e-mail, CPF) | L p, E p | — | L E | L E |
| Registros de consentimento | L p, X p | — | L X | L X |
| Sessões | L p, A p | L p, A p | L, A | L, A |
| Códigos de acesso (OTP) | — | — | L | L |
| Planos | L (público) | L | L | L C E |
| Assinaturas | L p, C p, A p | — | L E | L C E |
| Eventos de assinatura | L p | — | L X | L X |
| Pagamentos | L p | — | L X | L X |
| Eventos de pagamento (bruto) | — | — | L | L X |
| Lotes de envio | — | L | L C E | L C E |
| Tentativas de entrega | — | — | L E | L E |
| Registros de mensagem | L p (resumo, sem dados internos) | — | L X | L X |
| Mensagens recebidas | — | — | L | L X |
| Uploads de mídia | — | L C | L C E | L C E A |
| Usuários internos | — | L p, E p (só o próprio perfil e senha) | L, C (apenas EDITOR), E (apenas EDITOR), A (apenas EDITOR) |
L C E A |
| Trilha de auditoria | — | — | L X | L X |
| Configurações comuns | — | L | L E | L E |
| Configurações sensíveis | — | — | L (mascarado) | L (mascarado), E |
| Feature flags | — | L | L E | L C E A |
| Métricas diárias | — | L (somente conteúdo) | L X | L X |
| Entregas de webhook | — | — | L E (reprocessar) | L E |
| Execuções de job | — | — | L E (reexecutar) | L E |
Esta tabela é a única fonte do papel mínimo de qualquer recurso. O inventário de rotas da Seção 7 e o mapa de telas da Seção 15 derivam dela e nunca a contradizem. Onde uma rota ou uma tela precisou de leitura própria, a resolução é esta, e vale como norma:
| Rota ou tela | Papel mínimo resolvido |
|---|---|
GET /api/admin/subscribers e GET /api/admin/subscribers/{id} |
ADMIN. EDITOR não tem nenhuma permissão sobre assinantes. |
DELETE /api/admin/devotionals/{id} |
EDITOR quando status = 'DRAFT'; OWNER nos demais casos. O papel é resolvido pelo estado do recurso, não pela rota. |
POST /api/admin/admin-users e PATCH /api/admin/admin-users/{id} |
ADMIN quando o papel alvo é EDITOR; OWNER nos demais casos. |
GET /api/admin/settings, PUT /api/admin/settings/{key}, GET /api/admin/feature-flags, PATCH /api/admin/feature-flags/{key} |
ADMIN, respeitado ainda o editable_by de cada linha (Seção 26.8.2). |
PATCH /api/admin/plans/{code} |
OWNER. |
| Tela de envios do painel | EDITOR para leitura; ADMIN para reprocessar e cancelar lote. |
| Tela de configurações do painel | ADMIN para abrir; abas de planos e de administradores exigem OWNER. |
Regras que a tabela não expressa e que valem como norma:
- Toda leitura marcada com p é filtrada no repositório pelo identificador do assinante
contido na sessão, nunca por parâmetro vindo do cliente. Um assinante que troca o
identificador na URL recebe
404comcodeSUBSCRIBER_NOT_FOUND, e não403— para não confirmar a existência do registro alheio. - Nenhuma rota administrativa aceita filtro por telefone completo sem que o usuário tenha
permissão de leitura de assinantes. Busca por telefone parcial exige
ADMIN. - Exportações sempre geram registro na trilha de auditoria, com a quantidade de linhas exportadas e o filtro aplicado.
- Configurações sensíveis são: chaves de integração, token de webhook, segredo de assinatura de sessão, dados da entidade jurídica e limites de taxa de envio. A API nunca devolve o valor em texto claro; devolve apenas os quatro últimos caracteres e um indicador de que o valor está preenchido.
3.9 Catálogo de permissões nomeadas #
A verificação de acesso no código nunca compara papéis diretamente em regra de negócio. Ela compara permissões nomeadas, e o mapa de papel para permissões é único.
// packages/core/src/auth/permissions.ts
export const PERMISSIONS = [
'devotionals:read', 'devotionals:write', 'devotionals:publish', 'devotionals:delete',
'audio:read', 'audio:regenerate',
'templates:read', 'templates:write',
'subscribers:read', 'subscribers:write', 'subscribers:delete', 'subscribers:export',
'billing:read', 'billing:write', 'billing:reconcile',
'delivery:read', 'delivery:resend', 'delivery:reprocess',
'metrics:read', 'metrics:export',
'staff:read', 'staff:write:editor', 'staff:write:any',
'audit:read',
'settings:read', 'settings:write', 'settings:write:sensitive',
'flags:read', 'flags:write',
'jobs:read', 'jobs:write',
] as const
export type Permission = (typeof PERMISSIONS)[number]
const EDITOR_PERMISSIONS: Permission[] = [
'devotionals:read', 'devotionals:write', 'devotionals:publish',
'audio:read', 'audio:regenerate',
'templates:read', 'delivery:read', 'metrics:read',
'settings:read', 'flags:read',
]
const ADMIN_PERMISSIONS: Permission[] = [
...EDITOR_PERMISSIONS,
'devotionals:delete',
'templates:write',
'subscribers:read', 'subscribers:write', 'subscribers:delete', 'subscribers:export',
'billing:read', 'billing:write', 'billing:reconcile',
'delivery:resend', 'delivery:reprocess',
'metrics:export', 'audit:read',
'staff:read', 'staff:write:editor',
'settings:write', 'flags:write',
'jobs:read', 'jobs:write',
]
const OWNER_PERMISSIONS: Permission[] = [
...ADMIN_PERMISSIONS,
'staff:write:any',
'settings:write:sensitive',
]
export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
SUBSCRIBER: [], // assinante nunca usa permissões nomeadas; usa escopo próprio + entitlements
EDITOR: EDITOR_PERMISSIONS,
ADMIN: ADMIN_PERMISSIONS,
OWNER: OWNER_PERMISSIONS,
}
export function can(role: Role, permission: Permission): boolean {
return ROLE_PERMISSIONS[role].includes(permission)
}Consequência prática: para descobrir se um administrador pode reprocessar um lote, o código
pergunta can(session.role, 'delivery:reprocess'). Se amanhã a permissão migrar para
OWNER, muda uma linha, e não vinte rotas. A aplicação dessa verificação nas rotas HTTP é
descrita na Seção 8.
3.10 Escalonamento, rebaixamento e a regra do último proprietário #
Regras normativas, verificadas na camada de serviço e reforçadas por constraint no banco onde possível:
- Ninguém promove a si mesmo. Se
actor.id === target.ide o papel muda, a operação falha comCANNOT_CHANGE_OWN_ROLE. - Ninguém cria alguém acima de si. O papel alvo precisa ter posto menor ou igual ao do
autor. Um
ADMINsó cria e editaEDITOR. Tentativa de criarADMINfalha comINSUFFICIENT_ROLE_TO_ASSIGN. - Ninguém edita alguém acima de si. Um
ADMINque tenta desativar umOWNERrecebeINSUFFICIENT_ROLE_TO_ASSIGN. UmADMINque tenta editar outroADMINrecebe o mesmo erro: papéis de mesmo posto não se administram, exceto no posto de proprietário. - Último proprietário é intocável. Não é possível rebaixar, desativar ou excluir um
OWNERse ele for o único proprietário ativo. A verificação é feita dentro da mesma transação da alteração, com bloqueio de linha, e o erro éLAST_OWNER_PROTECTED. - Reautenticação obrigatória. Qualquer mudança de papel exige que o autor apresente novamente o segundo fator, dentro de uma janela de 5 minutos (Seção 8).
- Revogação de sessão. Alterar o papel de um usuário revoga imediatamente todas as sessões ativas dele. O papel gravado na sessão nunca é atualizado no lugar.
// packages/core/src/staff/change-role.ts (trecho normativo)
export async function assertCanAssignRole(actor: StaffContext, targetRole: StaffRole) {
if (ROLE_RANK[targetRole] > ROLE_RANK[actor.role]) {
throw new AppError('INSUFFICIENT_ROLE_TO_ASSIGN', 403)
}
if (ROLE_RANK[targetRole] === ROLE_RANK[actor.role] && actor.role !== 'OWNER') {
throw new AppError('INSUFFICIENT_ROLE_TO_ASSIGN', 403)
}
}
/** Executada SEMPRE dentro da transação que altera o papel ou desativa o usuário. */
export async function assertNotLastOwner(tx: Tx, targetId: string) {
const owners = await tx.$queryRaw<{ id: string }[]>`
SELECT id FROM admin_users
WHERE role = 'OWNER' AND deleted_at IS NULL AND is_active = true
FOR UPDATE
`
if (owners.length <= 1 && owners.some((o) => o.id === targetId)) {
throw new AppError('LAST_OWNER_PROTECTED', 409)
}
}Exemplo concreto de concorrência: dois proprietários, Ana e Bruno, tentam rebaixar um ao
outro no mesmo segundo. As duas transações disputam o mesmo conjunto de linhas bloqueado por
FOR UPDATE. A primeira a obter o bloqueio rebaixa o outro; a segunda, ao reexecutar a
consulta, encontra um único proprietário ativo, que é o próprio alvo, e falha com
LAST_OWNER_PROTECTED. Nunca existe estado com zero proprietários.
3.11 Trilha de auditoria de mudanças de papel #
Toda alteração de papel, criação de usuário interno, desativação e reativação grava um
registro na trilha de auditoria (tabela admin_audit_log, definida na Seção 6). A trilha é
somente-inserção: não há rota de edição nem de exclusão, e a permissão de escrita do banco
para a aplicação não inclui UPDATE nem DELETE nessa tabela.
Campos gravados nas ações de papel:
Os nomes de coluna são os da Seção 6.9, que é a dona da definição física de admin_audit_log:
não existem colunas actor_id, actor_admin_id, target_type nem target_id.
| Campo | Conteúdo |
|---|---|
actor_type |
ADMIN nestas ações; o enum completo é ADMIN, SUBSCRIBER e SYSTEM |
admin_user_id |
Usuário interno que executou a ação |
actor_role |
Papel do autor no momento da ação |
action |
staff.role_changed, staff.created, staff.deactivated, staff.reactivated; e, no grupo de assinantes, ADMIN_IMPERSONATION_STARTED e ADMIN_IMPERSONATION_ENDED (Seção 23.11.1) |
entity_type |
admin_user |
entity_id |
Usuário afetado |
before |
{ "role": "EDITOR", "isActive": true } |
after |
{ "role": "ADMIN", "isActive": true } |
reason |
Texto livre obrigatório, mínimo de 10 caracteres |
ip |
Endereço de origem |
user_agent |
Agente de usuário |
created_at |
Data e hora em UTC |
Exemplo de registro:
{
"id": "aud_01J9Z8Q6M0Q2W7X3B4C5D6E7F8",
"actorType": "ADMIN",
"adminUserId": "adm_01J9Z0000000000000000OWNER",
"actorRole": "OWNER",
"action": "staff.role_changed",
"entityType": "admin_user",
"entityId": "adm_01J9Z1111111111111111EDITR",
"before": { "role": "EDITOR", "isActive": true },
"after": { "role": "ADMIN", "isActive": true },
"reason": "Assume operação de suporte a partir de setembro.",
"ip": "189.45.10.22",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"createdAt": "2026-08-25T13:41:07.882Z"
}A trilha alimenta uma tela no painel administrativo com filtro por autor, por tipo de ação e
por período, com exportação em CSV disponível para ADMIN e OWNER. Retenção: 5 anos,
igual à dos registros de consentimento (Seção 22).
3.12 Casos de borda de autorização #
| Situação | Comportamento normativo |
|---|---|
| Sessão válida de um usuário interno desativado enquanto navegava | Toda requisição autenticada revalida is_active e deleted_at; a sessão é encerrada e a resposta é 401 com code SESSION_REVOKED |
| Papel alterado durante uma sessão ativa | Sessões revogadas na alteração (3.10, regra 6); a próxima requisição devolve 401 SESSION_REVOKED |
| Assinante tenta acessar rota administrativa | 403 com code FORBIDDEN_ROLE, registrado com nível de log warn |
| Usuário interno tenta acessar rota de assinante com sessão administrativa | 403 com code WRONG_SESSION_AUDIENCE; sessões de assinante e de equipe têm público distinto no token (Seção 8) |
| Assinante excluído logicamente com sessão ativa | 401 SESSION_REVOKED; nenhuma leitura de dados é permitida após a exclusão lógica |
| Editor solicita regeneração de áudio de devocional já enviado | 409 com code DEVOTIONAL_ALREADY_SENT; regeneração só é permitida antes do primeiro envio |
| Administrador tenta exportar base sem filtro | Permitido, com registro de auditoria e limite de 50.000 linhas por exportação; acima disso, o pedido é fatiado por período |
| Requisição sem sessão em rota que exige papel | 401 com code UNAUTHENTICATED, nunca 403 — a distinção entre "não autenticado" e "sem permissão" é obrigatória |
| Usuário interno com segundo fator não configurado | Acesso limitado à tela de configuração do segundo fator; qualquer outra rota devolve 403 com code MFA_SETUP_REQUIRED |
| Administrador em sessão de impersonação tenta qualquer escrita em nome do assinante | Recusado com 403 IMPERSONATION_READ_ONLY, independentemente do prefixo da rota. A trava vale para toda rota cuja guarda seja requireSubscriber, inclusive cancelamento de assinatura, troca de forma de pagamento, troca de plano, reenvio, pedido de eliminação e opt-out. Impersonação é somente leitura, sem exceção (Seção 8.13) |
4. Arquitetura e Stack Tecnológica #
4.1 A arquitetura em um parágrafo #
Um monorepo com três aplicações e três pacotes compartilhados. A aplicação web serve a página de vendas, os dois painéis e todas as rotas HTTP, inclusive os webhooks. O worker executa tudo que é assíncrono ou agendado: planejar o lote do dia, gerar áudio, enviar mensagens, processar eventos de pagamento e reconciliar cobranças. A CLI de operação existe para semear, corrigir e reprocessar sem abrir o banco na mão. Postgres é a única fonte de verdade transacional; Redis é fila e cache, nunca fonte de verdade. Um único servidor com Docker Compose atende o alvo de escala do primeiro ano.
4.2 Stack canônica #
Esta seção é a dona de todas as versões de dependência do projeto. Nenhuma outra seção declara versão; todas referenciam esta tabela.
| Camada | Escolha | Linha de versão |
|---|---|---|
| Runtime | Node.js LTS ("Krypton") | 24.x |
| Linguagem | TypeScript | 7.x |
| Gerenciador de pacotes | pnpm workspaces | 10.x |
| App web (landing + painéis + API HTTP) | Next.js App Router | 16.x |
| UI | React | 19.x |
| Estilo | Tailwind CSS | 4.x |
| Componentes | shadcn/ui (primitivos Radix UI) | corrente |
| Formulários | react-hook-form | 7.x |
| Validação (compartilhada web + worker) | Zod | 4.x |
| Estado de servidor no cliente | TanStack Query | 5.x |
| Gráficos do dashboard | Recharts | 3.x |
| Banco de dados | PostgreSQL | 17.x |
| ORM e migrações | Prisma | 7.x |
| Fila e agendamento | BullMQ sobre Redis | BullMQ 6.x / Redis 8.x |
| Cliente Redis | ioredis | 6.x |
| HTTP interno do worker (health e métricas) | Fastify | 5.x |
| Logs | pino + pino-http | pino 10.x |
| Sessões e JWT | jose | 6.x |
| Hash de senha (usuários internos) | argon2 | 0.4x |
| Segundo fator TOTP | otplib | 13.x |
| Datas e fuso horário | date-fns + date-fns-tz | 4.x |
| Storage de mídia | S3-compatível via @aws-sdk/client-s3 |
3.x |
| Transcodificação de áudio | ffmpeg (binário do sistema operacional) | 7.x |
| Identificadores | ulid |
3.x |
| Telefone | libphonenumber-js |
corrente |
| Testes unitários e de integração | Vitest | 4.x |
| Testes de ponta a ponta | Playwright | 1.6x |
| E-mail transacional | Resend | SDK corrente |
| Containers | Docker e Docker Compose | Engine 27.x ou superior |
| Proxy reverso e TLS | Caddy | 2.x |
| Integração contínua | GitHub Actions | — |
Ferramentas de desenvolvimento sem linha de versão fixada — instale a estável corrente no
momento do build: ESLint com typescript-eslint, Prettier, eslint-plugin-import,
eslint-plugin-jsx-a11y, commitlint com @commitlint/config-conventional, lefthook
para hooks de Git, tsx para execução de scripts TypeScript e dotenv-cli.
Estas linhas de versão são um piso conhecido-bom, não um lockfile. No momento do build, instale a release estável corrente de cada dependência (
pnpm add <pkg>@latest, ou o equivalente do ecossistema), confirme que a linha major ainda corresponde e deixe o lockfile registrar as versões exatas resolvidas.
O pnpm-lock.yaml é versionado e obrigatório. A integração contínua executa
pnpm install --frozen-lockfile; divergência entre o manifesto e o lockfile reprova o
build. Atualizações de dependência entram por commit próprio, com prefixo chore(deps).
4.3 Serviços externos e suas interfaces #
| Serviço | Uso | Base | Autenticação |
|---|---|---|---|
| WhatsApp Business Cloud API (Meta Graph API) | Envio de templates e mensagens livres, upload de mídia, webhooks de entrada e de status | https://graph.facebook.com/v26.0 |
Token de acesso permanente no header Authorization: Bearer |
| Asaas | Cliente, assinatura recorrente, cobrança PIX, webhooks de pagamento | Produção https://api.asaas.com/v3; sandbox https://api-sandbox.asaas.com/v3 |
Header access_token |
| ElevenLabs | Síntese de voz primária | https://api.elevenlabs.io/v1 |
Chave de API em header |
| Google Cloud Text-to-Speech | Síntese de voz de fallback | texttospeech.googleapis.com/v1 |
Conta de serviço |
| Resend | E-mail transacional | SDK oficial | Chave de API |
| Armazenamento compatível com S3 | Ativos de áudio e mídia | Endpoint configurável | Chave e segredo |
A versão da Graph API acima é o piso conhecido-bom. No momento do build, o executor confirma qual é a versão estável corrente aceita pelo endpoint e usa a mais nova, ajustando apenas a constante de versão no cliente tipado. Nenhuma outra parte do código conhece a versão.
4.4 Diagrama de componentes #
Internet
│
┌────────▼─────────┐
│ Caddy (TLS, │ palavradiaria.com.br
│ proxy reverso) │ app.palavradiaria.com.br
└───┬──────────┬───┘ api.palavradiaria.com.br
│ │
┌────────────▼───┐ ┌──▼──────────────────┐
│ apps/web │ │ (mesma aplicação: │
│ Next.js │ │ rotas /api/* │
│ ─ landing │ │ e webhooks) │
│ ─ painel │ └──┬──────────────────┘
│ assinante │ │
│ ─ painel │ │ grava evento cru + enfileira
│ admin │ │
└───┬────────┬───┘ │
│ │ │
│ └──────────┼───────────────┐
│ │ │
┌─────▼─────────┐ ┌─────▼──────────┐ │
│ PostgreSQL │◄──┤ Redis │ │
│ fonte única │ │ filas BullMQ │ │
│ de verdade │ │ + cache leve │ │
└─────▲─────────┘ └─────▲──────────┘ │
│ │ │
│ ┌─────┴───────────────▼────┐
└─────────────┤ apps/worker │
│ treze filas, Seção 18.8 │
│ ─ send.plan (05:40) │
│ ─ send.dispatch │
│ ─ send.followup │
│ ─ whatsapp.inbound │
│ ─ tts.generate │
│ ─ media.upload │
│ ─ billing.webhook │
│ ─ billing.reconcile │
│ ─ billing.lifecycle │
│ ─ metrics.rollup │
│ ─ maintenance.cleanup │
│ ─ email.send │
│ ─ messaging.pause │
│ Fastify: /api/internal/health
│ /api/internal/metrics
└──┬───────┬───────┬───────┘
│ │ │
┌───────────────▼┐ ┌────▼─────┐ ┌▼──────────────────┐
│ WhatsApp Cloud │ │ Asaas │ │ TTS (ElevenLabs / │
│ API (Meta) │ │ API v3 │ │ Google) + S3 │
└────────────────┘ └──────────┘ └───────────────────┘
apps/ops ── CLI executada sob demanda no host,
fala com Postgres, Redis e as integraçõesA lista de filas acima é exatamente a da Seção 18.8, que é a dona: são treze, com nome
único no formato dominio.acao. Nenhuma outra seção usa apelido ou variação. Não existe fila
de mensagens mortas: um job que esgota as tentativas fica no estado failed da própria fila
e grava uma linha em job_runs com status = 'DEAD', que é a dead-letter do sistema.
Regra de fronteira: somente o worker fala com o canal de mensageria e com o provedor de voz. A aplicação web nunca envia mensagem de forma síncrona dentro de uma requisição HTTP. Quando o painel pede um reenvio, ele enfileira um job e responde imediatamente. Isso mantém o p95 das rotas dentro da meta e evita que uma lentidão de terceiro derrube a interface.
4.5 Estrutura do monorepo #
palavra-diaria/
├── apps/
│ ├── web/ # Next.js: público, painéis e API HTTP
│ │ ├── app/
│ │ │ ├── (marketing)/ # rotas públicas: /, /planos, /faq, /termos...
│ │ │ ├── (subscriber)/ # painel do assinante, exige sessão SUBSCRIBER
│ │ │ ├── (admin)/ # painel administrativo, exige sessão de equipe
│ │ │ ├── api/ # route handlers; um arquivo route.ts por recurso
│ │ │ │ ├── public/
│ │ │ │ ├── auth/
│ │ │ │ ├── signups/
│ │ │ │ ├── sessions/
│ │ │ │ ├── me/
│ │ │ │ ├── devotionals/
│ │ │ │ ├── checkout/
│ │ │ │ ├── subscriptions/
│ │ │ │ ├── payments/
│ │ │ │ ├── admin/
│ │ │ │ ├── internal/ # health, ready, metrics, version
│ │ │ │ └── webhooks/ # asaas/route.ts, whatsapp/route.ts
│ │ │ ├── layout.tsx
│ │ │ └── not-found.tsx
│ │ ├── src/
│ │ │ ├── components/
│ │ │ │ ├── ui/ # primitivos shadcn/ui, sem regra de negócio
│ │ │ │ ├── marketing/
│ │ │ │ ├── subscriber/
│ │ │ │ └── admin/
│ │ │ ├── hooks/ # hooks de cliente, TanStack Query
│ │ │ ├── i18n/pt-BR.ts # arquivo único de textos de interface
│ │ │ ├── legal/consent/ # versões do texto de consentimento
│ │ │ ├── lib/ # http, formatadores, guards de sessão
│ │ │ └── styles/theme.css # tokens de cor, tipografia e espaçamento
│ │ ├── public/
│ │ ├── middleware.ts # roteamento por audiência de sessão
│ │ ├── next.config.ts
│ │ └── package.json
│ ├── worker/ # processos assíncronos
│ │ ├── src/
│ │ │ ├── queues/ # definição de filas e opções de retry
│ │ │ ├── jobs/ # um arquivo por fila; nome do arquivo = nome da fila
│ │ │ │ │ # com ponto trocado por hífen (Seção 18.8)
│ │ │ │ ├── send-plan.ts
│ │ │ │ ├── send-dispatch.ts
│ │ │ │ ├── send-followup.ts
│ │ │ │ ├── whatsapp-inbound.ts
│ │ │ │ ├── tts-generate.ts
│ │ │ │ ├── media-upload.ts
│ │ │ │ ├── billing-webhook.ts
│ │ │ │ ├── billing-reconcile.ts
│ │ │ │ ├── billing-lifecycle.ts
│ │ │ │ ├── metrics-rollup.ts
│ │ │ │ ├── maintenance-cleanup.ts
│ │ │ │ ├── email-send.ts
│ │ │ │ └── messaging-pause.ts
│ │ │ ├── scheduler.ts # registro dos jobs repetíveis com fuso
│ │ │ ├── http.ts # Fastify: /api/internal/health, /api/internal/ready, /api/internal/metrics
│ │ │ └── main.ts
│ │ └── package.json
│ └── ops/ # CLI de operação
│ ├── src/commands/ # seed, backfill, resend, health, template-sync
│ └── package.json
├── packages/
│ ├── db/ # Prisma: schema, client, migrações, seeds
│ │ ├── prisma/
│ │ │ ├── schema.prisma
│ │ │ ├── migrations/
│ │ │ └── seed.ts
│ │ └── src/index.ts # singleton do client + helpers de transação
│ ├── core/ # domínio puro, sem I/O
│ │ └── src/
│ │ ├── auth/ # papéis, permissões, contexto de sessão
│ │ ├── entitlements/ # resolveEntitlements e limites por nível
│ │ ├── subscription/ # máquina de estados da assinatura
│ │ ├── devotional/ # estados editoriais, teaser, roteiro de narração
│ │ ├── delivery/ # cálculo do lote, chaves de idempotência
│ │ ├── phone.ts # normalização E.164 e variantes brasileiras
│ │ ├── errors.ts # AppError e catálogo de códigos
│ │ ├── logger.ts # fábrica pino e regras de redação
│ │ ├── schemas/ # Zod compartilhado entre web e worker
│ │ └── time.ts # helpers de fuso America/Sao_Paulo
│ └── integrations/ # clientes tipados de terceiros
│ └── src/
│ ├── whatsapp/ # provider, tipos, mapeamento de erros
│ ├── asaas/
│ ├── tts/ # elevenlabs.ts, google.ts, provider.ts
│ ├── storage/ # cliente S3 e URLs assinadas
│ └── email/
├── docker/ # Dockerfile.web, Dockerfile.worker, Caddyfile
├── .github/workflows/ # ci.yml, deploy.yml
├── docker-compose.yml
├── docker-compose.prod.yml
├── .env.example
├── pnpm-workspace.yaml
├── package.json # scripts raiz: dev, build, test, lint, typecheck
├── tsconfig.base.json
└── README.mdDecisão registrada: não há Turborepo, Nx ou qualquer orquestrador de build no MVP. Com
três aplicações e três pacotes, os filtros nativos do gerenciador de pacotes
(pnpm --filter web build) resolvem o problema sem adicionar uma camada de cache para
manter. Se o tempo de build ultrapassar cinco minutos na integração contínua, a decisão é
revista.
4.6 Responsabilidade de cada aplicação #
apps/web — superfície HTTP única. Serve HTML das páginas públicas e dos painéis, e
expõe todas as rotas /api/*, incluindo os webhooks. Responsabilidades exclusivas: renderizar
interface, validar entrada, autenticar e autorizar, escrever no banco o que é síncrono por
natureza (cadastro, aceite de consentimento, criação de assinatura) e enfileirar tudo que
é lento. Proibições: chamar o canal de mensageria; chamar o provedor de voz; executar
transcodificação; rodar laços sobre a base inteira.
apps/worker — dono de todo trabalho assíncrono, agendado e de longa duração. Registra
os jobs repetíveis com fuso America/Sao_Paulo, consome as filas, aplica limite de taxa,
retries com espera exponencial e idempotência. Expõe um servidor HTTP interno mínimo com
verificação de saúde e métricas, acessível apenas na rede interna do Compose. É o único
processo com credenciais do canal de mensageria e do provedor de voz.
apps/ops — comandos de operação executados sob demanda: semear planos e usuário
inicial, sincronizar templates com a plataforma, reprocessar um lote, reenviar para um
assinante, recalcular métricas de um período, verificar saúde das integrações, exportar
dados de um titular. Toda operação destrutiva exige confirmação explícita por argumento e
grava registro na trilha de auditoria com autor ops-cli e o usuário do sistema
operacional.
4.7 Responsabilidade de cada pacote #
packages/db — dono do schema e do acesso ao banco. Exporta o client singleton, o
helper de transação e os tipos gerados. Contém as migrações e os seeds. Nenhuma aplicação
instancia o client por conta própria; todas importam daqui, o que garante um único pool de
conexões por processo.
packages/core — domínio puro, sem entrada e sem saída. Regras de nível de acesso,
máquina de estados da assinatura, estados editoriais, normalização de telefone, cálculo do
lote diário, chaves de idempotência, classe de erro, catálogo de códigos, fábrica de logger
e todos os schemas Zod. Regra dura: este pacote não importa packages/db nem
packages/integrations, não faz chamada de rede, não lê variável de ambiente e não usa
relógio global sem receber o instante por parâmetro. Isso o torna testável sem infraestrutura
e é o que permite que web e worker apliquem exatamente a mesma regra.
packages/integrations — clientes tipados de terceiros. Cada integração expõe uma
interface e uma implementação. Traduz erros de terceiros para o catálogo de erros interno,
aplica tempo limite, retry e registro estruturado. Nunca contém regra de negócio: decidir
se uma mensagem deve ser enviada é do domínio; como enviar é daqui.
O grafo de dependências entre pacotes é acíclico e verificado por regra de lint:
core ◄── db core ◄── integrations
▲ ▲
└────── web ────────────┘
└────── worker ─────────┘
└────── ops ────────────┘4.8 Fluxos de ponta a ponta #
Os quatro caminhos abaixo são os que definem a arquitetura. O detalhamento de cada passo está nas seções donas; aqui interessa quem chama quem, o que é síncrono e onde está a fronteira transacional.
4.8.1 Cadastro e opt-in #
Navegador apps/web Postgres Redis/Fila worker Canal
│ │ │ │ │ │
│ POST /api/signups │ │ │ │
├───────────────►│ valida Zod │ │ │ │
│ │ normaliza telefone│ │ │ │
│ │ (E.164 validado │ │ │ │
│ │ ANTES de cifrar) │ │ │ │
│ ├── TX ────────────►│ subscriber (PENDING_VERIFICATION) │
│ │ │ consent_event (append-only) │ │
│ │ │ otp_code (hash, TTL 10 min) │ │
│ │◄── commit ────────┤ │ │ │
│ ├───────────────────┼──────────────►│ send.dispatch │ │
│◄─ 202 {status} │ │ ├───────────────►│ template │
│ │ │ │ ├─────────────►│
│ POST /api/signups/verifications │ │ │ │
├───────────────►│ confere hash e tentativas │ │ │
│ ├── TX ────────────►│ marca telefone verificado │ │
│ │ │ cria sessão (30 dias) │ │
│◄─ 200 + cookie │ │ │ │ │
│ │ │ │ send.dispatch │ │
│ │ │ ├───────────────►│ boas-vindas │
│ │ │ │ ├─────────────►│
│ │ (assinante responde SIM ou toca no botão) │ │
│ │ webhook de entrada ──────────────────────────────►│ whatsapp. │
│ │ │ │ inbound │
│ │ │◄─ opt_in_confirmed_at ─────────┤ │
│ │ │ nível FREE, elegível ao envio │ │Regra crítica: enquanto opt_in_confirmed_at for nulo, o assinante não entra em nenhum
lote. O cadastro é reversível e não gera cobrança.
4.8.2 Pagamento e liberação do acesso #
Painel apps/web Asaas Postgres Fila worker
│ │ │ │ │ │
│ POST /api/me/subscription │ │ │ │
├───────────────►│ valida CPF e plano │ │ │
│ ├── TX ──────────────────────────► │ subscription (PENDING_PAYMENT)
│ │◄─ commit ─────────────────────── │ │ │
│ ├─ POST /v3/customers ──►│ │ │ │
│ ├─ POST /v3/subscriptions ►│ externalReference = id nosso │
│ │◄─ id da assinatura ─────┤ │ │ │
│◄─ 200 {checkout│ ou QR PIX} │ │ │ │ │
│ │ │ │ │ │ │
│ (assinante paga) │ │ │ │ │
│ │ POST /api/webhooks/asaas │ │ │
│ │◄────────────────┤ PAYMENT_CONFIRMED │ │
│ ├── TX ──────────────────────────► │ payment_event (dedupe por id)
│ ├───────────────────────────────────┼─────────────►│ billing.webhook
│ ├─ 200 em < 2 s ─►│ │ │ │ │
│ │ │ │ │ ├── TX ───────►│
│ │ │ │ subscription ACTIVE + tier PAID │
│ │ │ │ subscription_event + payment │
│ │ │ │ │ │ notifica canalO handler do webhook faz o mínimo: autentica, persiste o evento cru, enfileira e responde.
Todo o efeito colateral acontece no worker, de forma idempotente por identificador de
evento. O motivo é operacional: a operadora pausa a fila de webhooks após falhas
consecutivas, e um handler lento vira uma falha. Pela mesma razão, corpo inválido nunca
produz resposta não-2xx: autenticado o remetente, qualquer falha posterior responde 200
e é persistida para reprocessamento, conforme a Seção 7.15.1. A única exceção é 413 para
corpo acima do limite, medido antes da leitura.
4.8.3 Envio diário #
05:40 scheduler (job repetível, fuso nomeado America/Sao_Paulo)
│
├─► send.plan ── chave de idempotência plan:{data}
│ ├─ carrega devocional publicado da data
│ ├─ se não houver → alerta crítico e encerra sem enviar
│ ├─ seleciona destinatários:
│ │ PAID → todos com opt-in confirmado e sem opt-out
│ │ FREE → somente no dia do envio gratuito
│ ├─ cria send_batch + uma delivery_attempt por assinante
│ └─ enfileira send.dispatch com atraso até 06:00
│
06:00 send.dispatch (limite de 20 mensagens por segundo)
│ ├─ RELÊ o assinante por chave primária, imediatamente antes de chamar
│ │ o provedor: deleted_at, blocked_at, opt_out_at e tier
│ │ deleted_at/blocked_at → encerra como SKIPPED_INELIGIBLE
│ │ opt_out_at → encerra como SKIPPED_OPTED_OUT
│ │ tier FREE em item planejado como PAID → rebaixa na hora,
│ │ entrega só o texto, nunca o áudio
│ ├─ janela de conversa aberta?
│ │ sim → envia texto completo; se PAID, envia áudio; envia fechamento
│ │ não → envia template de convite com dois botões
│ ├─ grava message_log (status inicial: sent)
│ └─ erro conhecido do canal → política própria por código (Seção 27)
│
06:00+ webhooks de status ─► whatsapp.inbound ─► atualiza message_logs
│
06:30 resumo do lote no canal de alertas: planejados, enviados, falhos, custo estimado4.8.4 Resposta do assinante #
Assinante toca em "Ler e ouvir agora"
│
├─► Canal ─── POST /api/webhooks/whatsapp ───► apps/web
│ ├─ valida assinatura HMAC do corpo cru
│ ├─ grava inbound_message
│ ├─ enfileira whatsapp.inbound
│ └─ responde 200 imediatamente
│
└─► worker: whatsapp.inbound
├─ localiza assinante: wa_id_hmac → phone_hmac → variantes de nono dígito
├─ PRIMEIRA operação sobre a mensagem, antes de qualquer outra:
│ palavra de saída → opt_out_at, confirma uma única vez, para tudo.
│ Precedência absoluta, válida em qualquer estado do assinante,
│ inclusive em atendimento humano ou modo silencioso (Seção 19.5)
├─ atualiza service_window_expires_at = agora + 24 h
├─ classifica o restante:
│ payload OPEN_DEVOTIONAL → entrega o pacote do dia
│ payload SNOOZE → reagenda para 12:00 do mesmo dia
│ palavra de retorno → limpa opt_out_at, confirma
│ texto livre → responde com o menu de ajuda
└─ toda entrega respeita o nível de acesso relido no instante do envio4.9 Decisões de arquitetura e seus custos #
| # | Decisão | Alternativa descartada | Por quê | O que custa |
|---|---|---|---|---|
| A1 | Next.js servindo interface e API na mesma aplicação | API separada em Fastify | Um artefato, um deploy, tipos compartilhados sem contrato duplicado; o volume de rotas não justifica dois serviços | Rotas de API herdam o ciclo de vida do framework de interface; mitigado por manter as rotas finas |
| A2 | Worker em processo separado | Executar jobs dentro da aplicação web | Envio diário e transcodificação são longos e travariam requisições; escalar e reiniciar independente é requisito | Mais um processo para operar e observar |
| A3 | BullMQ sobre Redis | cron do sistema chamando um script |
Precisamos de retry com espera exponencial, limite de taxa, idempotência, jobs atrasados, visibilidade de falha e reprocessamento. cron não oferece nada disso e não sobrevive a reinício no meio do lote |
Redis vira dependência de operação; exige política de memória sem despejo |
| A4 | PostgreSQL como única fonte de verdade | Coleção de documentos ou banco gerenciado exótico | Dados fortemente relacionais, necessidade de transação em várias tabelas na revogação de acesso, e agregações analíticas simples | Escala vertical antes de horizontal |
| A5 | Prisma como ORM e ferramenta de migração | SQL puro | Tipos gerados, migrações versionadas e verificação em tempo de compilação; consultas críticas usam SQL bruto onde o plano importa | Camada extra; consultas complexas exigem escape para SQL |
| A6 | ULID gerado na aplicação | UUID v4 ou chave serial | Ordenável no tempo, o que preserva localidade de índice em tabelas de alto volume; gerado antes da ida ao banco, o que permite montar grafos de objetos e chaves de idempotência sem ida e volta; não expõe contagem de registros como serial | 26 caracteres em vez de 16 bytes; sem tipo nativo no banco |
| A7 | Armazenamento compatível com S3 | Disco local do servidor | O acervo de áudio é o ativo mais caro; disco local impede restaurar o serviço em outra máquina e complica backup | Egresso e latência de rede; mitigado por gerar o áudio uma vez por devocional |
| A8 | Canal de mensageria oficial, sem intermediário | Provedor de solução (BSP) | Ver 4.10 | Menos suporte humano em incidente |
| A9 | Um servidor com Docker Compose | Orquestrador de containers ou plataforma gerenciada | Alvo de escala do primeiro ano cabe folgadamente em uma máquina; a complexidade operacional de um orquestrador não se paga | Reinício do host derruba tudo; mitigado com verificação de saúde, reinício automático e restauração ensaiada |
| A10 | Redis com política sem despejo | Cache com despejo automático | Fila e cache dividem a instância; despejo silencioso apagaria jobs | Uso de memória precisa ser monitorado com alerta |
| A11 | Fuso operacional único | Horário por assinante | Simplifica o motor de envio, o planejamento e os testes; personalização está fora de escopo | Assinante fora do fuso recebe em horário estranho — aceito |
4.10 Canal oficial em vez de intermediário, e a camada que permite trocar #
Decisão: integrar diretamente à API oficial do canal de mensageria, sem provedor intermediário.
Motivos: custo por mensagem sem margem de terceiro; ausência de assinatura mensal fixa, relevante enquanto a base é pequena; acesso imediato a recursos novos, sem esperar o intermediário implementar; controle direto sobre o cadastro e a aprovação de templates; e um elo a menos na cadeia de entrega, o que reduz a superfície de falha do momento mais crítico do dia.
O que se perde: suporte humano em incidente, painéis prontos de análise, filas gerenciadas e ferramentas de atendimento. Como o produto é de entrega, não de atendimento, a perda é aceitável.
Gatilhos objetivos para migrar para um intermediário, decididos desde já: volume mensal acima de 500.000 mensagens; necessidade de mais de um número na mesma operação; ou dois incidentes de suspensão de conta em doze meses. Nenhum deles exige reescrever o motor de envio, por causa da abstração abaixo.
// packages/integrations/src/whatsapp/provider.ts
export interface WhatsAppProvider {
sendTemplate(input: SendTemplateInput): Promise<SendResult>
sendText(input: SendTextInput): Promise<SendResult>
sendAudio(input: SendAudioInput): Promise<SendResult>
uploadMedia(input: UploadMediaInput): Promise<{ mediaId: string; expiresAt: Date }>
markAsRead(messageId: string): Promise<void>
parseInboundWebhook(rawBody: string, signature: string): InboundEvent[]
}
export type SendResult =
| { ok: true; providerMessageId: string; conversationCategory: string | null }
| { ok: false; error: ProviderError }
/** Erro já traduzido para o vocabulário interno; o motor nunca vê código bruto. */
export interface ProviderError {
kind:
| 'OUTSIDE_SERVICE_WINDOW'
| 'UNDELIVERABLE'
| 'DELIVERY_THROTTLED'
| 'TEMPLATE_PROBLEM'
| 'RATE_LIMITED'
| 'ACCOUNT_PROBLEM'
| 'UNKNOWN'
providerCode: string
retryable: boolean
retryAfterMs?: number
message: string
}O motor de envio (Seção 18) depende apenas desta interface. Trocar de provedor significa
escrever uma segunda implementação e mudar uma linha de composição no início do worker. A
tradução de códigos de erro do canal para ProviderError.kind fica dentro da implementação,
e é o único lugar do sistema que conhece números de erro de terceiros. O mesmo padrão vale
para voz (TtsProvider, Seção 16) e para pagamentos.
4.11 Limites de escala do desenho #
Dimensionamento de referência: 10.000 assinantes no primeiro ano, sendo 3.000 pagos; arquitetura sustentada até 50.000 sem mudança estrutural. Uma máquina com 4 vCPU e 8 GB de memória atende com folga.
Contas que sustentam a afirmação: 3.000 mensagens a 20 por segundo levam 2,5 minutos de chamadas ao canal; a janela de envio permitida é de 20 minutos, o que deixa margem de oito vezes para retries. No domingo, 10.000 mensagens levam cerca de 8,5 minutos. O áudio é gerado uma vez por devocional, não por assinante: 30 gerações por mês, independentemente do tamanho da base. O volume diário de registros de mensagem em 50.000 assinantes fica na casa de 150.000 linhas por dia, o que a partição mensal absorve.
| Limite | Sinal de que foi atingido | O que muda |
|---|---|---|
| ~50.000 assinantes | Lote diário passando de 15 minutos | Aumentar o limite de taxa de envio e rodar dois processos de worker consumindo a mesma fila; nenhuma mudança de código |
| Postgres saturado em escrita | Espera de escrita acima de 50 ms no p95 | Mover o banco para instância própria e separar réplica de leitura para o dashboard |
| Redis acima de 70% da memória | Alerta de memória | Reduzir retenção de jobs concluídos e mover cache para instância separada |
| Volume acima de 500.000 mensagens/mês | Custo por assinante acima do limite de alerta | Reavaliar provedor de mensageria (4.10) e negociar volume |
| Mais de um número de origem | Necessidade de segmentar por marca ou região | Introduzir seleção de número por lote; exige tocar no motor de envio |
| Registros de mensagem acima de 100 milhões de linhas | Consulta de auditoria lenta | Mover partições antigas para armazenamento frio e manter apenas 6 meses ativos |
| Equipe editorial acima de cinco pessoas | Conflito de edição no mesmo devocional | Introduzir bloqueio otimista visível na interface; o histórico de revisões já existe |
O que não muda em nenhum desses cenários: a fonte única de verdade continua sendo o banco relacional; o áudio continua sendo gerado uma vez por devocional; e o envio continua idempotente por assinante e data.
4.12 Topologia de processos #
| Processo | Réplicas no MVP | Reinício | Verificação de saúde |
|---|---|---|---|
caddy |
1 | sempre | porta 443 respondendo |
web |
1 | sempre | GET /api/internal/health retornando 200 |
worker |
1 | sempre | GET /api/internal/health na porta interna |
postgres |
1 | sempre | pg_isready |
redis |
1 | sempre | PING |
As migrações são aplicadas na inicialização do container da aplicação web, com bloqueio de advertência no banco para impedir execução concorrente. O worker aguarda o banco estar na versão esperada antes de consumir filas; se a versão divergir, ele registra erro e não consome, para evitar processar com schema antigo. Detalhes de deploy, imagens e volumes estão na Seção 25.
5. Convenções de Código e Padrões de Projeto #
5.1 Princípios #
- Regra de negócio mora no domínio. Rota HTTP e job de fila são cascas finas.
- Uma regra, um lugar. Nível de acesso, normalização de telefone, estados da assinatura e chaves de idempotência existem uma única vez, no domínio compartilhado.
- Validação na borda, tipos por dentro. Toda entrada externa passa por Zod. Depois disso, o código confia nos tipos.
- Erro é dado, não exceção genérica. Todo erro previsível tem código estável.
- Explícito vence esperto. Sem metaprogramação, sem abstração antecipada, sem herança profunda.
5.2 Convenções de nomes #
| Elemento | Convenção | Exemplo |
|---|---|---|
| Arquivo TypeScript | kebab-case.ts |
send-dispatch.ts, resolve-entitlements.ts |
| Componente React | PascalCase.tsx, um componente exportado por arquivo |
DevotionalCard.tsx |
| Pasta | kebab-case, singular para domínio, plural para coleções de rota |
src/subscription/, app/api/signups/ |
| Tabela Postgres | snake_case plural |
message_logs, delivery_attempts |
| Coluna | snake_case; booleano com prefixo is_ ou has_; data e hora com sufixo _at; data pura com sufixo _on ou nome explícito |
is_active, opt_in_confirmed_at, scheduled_for |
| Modelo do ORM | PascalCase singular com mapeamento explícito para a tabela |
model MessageLog { @@map("message_logs") } |
| Tipo enumerado | SCREAMING_SNAKE_CASE nos valores, PascalCase no nome |
SubscriptionStatus.PENDING_PAYMENT |
| Campo JSON de API | camelCase; conversão só na borda, nunca no banco |
optInConfirmedAt |
| Rota HTTP | kebab-case, sem verbo; coleção é substantivo no plural |
/api/admin/devotionals, /api/admin/whatsapp-templates |
| Ação sobre a própria conta do assinante | Sob /api/me/, com nome de ação no singular |
/api/me/opt-out, /api/me/pause, /api/me/deletion-request, /api/me/subscription/cancel |
| Código de erro | SCREAMING_SNAKE_CASE, estável para sempre |
RESEND_LIMIT_REACHED |
| Fila e job | dominio.acao em minúsculas, da lista fechada de treze da Seção 18.8 |
send.dispatch, billing.reconcile |
| Arquivo de job | Nome da fila com o ponto trocado por hífen | send-dispatch.ts para send.dispatch |
| Variável de ambiente | SCREAMING_SNAKE_CASE com prefixo do serviço |
WHATSAPP_PHONE_NUMBER_ID |
| Chave de configuração em banco | grupo.chave, minúsculas, ponto como separador |
archive.free_days, ops.kill_switch |
| Campo monetário | Inteiro em centavos de BRL, sufixo _amount_cents na coluna e AmountCents no JSON |
subscriptions.amount_cents, amountCents |
| Custo unitário muito pequeno | Inteiro em milionésimos de BRL, sufixo _cost_micros |
message_logs.cost_micros, audio_assets.cost_micros |
| Função | camelCase, verbo no início |
resolveEntitlements, buildNarrationScript |
| Booleano em código | is, has, can, should |
canResend, hasOpenWindow |
| Constante de módulo | SCREAMING_SNAKE_CASE |
MAX_TEASER_LENGTH |
| Tipo e interface | PascalCase, sem prefixo I |
WhatsAppProvider, SendResult |
| Branch | tipo/escopo-curto |
feat/daily-send-engine |
| Commit | Conventional Commits (5.12) | feat(delivery): pular template com janela aberta |
Proibições explícitas: abreviações inventadas (subsc, dvtnl); prefixo húngaro; sufixo
Manager, Helper ou Util em nome de módulo; arquivo utils.ts genérico — o nome
descreve o que faz (format-currency.ts, sanitize-teaser.ts).
Duas regras de dinheiro que valem em todo o código, sem exceção: nunca use ponto
flutuante para valor monetário, e nunca misture centavos com micros na mesma fórmula sem
conversão explícita. Os divisores são fixos: centavos para reais divide por 100; micros
para reais divide por 1_000_000. A conversão entre o decimal do provedor de pagamento e os
centavos internos acontece em um único arquivo, packages/integrations/src/asaas/mapper.ts.
5.3 Imports e ordem #
Quatro grupos, separados por linha em branco, nesta ordem, com ordenação alfabética dentro de cada grupo. A regra é aplicada por lint com correção automática.
// 1. Node e bibliotecas externas
import { createHash } from 'node:crypto'
import { z } from 'zod'
// 2. Pacotes internos do monorepo
import { prisma } from '@pd/db'
import { AppError, resolveEntitlements } from '@pd/core'
import { whatsapp } from '@pd/integrations'
// 3. Módulos da própria aplicação, por alias absoluto
import { getSessionOrThrow } from '@/lib/session'
import { t } from '@/i18n/pt-BR'
// 4. Módulos irmãos, por caminho relativo curto
import { resendService } from './resend.service'
import type { ResendInput } from './resend.types'Regras adicionais: aliases @pd/* para pacotes e @/* para o interior de cada aplicação,
definidos em tsconfig.base.json; nada de ../../.. — a partir de dois níveis, use alias;
import type obrigatório para importações que só carregam tipos; nenhuma exportação padrão,
exceto onde o framework exige (páginas, layouts e route.ts); nenhum arquivo de reexportação
em massa dentro de uma aplicação, porque quebra o descarte de código morto.
5.4 Estrutura interna de um módulo de domínio #
Um módulo de domínio agrupa arquivos por assunto, nunca por tipo técnico. Exemplo do módulo
de assinatura em packages/core/src/subscription/:
subscription/
├── subscription.types.ts # tipos e uniões discriminadas do domínio
├── subscription.schemas.ts # Zod de entrada e saída deste assunto
├── subscription.errors.ts # códigos de erro específicos do assunto
├── subscription.state.ts # máquina de estados: transições permitidas e guardas
├── subscription.rules.ts # funções puras de regra (ex.: cálculo de fim de ciclo)
├── index.ts # superfície pública do módulo, explícita
└── __tests__/
├── subscription.state.test.ts
└── subscription.rules.test.tsO index.ts exporta apenas o que outros módulos podem usar. Importar um arquivo interno de
outro módulo é erro de lint. Isso mantém a superfície pequena e permite refatorar por dentro
sem quebrar consumidores.
5.5 Camadas: route handler, service, repository #
Três camadas, com responsabilidades sem sobreposição:
| Camada | Faz | Nunca faz |
|---|---|---|
| Route handler | Autentica, valida entrada com Zod, chama um service, formata a resposta no envelope padrão | Consulta o banco; contém if de regra de negócio |
| Service | Orquestra regra de domínio, transação, enfileiramento e efeitos colaterais | Conhece Request, Response, cookies ou cabeçalhos |
| Repository | Traduz entre domínio e banco; consultas e escritas | Decide regra; lança erro de negócio |
Exemplo completo de uma operação real — reenvio manual do devocional do dia pedido pelo assinante no painel. O contrato normativo desta rota está na Seção 7; abaixo ela serve de referência de forma.
// apps/web/app/api/devotionals/[devotionalId]/resends/route.ts
import { NextRequest } from 'next/server'
import { AppError } from '@pd/core'
import { getSubscriberSession } from '@/lib/session'
import { fail, ok } from '@/lib/http'
import { resendTodayDevotional } from '@/server/devotional/resend.service'
import { ResendTodaySchema } from '@/server/devotional/resend.schemas'
export async function POST(req: NextRequest) {
const requestId = req.headers.get('x-request-id') ?? undefined
try {
const session = await getSubscriberSession(req) // lança UNAUTHENTICATED
const body = ResendTodaySchema.parse(await req.json().catch(() => ({})))
const result = await resendTodayDevotional({
subscriberId: session.subscriberId,
channel: body.channel,
requestId,
})
return ok(result, { requestId })
} catch (error) {
return fail(error, { requestId })
}
}// apps/web/src/server/devotional/resend.service.ts
import { AppError, resolveEntitlements, todayInSaoPaulo } from '@pd/core'
import { prisma, withTransaction } from '@pd/db'
import { enqueue } from '@/lib/queue'
import { subscriberRepo } from './subscriber.repo'
import { devotionalRepo } from './devotional.repo'
import { resendRepo } from './resend.repo'
export async function resendTodayDevotional(input: {
subscriberId: string
channel: 'whatsapp'
requestId?: string
}) {
const today = todayInSaoPaulo()
return withTransaction(async (tx) => {
// 1. Carrega o agregado com bloqueio, para tornar a contagem de reenvios segura.
const subscriber = await subscriberRepo.findForUpdate(tx, input.subscriberId)
if (!subscriber) throw new AppError('SUBSCRIBER_NOT_FOUND', 404)
if (subscriber.optOutAt) throw new AppError('SUBSCRIBER_OPTED_OUT', 409)
// 2. Regra de domínio: o que este assinante tem direito de fazer hoje.
const entitlements = resolveEntitlements(subscriber, { now: today })
// 3. Existe devocional atribuído a este assinante hoje?
const devotional = await devotionalRepo.findSentToSubscriberOn(tx, subscriber.id, today)
if (!devotional) {
throw entitlements.tier === 'FREE'
? new AppError('NO_DEVOTIONAL_FOR_TODAY_ON_FREE_TIER', 409)
: new AppError('NO_DEVOTIONAL_FOR_TODAY', 409)
}
// 4. Limite diário de reenvio, contado no fuso operacional.
const used = await resendRepo.countForDay(tx, subscriber.id, today)
if (used >= entitlements.resendLimitPerDay) {
throw new AppError('RESEND_LIMIT_REACHED', 429, {
details: [{ field: 'resend', issue: 'daily_limit', limit: entitlements.resendLimitPerDay }],
})
}
// 5. Registro do consumo dentro da mesma transação: sem corrida, sem crédito extra.
const attempt = await resendRepo.create(tx, {
subscriberId: subscriber.id,
devotionalId: devotional.id,
day: today,
sequence: used + 1,
})
// 6. Efeito colateral fora do banco só depois do commit.
tx.afterCommit(() =>
enqueue('send.dispatch', {
subscriberId: subscriber.id,
devotionalId: devotional.id,
reason: 'MANUAL_RESEND',
idempotencyKey: `resend:${subscriber.id}:${today}:${used + 1}`,
}),
)
return {
status: 'QUEUED' as const,
remainingToday: entitlements.resendLimitPerDay - (used + 1),
attemptId: attempt.id,
}
})
}// apps/web/src/server/devotional/resend.repo.ts
import type { Tx } from '@pd/db'
export const resendRepo = {
countForDay(tx: Tx, subscriberId: string, day: string) {
return tx.deliveryAttempt.count({
where: { subscriberId, deliveryDate: day, reason: 'MANUAL_RESEND' },
})
},
create(tx: Tx, data: { subscriberId: string; devotionalId: string; day: string; sequence: number }) {
return tx.deliveryAttempt.create({
data: {
subscriberId: data.subscriberId,
devotionalId: data.devotionalId,
deliveryDate: data.day,
reason: 'MANUAL_RESEND',
idempotencyKey: `resend:${data.subscriberId}:${data.day}:${data.sequence}`,
status: 'QUEUED',
},
})
},
}Três pontos que o exemplo fixa como norma: a contagem e a gravação do consumo acontecem na mesma transação, com bloqueio de linha, o que elimina a corrida de dois cliques simultâneos; o enfileiramento só ocorre após o commit, o que impede job órfão apontando para um registro que não existe; e a regra de quanto vale o limite vem do domínio, não da rota.
5.6 Validação com Zod #
Os schemas de entrada e saída vivem em packages/core/src/schemas/ quando são compartilhados
entre a aplicação web e o worker, e ao lado do módulo quando são locais. Web e worker
importam o mesmo schema — nunca existem duas definições da mesma forma de dado.
// packages/core/src/schemas/subscriber.schemas.ts
import { z } from 'zod'
import { normalizePhoneBR } from '../phone'
/**
* A validação de formato E.164 acontece **aqui**, na borda, antes de o valor ser cifrado.
* A coluna que guarda o telefone é um envelope cifrado (Seção 6.3) e por isso não pode
* carregar CHECK de formato: o banco não vê o valor. Zod é a única barreira de formato.
*/
export const PhoneBRSchema = z
.string()
.trim()
.min(10, 'Telefone muito curto.')
.transform((raw, ctx) => {
const parsed = normalizePhoneBR(raw)
if (!parsed.ok) {
ctx.addIssue({ code: 'custom', message: 'Informe um celular brasileiro válido com DDD.' })
return z.NEVER
}
return parsed.e164 // sempre +55DDNNNNNNNNN a partir daqui
})
export const RegisterSubscriberSchema = z.object({
name: z.string().trim().min(2).max(80),
phone: PhoneBRSchema,
email: z.email().optional(),
consentAccepted: z.literal(true, { message: 'É necessário aceitar para continuar.' }),
consentVersion: z.string().regex(/^v\d+$/),
})
export type RegisterSubscriberInput = z.infer<typeof RegisterSubscriberSchema>Regras normativas de validação:
- Todo
parseacontece na borda: route handler, consumidor de fila e comando da CLI. Nunca no meio de um service. - Falha de validação vira
VALIDATION_FAILEDcomdetailsderivado das issues do Zod, uma entrada por campo, conforme o envelope de erro da Seção 7. - Payload de job também é validado na entrada do worker. Um job antigo, enfileirado antes de um deploy, pode ter forma incompatível: nesse caso o job falha de forma explícita, em vez de corromper dados.
- Mensagens de validação são escritas em português do Brasil e seguras para exibir ao usuário final.
z.coerceé proibido em entrada vinda da rede. Conversão explícita evita aceitar"0"onde se espera booleano.
5.7 Tratamento de erro no código #
Existe uma única classe de erro de negócio, e ela se chama AppError — em todo o
sistema, em toda seção deste documento e em todo arquivo do repositório. O nome ApiError
é proibido: não existe classe com esse nome, e uma regra de lint
(no-restricted-syntax sobre NewExpression[callee.name='ApiError'] e sobre a declaração da
classe) reprova o build se ele aparecer. A classe vive em packages/core/src/errors.ts e
carrega o código estável, o status HTTP e os detalhes que o envelope de erro da Seção 7
exige. A mensagem em português vem do catálogo, não de texto solto no meio do código.
// packages/core/src/errors.ts
export interface ErrorDetail { field?: string; issue: string; [k: string]: unknown }
export class AppError extends Error {
readonly code: string
readonly status: number
readonly details?: ErrorDetail[]
readonly cause?: unknown
/** true = falha esperada de negócio; registrada como warn, não como error. */
readonly expected: boolean
constructor(
code: string,
status = 400,
options: { details?: ErrorDetail[]; cause?: unknown; expected?: boolean } = {},
) {
super(ERROR_MESSAGES[code] ?? 'Não foi possível concluir a operação.')
this.name = 'AppError'
this.code = code
this.status = status
this.details = options.details
this.cause = options.cause
this.expected = options.expected ?? status < 500
}
}
export function isAppError(e: unknown): e is AppError {
return e instanceof AppError
}O mapeamento para HTTP acontece em um único lugar por aplicação:
// apps/web/src/lib/http.ts
import { ZodError } from 'zod'
import { AppError, isAppError } from '@pd/core'
import { logger } from '@/lib/logger'
export function ok<T>(data: T, meta: { requestId?: string } = {}) {
return Response.json(
{ data, meta: { requestId: meta.requestId, timestamp: new Date().toISOString() } },
{ status: 200, headers: meta.requestId ? { 'X-Request-Id': meta.requestId } : undefined },
)
}
export function fail(error: unknown, meta: { requestId?: string } = {}) {
const mapped = toAppError(error)
const log = logger.child({ requestId: meta.requestId, code: mapped.code })
if (mapped.expected) log.warn({ details: mapped.details }, 'request_failed_expected')
else log.error({ err: mapped, cause: mapped.cause }, 'request_failed_unexpected')
return Response.json(
{
error: { code: mapped.code, message: mapped.message, details: mapped.details },
meta: { requestId: meta.requestId, timestamp: new Date().toISOString() },
},
{ status: mapped.status },
)
}
function toAppError(error: unknown): AppError {
if (isAppError(error)) return error
if (error instanceof ZodError) {
return new AppError('VALIDATION_FAILED', 422, {
details: error.issues.map((i) => ({ field: i.path.join('.') || undefined, issue: i.code })),
})
}
return new AppError('INTERNAL_ERROR', 500, { cause: error, expected: false })
}Regras: nenhum catch silencioso; nenhum throw de string ou objeto literal; erro de
terceiro é traduzido na camada de integração, nunca vaza para a rota; detalhe interno
(consulta SQL, corpo de resposta de terceiro, rastreamento de pilha) nunca entra no campo
message, que é exibido ao usuário. O catálogo completo de códigos é da Seção 7.
5.8 Logging estruturado #
Um logger, criado uma vez por processo, sempre em JSON. Nunca console.log em código de
produção — é erro de lint.
// packages/core/src/logger.ts
import pino from 'pino'
export function createLogger(service: 'web' | 'worker' | 'ops') {
return pino({
level: process.env.LOG_LEVEL ?? 'info',
base: { service, env: process.env.APP_ENV, version: process.env.BUILD_SHA },
timestamp: pino.stdTimeFunctions.isoTime,
redact: {
paths: [
'req.headers.authorization', 'req.headers.cookie', 'req.headers["access_token"]',
'req.headers["asaas-access-token"]', 'req.headers["x-hub-signature-256"]',
'*.password', '*.passwordHash', '*.otp', '*.otpHash', '*.token', '*.accessToken',
'*.creditCard', '*.creditCardNumber', '*.ccv', '*.cpfCnpj', '*.cpf',
'*.phone', '*.phoneE164', '*.waId', '*.email', '*.apiKey', '*.secret',
// URL assinada É credencial: quem tem a URL tem o arquivo.
'url', 'mediaUrl', 'signedUrl', 'downloadUrl', 'audioUrl', 'videoUrl',
'presignedUrl', 'invoiceUrl', '*.url', '*.mediaUrl', '*.signedUrl',
'*.downloadUrl', '*.audioUrl', '*.videoUrl', '*.presignedUrl', '*.invoiceUrl',
],
censor: '[REDACTED]',
},
})
}Campos obrigatórios em todo registro de log, além dos automáticos: requestId nas rotas
HTTP; jobId, jobName e attempt nos jobs; subscriberId quando houver assinante;
devotionalId quando houver devocional; code quando houver erro. Mensagens de log são
identificadores estáveis em inglês, em snake_case (daily_batch_planned,
whatsapp_send_failed), porque são consultadas por filtro — a prosa em português fica na
interface, não no log.
Proibições de conteúdo: telefone completo, número de documento, endereço de e-mail, código
de acesso, token, corpo de mensagem enviada ao assinante, URL assinada de armazenamento ou
de mídia da plataforma de mensagens e qualquer credencial. Para correlacionar sem expor,
registre o identificador interno do assinante e, quando for inevitável identificar o telefone
em investigação, apenas os quatro últimos dígitos, no campo phoneLast4. Mídia é referida
por mediaKey — o caminho no bucket, sem host e sem assinatura — ou pelo identificador de
mídia da plataforma, nunca pela URL completa.
5.9 Padrão de teste #
| Tipo | Arquivo | Onde roda | Objetivo |
|---|---|---|---|
| Unitário | *.test.ts ao lado do módulo, em __tests__/ |
Sem banco, sem rede | Funções puras do domínio |
| Integração | *.integration.test.ts |
Postgres real em container | Repositórios, transações, jobs |
| Ponta a ponta | *.e2e.ts em e2e/ |
Aplicação em execução | Fluxos do usuário |
Convenção de nomes de caso: descreve o sujeito > deve <comportamento esperado> quando <condição>. Exemplo: resolveEntitlements > deve limitar o acervo a 7 dias quando o nível é FREE.
Factories ficam em packages/core/src/testing/factories/ e produzem objetos válidos por
padrão, com sobrescrita parcial: makeSubscriber({ tier: 'PAID' }). Fixtures de payload de
terceiros ficam em __fixtures__/ como JSON real capturado, e não inventado à mão. Toda
suíte de integração roda dentro de uma transação revertida ao final, exceto quando o teste
verifica commit; nesse caso, a base é truncada por lista explícita de tabelas.
Proibições: teste que depende de horário real — o instante é sempre injetado; teste que depende da ordem de execução; e chamada de rede a serviço externo em qualquer suíte, sem exceção. A estratégia completa, com metas de cobertura, é da Seção 24.
5.10 Lint e formatação #
// eslint.config.js (raiz do monorepo, formato flat)
import js from '@eslint/js'
import ts from 'typescript-eslint'
import importPlugin from 'eslint-plugin-import'
import a11y from 'eslint-plugin-jsx-a11y'
export default ts.config(
js.configs.recommended,
...ts.configs.strictTypeChecked,
{
plugins: { import: importPlugin, 'jsx-a11y': a11y },
languageOptions: { parserOptions: { projectService: true } },
rules: {
'no-console': 'error',
'no-restricted-syntax': [
'error',
{ selector: "NewExpression[callee.name='Date'][arguments.length=0]", message: 'Injete o instante; não leia o relógio dentro do domínio.' },
],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'import/order': ['error', {
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
pathGroups: [{ pattern: '@pd/**', group: 'internal' }, { pattern: '@/**', group: 'internal' }],
'newlines-between': 'always',
alphabetize: { order: 'asc', caseInsensitive: true },
}],
'import/no-default-export': 'error',
'jsx-a11y/alt-text': 'error',
'jsx-a11y/label-has-associated-control': 'error',
},
},
{
// O framework de interface exige exportação padrão nestes arquivos.
files: ['apps/web/app/**/{page,layout,route,error,loading,not-found}.tsx', 'apps/web/app/**/route.ts'],
rules: { 'import/no-default-export': 'off' },
},
{
// O domínio é puro: não conhece banco, rede nem ambiente.
files: ['packages/core/**'],
rules: {
'no-restricted-imports': ['error', { patterns: ['@pd/db', '@pd/integrations', 'node:fs', 'node:net'] }],
},
},
)// .prettierrc.json
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"arrowParens": "always",
"plugins": ["prettier-plugin-tailwindcss"]
}A verificação pnpm lint, pnpm typecheck e pnpm format:check roda em hook de pré-commit
sobre os arquivos alterados e novamente na integração contínua sobre o repositório inteiro.
Aviso de lint não existe: toda regra é erro ou não está configurada. Desativação pontual
exige // eslint-disable-next-line <regra> -- motivo com o motivo escrito; comentário de
desativação sem justificativa é reprovado na revisão.
5.11 Commits e branches #
Commits seguem Conventional Commits, com o assunto em português, no imperativo, sem ponto final e com no máximo 72 caracteres:
feat(delivery): pular template quando a janela de 24h estiver aberta
fix(billing): revogar acesso na mesma transação do evento de atraso
refactor(core): extrair cálculo de fim de ciclo para subscription.rules
test(phone): cobrir variantes de nono dígito em números de celular
chore(deps): atualizar dependências de desenvolvimento
docs(readme): registrar decisões configuráveis aplicadasTipos aceitos: feat, fix, refactor, perf, test, docs, build, ci, chore,
revert. Escopos aceitos, alinhados aos módulos: web, worker, ops, db, core,
integrations, auth, billing, delivery, content, media, metrics, infra.
Mudança incompatível usa ! após o escopo e um rodapé BREAKING CHANGE:.
Branches: feat/, fix/, chore/, refactor/ seguidos de descrição curta em kebab-case.
A branch main é protegida, sempre implantável, e recebe apenas merge com fast-forward após
revisão e integração contínua verde. Rebase antes do merge; merge com histórico de merge
comercial não é aceito. Uma branch por unidade de trabalho, vida útil máxima de três dias.
5.12 Comentários, TODOs e código morto #
- Comentário explica por quê, nunca o quê. Se o código precisa de comentário para ser entendido, o problema é o nome da função.
TODOeFIXMEsão proibidos no código versionado. Trabalho pendente vira tarefa registrada fora do código. A regra é verificada na integração contínua: a presença deTODO,FIXMEouXXXem arquivo versionado reprova o build.- Código morto é removido, não comentado. O histórico de versão é o arquivo morto.
- Nada de código "preparado para o futuro": parâmetro sem uso, flag que nunca liga, abstração com uma única implementação prevista. Cada um deles é removido na revisão.
- Comentário obrigatório em três situações: contorno de limitação de terceiro (com o código de erro que o motiva), decisão contraintuitiva de desempenho e trecho que reproduz uma regra legal ou de plataforma.
- Documentação de módulo, quando necessária, é um bloco no topo do arquivo
index.ts, descrevendo a superfície pública em três linhas.
5.13 Acessibilidade nos componentes #
O alvo é WCAG 2.2 nível AA em todas as superfícies, verificado conforme a Seção 24. Regras que valem no momento de escrever o componente:
- HTML semântico primeiro:
buttonpara ação,apara navegação,labelassociado a todo campo.divcomonClické erro de lint. - Todo campo de formulário tem rótulo visível; texto de ajuda ligado por
aria-describedby; erro anunciado comrole="alert"e vinculado ao campo poraria-errormessage. - Foco sempre visível, com anel de contraste mínimo de 3:1. Remover o indicador de foco é proibido.
- Ordem de tabulação segue a ordem visual. Diálogos prendem o foco, devolvem o foco ao
elemento de origem ao fechar e respondem à tecla
Escape. - Estados de carregamento e conclusão são anunciados por região viva (
aria-live="polite"). Isso vale para o player de áudio e para o botão de reenvio. - Contraste mínimo de 4,5:1 para texto normal e 3:1 para texto grande e componentes de interface. A paleta padrão de 1.5 já atende.
- Alvo de toque mínimo de 44 por 44 pixels em telas pequenas.
- Nenhuma informação transmitida só por cor: estado de assinatura tem rótulo em texto, além da cor.
- Animação respeita
prefers-reduced-motion; transições decorativas são suprimidas. - Áudio do devocional é acompanhado do texto completo na mesma página, o que satisfaz a alternativa textual para conteúdo sonoro.
- Idioma declarado no documento:
<html lang="pt-BR">.
5.14 Textos de interface #
Todo texto exibido ao usuário vive em um único arquivo por aplicação, nunca solto no JSX. Não há biblioteca de internacionalização, porque não há segundo idioma — e adicionar uma agora seria complexidade sem uso.
// apps/web/src/i18n/pt-BR.ts
export const messages = {
brand: { name: 'Palavra Diária' },
common: {
save: 'Salvar',
cancel: 'Cancelar',
loading: 'Carregando…',
genericError: 'Não foi possível concluir. Tente novamente em instantes.',
},
subscriber: {
resend: {
button: 'Reenviar devocional de hoje',
queued: 'Enviamos de novo. Deve chegar em alguns segundos.',
limitReached: 'Você já usou seus reenvios de hoje. Amanhã libera de novo.',
freeTierNoDevotional: 'No plano gratuito o devocional chega aos domingos.',
},
archive: {
lockedTitle: 'Disponível no plano pago',
lockedBody: 'Assine para ler todo o acervo e receber o áudio todos os dias.',
},
},
} as const
type Leaves<T> = T extends string ? '' : { [K in keyof T & string]: `${K}${Leaves<T[K]> extends '' ? '' : `.${Leaves<T[K]>}`}` }[keyof T & string]
export type MessageKey = Leaves<typeof messages>
export function t(key: MessageKey): string {
return key.split('.').reduce<unknown>((acc, part) => (acc as Record<string, unknown>)[part], messages) as string
}Regras: chave em inglês, valor em português do Brasil; agrupamento por superfície, não por
tela; texto literal em JSX é erro de revisão; mensagens de erro exibidas ao usuário vêm do
catálogo de erros (Seção 7) e não deste arquivo, para que a mesma falha diga a mesma coisa na
interface e na API; valores dinâmicos usam função em vez de concatenação
(t('x.y') para texto fixo, funções nomeadas para texto com variável), preservando a
concordância em português.
5.15 Lista de verificação antes de abrir uma revisão #
pnpm lint,pnpm typecheck,pnpm testepnpm format:checkpassam localmente.- Nenhum
console.log,TODO,FIXMEou código comentado. - Toda entrada externa nova tem schema Zod e todo erro novo tem código no catálogo.
- Nenhuma regra de negócio nova dentro de route handler ou de componente.
- Nenhum dado pessoal novo em log; campos sensíveis adicionados à lista de redação.
- Migração de banco acompanhada de teste de integração e reversível.
- Texto de interface no arquivo único, não no JSX.
- Componente novo navegável por teclado e com rótulos associados.
- Commit no formato convencional, com escopo válido.
- Nada fora do escopo definido em 2.7 foi introduzido.
6. Modelo de Dados e Schema do Banco #
Esta seção é a fonte única da verdade do modelo de dados. Nenhuma outra seção define tabelas, colunas, índices ou enums. Onde outra seção precisar falar de persistência, ela referencia a subseção correspondente aqui.
O banco é PostgreSQL na linha de versão declarada na Seção 4. O ORM e o mecanismo de migrations são Prisma, também na linha declarada na Seção 4.
6.1 Princípios do modelo e convenções aplicadas #
6.1.1 Regras estruturais #
| Regra | Decisão |
|---|---|
| Nomenclatura de tabelas | snake_case, plural. Ex.: subscribers, message_logs. |
| Nomenclatura de colunas | snake_case. |
| Modelos Prisma | PascalCase singular com @@map("<tabela>"). |
| Enums | SCREAMING_SNAKE_CASE, tipo nativo do Postgres criado pelo Prisma. |
| Chave primária | id CHAR(26) contendo um ULID gerado na aplicação. Nunca UUID, nunca serial. |
| Timestamps | timestamptz, sempre gravados em UTC. Conversão para America/Sao_Paulo só na borda de apresentação e no planejador de envios. |
| Datas de calendário | date puro quando a semântica é "dia do calendário brasileiro" (ex.: devotionals.scheduled_for). |
| Dinheiro | inteiro em centavos de BRL, coluna sempre com sufixo _amount_cents ou _cents, tipo integer. Divisor para BRL: 100. Nunca float, nunca numeric, nunca string. |
| Custos fracionários de fornecedor | inteiro em micros de BRL (10⁻⁶ BRL), sufixo _cost_micros ou _micros, tipo bigint. Divisor para BRL: 1.000.000. Exceção deliberada à regra de centavos, porque custo unitário de TTS e de mensagem WhatsApp é menor que um centavo. |
| Mistura de unidades monetárias | proibida. Centavos e micros nunca entram na mesma soma ou comparação sem conversão explícita e escrita no próprio SQL ou no próprio código. Toda fórmula de outra seção que envolva dinheiro cita a coluna com o sufixo e o divisor corretos. |
| Soft delete | apenas em subscribers, devotionals e admin_users, via deleted_at timestamptz NULL. Todas as outras 25 tabelas usam hard delete. |
| Booleanos | prefixo is_ ou has_, nunca negativos (is_active, não is_not_active). |
| Colunas de auditoria | created_at obrigatório em todas as tabelas; updated_at apenas onde a linha é mutável. |
6.1.2 Por que ULID e não UUID #
ULID é ordenável por tempo de criação. Isso dá três ganhos diretos neste produto:
- Índices B-tree com inserção quase sequencial. As tabelas de maior volume
(
message_logs,delivery_attempts,inbound_messages) recebem escrita concentrada entre 05:40 e 06:20. Com UUIDv4 a inserção espalha por todo o índice e infla o WAL. - Paginação por cursor barata. A ordenação padrão
ORDER BY id DESCjá é cronológica, então o cursor da Seção 7.8 pode ser o próprioidna maioria das listas. - Depuração. O ULID
01K3F8QZ7M0000000000000000carrega o instante de criação em seus 10 primeiros caracteres, o que acelera correlação com logs.
Geração exclusivamente na aplicação, via biblioteca ulid (linha 3.x). O banco não
tem default para id — a ausência de default é intencional e força o caminho único de
geração. Nenhuma migration ou seed pode inserir id fora desse formato.
Validação de formato garantida por CHECK global em todas as tabelas com id:
CONSTRAINT chk_<tabela>_id_ulid CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$')O alfabeto é o Crockford Base32 (sem I, L, O, U), sempre em maiúsculas.
6.1.3 Prefixos públicos de identificador #
O id no banco é o ULID cru. Na API pública ele é exposto com prefixo por entidade,
convertido na borda de serialização e nunca persistido com prefixo.
| Entidade | Prefixo público | Exemplo |
|---|---|---|
subscribers |
sub_ |
sub_01K3F8QZ7MHV2N9R4B6T0XYZAB |
subscriptions |
subn_ |
subn_01K3F8R1P2... |
devotionals |
dev_ |
dev_01K3F8R3T5... |
payments |
pay_ |
pay_01K3F8R5W7... |
audio_assets |
aud_ |
aud_01K3F8R7Y9... |
message_logs |
msg_ |
msg_01K3F8R9A1... |
delivery_attempts |
att_ |
att_01K3F8RBC3... |
send_batches |
bat_ |
bat_01K3F8RDE5... |
sessions |
ses_ |
ses_01K3F8RFG7... |
admin_users |
adm_ |
adm_01K3F8RHJ9... |
plans |
pln_ |
pln_01K3F8RKL1... |
| requisições HTTP | req_ |
req_01K3F8RMN3... |
Um identificador recebido pela API com prefixo errado devolve VALIDATION_ERROR
(catálogo na Seção 7.11), nunca NOT_FOUND — o prefixo é sintaxe, não existência.
6.1.4 Concorrência e versionamento otimista #
Três tabelas têm edição concorrente real (dois administradores no mesmo recurso) e por
isso carregam coluna version integer NOT NULL DEFAULT 1:
devotionals— dois editores no mesmo devocional;settings— dois administradores ajustando runtime;whatsapp_templates— submissão concorrente à Meta.
O UPDATE sempre inclui WHERE version = :expectedVersion e incrementa a coluna. Zero
linhas afetadas devolve OPTIMISTIC_LOCK_FAILED (Seção 7.11). Exemplo:
UPDATE devotionals
SET title = $2, reflection_md = $3, version = version + 1, updated_at = now()
WHERE id = $1 AND version = $4 AND deleted_at IS NULL;
-- 0 linhas => OPTIMISTIC_LOCK_FAILEDAs demais tabelas não usam versionamento otimista: ou são append-only, ou têm dono único (o worker), ou a última escrita legitimamente vence.
6.1.5 Extensões Postgres exigidas #
CREATE EXTENSION IF NOT EXISTS citext; -- e-mails case-insensitive
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- digest() para hashes em migrations e seeds
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- busca por trecho em títulos de devocionais
CREATE EXTENSION IF NOT EXISTS btree_gin; -- índices compostos com jsonb em auditoria
CREATE EXTENSION IF NOT EXISTS unaccent; -- normalização de busca editorial em PT-BRunaccent é usado apenas na busca administrativa de devocionais. Palavras-chave de
opt-out chegam pelo WhatsApp e são normalizadas em TypeScript (String.normalize('NFD')),
não no banco, porque o worker precisa decidir sem ida ao banco.
6.1.6 Catálogo de enums #
Todos os enums são tipos nativos do Postgres, criados e versionados pelo Prisma.
Adicionar valor a enum é operação de migration própria (ALTER TYPE ... ADD VALUE), nunca
misturada com outras alterações, porque em Postgres o novo valor não pode ser usado na
mesma transação em que foi criado.
| Enum | Valores | Onde é usado |
|---|---|---|
SubscriberTier |
FREE, PAID |
subscribers.tier, plans.tier_granted, delivery_attempts.tier_at_send, feature_flags.target_tier |
SubscriberStatus |
PENDING_VERIFICATION, VERIFIED_PENDING_OPTIN, ACTIVE_FREE, ACTIVE_PAID, PAUSED, OPTED_OUT, BLOCKED |
subscribers.status |
Role |
SUBSCRIBER, EDITOR, ADMIN, OWNER |
admin_users.role, sessions.role, settings.editable_by |
ConsentType |
OPT_IN_WEB, OPT_IN_WHATSAPP, OPT_OUT, RE_OPT_IN, TERMS_ACCEPTED, PRIVACY_ACCEPTED, DATA_EXPORT_REQUESTED, DATA_DELETION_REQUESTED, PAUSE_STARTED, PAUSE_ENDED, SERVICE_TERMS, SENSITIVE_DATA, MARKETING, COOKIE_CONSENT |
consent_events.type |
ConsentChannel |
WEB, WHATSAPP, EMAIL, ADMIN, OPS_CLI |
consent_events.channel |
SessionSubjectType |
SUBSCRIBER, ADMIN |
sessions.subject_type |
SessionScope |
FULL, RESTRICTED, IMPERSONATION_READONLY |
sessions.scope |
OtpPurpose |
LOGIN, PHONE_VERIFICATION, PHONE_CHANGE, EMAIL_VERIFICATION |
otp_codes.purpose |
OtpChannel |
WHATSAPP, EMAIL_LINK |
otp_codes.channel |
PlanInterval |
MONTHLY, YEARLY |
plans.interval |
SubscriptionStatus |
PENDING_PAYMENT, ACTIVE, CANCELED, EXPIRED, REFUNDED |
subscriptions.status |
BillingType |
CREDIT_CARD, PIX |
subscriptions.billing_type, payments.billing_type |
PaymentStatus |
PENDING, CONFIRMED, RECEIVED, OVERDUE, REFUNDED, CHARGEBACK_REQUESTED, CHARGEBACK_DISPUTE, DELETED, FAILED |
payments.status |
SubscriptionEventType |
CREATED, ACTIVATED, RENEWED, CANCEL_REQUESTED, CANCELED, EXPIRED, REFUNDED, CHARGEBACK, REVOKED, REACTIVATED, PLAN_CHANGED |
subscription_events.type |
DevotionalStatus |
DRAFT, READY, AUDIO_PENDING, AUDIO_READY, PUBLISHED, SENT |
devotionals.status |
AudioProvider |
ELEVENLABS, GOOGLE_TTS |
audio_assets.provider |
AudioAssetStatus |
PENDING, PROCESSING, READY, FAILED |
audio_assets.status |
AudioFormat |
OGG_OPUS, MP3, MP4 |
audio_assets.format |
TemplateCategory |
UTILITY, MARKETING, AUTHENTICATION |
whatsapp_templates.category e .effective_category |
TemplateStatus |
DRAFT, PENDING, APPROVED, REJECTED, PAUSED, DISABLED |
whatsapp_templates.status |
MessageDirection |
OUTBOUND, INBOUND |
message_logs.direction |
MessageKind |
TEMPLATE, TEXT, AUDIO, VIDEO, IMAGE, INTERACTIVE, REACTION, UNKNOWN |
message_logs.kind, inbound_messages.kind |
MessageStatus |
QUEUED, SENT, DELIVERED, READ, FAILED, DEFERRED |
message_logs.status |
DeliveryStep |
TEMPLATE_INVITE, FULL_TEXT, AUDIO, CLOSING, VIDEO_FALLBACK |
delivery_attempts.step |
DeliveryAttemptStatus |
PLANNED, IN_FLIGHT, SUCCEEDED, FAILED, SKIPPED, SKIPPED_OPTED_OUT, SKIPPED_INELIGIBLE, DEFERRED |
delivery_attempts.status |
SendBatchStatus |
PLANNING, PLANNED, RUNNING, HALTED_BY_GUARD, COMPLETED, FAILED, CANCELED |
send_batches.status |
WebhookSource |
ASAAS, WHATSAPP |
webhook_deliveries.source |
WebhookDirection |
INBOUND, OUTBOUND |
webhook_deliveries.direction |
WebhookProcessingStatus |
RECEIVED, PROCESSING, PROCESSED, FAILED, IGNORED |
webhook_deliveries.processing_status, payment_events.processing_status |
WebhookProcessingResult |
OK, SCHEMA_REJECTED, PARSE_ERROR, PERSIST_FAILED, UNKNOWN_EVENT, DUPLICATE |
webhook_deliveries.processing_result |
MediaUploadStatus |
PENDING, UPLOADED, EXPIRED, FAILED |
media_uploads.status |
MediaPurpose |
DAILY_AUDIO, VIDEO_FALLBACK, TEMPLATE_HEADER |
media_uploads.purpose |
JobRunStatus |
RUNNING, SUCCEEDED, FAILED, DEAD |
job_runs.status |
SettingValueType |
STRING, INT, BOOL, JSON, DECIMAL |
settings.value_type |
InboundIntent |
OPT_OUT, OPT_IN, OPEN_DEVOTIONAL, SNOOZE, HELP, SUPPORT, UNKNOWN |
inbound_messages.intent |
São 35 tipos enum. Quatro deles merecem explicação, porque a escolha de valores é consequência direta de decisões tomadas em outras seções:
SubscriberStatustem sete valores eDELETEDnão está entre eles. Conta excluída não é um estado do enum: ésubscribers.deleted_atpreenchido. Misturar exclusão com estado operacional criaria um oitavo valor que nenhuma transição consegue deixar, e quebraria o índice parcial de elegibilidade, que já filtra pordeleted_at IS NULL. As transições completas estão na Seção 11.8.1; a garantia física está em 6.3.3.BLOCKEDtem entrada (bloqueio administrativo ou erro permanente da Meta) e saída (desbloqueio administrativo ou mensagem recebida do assinante) — não é estado morto.JobRunStatustemDEAD. Não existe fila de dead-letter no sistema. Um job que esgota as tentativas fica no estadofailedda própria fila do BullMQ e grava uma linha emjob_runscomstatus = 'DEAD'.job_runsé a dead-letter do sistema (Seção 18.8). Nenhuma seção pode introduzir uma fila com sufixo.dlq.DeliveryAttemptStatustem dois motivos de pulo explícitos.SKIPPED_OPTED_OUTeSKIPPED_INELIGIBLEexistem porque o tier e o consentimento são revalidados no instante do disparo, e não no planejamento (Seção 18.5): o item precisa registrar por que não saiu, sem depender de texto livre.SKIPPEDgenérico permanece para os pulos decididos já no planejamento.SessionScopeexiste para que a impersonação seja estruturalmente somente leitura. Uma sessãoIMPERSONATION_READONLYé recusada em qualquer método não seguro antes de chegar ao handler (Seção 8.13.3), e uma sessãoRESTRICTEDé a que a reverificação por dormência emite (Seção 8.15.1.1). O escopo mora na linha da sessão, no banco, e não apenas no token — assim ele não pode ser perdido em uma renovação.
6.1.7 Lista fechada de tabelas #
O modelo tem exatamente 28 tabelas. A lista é fechada: nenhuma outra seção pode
introduzir tabela nova. Necessidade de persistência não coberta usa settings
(chave/valor tipado, Seção 6.23) e declara isso explicitamente.
| # | Tabela | Grupo | Volume esperado em 1 ano (10.000 assinantes) |
|---|---|---|---|
| 1 | subscribers |
Identidade | 10.000 linhas |
| 2 | subscriber_profiles |
Identidade | 10.000 linhas |
| 3 | consent_events |
Conformidade | 45.000 linhas |
| 4 | sessions |
Autenticação | 60.000 linhas (com expurgo) |
| 5 | otp_codes |
Autenticação | 120.000 linhas (com expurgo) |
| 6 | admin_users |
Administração | dezenas |
| 7 | admin_audit_log |
Administração | 80.000 linhas |
| 8 | plans |
Cobrança | 2 linhas |
| 9 | subscriptions |
Cobrança | 4.500 linhas |
| 10 | subscription_events |
Cobrança | 40.000 linhas |
| 11 | payments |
Cobrança | 30.000 linhas |
| 12 | payment_events |
Cobrança | 150.000 linhas |
| 13 | devotionals |
Editorial | 365 linhas |
| 14 | devotional_revisions |
Editorial | 3.000 linhas |
| 15 | audio_assets |
Mídia | 730 linhas |
| 16 | whatsapp_templates |
Mensageria | dezenas |
| 17 | message_logs |
Mensageria | 6.000.000 linhas — particionada |
| 18 | delivery_attempts |
Envio | 4.000.000 linhas |
| 19 | inbound_messages |
Mensageria | 900.000 linhas |
| 20 | send_batches |
Envio | 365 linhas |
| 21 | settings |
Configuração | ~60 linhas |
| 22 | feature_flags |
Configuração | ~15 linhas |
| 23 | daily_metrics |
Analytics | 120.000 linhas — formato longo, uma linha por dia, métrica e dimensão |
| 24 | webhook_deliveries |
Integrações | 400.000 linhas |
| 25 | media_uploads |
Mídia | 900 linhas |
| 26 | job_runs |
Operação | 200.000 linhas |
| 27 | admin_trusted_devices |
Administração | dezenas |
| 28 | unsubscribe_tokens |
Conformidade | ~15.000 linhas (com expurgo) |
A tabela 28, unsubscribe_tokens, é a persistência do link público de descadastro
(POST /api/public/unsubscribe). Ela é definida por completo em 6.7.4 — colunas, tipos,
índices, chave estrangeira com ON DELETE e retenção — e nenhuma outra seção redefine
qualquer parte dela: as Seções 20 e 22 apenas a referenciam.
Duas tabelas concentram mais de 80% do volume: message_logs e delivery_attempts.
O tratamento específico delas está em 6.33 (particionamento e retenção) e 6.34
(manutenção).
6.1.8 Invariante de dado pessoal #
Nenhuma tabela deste modelo armazena telefone, wa_id, e-mail ou CPF em texto claro.
Onde a busca por igualdade é necessária, ela é feita pela coluna _hmac correspondente —
um índice cego, HMAC-SHA-256 determinístico do valor normalizado, com chave própria e
distinta da chave de cifra. As colunas cifradas e as colunas de índice cego são declaradas
aqui, na Seção 6, e existem desde a primeira migration: não são endurecimento
posterior, são schema. A Seção 22 descreve o algoritmo do envelope, a gestão da chave e o
procedimento de rotação, e referencia esta seção para saber quais colunas existem.
As quatro duplas são:
| Valor pessoal | Coluna cifrada | Coluna de índice cego | Unicidade |
|---|---|---|---|
| Telefone E.164 | subscribers.phone_e164 (text) |
subscribers.phone_hmac (char(64)) |
única entre contas vivas |
| Identificador do WhatsApp | subscribers.wa_id (text) |
subscribers.wa_id_hmac (char(64)) |
única quando presente |
subscribers.email (text) |
subscribers.email_hmac (char(64)) |
única entre contas vivas | |
| CPF/CNPJ | subscriber_profiles.cpf (text) |
subscriber_profiles.cpf_hmac (char(64)) |
não única, por decisão explícita (6.4.1) |
Três regras derivam disso e valem em todo o documento:
- Nenhum
CHECKde banco valida o conteúdo de uma coluna cifrada. O banco só enxerga o envelope, então validar formato de telefone, de e-mail ou de CPF em SQL é impossível. A validação de formato é da camada de aplicação, por Zod, na borda, antes de cifrar (Seção 22.2.1). O únicoCHECKadmissível sobre coluna cifrada é o do formato do envelope (~ '^v[0-9]+:'), que não revela nada. - Nenhuma consulta compara coluna cifrada por igualdade. A cifra é aleatorizada por
nonce: dois envelopes do mesmo telefone são bytes diferentes. Toda busca por telefone,
wa_id, e-mail ou CPF usa a coluna_hmac(6.35.3). - Um teste de schema percorre
information_schema.columnse falha se encontrar coluna chamadaphone_e164,wa_id,emailoucpfcujo tipo não sejatextcom o CHECK de envelope, ou cuja coluna_hmaccorrespondente esteja ausente. O teste roda no pipeline, contra o banco recém-migrado.
6.2 Diagrama ER #
┌──────────────────────┐
│ plans │
│ code (uniq) │
│ interval │
│ amount_cents │
└───────────┬──────────┘
│ 1
│
│ N
┌──────────────────┐ 1 1 ┌──────┴───────────────┐ 1 N ┌────────────────────────┐
│ subscriber_ ├─────────┤ subscriptions ├────────┤ subscription_events │
│ profiles │ │ status │ │ type / from / to │
│ cpf (cifrado) │ │ billing_type │ │ cancel_reason │
│ cpf_hmac (idx) │ │ current_period_end │ └────────────────────────┘
└──────────────────┘ │ pending_plan_id │
│ 1 └──────┬───────────────┘
│ │ 1
│ 1 │
┌─────────┴────────────┐ │ N ┌──────────────────────┐
│ subscribers │ ┌──────┴──────────┤ payment_events │
│ phone_e164 (cifr.) │ │ payments │ asaas_event_id │
│ phone_hmac (uniq) │ 1 N│ asaas_payment │ (uniq) │
│ wa_id (cifrado) ├─────┤ _id (uniq) └──────────────────────┘
│ wa_id_hmac (uq p.) │ └─────────────────┘
│ email (cifrado) │
│ email_hmac (uq p.) │
│ tier / status │
│ opt_in_confirmed_at │
│ paused_until │
│ service_window_ │
│ expires_at │ 1 N ┌──────────────────────┐
│ deleted_at ├───────┤ consent_events │ append-only
└──┬───┬───┬───┬───┬───┘ └──────────────────────┘
│ │ │ │ │
│ │ │ │ │ N ┌────────────────┐
│ │ │ │ └────┤ sessions │──┐ self-FK (rotação)
│ │ │ │ │ refresh_hash │◄─┘
│ │ │ │ └────────────────┘
│ │ │ │ N ┌────────────────┐
│ │ │ └────┤ otp_codes │
│ │ │ └────────────────┘
│ │ │ N ┌──────────────────────┐
│ │ └────┤ inbound_messages │ wamid (uniq)
│ │ └──────────────────────┘
│ │ N ┌──────────────────────────┐ N 1 ┌───────────────────────┐
│ └────┤ delivery_attempts ├───────┤ send_batches │
│ │ idempotency_key (uniq) │ │ idempotency_key(uniq) │
│ │ step / status │ │ devotional_date │
│ └────────────┬─────────────┘ └───────────┬───────────┘
│ │ N │ N
│ N │ │
┌──┴──────────────────┐ │ 1 │ 1
│ message_logs │ │ ┌────────────┴───────────┐
│ PARTITION BY RANGE │ │ │ devotionals │
│ (created_at) │ └────────────────────┤ scheduled_for (uniq) │
│ wamid │ N 1 │ status / teaser │
│ status/error_code │ │ version │
└─────────────────────┘ └──┬──────────┬──────────┘
│ 1 │ 1
│ N │ N
┌──────────────┴───┐ ┌───┴─────────────────────┐
│ audio_assets │ │ devotional_revisions │
│ provider/format │ │ revision_number │
└────────┬─────────┘ └─────────────────────────┘
│ 1
│ N
┌────────┴─────────┐
│ media_uploads │
│ whatsapp_media_id│
│ expires_at (30d) │
└──────────────────┘
┌──────────────────┐ 1 N ┌────────────────────┐
│ admin_users ├────────┤ admin_audit_log │
│ email (uniq) │ │ action/entity │
│ totp_secret_enc │ └────────────────────┘
└────────┬─────────┘
│ 1
│ N
┌────────┴──────────────┐
│ admin_trusted_devices │
│ token_hash (uniq) │
│ expires_at (30d) │
└───────────────────────┘
┌──────────────────────┐ 1 N ┌─────────────────────────┐
│ subscribers ├────────┤ unsubscribe_tokens │ ON DELETE CASCADE
│ (a mesma acima) │ │ token_hash (uniq) │
└──────────────────────┘ │ expires_at / used_at │
└─────────────────────────┘
TABELAS SEM RELACIONAMENTO OBRIGATÓRIO (configuração, mensageria e operação):
┌──────────────┐ ┌────────────────┐ ┌──────────────────────────┐ ┌────────────────────┐
│ settings │ │ feature_flags │ │ daily_metrics │ │ whatsapp_templates │
│ key (PK) │ │ key (PK) │ │ (metric_date, metric_key,│ │ (name, language) │
│ grupo.chave │ │ snake_case │ │ dimension) uq — formato │ │ uq │
│ │ │ │ │ longo │ │ │
└──────────────┘ └────────────────┘ └──────────────────────────┘ └────────────────────┘
┌────────────────────────┐ ┌──────────────────────────────┐
│ webhook_deliveries │ │ job_runs │
│ direction / source │ │ job_name / queue │
│ processing_result │ │ status DEAD = dead-letter │
└────────────────────────┘ └──────────────────────────────┘
LEGENDA: 1──N = um para muitos. (uniq) = índice único. (uniq part.) / (uq p.) = índice
único parcial. (cifr.) / (cifrado) = envelope AES-256-GCM, nunca indexado. (idx) = índice
não único.6.3 Tabela subscribers #
Registro central do assinante: identidade por telefone, tier vigente, estado de opt-in e estado da janela de atendimento do WhatsApp.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. Chave primária. |
phone_e164 |
text |
não | — | Telefone canônico em E.164 (com o nono dígito quando celular brasileiro), guardado como envelope cifrado AES-256-GCM no formato v1:<iv>:<ct>:<tag> (Seção 22.5.3). Nunca indexado, nunca comparado por igualdade. O formato E.164 é validado por Zod antes de cifrar (6.1.8). |
phone_hmac |
char(64) |
não | — | HMAC-SHA-256, em hex minúsculo, do E.164 normalizado, com a chave PHONE_INDEX_KEY. É a coluna de identidade e de unicidade. Toda busca por telefone passa por aqui. |
wa_id |
text |
sim | NULL |
Identificador devolvido pela Meta no primeiro contato bem-sucedido. Envelope cifrado, mesmo formato. Pode diferir de phone_e164 (ver 6.36.1). |
wa_id_hmac |
char(64) |
sim | NULL |
HMAC do wa_id. Resolve o webhook de entrada em uma única leitura. |
wa_id_changed_at |
timestamptz |
sim | NULL |
Última troca do wa_id associado a este telefone. Preenchê-la revoga todas as sessões do assinante e exige nova verificação por código antes de liberar qualquer dado pessoal (6.36.1). |
display_name |
varchar(120) |
sim | NULL |
Nome de exibição informado no cadastro ou no perfil do WhatsApp. É o nome do assinante em todo o documento; não existe subscribers.name. |
email |
text |
sim | NULL |
E-mail opcional, usado no fallback de login e em recibos. Envelope cifrado, mesmo formato. |
email_hmac |
char(64) |
sim | NULL |
HMAC do e-mail já em minúsculas. Substitui o antigo índice único sobre citext. |
email_verified_at |
timestamptz |
sim | NULL |
Preenchido quando o magic link é consumido. Só e-mail verificado habilita o fallback de login. |
phone_verified_at |
timestamptz |
sim | NULL |
Instante em que o telefone foi comprovado por código no WhatsApp. Nulo enquanto o cadastro está em PENDING_VERIFICATION. |
tier |
SubscriberTier |
não | 'FREE' |
Tier vigente. Fonte de verdade para entitlements. Relido no instante do disparo de cada mensagem, nunca apenas no planejamento (Seção 18.5). |
status |
SubscriberStatus |
não | 'PENDING_VERIFICATION' |
Estado operacional materializado (ver 6.3.4). |
locale |
varchar(10) |
não | 'pt-BR' |
Locale de apresentação. Valor único no MVP. |
timezone |
varchar(40) |
não | 'America/Sao_Paulo' |
Fuso de apresentação. Não personalizável no MVP; a coluna existe para não exigir migration futura. |
opt_in_confirmed_at |
timestamptz |
sim | NULL |
Instante da confirmação ativa no WhatsApp. Nenhum devocional é enviado com este campo nulo. |
opt_out_at |
timestamptz |
sim | NULL |
Instante do opt-out. Envios cessam imediatamente. |
opt_out_reason |
varchar(80) |
sim | NULL |
Palavra-chave e origem do opt-out, no mesmo campo. Ex.: keyword:SAIR, panel, link, admin. É o único nome desta coluna em todo o documento: não existe opt_out_source, e uma seção que precise da origem lê o prefixo daqui. |
opt_out_confirmation_sent_at |
timestamptz |
sim | NULL |
Instante do envio da confirmação de opt-out. Verificada dentro da mesma transação que grava opt_out_at, para que uma reentrega de webhook não produza duas mensagens de "você foi removido" (Seção 27.5.1). Sem índice. |
email_marketing_consent_at |
timestamptz |
sim | NULL |
Consentimento específico para e-mail de marketing, distinto do consentimento de serviço. A régua de reengajamento por e-mail (Seção 20.11) só alcança quem tem esta coluna preenchida. Sem índice: a régua já filtra por opt_out_at IS NULL. |
billing_blocked_at |
timestamptz |
sim | NULL |
Instante em que o assinante passou a ser recusado em novo checkout com cartão, por contestação confirmada. Não impede pagamento por PIX. Escrita na mesma transação de revokePaidAccess() no evento de contestação (Seção 12.11). |
service_window_expires_at |
timestamptz |
sim | NULL |
Fim da janela de atendimento de 24h. Governa a escolha entre template e free-form. |
last_inbound_at |
timestamptz |
sim | NULL |
Última mensagem recebida do assinante. |
last_delivered_at |
timestamptz |
sim | NULL |
Último devocional confirmado como entregue. |
consecutive_window_misses |
smallint |
não | 0 |
Dias consecutivos em que o assinante PAID recebeu mensagem entregue ou lida e não abriu a janela. É o único nome deste contador em todo o documento; no_interaction_days não existe. Em 3, o 4º dia usa o fallback de vídeo. |
undelivered_days |
smallint |
não | 0 |
Dias em que a única saída foi adiada, suprimida ou falhou. Contador separado, porque nesses dias o assinante não teve oportunidade de interagir e empurrá-lo ao vídeo só agrava a supressão (Seção 17.3). Alimenta a regra de entrega mínima do plano pago. |
undeliverable_count |
smallint |
não | 0 |
Falhas permanentes consecutivas de entrega ao número. Base do bloqueio automático por erro 131026 reincidente. |
unrecognized_replies_in_window |
smallint |
não | 0 |
Respostas não reconhecidas na janela corrente. Alimenta a proteção anti-loop da Seção 19.6. Zerado a cada mensagem reconhecida. |
blocked_at |
timestamptz |
sim | NULL |
Bloqueio operacional (bloqueio administrativo, número inválido, erro 131026 reincidente). |
blocked_reason |
varchar(120) |
sim | NULL |
Motivo do bloqueio, legível por operador. |
paused_until |
timestamptz |
sim | NULL |
Fim da pausa temporária. Sempre 03:00 de America/Sao_Paulo do dia seguinte ao último dia pausado, para que o retorno caia antes do envio das 06:00 (Seção 20.6.1). Nulo significa sem pausa. |
courtesy_until |
timestamptz |
sim | NULL |
Fim da cortesia concedida por administrador. Enquanto vigente, o assinante tem acesso pago sem assinatura (Seção 13.11). |
human_handoff_until |
timestamptz |
sim | NULL |
Fim do atendimento humano. Enquanto vigente, as respostas automáticas ficam suspensas (Seção 19.7). |
autoreply_suppressed_until |
timestamptz |
sim | NULL |
Fim da supressão de resposta automática disparada pela proteção anti-loop (Seção 19.6). |
marketing_blocked_at |
timestamptz |
sim | NULL |
Recusa específica de mensagens de categoria MARKETING, que não implica opt-out do serviço. |
welcome_backfill_sent_at |
timestamptz |
sim | NULL |
Instante do envio do devocional de boas-vindas retroativo, quando o cadastro ocorre depois do envio do dia (Seção 11.10). |
asaas_customer_id |
varchar(32) |
sim | NULL |
Identificador do cliente no provedor de pagamento, no nível do assinante. Criado uma vez e reaproveitado por todas as assinaturas. |
last_login_at |
timestamptz |
sim | NULL |
Último login bem-sucedido no painel. Junto com last_inbound_at, define a dormência de 90 dias que rebaixa a sessão para RESTRICTED (Seção 8.15.1.1). |
source |
varchar(40) |
não | 'landing' |
Canal de aquisição. Ex.: landing, admin, import, ops_cli. |
current_subscription_id |
char(26) |
sim | NULL |
Atalho para a assinatura vigente. Denormalização deliberada (ver 6.3.5). |
anonymized_at |
timestamptz |
sim | NULL |
Instante do estágio 1 (pseudonimização) do pedido de eliminação por LGPD. |
fully_anonymized_at |
timestamptz |
sim | NULL |
Instante do estágio 2 (anonimização), quando a retenção fiscal vence e CPF e identificadores do provedor de pagamento também são zerados (Seção 22.9). |
deleted_at |
timestamptz |
sim | NULL |
Soft delete. Não é um valor de status: conta excluída continua com o último status operacional e é filtrada por esta coluna. |
created_at |
timestamptz |
não | now() |
— |
updated_at |
timestamptz |
não | now() |
Atualizado pela aplicação em toda escrita. |
6.3.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
subscribers_pkey |
PRIMARY KEY (id) |
Acesso por identificador. |
uq_subscribers_phone_hmac |
UNIQUE (phone_hmac) WHERE deleted_at IS NULL |
Identidade do assinante. Substitui integralmente o antigo índice sobre phone_e164, que hoje é coluna cifrada e não pode ser comparada por igualdade. Impede duplicidade entre contas vivas; telefone de conta excluída volta a ser utilizável só depois da anonimização (ver 6.36.1). |
uq_subscribers_wa_id_hmac |
UNIQUE (wa_id_hmac) WHERE wa_id_hmac IS NOT NULL |
Resolução de webhook de entrada em uma única leitura. Parcial porque a maioria das linhas nasce sem wa_id. |
uq_subscribers_email_hmac |
UNIQUE (email_hmac) WHERE email_hmac IS NOT NULL AND deleted_at IS NULL |
E-mail é opcional e único entre contas vivas. |
ix_subscribers_send_eligible |
(tier, status) WHERE opt_in_confirmed_at IS NOT NULL AND opt_out_at IS NULL AND deleted_at IS NULL AND blocked_at IS NULL |
Índice parcial que atende a consulta de planejamento diário (6.35.1). Reduz a varredura ao conjunto elegível. |
ix_subscribers_service_window |
(service_window_expires_at) WHERE service_window_expires_at IS NOT NULL |
Seleciona quem tem janela aberta para o atalho free-form. |
ix_subscribers_current_subscription |
(current_subscription_id) WHERE current_subscription_id IS NOT NULL |
Junção reversa a partir da assinatura. |
ix_subscribers_created_at |
(created_at DESC) |
Lista administrativa e métrica de novos assinantes por dia. |
ix_subscribers_window_misses |
(consecutive_window_misses) WHERE tier = 'PAID' AND consecutive_window_misses >= 3 |
Localiza candidatos ao fallback de vídeo sem varrer a tabela. |
uq_subscribers_asaas_customer |
UNIQUE (asaas_customer_id) WHERE asaas_customer_id IS NOT NULL |
Um cadastro no provedor de pagamento pertence a um assinante só. Resolve webhook de cliente sem varredura. |
ix_subscribers_paused_until |
(paused_until) WHERE paused_until IS NOT NULL |
Job das 05:30 que limpa pausas encerradas e enfileira a saudação de retorno. |
ix_subscribers_courtesy_until |
(courtesy_until) WHERE courtesy_until IS NOT NULL |
Job de ciclo que expira cortesias vencidas. |
ix_subscribers_dormant |
(last_inbound_at NULLS FIRST, last_login_at NULLS FIRST) WHERE deleted_at IS NULL |
Avaliação da dormência de 90 dias no momento do login (Seção 8.15.1.1), sem varrer a tabela. |
ix_subscribers_billing_blocked |
(billing_blocked_at) WHERE billing_blocked_at IS NOT NULL |
Lista de contas com cartão recusado por contestação. Parcial porque a esmagadora maioria das linhas tem a coluna nula. |
6.3.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE | Observação |
|---|---|---|---|---|
current_subscription_id |
subscriptions(id) |
SET NULL |
CASCADE |
FK circular com subscriptions.subscriber_id. Declarada DEFERRABLE INITIALLY DEFERRED para permitir criação de assinante e assinatura na mesma transação. |
6.3.3 Constraints CHECK #
ALTER TABLE subscribers
ADD CONSTRAINT chk_subscribers_phone_envelope
CHECK (phone_e164 ~ '^v[0-9]+:'),
ADD CONSTRAINT chk_subscribers_phone_hmac
CHECK (phone_hmac ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_subscribers_wa_id_envelope
CHECK (wa_id IS NULL OR wa_id ~ '^v[0-9]+:'),
ADD CONSTRAINT chk_subscribers_wa_id_hmac
CHECK (wa_id_hmac IS NULL OR wa_id_hmac ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_subscribers_wa_id_pair
CHECK ((wa_id IS NULL) = (wa_id_hmac IS NULL)),
ADD CONSTRAINT chk_subscribers_email_envelope
CHECK (email IS NULL OR email ~ '^v[0-9]+:'),
ADD CONSTRAINT chk_subscribers_email_hmac
CHECK (email_hmac IS NULL OR email_hmac ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_subscribers_email_pair
CHECK ((email IS NULL) = (email_hmac IS NULL)),
ADD CONSTRAINT chk_subscribers_optout_after_optin
CHECK (opt_out_at IS NULL OR opt_in_confirmed_at IS NULL
OR opt_out_at >= opt_in_confirmed_at),
ADD CONSTRAINT chk_subscribers_window_misses
CHECK (consecutive_window_misses BETWEEN 0 AND 365),
ADD CONSTRAINT chk_subscribers_undelivered_days
CHECK (undelivered_days BETWEEN 0 AND 365),
ADD CONSTRAINT chk_subscribers_undeliverable_count
CHECK (undeliverable_count BETWEEN 0 AND 365),
ADD CONSTRAINT chk_subscribers_unrecognized_replies
CHECK (unrecognized_replies_in_window BETWEEN 0 AND 100),
ADD CONSTRAINT chk_subscribers_status_verification
CHECK (status <> 'PENDING_VERIFICATION' OR phone_verified_at IS NULL),
ADD CONSTRAINT chk_subscribers_status_verified_pending
CHECK (status <> 'VERIFIED_PENDING_OPTIN'
OR (phone_verified_at IS NOT NULL AND opt_in_confirmed_at IS NULL)),
ADD CONSTRAINT chk_subscribers_status_active_needs_optin
CHECK (status NOT IN ('ACTIVE_FREE','ACTIVE_PAID','PAUSED')
OR opt_in_confirmed_at IS NOT NULL),
ADD CONSTRAINT chk_subscribers_status_tier_matches
CHECK ((status <> 'ACTIVE_PAID' OR tier = 'PAID')
AND (status <> 'ACTIVE_FREE' OR tier = 'FREE')),
ADD CONSTRAINT chk_subscribers_status_paused
CHECK (status <> 'PAUSED' OR paused_until IS NOT NULL),
ADD CONSTRAINT chk_subscribers_status_optout
CHECK ((status = 'OPTED_OUT') = (opt_out_at IS NOT NULL
AND blocked_at IS NULL
AND deleted_at IS NULL)),
ADD CONSTRAINT chk_subscribers_status_blocked
CHECK ((status = 'BLOCKED') = (blocked_at IS NOT NULL AND deleted_at IS NULL)),
ADD CONSTRAINT chk_subscribers_blocked_pair
CHECK ((blocked_at IS NULL) = (blocked_reason IS NULL)),
ADD CONSTRAINT chk_subscribers_anonymized
CHECK (anonymized_at IS NULL OR deleted_at IS NOT NULL),
ADD CONSTRAINT chk_subscribers_full_anonymized_order
CHECK (fully_anonymized_at IS NULL
OR (anonymized_at IS NOT NULL AND fully_anonymized_at >= anonymized_at));Três observações que evitam erro de implementação:
- Não existe
CHECKde formato E.164 nesta tabela, e não pode existir.phone_e164guarda um envelope cifrado; umCHECKcom a expressão'^\+[1-9][0-9]{7,14}$'sobre ele faria todoINSERTde assinante falhar. A validação do formato é do Zod, na borda, antes de cifrar (6.1.8, regra 1). O que o banco valida aqui é apenas o formato do envelope e o formato hexadecimal do índice cego. wa_idnão carrega o+: a Meta devolve o número sem sinal. Comparar telefone comwa_idexige remover o+antes de calcular o HMAC. Isso é responsabilidade depackages/core/src/phone.ts, nunca de SQL ad hoc, e a normalização canônica está na Seção 11.4.DELETEDnão aparece em nenhum destes CHECKs porque não é um valor deSubscriberStatus. Exclusão édeleted_at IS NOT NULL, e os dois CHECKs deOPTED_OUTeBLOCKEDjá exigemdeleted_at IS NULLpara valer, o que mantém a linha excluída fora das duas equivalências sem precisar de um estado próprio.
6.3.4 A coluna status é derivada, e por que ela existe assim mesmo #
status é redundante em relação a phone_verified_at, opt_in_confirmed_at,
paused_until, opt_out_at, blocked_at e tier. A redundância é deliberada: a consulta
de planejamento diário roda uma vez por dia sobre toda a base e precisa de um índice
parcial estreito. Índice parcial sobre seis colunas nulas produz plano pior do que sobre um
enum.
O enum tem sete valores. Regra de consistência, aplicada em uma única função
applySubscriberStatus() em packages/core e garantida pelos CHECKs de 6.3.3:
| Ordem | Condição | status resultante |
|---|---|---|
| 1 | blocked_at IS NOT NULL |
BLOCKED |
| 2 | senão, opt_out_at IS NOT NULL |
OPTED_OUT |
| 3 | senão, phone_verified_at IS NULL |
PENDING_VERIFICATION |
| 4 | senão, opt_in_confirmed_at IS NULL |
VERIFIED_PENDING_OPTIN |
| 5 | senão, paused_until > now() |
PAUSED |
| 6 | senão, tier = 'PAID' |
ACTIVE_PAID |
| 7 | senão | ACTIVE_FREE |
A precedência é fixa nessa ordem. Nenhum caminho de escrita pode gravar status direto.
Exclusão não é status. Conta excluída tem deleted_at preenchido e conserva o último
status operacional que tinha. Toda consulta de produto filtra por deleted_at IS NULL, e
os índices parciais de 6.3.1 já embutem esse filtro. O motivo é prático: se DELETED fosse
um valor do enum, seria um estado sem transição de saída, e as duas equivalências de
OPTED_OUT e BLOCKED deixariam de valer no instante da exclusão, obrigando a reescrever
o status de uma linha que ninguém mais deveria tocar.
BLOCKED tem entrada e saída. Entra por bloqueio administrativo explícito ou por erro
permanente da Meta (código 131026 em três dias consecutivos, ou bloqueio pelo assinante
detectado por webhook), gravando blocked_at e blocked_reason; os envios cessam e a
cobrança não é alterada. Sai por desbloqueio administrativo ou por qualquer mensagem
recebida do assinante, que zera blocked_at e blocked_reason e devolve o assinante ao
status resolvido pela tabela acima. As transições completas, com gatilho e efeito, estão na
Seção 11.8.1.
6.3.5 A denormalização current_subscription_id #
Sem essa coluna, saber o tier vigente de um assinante exige uma junção com
subscriptions filtrando por status e período. No pico de 05:40, com 10.000 linhas, isso
é aceitável; com 50.000, não. A coluna guarda a assinatura vigente e é atualizada na mesma
transação que muda subscriptions.status.
tier continua sendo a fonte de verdade dos entitlements. current_subscription_id é
navegação, não autorização. Um job de reconciliação diário (Seção 6.35.7 traz a consulta)
detecta divergência entre subscribers.tier e o estado real da assinatura e emite alerta.
6.4 Tabela subscriber_profiles #
Dados cadastrais e sensíveis do assinante, separados da tabela principal para que o caminho quente de envio nunca carregue CPF, IP ou user-agent.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
subscriber_id |
char(26) |
não | — | PK e FK. Relação 1:1 com subscribers. |
full_name |
varchar(160) |
sim | NULL |
Nome completo exigido pela criação de cliente no provedor de pagamento. |
cpf |
text |
sim | NULL |
CPF/CNPJ, apenas dígitos, guardado como envelope cifrado AES-256-GCM no formato v1:<iv>:<ct>:<tag> (Seção 22.5.3). Nunca comparado por igualdade. A validação de dígito verificador é do Zod, antes de cifrar. |
cpf_last4 |
char(4) |
sim | NULL |
Últimos 4 dígitos, em claro, para conferência visual no suporte. |
cpf_hmac |
char(64) |
sim | NULL |
HMAC-SHA-256 do CPF (apenas dígitos) com a chave PHONE_INDEX_KEY, em hex. Serve para detectar o mesmo CPF em cadastros diferentes e para consultar a lista de bloqueio por fraude — não para impedir (ver 6.4.1). |
birth_date |
date |
sim | NULL |
Opcional. Usado só em segmentação editorial futura. |
city |
varchar(120) |
sim | NULL |
— |
state |
char(2) |
sim | NULL |
UF. |
church_name |
varchar(160) |
sim | NULL |
Campo opcional de contexto, coletado no onboarding. |
preferred_voice |
varchar(60) |
sim | NULL |
Reservado. No MVP a voz é global; a coluna evita migration futura. |
utm_source |
varchar(120) |
sim | NULL |
— |
utm_medium |
varchar(120) |
sim | NULL |
— |
utm_campaign |
varchar(120) |
sim | NULL |
— |
referrer |
text |
sim | NULL |
Referrer HTTP do cadastro. |
signup_ip |
inet |
sim | NULL |
IP do cadastro. Prova de consentimento. |
signup_user_agent |
text |
sim | NULL |
User-agent do cadastro. |
admin_notes |
text |
sim | NULL |
Notas internas de suporte. Nunca exposto ao assinante. |
created_at |
timestamptz |
não | now() |
— |
updated_at |
timestamptz |
não | now() |
— |
6.4.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
subscriber_profiles_pkey |
PRIMARY KEY (subscriber_id) |
Garante o 1:1 e serve à junção. |
ix_subscriber_profiles_cpf_hmac |
INDEX (cpf_hmac) WHERE cpf_hmac IS NOT NULL |
Índice não único, deliberadamente. Localiza todas as contas que usaram o mesmo CPF, para a pontuação de risco e para a lista de bloqueio por fraude. |
ix_subscriber_profiles_utm |
(utm_campaign) WHERE utm_campaign IS NOT NULL |
Relatório de aquisição por campanha. |
A unicidade de CPF não é imposta, e isso é uma decisão, não um esquecimento. Duas razões concretas, ambas com caso real no Brasil:
- Um titular que pediu eliminação mantém
cpfecpf_hmacpor até 5 anos, por obrigação fiscal (Seção 22.9, estágio 1). Um índice único o impediria de voltar a assinar: oINSERTcolidiria com a linha anonimizada dele mesmo, e o checkout devolveria um erro de banco que nenhum operador de suporte consegue interpretar. - É corriqueiro que uma pessoa contrate para um familiar usando o próprio CPF — mãe que assina para si e depois para a filha. Com índice único, a segunda assinatura falha.
O mesmo cpf_hmac em mais de duas contas ativas incrementa a pontuação de risco da Seção
22.6.3 em 25 pontos e aparece na tela de assinantes como sinal para o operador. Detectar e
sinalizar é o comportamento desejado; bloquear no banco não é.
6.4.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
subscriber_id |
subscribers(id) |
CASCADE |
CASCADE |
CASCADE é correto aqui e só aqui no grupo de identidade: quando um assinante é
apagado de fato (hard delete, que só ocorre no expurgo pós-anonimização), o perfil não
tem razão de existir. Soft delete não dispara cascade.
6.4.3 Constraints CHECK #
ALTER TABLE subscriber_profiles
ADD CONSTRAINT chk_profiles_state CHECK (state IS NULL OR state ~ '^[A-Z]{2}$'),
ADD CONSTRAINT chk_profiles_cpf_last4 CHECK (cpf_last4 IS NULL OR cpf_last4 ~ '^[0-9]{4}$'),
ADD CONSTRAINT chk_profiles_cpf_envelope CHECK (cpf IS NULL OR cpf ~ '^v[0-9]+:'),
ADD CONSTRAINT chk_profiles_cpf_hmac CHECK (cpf_hmac IS NULL OR cpf_hmac ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_profiles_cpf_pair
CHECK ((cpf IS NULL) = (cpf_hmac IS NULL)),
ADD CONSTRAINT chk_profiles_birth_date
CHECK (birth_date IS NULL OR birth_date BETWEEN DATE '1900-01-01' AND CURRENT_DATE);Como em subscribers, nenhum CHECK valida o conteúdo do CPF: a coluna guarda um
envelope. O dígito verificador é conferido por Zod na borda, antes de cifrar, e o erro
correspondente é TAX_ID_INVALID (Seção 7.11). O CHECK de envelope e o de formato
hexadecimal do índice cego são os únicos que o banco consegue aplicar sem enxergar o valor.
6.5 Tabela consent_events #
Trilha imutável de consentimento. É a prova documental exigida pela LGPD e a defesa em caso de reclamação de spam no WhatsApp.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
subscriber_id |
char(26) |
não | — | Titular do consentimento. |
type |
ConsentType |
não | — | Natureza do evento. |
channel |
ConsentChannel |
não | — | Onde o ato ocorreu. |
granted |
boolean |
não | — | true para concessão, false para revogação. |
policy_version |
varchar(20) |
não | — | Versão do texto de política vigente. Ex.: 2026-08-01. É o único nome deste campo: não existe text_version. |
consent_text |
text |
não | — | Texto exato exibido ou enviado, congelado no momento do ato. |
consent_text_hash |
char(64) |
não | — | sha256 do texto, em hex. Detecta adulteração. É o único nome deste campo: não existe text_hash. |
ip |
inet |
sim | NULL |
IP quando o canal é WEB. |
user_agent |
text |
sim | NULL |
User-agent quando o canal é WEB. |
evidence |
jsonb |
sim | NULL |
Evidência específica do canal. Para WhatsApp: {"wamid":"...","buttonPayload":"CONFIRM_OPT_IN"}. |
occurred_at |
timestamptz |
não | now() |
Instante do ato. |
created_at |
timestamptz |
não | now() |
Instante da gravação. Difere de occurred_at quando o evento chega por webhook atrasado. |
Sem updated_at. Sem deleted_at. A tabela é append-only, com garantia no banco
(ver 6.36.3).
6.5.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
consent_events_pkey |
PRIMARY KEY (id) |
— |
ix_consent_events_subscriber |
(subscriber_id, occurred_at DESC) |
Linha do tempo de consentimento do titular. Atende a exportação de dados e a defesa de reclamação. |
ix_consent_events_type_time |
(type, occurred_at DESC) |
Relatórios de opt-in e opt-out por período. |
ix_consent_events_policy_version |
(policy_version) |
Quantos titulares aceitaram cada versão da política. |
6.5.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
subscriber_id |
subscribers(id) |
RESTRICT |
CASCADE |
RESTRICT é intencional e é a razão de a tabela sobreviver ao expurgo do titular: o
processo de eliminação por LGPD anonimiza o assinante (zera telefone, e-mail e nome)
mas não apaga a linha, exatamente para preservar o vínculo com o consentimento durante os
5 anos de retenção. Um DELETE acidental em subscribers falha em vez de destruir prova.
6.5.3 Constraints CHECK #
ALTER TABLE consent_events
ADD CONSTRAINT chk_consent_hash CHECK (consent_text_hash ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_consent_policy_version CHECK (policy_version ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'),
ADD CONSTRAINT chk_consent_web_evidence
CHECK (channel <> 'WEB' OR ip IS NOT NULL),
ADD CONSTRAINT chk_consent_granted_type
CHECK ((type IN ('OPT_OUT') AND granted = false)
OR (type NOT IN ('OPT_OUT')));O último CHECK trava a combinação sem sentido type = 'OPT_OUT' AND granted = true.
Três regras de uso, que outras seções referenciam em vez de redefinir:
- A coluna de contexto chama-se
evidence, e é a única. Não existeconsent_events.metadata, e as tabelas de anonimização e de retenção das Seções 20 e 22 citampolicy_versioneconsent_text_hash, nuncatext_versionnemtext_hash, que não existem. O início e o fim de pausa gravamtype = 'PAUSE_STARTED'comgranted = falseeevidence = {"days": 7, "pausedUntil": "2026-09-02T06:00:00Z"}, etype = 'PAUSE_ENDED'comgranted = true. - A grafia do reingresso é
RE_OPT_IN, com sublinhados.REOPTINnão existe no enum e umINSERTcom esse valor falha. consent_text_hashé o SHA-256 do texto após normalização canônica:NFC, quebras de linha convertidas para\n, espaços em sequência colapsados em um e espaços removidos das pontas. A normalização torna o hash imune a reformatação do arquivo de textos — sem ela, um formatador de código reindentando o arquivo mudaria o hash de uma versão já apresentada e destruiria a prova de qual texto o titular leu.
6.6 Tabela sessions #
Sessões ativas de assinantes e administradores. É o registro que torna o JWT revogável. A semântica de autenticação está na Seção 8.8; aqui fica só a estrutura.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. Vai no claim jti do JWT. |
subject_type |
SessionSubjectType |
não | — | SUBSCRIBER ou ADMIN. |
subscriber_id |
char(26) |
sim | NULL |
Preenchido quando subject_type = 'SUBSCRIBER'. |
admin_user_id |
char(26) |
sim | NULL |
Preenchido quando subject_type = 'ADMIN'. |
role |
Role |
não | — | Papel congelado na emissão. Revalidado a cada refresh. |
scope |
SessionScope |
não | 'FULL' |
Alcance da sessão. RESTRICTED é emitida pela reverificação por dormência (Seção 8.15.1.1) e recusa dado pessoal. IMPERSONATION_READONLY recusa qualquer método não seguro, em qualquer rota, antes de chegar ao handler (Seção 8.13.3). |
refresh_token_hash |
char(64) |
não | — | sha256 do refresh token opaco. O token em claro nunca é persistido. |
parent_session_id |
char(26) |
sim | NULL |
Sessão que originou esta por rotação. Detecta reuso de refresh token. |
impersonated_by_admin_id |
char(26) |
sim | NULL |
Preenchido em sessão de impersonação (Seção 8.13). |
impersonation_reason |
text |
sim | NULL |
Justificativa registrada ao iniciar a impersonação. Exibida ao titular na lista Acessos do suporte (Seção 8.9.1), sem o nome do operador. |
ip |
inet |
sim | NULL |
IP da emissão. |
user_agent |
text |
sim | NULL |
User-agent da emissão. |
device_fingerprint |
char(64) |
sim | NULL |
sha256 de user-agent + accept-language + plataforma. Detecta dispositivo novo. |
issued_at |
timestamptz |
não | now() |
— |
last_seen_at |
timestamptz |
não | now() |
Atualizado no máximo uma vez a cada 5 minutos, para não gerar escrita por requisição. |
expires_at |
timestamptz |
não | — | Fim absoluto da sessão. |
revoked_at |
timestamptz |
sim | NULL |
Revogação explícita. |
revoked_reason |
varchar(60) |
sim | NULL |
logout, logout_all, rotation, reuse_detected, password_change, admin_revoke, tier_downgrade, wa_id_changed, impersonation_ended. |
created_at |
timestamptz |
não | now() |
— |
6.6.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
sessions_pkey |
PRIMARY KEY (id) |
Validação do jti a cada requisição autenticada. |
uq_sessions_refresh_hash |
UNIQUE (refresh_token_hash) |
Localiza a sessão pelo refresh token em O(1) e impede colisão. |
ix_sessions_subscriber_active |
(subscriber_id, expires_at DESC) WHERE revoked_at IS NULL AND subscriber_id IS NOT NULL |
Lista "meus dispositivos" e executa "sair de todos". |
ix_sessions_admin_active |
(admin_user_id, expires_at DESC) WHERE revoked_at IS NULL AND admin_user_id IS NOT NULL |
Mesma coisa para administradores. |
ix_sessions_expires_at |
(expires_at) |
Expurgo noturno de sessões vencidas. |
ix_sessions_parent |
(parent_session_id) WHERE parent_session_id IS NOT NULL |
Rastreia a cadeia de rotação ao detectar reuso. |
ix_sessions_impersonation |
(impersonated_by_admin_id, issued_at DESC) WHERE impersonated_by_admin_id IS NOT NULL |
Auditoria de impersonação. |
6.6.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
subscriber_id |
subscribers(id) |
CASCADE |
CASCADE |
admin_user_id |
admin_users(id) |
CASCADE |
CASCADE |
parent_session_id |
sessions(id) |
SET NULL |
CASCADE |
impersonated_by_admin_id |
admin_users(id) |
SET NULL |
CASCADE |
6.6.3 Constraints CHECK #
ALTER TABLE sessions
ADD CONSTRAINT chk_sessions_exactly_one_subject
CHECK (num_nonnulls(subscriber_id, admin_user_id) = 1),
ADD CONSTRAINT chk_sessions_subject_type_matches
CHECK ((subject_type = 'SUBSCRIBER' AND subscriber_id IS NOT NULL)
OR (subject_type = 'ADMIN' AND admin_user_id IS NOT NULL)),
ADD CONSTRAINT chk_sessions_role_matches_subject
CHECK ((subject_type = 'SUBSCRIBER' AND role = 'SUBSCRIBER')
OR (subject_type = 'ADMIN' AND role IN ('EDITOR','ADMIN','OWNER'))),
ADD CONSTRAINT chk_sessions_expiry CHECK (expires_at > issued_at),
ADD CONSTRAINT chk_sessions_revoked_reason
CHECK ((revoked_at IS NULL) = (revoked_reason IS NULL)),
ADD CONSTRAINT chk_sessions_impersonation_scope
CHECK ((impersonated_by_admin_id IS NULL) = (scope <> 'IMPERSONATION_READONLY')),
ADD CONSTRAINT chk_sessions_impersonation_reason
CHECK (impersonated_by_admin_id IS NULL
OR (impersonation_reason IS NOT NULL
AND length(btrim(impersonation_reason)) >= 10)),
ADD CONSTRAINT chk_sessions_restricted_is_subscriber
CHECK (scope <> 'RESTRICTED' OR subject_type = 'SUBSCRIBER');chk_sessions_impersonation_scope é a garantia estrutural de que impersonação é somente
leitura: uma sessão com administrador impersonando é obrigatoriamente
IMPERSONATION_READONLY, e o invólucro de API recusa todo POST, PATCH, PUT e
DELETE nessa sessão antes de o handler existir. A trava vale para todas as rotas que
alteram estado do assinante, sem exceção de prefixo — inclusive cancelamento de assinatura,
troca de forma de pagamento, troca de plano, reenvio, pedido de eliminação e opt-out, que
não vivem sob /api/me/. A verificação por handler continua existindo, como segunda
camada, nunca como única.
6.7 Tabela otp_codes #
Códigos de uso único para login do assinante e verificações. Cobre tanto o OTP numérico do WhatsApp quanto o token do magic link por e-mail. A semântica está na Seção 8.2 e 8.3.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. Também é o sal de derivação do hash. |
purpose |
OtpPurpose |
não | — | Para que serve o código. |
channel |
OtpChannel |
não | — | WHATSAPP (6 dígitos) ou EMAIL_LINK (token de 32 bytes). |
phone_e164 |
text |
sim | NULL |
Destino quando o canal é WhatsApp. Envelope cifrado, mesmo formato de subscribers.phone_e164 (6.1.8). |
phone_hmac |
char(64) |
sim | NULL |
HMAC do destino. É por esta coluna que se conta quantos pedidos aquele número recebeu na última hora. |
email |
text |
sim | NULL |
Destino quando o canal é e-mail. Envelope cifrado. |
email_hmac |
char(64) |
sim | NULL |
HMAC do e-mail em minúsculas. |
subscriber_id |
char(26) |
sim | NULL |
Preenchido quando o destinatário já existe. Nulo em verificação de número novo. |
code_hash |
char(64) |
não | — | sha256(pepper ‖ id ‖ código) em hex. O código em claro nunca é persistido nem logado. |
attempts |
smallint |
não | 0 |
Tentativas de verificação já feitas. |
max_attempts |
smallint |
não | 5 |
Limite de tentativas. |
expires_at |
timestamptz |
não | — | Vencimento. 10 minutos para WhatsApp, 20 para magic link. |
consumed_at |
timestamptz |
sim | NULL |
Instante do uso bem-sucedido. Torna o código inutilizável. |
invalidated_at |
timestamptz |
sim | NULL |
Invalidação por reenvio, login bem-sucedido por outro código ou ação de suporte. |
request_ip |
inet |
sim | NULL |
IP que pediu o código. Alimenta o limite por IP. |
delivery_message_log_id |
char(26) |
sim | NULL |
Mensagem de entrega correspondente, quando o canal é WhatsApp. |
created_at |
timestamptz |
não | now() |
— |
6.7.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
otp_codes_pkey |
PRIMARY KEY (id) |
— |
ix_otp_codes_phone_active |
(phone_hmac, created_at DESC) WHERE consumed_at IS NULL AND invalidated_at IS NULL |
Busca o código vigente de um número. A contagem de pedidos por número na última hora, que é o limite anti-enumeração da Seção 8.2.1, também sai daqui. |
ix_otp_codes_email_active |
(email_hmac, created_at DESC) WHERE consumed_at IS NULL AND invalidated_at IS NULL AND email_hmac IS NOT NULL |
Mesma coisa para magic link. |
ix_otp_codes_ip_window |
(request_ip, created_at DESC) WHERE request_ip IS NOT NULL |
Limite por IP e detecção de varredura de números. |
ix_otp_codes_expires_at |
(expires_at) |
Expurgo horário. |
ix_otp_codes_subscriber |
(subscriber_id, created_at DESC) WHERE subscriber_id IS NOT NULL |
Histórico de tentativas de login exibido no suporte. |
6.7.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
subscriber_id |
subscribers(id) |
CASCADE |
CASCADE |
delivery_message_log_id não é FK. message_logs é particionada e sua chave primária
é composta (id, created_at), então uma FK simples por id não é declarável. A coluna é
uma referência lógica, resolvida na aplicação. Isso está documentado em 6.19.4.
6.7.3 Constraints CHECK #
ALTER TABLE otp_codes
ADD CONSTRAINT chk_otp_destination
CHECK ((channel = 'WHATSAPP' AND phone_e164 IS NOT NULL AND phone_hmac IS NOT NULL)
OR (channel = 'EMAIL_LINK' AND email IS NOT NULL AND email_hmac IS NOT NULL)),
ADD CONSTRAINT chk_otp_phone_envelope
CHECK (phone_e164 IS NULL OR phone_e164 ~ '^v[0-9]+:'),
ADD CONSTRAINT chk_otp_email_envelope
CHECK (email IS NULL OR email ~ '^v[0-9]+:'),
ADD CONSTRAINT chk_otp_phone_hmac
CHECK (phone_hmac IS NULL OR phone_hmac ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_otp_email_hmac
CHECK (email_hmac IS NULL OR email_hmac ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_otp_attempts CHECK (attempts >= 0 AND attempts <= max_attempts),
ADD CONSTRAINT chk_otp_max_attempts CHECK (max_attempts BETWEEN 1 AND 10),
ADD CONSTRAINT chk_otp_expiry CHECK (expires_at > created_at),
ADD CONSTRAINT chk_otp_code_hash CHECK (code_hash ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_otp_single_terminal_state
CHECK (consumed_at IS NULL OR invalidated_at IS NULL);O último CHECK impede que um código esteja simultaneamente consumido e invalidado, o que tornaria ambígua a auditoria de "este código foi usado ou barrado?".
6.7.4 Tabela unsubscribe_tokens #
Tokens opacos de uso único do link público de descadastro, aquele que acompanha o rodapé do e-mail e a mensagem de confirmação. É a 28ª tabela do modelo e a última da lista fechada de 6.1.7. Esta subseção é a única dona da estrutura: a Seção 20, que é a dona do comportamento do opt-out, referencia daqui e não redefine coluna, tipo nem índice.
Ela não cabe em otp_codes: o token do descadastro não é um código de autenticação, não
tem contador de tentativas, não tem canal de entrega próprio e a sua validade é de dias, e
não de minutos. Reaproveitar otp_codes exigiria um valor novo no enum OtpPurpose e
colunas que nunca seriam preenchidas — uma tabela própria é mais barata do que um enum
poluído.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. Chave primária. |
subscriber_id |
char(26) |
não | — | Titular a quem o link pertence. |
token_hash |
char(64) |
não | — | sha256 do token opaco, em hex minúsculo. O token em claro é entregue uma única vez, no corpo do e-mail ou da mensagem, e nunca é persistido nem registrado em log. |
created_at |
timestamptz |
não | now() |
Emissão. |
expires_at |
timestamptz |
não | — | Vencimento. created_at + 90 dias, para que um e-mail antigo ainda funcione dentro de um ciclo razoável de caixa de entrada. |
used_at |
timestamptz |
sim | NULL |
Instante do consumo. Preenchido torna o token inutilizável: o segundo clique no mesmo link devolve UNSUBSCRIBE_TOKEN_INVALID (Seção 7.11). |
Sem updated_at: a linha nasce e, no máximo, recebe used_at uma vez.
Índices
| Índice | Definição | Justificativa |
|---|---|---|
unsubscribe_tokens_pkey |
PRIMARY KEY (id) |
— |
uq_unsubscribe_token_hash |
UNIQUE (token_hash) |
Resolve o token apresentado em uma leitura e impede colisão. |
ix_unsubscribe_tokens_subscriber |
(subscriber_id, created_at DESC) |
Todos os links emitidos para um titular, em ordem, no atendimento. |
ix_unsubscribe_tokens_expiring |
(expires_at) WHERE used_at IS NULL |
Expurgo dos tokens vencidos e nunca usados, que são a maioria. |
Chave estrangeira
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
subscriber_id |
subscribers(id) |
CASCADE |
CASCADE |
CASCADE aqui é o oposto do RESTRICT de consent_events, e por um motivo: o token não é
prova de nada. Ele é uma credencial de curta vida; sumindo o titular, ela perde qualquer
função e mantê-la só deixa um segredo vivo apontando para uma conta que não existe. A prova
do descadastro fica em consent_events, com type = 'OPT_OUT', que é append-only.
Constraints CHECK
ALTER TABLE unsubscribe_tokens
ADD CONSTRAINT chk_unsub_id_ulid CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
ADD CONSTRAINT chk_unsub_token_hash CHECK (token_hash ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_unsub_expiry CHECK (expires_at > created_at),
ADD CONSTRAINT chk_unsub_used_after_created
CHECK (used_at IS NULL OR used_at >= created_at);DDL completo, para a migration M4 (a criação da tabela e a FK) e para M10 e M11 (o
índice parcial e os CHECK, que o Prisma não representa — 6.31.2):
CREATE TABLE unsubscribe_tokens (
id char(26) NOT NULL,
subscriber_id char(26) NOT NULL,
token_hash char(64) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
used_at timestamptz,
CONSTRAINT unsubscribe_tokens_pkey PRIMARY KEY (id),
CONSTRAINT unsubscribe_tokens_subscriber_id_fkey
FOREIGN KEY (subscriber_id) REFERENCES subscribers(id)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX uq_unsubscribe_token_hash
ON unsubscribe_tokens (token_hash);
CREATE INDEX ix_unsubscribe_tokens_subscriber
ON unsubscribe_tokens (subscriber_id, created_at DESC);
CREATE INDEX ix_unsubscribe_tokens_expiring
ON unsubscribe_tokens (expires_at) WHERE used_at IS NULL;Retenção. Expurgo físico 30 dias depois de expires_at, junto com o expurgo de
otp_codes (6.33.1 e 6.33.2), e imediato em qualquer token do titular no momento em que o
opt-out é gravado — quem já saiu não precisa de um link para sair de novo. Volume esperado
em 1 ano, com 10.000 assinantes: cerca de 15.000 linhas.
6.8 Tabela admin_users #
Contas do painel administrativo. Não há autocadastro: contas nascem por seed ou por
criação feita por outra conta com papel OWNER ou ADMIN.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
email |
citext |
não | — | Login. Case-insensitive. |
name |
varchar(120) |
não | — | Nome exibido no painel e na auditoria. |
password_hash |
text |
não | — | Hash argon2id completo, no formato PHC ($argon2id$v=19$m=...). Parâmetros na Seção 8.5. |
role |
Role |
não | 'EDITOR' |
Papel. Nunca SUBSCRIBER. |
totp_secret_encrypted |
bytea |
sim | NULL |
Segredo TOTP cifrado com AES-256-GCM. Nulo até a conclusão do enrolamento. |
totp_enrolled_at |
timestamptz |
sim | NULL |
Conclusão do enrolamento. Enquanto nulo, o login exige enrolar antes de qualquer outra ação. |
totp_recovery_codes |
jsonb |
sim | NULL |
Array de objetos {"hash":"<sha256>","usedAt":null}. 10 códigos. Nunca em claro. |
failed_login_count |
smallint |
não | 0 |
Total agregado de falhas de senha ou TOTP, apenas para alerta. A escada de bloqueio incide sobre o par (conta, origem) e é mantida em Redis sob authfail:{adminId}:{ipPrefix} (Seção 8.7); esta coluna nunca decide um bloqueio sozinha. |
locked_until |
timestamptz |
sim | NULL |
Bloqueio global da conta, usado só na salvaguarda contra ataque distribuído: 20 falhas agregadas em 1 hora vindas de 3 ou mais origens distintas trancam a conta por 1 hora e emitem alerta crítico. |
last_login_at |
timestamptz |
sim | NULL |
— |
last_login_ip |
inet |
sim | NULL |
— |
password_changed_at |
timestamptz |
não | now() |
Base para expiração de senha e para invalidar sessões antigas. |
must_change_password |
boolean |
não | false |
Força troca no próximo login. Verdadeiro nas contas criadas por seed. |
created_by_admin_id |
char(26) |
sim | NULL |
Quem criou a conta. Nulo apenas na conta de seed. |
deleted_at |
timestamptz |
sim | NULL |
Soft delete. Conta desativada não faz login e não aparece em listas. |
created_at |
timestamptz |
não | now() |
— |
updated_at |
timestamptz |
não | now() |
— |
6.8.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
admin_users_pkey |
PRIMARY KEY (id) |
— |
uq_admin_users_email |
UNIQUE (email) WHERE deleted_at IS NULL |
Login único entre contas vivas. Permite reaproveitar o e-mail depois de uma conta ser desativada. |
ix_admin_users_role |
(role) WHERE deleted_at IS NULL |
Lista por papel e verificação de "existe ao menos um OWNER". |
ix_admin_users_locked |
(locked_until) WHERE locked_until IS NOT NULL |
Desbloqueio automático em lote. |
6.8.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
created_by_admin_id |
admin_users(id) |
SET NULL |
CASCADE |
6.8.3 Constraints CHECK #
ALTER TABLE admin_users
ADD CONSTRAINT chk_admin_role_not_subscriber CHECK (role <> 'SUBSCRIBER'),
ADD CONSTRAINT chk_admin_email_shape CHECK (email ~ '^[^@[:space:]]+@[^@[:space:]]+\.[a-z]{2,}$'),
ADD CONSTRAINT chk_admin_password_hash_argon2 CHECK (password_hash LIKE '$argon2id$%'),
ADD CONSTRAINT chk_admin_failed_login CHECK (failed_login_count BETWEEN 0 AND 100),
ADD CONSTRAINT chk_admin_totp_pair
CHECK ((totp_secret_encrypted IS NULL AND totp_enrolled_at IS NULL)
OR (totp_secret_encrypted IS NOT NULL));Regra adicional garantida na aplicação, não no banco: sempre existe ao menos uma conta
OWNER ativa. Tentativa de rebaixar ou desativar o último OWNER devolve
OWNER_REQUIRED (Seção 7.11). Não é um CHECK porque a verificação é sobre o conjunto da
tabela, não sobre a linha, e um trigger para isso criaria contenção no UPDATE.
6.8.4 Tabela admin_trusted_devices #
Dispositivos que o administrador marcou como confiáveis, dispensando o segundo fator em
logins seguintes. É a 27ª tabela do modelo; a 28ª e última é unsubscribe_tokens (6.7.4).
Ela existe por um motivo específico: um fingerprint derivado de cabeçalhos não pode
governar autenticação. O sessions.device_fingerprint é sha256 de user-agent,
Accept-Language e plataforma declarada — três valores inteiramente controlados pelo
cliente e de entropia baixíssima. Usar esse valor para dispensar o TOTP significaria que
adivinhar a combinação de cabeçalhos mais comum entre administradores brasileiros
(Chrome em Macintosh com pt-BR) burla o segundo fator com a senha na mão. O que
dispensa o TOTP passa a ser um segredo emitido pelo servidor, guardado só como hash.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
admin_user_id |
char(26) |
não | — | Dono do dispositivo. |
token_hash |
char(64) |
não | — | sha256, em hex, dos 32 bytes de crypto.randomBytes entregues no cookie __Host-admin-device. O valor em claro nunca é persistido. |
label |
varchar(80) |
sim | NULL |
Rótulo legível, montado a partir do user-agent no momento da criação. Informativo apenas. |
created_at |
timestamptz |
não | now() |
— |
expires_at |
timestamptz |
não | — | created_at + 30 dias. |
last_used_at |
timestamptz |
sim | NULL |
Último login que apresentou este cookie. |
Índices
| Índice | Definição | Justificativa |
|---|---|---|
admin_trusted_devices_pkey |
PRIMARY KEY (id) |
— |
uq_admin_trusted_token |
UNIQUE (token_hash) |
Resolve o cookie em uma leitura e impede colisão. |
ix_admin_trusted_admin |
(admin_user_id, expires_at DESC) |
Lista de dispositivos confiáveis do administrador e revogação em massa. |
ix_admin_trusted_expires |
(expires_at) |
Expurgo dos vencidos. |
Chave estrangeira
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
admin_user_id |
admin_users(id) |
CASCADE |
CASCADE |
Constraints CHECK
ALTER TABLE admin_trusted_devices
ADD CONSTRAINT chk_trusted_token_hash CHECK (token_hash ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_trusted_expiry CHECK (expires_at > created_at);Regras de uso, garantidas na aplicação:
- A senha continua sendo exigida sempre. O cookie dispensa apenas o TOTP.
- Toda a lista do administrador é apagada em qualquer troca de senha, em uso de código de
recuperação e em
logout-all. sessions.device_fingerprintpermanece exclusivamente como sinal informativo para a notificação de dispositivo novo, e é proibido usá-lo em qualquer decisão de autorização. Um teste de arquitetura falha sedevice_fingerprintfor lido fora do módulo de notificação.
6.9 Tabela admin_audit_log #
Registro de toda ação administrativa que muda estado. É append-only na prática, com a
mesma proteção de consent_events (ver 6.36.3).
Esta tabela tem um único esquema, e é o desta subseção. As Seções 13, 15 e 23, que listam campos da auditoria em tela, em exportação e em documentação de formato, usam exatamente os nomes e os tipos abaixo. A tabela é append-only com 5 anos de retenção: errar o nome de uma coluna aqui não é reversível por migration barata, então a lista de nomes proibidos ao final desta subseção é tão normativa quanto a própria tabela.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
admin_user_id |
char(26) |
sim | NULL |
Autor administrativo da ação. Nulo quando actor_type é SYSTEM ou SUBSCRIBER. É o único nome deste campo; actor_admin_id e actor_id não existem. |
actor_type |
varchar(20) |
não | 'ADMIN' |
Natureza do autor: ADMIN, SUBSCRIBER ou SYSTEM. É o único dos campos de ator que não é redundante: admin_user_id nulo, sozinho, não distingue "foi o sistema" de "foi o próprio titular" — e o titular grava auditoria quando age sobre a própria conta (Seção 14.6.2). |
actor_role |
varchar(20) |
não | — | Papel efetivo no momento da ação: EDITOR, ADMIN, OWNER ou SYSTEM. Gravado em vez de derivado, porque o papel do administrador muda depois e o registro precisa dizer o que ele podia fazer naquele dia. |
action |
varchar(80) |
não | — | Verbo canônico. Ex.: devotional.publish, subscriber.impersonate.start, settings.update. |
entity_type |
varchar(60) |
sim | NULL |
Nome da tabela ou agregado afetado. É o único nome deste campo; target_type não existe. |
entity_id |
char(26) |
sim | NULL |
Identificador do recurso afetado. É o único nome deste campo; target_id não existe. |
before |
jsonb |
sim | NULL |
Estado anterior, apenas com os campos alterados. Campos sensíveis já redigidos. É o único nome deste campo; before_json não existe. |
after |
jsonb |
sim | NULL |
Estado posterior, mesmos campos. É o único nome deste campo; after_json não existe. |
changed_fields |
text[] |
não | '{}' |
Nomes dos campos alterados nesta ação. Permite filtrar a auditoria por campo sem abrir o JSON de before e after, que é a consulta cara. |
metadata |
jsonb |
sim | NULL |
Dados estruturados específicos da ação — por exemplo days e previousCourtesyUntil na concessão de cortesia (Seção 13.11.3). Nunca contém dado pessoal em texto claro. |
record_hash |
char(64) |
não | — | sha256, em hex minúsculo, do conteúdo canônico da linha concatenado ao record_hash da linha anterior. É o encadeamento que detecta adulteração (Seção 23.11.3). Calculado no gatilho de inserção, junto com a proteção append-only de 6.36.3. |
reason |
text |
sim | NULL |
Justificativa. Obrigatória em ações destrutivas e em impersonação. |
ip |
inet |
sim | NULL |
— |
user_agent |
text |
sim | NULL |
— |
request_id |
char(26) |
sim | NULL |
ULID da requisição HTTP. Correlaciona com os logs da aplicação. |
session_id |
char(26) |
sim | NULL |
Sessão usada. Referência lógica; sessões expurgadas não invalidam a auditoria. |
created_at |
timestamptz |
não | now() |
Carimbo da ação, sempre timestamptz em UTC. É a única coluna de tempo desta tabela. |
Nomes que não são colunas desta tabela, e que nenhuma seção pode citar como se fossem:
| Nome citado em outra seção | O que usar |
|---|---|
actor_admin_id, actor_id |
admin_user_id |
target_type, target_id |
entity_type, entity_id. Os parâmetros de query da rota de auditoria acompanham: entityType e entityId (Seção 15.12.15) |
before_json, after_json |
before, after |
actor_email |
Não é persistido. O e-mail do autor é resolvido na exportação a partir de admin_user_id, porque duplicar o e-mail em 80.000 linhas append-only cria uma segunda cópia de dado pessoal que a troca de e-mail do administrador nunca alcança. |
created_at_utc, created_at_brt |
Não são colunas. São projeções de apresentação de created_at, calculadas na geração do CSV de exportação (Seção 15.10.3): a primeira é a própria coluna, a segunda é a conversão para America/Sao_Paulo. |
6.9.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
admin_audit_log_pkey |
PRIMARY KEY (id) |
— |
ix_audit_admin_time |
(admin_user_id, created_at DESC) |
"O que este administrador fez?" |
ix_audit_entity |
(entity_type, entity_id, created_at DESC) |
"Quem mexeu neste devocional?" |
ix_audit_action_time |
(action, created_at DESC) |
Relatório por tipo de ação e alerta de ações sensíveis. |
ix_audit_request_id |
(request_id) WHERE request_id IS NOT NULL |
Correlação com log de aplicação a partir do X-Request-Id. |
ix_audit_actor_type_time |
(actor_type, created_at DESC) |
Separa a ação do administrador da ação do sistema e da ação do próprio titular nos relatórios de acesso. |
ix_audit_changed_fields |
GIN (changed_fields) |
Filtra "quem mexeu neste campo?" sem abrir before e after. |
6.9.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
admin_user_id |
admin_users(id) |
RESTRICT |
CASCADE |
RESTRICT protege a trilha: administradores são desativados por soft delete, nunca
apagados. Um DELETE real falha, o que é o comportamento desejado.
6.9.3 Constraints CHECK #
ALTER TABLE admin_audit_log
ADD CONSTRAINT chk_audit_action_shape CHECK (action ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$'),
ADD CONSTRAINT chk_audit_entity_pair
CHECK ((entity_type IS NULL) = (entity_id IS NULL)),
ADD CONSTRAINT chk_audit_actor_type
CHECK (actor_type IN ('ADMIN','SUBSCRIBER','SYSTEM')),
ADD CONSTRAINT chk_audit_actor_role
CHECK (actor_role IN ('EDITOR','ADMIN','OWNER','SYSTEM')),
ADD CONSTRAINT chk_audit_admin_actor_pair
CHECK ((actor_type = 'ADMIN') = (admin_user_id IS NOT NULL)),
ADD CONSTRAINT chk_audit_system_role
CHECK (actor_type <> 'SYSTEM' OR actor_role = 'SYSTEM'),
ADD CONSTRAINT chk_audit_record_hash CHECK (record_hash ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_audit_reason_required
CHECK (action NOT IN ('subscriber.impersonate.start','subscriber.delete',
'devotional.delete','admin_user.delete','settings.update')
OR (reason IS NOT NULL AND length(btrim(reason)) >= 10));O último CHECK exige justificativa de no mínimo 10 caracteres nas cinco ações consideradas sensíveis. Sem isso a auditoria vira ruído.
chk_audit_admin_actor_pair é o que amarra os dois campos de ator: actor_type = 'ADMIN'
exige admin_user_id, e qualquer outro valor exige que ele seja nulo. Sem essa amarra, uma
linha com actor_type = 'SYSTEM' e um admin_user_id preenchido atribuiria a uma pessoa
uma ação que ela não praticou — o pior defeito possível em uma trilha de auditoria.
action é sempre grupo.verbo em minúsculas, e o CHECK acima recusa qualquer outra
grafia. Onde outra seção citar uma ação em maiúsculas, o nome canônico é o desta tabela.
As ações que outras seções acionam e que precisam existir com estes nomes exatos são:
| Ação | Quando é gravada |
|---|---|
subscriber.impersonate.start |
Início de sessão de impersonação (Seção 8.13.4). Exige reason. |
subscriber.impersonate.end |
Encerramento da sessão de impersonação, por logout ou por expiração. |
subscriber.viewed |
Abertura da ficha de um assinante. Alimenta o alerta de acesso em massa. |
subscriber.pii_revealed |
Revelação de telefone, e-mail ou CPF em claro no painel. after guarda só o nome do campo revelado, nunca o valor. |
subscriber.anonymized |
Estágio 1 do pedido de eliminação (pseudonimização). |
subscriber.fully_anonymized |
Estágio 2, quando a retenção fiscal vence e CPF e identificadores do provedor de pagamento são zerados (Seção 22.9). |
data_export.downloaded |
Cada download do pacote de portabilidade, sob sessão plena. |
settings.update |
Alteração de chave de configuração. Exige reason. |
O par subscriber.viewed / subscriber.pii_revealed é o único controle capaz de detectar
um administrador vasculhando a base. Registrar sem alertar não detecta nada: os limiares e
os destinatários do alerta estão na Seção 23.8.
6.10 Tabela plans #
Catálogo de planos vendáveis. Duas linhas no MVP: plan_monthly e plan_annual.
O plano gratuito não é uma linha desta tabela. Ser gratuito é a ausência de assinatura
vigente. Onde a interface precisar exibir um "plano gratuito" — a tabela comparativa da
página de vendas, por exemplo —, o item é sintetizado pelo handler, com valores fixos e
priceCents: 0, e nunca corresponde a nenhuma linha aqui. Nenhuma escrita usa o código
plan_free, e nenhum seed o cria. Um executor que criar essa linha para "fazer a rota
funcionar" quebra o cálculo de MRR, a contagem de assinantes pagos e a regra de uma
assinatura viva por assinante.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
code |
varchar(40) |
não | — | Identificador estável usado em código e URL. plan_monthly, plan_annual. |
name |
varchar(80) |
não | — | Nome exibido. Ex.: Mensal. |
description |
varchar(200) |
sim | NULL |
Linha de apoio na página de vendas. |
interval |
PlanInterval |
não | — | MONTHLY ou YEARLY. |
amount_cents |
integer |
não | — | Preço em centavos de BRL. 1990 = R$ 19,90. |
currency |
char(3) |
não | 'BRL' |
ISO 4217. |
tier_granted |
SubscriberTier |
não | 'PAID' |
Tier concedido enquanto a assinatura estiver ativa. |
asaas_cycle |
varchar(20) |
não | — | Valor de ciclo aceito pelo provedor de pagamento: MONTHLY ou YEARLY. |
is_active |
boolean |
não | true |
Plano fechado para novas vendas fica false, sem afetar assinaturas existentes. |
sort_order |
smallint |
não | 0 |
Ordem de exibição na página de vendas. |
created_at |
timestamptz |
não | now() |
— |
updated_at |
timestamptz |
não | now() |
— |
6.10.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
plans_pkey |
PRIMARY KEY (id) |
— |
uq_plans_code |
UNIQUE (code) |
Referência estável a partir do código, da URL de checkout e dos seeds. |
ix_plans_active_order |
(is_active, sort_order) |
Montagem da grade de preços. |
6.10.2 Constraints CHECK #
ALTER TABLE plans
ADD CONSTRAINT chk_plans_code_shape CHECK (code ~ '^plan_[a-z0-9_]{2,30}$'),
ADD CONSTRAINT chk_plans_amount CHECK (amount_cents > 0 AND amount_cents <= 100000000),
ADD CONSTRAINT chk_plans_currency CHECK (currency = 'BRL'),
ADD CONSTRAINT chk_plans_cycle_matches_interval
CHECK ((interval = 'MONTHLY' AND asaas_cycle = 'MONTHLY')
OR (interval = 'YEARLY' AND asaas_cycle = 'YEARLY'));Preço nunca é editado in loco depois de existirem assinaturas. Mudança de preço cria
uma linha nova com novo code (ex.: plan_monthly_v2) e marca a anterior como
is_active = false. Isso preserva o histórico e é o motivo de subscriptions guardar
amount_cents como snapshot próprio (6.11).
6.11 Tabela subscriptions #
Assinatura paga de um assinante. A máquina de estados e as regras de revogação imediata estão na Seção 13; aqui fica a estrutura que as sustenta.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. É também o externalReference enviado ao provedor de pagamento. |
subscriber_id |
char(26) |
não | — | Dono da assinatura. |
plan_id |
char(26) |
não | — | Plano contratado. |
status |
SubscriptionStatus |
não | 'PENDING_PAYMENT' |
Estado atual. |
billing_type |
BillingType |
não | — | CREDIT_CARD ou PIX. |
amount_cents |
integer |
não | — | Snapshot do preço no momento da contratação. |
currency |
char(3) |
não | 'BRL' |
— |
asaas_customer_id |
varchar(40) |
sim | NULL |
Identificador do cliente no provedor. Cópia de subscribers.asaas_customer_id, preenchida antes da criação da assinatura. |
asaas_subscription_id |
varchar(40) |
sim | NULL |
Identificador da assinatura no provedor. |
asaas_card_token |
varchar(64) |
sim | NULL |
Token do cartão tokenizado no provedor. Não é dado de cartão: é uma referência opaca. Número completo, CVV e validade nunca transitam para persistência nem para log. |
card_brand |
varchar(20) |
sim | NULL |
Bandeira do cartão vinculado. Ex.: VISA. |
card_last4 |
char(4) |
sim | NULL |
Últimos 4 dígitos, para o assinante reconhecer o cartão no painel. |
card_exp_month |
smallint |
sim | NULL |
Mês de validade, 1 a 12. Base do aviso de cartão a vencer. |
card_exp_year |
smallint |
sim | NULL |
Ano de validade, quatro dígitos. |
pending_plan_id |
char(26) |
sim | NULL |
Plano para o qual a assinatura migra no próximo ciclo, quando há troca de plano agendada. |
pending_plan_effective_at |
timestamptz |
sim | NULL |
Instante em que a troca de plano passa a valer. Sempre o fim do período pago vigente. |
cancel_at_period_end |
boolean |
não | false |
O sinalizador. Verdadeiro quando a assinatura não gera nova cobrança mas o período já pago segue valendo. Ligado no cancelamento voluntário (Seção 13.4.1, caso b) e no opt-out (Seção 13.4.6). É o campo que o job billing.lifecycle lê para decidir se renova. |
billing_suspended_at |
timestamptz |
sim | NULL |
O carimbo, e só quando a origem foi o opt-out. Guarda o instante em que cancel_at_period_end foi ligado por opt-out; permanece nulo quando a origem foi cancelamento voluntário pelo painel. As duas colunas não são redundantes: o booleano diz o quê (não renova), o carimbo diz por quê e quando, e é ele que a régua de 30 dias usa para encerrar a assinatura sem reativação. Envios param na hora e a cobrança do ciclo seguinte não é emitida; sem reativação em 30 dias, a assinatura é encerrada ao fim do período já pago (Seção 13.4.6). |
end_reason |
varchar(40) |
sim | NULL |
Causa canônica do encerramento. Ex.: subscriber_request, payment_overdue, refund, chargeback, optout_timeout, admin. |
started_at |
timestamptz |
sim | NULL |
Primeira confirmação de pagamento. |
current_period_start |
timestamptz |
sim | NULL |
Início do ciclo pago vigente. |
current_period_end |
timestamptz |
sim | NULL |
Fim do ciclo pago vigente. Fronteira do acesso após cancelamento. |
next_due_date |
date |
sim | NULL |
Próximo vencimento informado pelo provedor. Base dos lembretes D-3 e D-1. |
cancel_requested_at |
timestamptz |
sim | NULL |
Pedido de cancelamento feito pelo assinante. |
canceled_at |
timestamptz |
sim | NULL |
Efetivação do cancelamento. |
cancel_reason |
varchar(120) |
sim | NULL |
Motivo escolhido no painel ou informado pelo suporte. |
ends_at |
timestamptz |
sim | NULL |
Instante em que o acesso PAID termina de fato. |
expired_at |
timestamptz |
sim | NULL |
Revogação por inadimplência. |
refunded_at |
timestamptz |
sim | NULL |
Estorno. |
last_payment_id |
char(26) |
sim | NULL |
Último pagamento associado. |
reconciled_at |
timestamptz |
sim | NULL |
Última conferência bem-sucedida contra o provedor. |
metadata |
jsonb |
sim | NULL |
Campos auxiliares do provedor que não merecem coluna própria. |
created_at |
timestamptz |
não | now() |
— |
updated_at |
timestamptz |
não | now() |
— |
6.11.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
subscriptions_pkey |
PRIMARY KEY (id) |
— |
uq_subscriptions_asaas_id |
UNIQUE (asaas_subscription_id) WHERE asaas_subscription_id IS NOT NULL |
Resolve o webhook do provedor em uma leitura e impede duplicidade de vínculo. |
uq_subscriptions_one_live_per_subscriber |
UNIQUE (subscriber_id) WHERE status IN ('PENDING_PAYMENT','ACTIVE') |
Regra de negócio no banco: um assinante não pode ter duas assinaturas vivas. Tentativa concorrente falha com violação de unicidade, traduzida para SUBSCRIPTION_ALREADY_ACTIVE. |
ix_subscriptions_subscriber_time |
(subscriber_id, created_at DESC) |
Histórico de assinaturas no painel e no suporte. |
ix_subscriptions_status_period_end |
(status, current_period_end) |
Encontra assinaturas canceladas cujo período pago venceu e precisam ser rebaixadas. |
ix_subscriptions_next_due |
(next_due_date) WHERE status = 'ACTIVE' |
Seleciona alvos dos lembretes D-3 e D-1. |
ix_subscriptions_reconcile |
(reconciled_at NULLS FIRST) WHERE status IN ('PENDING_PAYMENT','ACTIVE') |
Fila da reconciliação diária, do mais desatualizado para o mais recente. |
ix_subscriptions_plan |
(plan_id) |
Contagem por plano no dashboard. |
ix_subscriptions_cancel_at_period_end |
(current_period_end) WHERE cancel_at_period_end = true |
Varredura do job billing.lifecycle: quais assinaturas terminam e não renovam. Parcial porque a esmagadora maioria das linhas vivas renova. |
6.11.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
subscriber_id |
subscribers(id) |
RESTRICT |
CASCADE |
plan_id |
plans(id) |
RESTRICT |
CASCADE |
pending_plan_id |
plans(id) |
RESTRICT |
CASCADE |
last_payment_id |
payments(id) |
SET NULL |
CASCADE |
RESTRICT em subscriber_id e plan_id é obrigação fiscal: histórico financeiro não
some porque alguém apagou um cadastro. last_payment_id é FK circular com
payments.subscription_id, declarada DEFERRABLE INITIALLY DEFERRED.
6.11.3 Constraints CHECK #
ALTER TABLE subscriptions
ADD CONSTRAINT chk_subs_amount CHECK (amount_cents > 0),
ADD CONSTRAINT chk_subs_currency CHECK (currency = 'BRL'),
ADD CONSTRAINT chk_subs_period CHECK (current_period_end IS NULL
OR current_period_start IS NULL
OR current_period_end > current_period_start),
ADD CONSTRAINT chk_subs_active_needs_period
CHECK (status <> 'ACTIVE' OR current_period_end IS NOT NULL),
ADD CONSTRAINT chk_subs_canceled_fields
CHECK (status <> 'CANCELED' OR canceled_at IS NOT NULL),
ADD CONSTRAINT chk_subs_expired_fields
CHECK (status <> 'EXPIRED' OR expired_at IS NOT NULL),
ADD CONSTRAINT chk_subs_refunded_fields
CHECK (status <> 'REFUNDED' OR refunded_at IS NOT NULL),
ADD CONSTRAINT chk_subs_cancel_order
CHECK (cancel_requested_at IS NULL OR canceled_at IS NULL
OR canceled_at >= cancel_requested_at),
ADD CONSTRAINT chk_subs_card_pair
CHECK ((card_brand IS NULL) = (card_last4 IS NULL)),
ADD CONSTRAINT chk_subs_card_last4
CHECK (card_last4 IS NULL OR card_last4 ~ '^[0-9]{4}$'),
ADD CONSTRAINT chk_subs_card_expiry
CHECK ((card_exp_month IS NULL) = (card_exp_year IS NULL)),
ADD CONSTRAINT chk_subs_card_exp_month
CHECK (card_exp_month IS NULL OR card_exp_month BETWEEN 1 AND 12),
ADD CONSTRAINT chk_subs_card_exp_year
CHECK (card_exp_year IS NULL OR card_exp_year BETWEEN 2020 AND 2100),
ADD CONSTRAINT chk_subs_card_only_for_card
CHECK (billing_type = 'CREDIT_CARD' OR asaas_card_token IS NULL),
ADD CONSTRAINT chk_subs_pending_plan_pair
CHECK ((pending_plan_id IS NULL) = (pending_plan_effective_at IS NULL)),
ADD CONSTRAINT chk_subs_pending_plan_differs
CHECK (pending_plan_id IS NULL OR pending_plan_id <> plan_id),
ADD CONSTRAINT chk_subs_end_reason_shape
CHECK (end_reason IS NULL OR end_reason ~ '^[a-z][a-z0-9_]{2,39}$'),
ADD CONSTRAINT chk_subs_ended_needs_reason
CHECK (status NOT IN ('CANCELED','EXPIRED','REFUNDED') OR end_reason IS NOT NULL),
ADD CONSTRAINT chk_subs_suspension_implies_flag
CHECK (billing_suspended_at IS NULL OR cancel_at_period_end = true);chk_subs_pending_plan_differs impede o caso silencioso de uma troca de plano agendada
para o mesmo plano, que passaria pelo UPDATE e nunca produziria efeito visível.
chk_subs_ended_needs_reason garante que toda assinatura encerrada carregue a causa: sem
ela, o relatório de churn da Seção 21 mede quantidade e não consegue medir motivo.
chk_subs_suspension_implies_flag amarra as duas colunas de suspensão no único sentido em
que a implicação vale: carimbo preenchido exige sinalizador ligado, nunca o contrário.
O inverso seria errado — o cancelamento voluntário liga o sinalizador e deixa o carimbo
nulo de propósito, porque ali não houve opt-out. Sem esta trava, uma escrita parcial
produziria uma assinatura marcada como suspensa por opt-out que o job de ciclo continuaria
renovando.
6.11.4 Exemplo concreto de linha #
Assinante contrata o plano mensal por PIX em 2026-08-25 e o pagamento é confirmado no mesmo dia:
{
"id": "01K3F8R1P2ABCDEFGHJKMNPQRS",
"subscriberId": "01K3F8QZ7MHV2N9R4B6T0XYZAB",
"planId": "01K3F8RKL1PLANMONTHLY00001",
"status": "ACTIVE",
"billingType": "PIX",
"amountCents": 1990,
"currency": "BRL",
"startedAt": "2026-08-25T13:04:11.000Z",
"currentPeriodStart": "2026-08-25T13:04:11.000Z",
"currentPeriodEnd": "2026-09-25T13:04:11.000Z",
"nextDueDate": "2026-09-25"
}Se em 2026-09-25 o provedor emitir PAYMENT_OVERDUE, a mesma transação grava
status = 'EXPIRED', expired_at = now(), ends_at = now() e
subscribers.tier = 'FREE'. Não há carência: o envio de 2026-09-26 já sai sem áudio, e no
domingo seguinte o assinante volta ao regime semanal.
6.12 Tabela subscription_events #
Histórico imutável das transições de assinatura. Serve de trilha para suporte, para o cálculo de churn (Seção 21) e para reconstruir o estado em caso de divergência.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
subscription_id |
char(26) |
não | — | Assinatura afetada. |
subscriber_id |
char(26) |
não | — | Denormalizado para consultar a linha do tempo do assinante sem junção. |
type |
SubscriptionEventType |
não | — | Tipo da transição. |
from_status |
SubscriptionStatus |
sim | NULL |
Estado anterior. Nulo no evento CREATED. |
to_status |
SubscriptionStatus |
não | — | Estado resultante. |
actor |
varchar(40) |
não | — | system, subscriber, admin, payment_webhook, reconciliation. |
actor_id |
char(26) |
sim | NULL |
Identificador do ator quando aplicável. |
payment_id |
char(26) |
sim | NULL |
Pagamento que motivou a transição. |
payment_event_id |
char(26) |
sim | NULL |
Evento de webhook que motivou a transição. |
cancel_reason |
varchar(40) |
sim | NULL |
Motivo escolhido na lista fechada da tela de cancelamento. Ex.: too_expensive, not_using, content, technical, other. É o campo que alimenta o relatório de motivos de churn. |
cancel_comment |
varchar(500) |
sim | NULL |
Comentário livre do assinante no cancelamento. Obrigatório quando cancel_reason = 'other'. |
reason |
text |
sim | NULL |
Explicação legível, escrita pelo sistema ou pelo operador. Não confundir com cancel_reason, que é a escolha do assinante em lista fechada. |
metadata |
jsonb |
sim | NULL |
Contexto adicional. |
occurred_at |
timestamptz |
não | now() |
— |
created_at |
timestamptz |
não | now() |
— |
6.12.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
subscription_events_pkey |
PRIMARY KEY (id) |
— |
ix_sub_events_subscription |
(subscription_id, occurred_at DESC) |
Linha do tempo da assinatura. |
ix_sub_events_subscriber |
(subscriber_id, occurred_at DESC) |
Linha do tempo do assinante, sem junção. |
ix_sub_events_type_time |
(type, occurred_at DESC) |
Churn mensal e conversão, calculados por tipo e janela. |
ix_sub_events_payment |
(payment_id) WHERE payment_id IS NOT NULL |
Rastreia o efeito de um pagamento específico. |
6.12.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
subscription_id |
subscriptions(id) |
CASCADE |
CASCADE |
subscriber_id |
subscribers(id) |
RESTRICT |
CASCADE |
payment_id |
payments(id) |
SET NULL |
CASCADE |
payment_event_id |
payment_events(id) |
SET NULL |
CASCADE |
6.12.3 Constraints CHECK #
ALTER TABLE subscription_events
ADD CONSTRAINT chk_sub_events_actor
CHECK (actor IN ('system','subscriber','admin','payment_webhook','reconciliation')),
ADD CONSTRAINT chk_sub_events_created_transition
CHECK ((type <> 'CREATED') OR (from_status IS NULL)),
ADD CONSTRAINT chk_sub_events_admin_actor
CHECK (actor <> 'admin' OR actor_id IS NOT NULL),
ADD CONSTRAINT chk_sub_events_cancel_reason_shape
CHECK (cancel_reason IS NULL OR cancel_reason ~ '^[a-z][a-z0-9_]{2,39}$'),
ADD CONSTRAINT chk_sub_events_cancel_other_needs_comment
CHECK (cancel_reason <> 'other'
OR (cancel_comment IS NOT NULL AND length(btrim(cancel_comment)) >= 10)),
ADD CONSTRAINT chk_sub_events_cancel_reason_only_on_cancel
CHECK (cancel_reason IS NULL OR type IN ('CANCEL_REQUESTED','CANCELED'));Esta tabela é o inventário de quais transições ocorreram e de qual estado de
assinatura cada uma gravou. O efeito sobre o acesso do assinante não é descrito aqui nem
em nenhuma tabela de eventos: ele é sempre e apenas o da Seção 13.3, aplicado por
revokePaidAccess() na mesma transação. Repetir o efeito por extenso em cada linha criaria
tantos lugares para divergir quantos forem os tipos de evento.
6.13 Tabela payments #
Cobranças individuais emitidas pelo provedor de pagamento. Uma assinatura mensal gera uma linha por ciclo; uma anual, uma por ano.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
subscription_id |
char(26) |
não | — | Assinatura de origem. |
subscriber_id |
char(26) |
não | — | Denormalizado para relatório financeiro por assinante. |
asaas_payment_id |
varchar(40) |
não | — | Identificador da cobrança no provedor. |
status |
PaymentStatus |
não | 'PENDING' |
Estado da cobrança. |
billing_type |
BillingType |
não | — | Forma de pagamento efetiva. |
amount_cents |
integer |
não | — | Valor bruto cobrado, em centavos de BRL. Divisor para BRL: 100. |
net_amount_cents |
integer |
sim | NULL |
Valor líquido após taxas, quando informado. |
fee_cents |
integer |
não | 0 |
Taxa cobrada pelo provedor, em centavos. Quando o provedor informa apenas bruto e líquido, é amount_cents - net_amount_cents. Existe como coluna própria porque a receita líquida da Seção 21 precisa da taxa mesmo quando o líquido chega nulo. |
refunded_amount_cents |
integer |
não | 0 |
Total estornado. Permite estorno parcial. |
currency |
char(3) |
não | 'BRL' |
— |
due_date |
date |
não | — | Vencimento. |
confirmed_at |
timestamptz |
sim | NULL |
Confirmação do pagamento pelo provedor. |
received_at |
timestamptz |
sim | NULL |
Recebimento efetivo (compensação). |
credit_date |
date |
sim | NULL |
Data prevista de crédito na conta. |
invoice_url |
text |
sim | NULL |
Fatura hospedada pelo provedor. |
receipt_url |
text |
sim | NULL |
Comprovante hospedado pelo provedor, disponível após a confirmação. É o link que o extrato do assinante oferece para download. |
chargeback_stage |
varchar(20) |
sim | NULL |
Estágio da contestação, quando existe: REQUESTED, IN_DISPUTE, LOST, WON, REVERSED. Nulo quando não há contestação. |
pix_payload |
text |
sim | NULL |
Código copia-e-cola do PIX. |
pix_qr_code_base64 |
text |
sim | NULL |
QR Code em base64, entregue ao front. |
pix_expires_at |
timestamptz |
sim | NULL |
Vencimento do QR Code. |
card_brand |
varchar(20) |
sim | NULL |
Bandeira. Ex.: VISA. |
card_last4 |
char(4) |
sim | NULL |
Últimos 4 dígitos. Nenhum outro dado de cartão é persistido. |
failure_code |
varchar(40) |
sim | NULL |
Código de recusa do provedor. |
failure_message |
text |
sim | NULL |
Mensagem de recusa. |
description |
varchar(200) |
sim | NULL |
Descrição exibida na fatura. |
reconciled_at |
timestamptz |
sim | NULL |
Última conferência contra o provedor. |
created_at |
timestamptz |
não | now() |
— |
updated_at |
timestamptz |
não | now() |
— |
6.13.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
payments_pkey |
PRIMARY KEY (id) |
— |
uq_payments_asaas_id |
UNIQUE (asaas_payment_id) |
Idempotência do processamento de webhook: a mesma cobrança nunca vira duas linhas. |
ix_payments_subscription_time |
(subscription_id, created_at DESC) |
Histórico de cobranças da assinatura. |
ix_payments_subscriber_time |
(subscriber_id, created_at DESC) |
Extrato do assinante no painel. |
ix_payments_status_due |
(status, due_date) |
Localiza vencidos e a vencer para lembretes e reconciliação. |
ix_payments_confirmed_at |
(confirmed_at DESC) WHERE confirmed_at IS NOT NULL |
Receita confirmada por período. |
ix_payments_pix_expiring |
(pix_expires_at) WHERE status = 'PENDING' AND pix_expires_at IS NOT NULL |
Regenera QR Code vencido antes do lembrete D0. |
6.13.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
subscription_id |
subscriptions(id) |
RESTRICT |
CASCADE |
subscriber_id |
subscribers(id) |
RESTRICT |
CASCADE |
6.13.3 Constraints CHECK #
ALTER TABLE payments
ADD CONSTRAINT chk_payments_amount CHECK (amount_cents > 0),
ADD CONSTRAINT chk_payments_refund_bounds
CHECK (refunded_amount_cents >= 0 AND refunded_amount_cents <= amount_cents),
ADD CONSTRAINT chk_payments_card_pair
CHECK ((card_brand IS NULL) = (card_last4 IS NULL)),
ADD CONSTRAINT chk_payments_card_last4 CHECK (card_last4 IS NULL OR card_last4 ~ '^[0-9]{4}$'),
ADD CONSTRAINT chk_payments_card_only_for_card
CHECK (billing_type = 'CREDIT_CARD' OR card_brand IS NULL),
ADD CONSTRAINT chk_payments_pix_only_for_pix
CHECK (billing_type = 'PIX' OR pix_payload IS NULL),
ADD CONSTRAINT chk_payments_confirmed_status
CHECK (status NOT IN ('CONFIRMED','RECEIVED') OR confirmed_at IS NOT NULL),
ADD CONSTRAINT chk_payments_fee_bounds
CHECK (fee_cents >= 0 AND fee_cents <= amount_cents),
ADD CONSTRAINT chk_payments_net_bounds
CHECK (net_amount_cents IS NULL
OR (net_amount_cents >= 0 AND net_amount_cents <= amount_cents)),
ADD CONSTRAINT chk_payments_chargeback_stage
CHECK (chargeback_stage IS NULL
OR chargeback_stage IN ('REQUESTED','IN_DISPUTE','LOST','WON','REVERSED')),
ADD CONSTRAINT chk_payments_chargeback_status
CHECK (chargeback_stage IS NULL
OR status IN ('CHARGEBACK_REQUESTED','CHARGEBACK_DISPUTE','REFUNDED'));Dado de cartão é limitado a bandeira e 4 últimos dígitos por decisão explícita: número completo, CVV e validade nunca transitam para persistência nem para log. A tokenização acontece no provedor.
Todos os valores monetários desta tabela são inteiros em centavos de BRL, com sufixo
_cents e divisor 100. Não existe coluna value, fee_value ou paid_at aqui, e
nenhuma seção pode citá-las: o valor bruto é amount_cents, a taxa é fee_cents, o
líquido é net_amount_cents, e o instante do pagamento é
coalesce(confirmed_at, received_at, due_date).
O caso paymentDate, resolvido em definitivo. O provedor de pagamento devolve três
campos de data no mesmo evento: confirmedDate, paymentDate e clientPaymentDate. O
mapeamento canônico, que a Seção 12.4.3 aplica e nenhuma outra seção altera, é:
| Campo do provedor | Coluna de destino | Regra |
|---|---|---|
confirmedDate |
confirmed_at |
Preferência absoluta quando vier preenchido. |
paymentDate / clientPaymentDate |
confirmed_at |
Usados apenas quando confirmedDate vier vazio; entre os dois, vence paymentDate. |
creditDate |
credit_date |
Data prevista de crédito, que não é instante de pagamento. |
O nome vencedor é confirmed_at, e o instante canônico do pagamento, em toda consulta,
todo relatório e todo extrato, é a expressão coalesce(confirmed_at, received_at, due_date). Não existe, e não pode ser criada, uma coluna paid_at — nem como coluna,
nem como destino de mapeamento de campo externo. Onde um SELECT precisar do rótulo, ele é
um apelido calculado da expressão acima, como em 6.35.6, e nunca uma coluna.
6.14 Tabela payment_events #
Recepção bruta e idempotente dos eventos de cobrança vindos do provedor. O handler HTTP
apenas insere aqui e responde 200; o processamento é assíncrono.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
asaas_event_id |
varchar(60) |
não | — | Identificador do evento no provedor. Chave de idempotência. |
event_type |
varchar(60) |
não | — | Ex.: PAYMENT_CONFIRMED. |
asaas_payment_id |
varchar(40) |
sim | NULL |
Extraído do corpo para indexação. |
asaas_subscription_id |
varchar(40) |
sim | NULL |
Idem. |
payment_id |
char(26) |
sim | NULL |
Vínculo resolvido durante o processamento. |
subscription_id |
char(26) |
sim | NULL |
Idem. |
payload |
jsonb |
não | — | Corpo completo recebido, sem alteração. |
signature_valid |
boolean |
não | — | Resultado da comparação em tempo constante do token do webhook. |
processing_status |
WebhookProcessingStatus |
não | 'RECEIVED' |
Andamento. É o único nome do desfecho do processamento nesta tabela: não existe payment_events.processing_result. Cuidado com a semelhança: webhook_deliveries tem legitimamente as duas colunas (6.26), porque lá o transporte e o negócio são registros distintos; aqui só existe o andamento. |
attempts |
smallint |
não | 0 |
Tentativas de processamento. |
last_error |
text |
sim | NULL |
Erro da última tentativa. |
next_retry_at |
timestamptz |
sim | NULL |
Próxima tentativa, com backoff exponencial. |
processed_at |
timestamptz |
sim | NULL |
Conclusão. |
received_at |
timestamptz |
não | now() |
Chegada do webhook. |
created_at |
timestamptz |
não | now() |
— |
6.14.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
payment_events_pkey |
PRIMARY KEY (id) |
— |
uq_payment_events_asaas_event_id |
UNIQUE (asaas_event_id) |
Idempotência estrutural: reentrega do mesmo evento colide e é descartada em silêncio com resposta 200. |
ix_payment_events_pending |
(next_retry_at) WHERE processing_status IN ('RECEIVED','PROCESSING','FAILED') |
Fila de processamento e de retentativa. Índice parcial mantém a árvore pequena mesmo com 150 mil linhas. |
ix_payment_events_asaas_payment |
(asaas_payment_id) WHERE asaas_payment_id IS NOT NULL |
Todos os eventos de uma cobrança, em ordem, para diagnóstico. |
ix_payment_events_type_time |
(event_type, received_at DESC) |
Volume por tipo de evento e detecção de fila pausada. |
ix_payment_events_received_at |
(received_at) |
Expurgo após 5 anos e relatórios por período. |
6.14.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
payment_id |
payments(id) |
SET NULL |
CASCADE |
subscription_id |
subscriptions(id) |
SET NULL |
CASCADE |
SET NULL porque o evento bruto é a prova do que o provedor mandou e vale por si, mesmo
que o vínculo interno deixe de existir.
6.14.3 Constraints CHECK #
ALTER TABLE payment_events
ADD CONSTRAINT chk_pay_events_attempts CHECK (attempts BETWEEN 0 AND 50),
ADD CONSTRAINT chk_pay_events_processed
CHECK (processing_status <> 'PROCESSED' OR processed_at IS NOT NULL),
ADD CONSTRAINT chk_pay_events_payload_object
CHECK (jsonb_typeof(payload) = 'object');6.14.4 Como a idempotência funciona na prática #
O handler executa exatamente isto:
INSERT INTO payment_events (id, asaas_event_id, event_type, asaas_payment_id,
asaas_subscription_id, payload, signature_valid)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (asaas_event_id) DO NOTHING
RETURNING id;Zero linhas em RETURNING significa reentrega. O handler responde 200 com
{"data":{"received":true,"duplicate":true}} sem enfileirar nada. Isso satisfaz a
exigência do provedor de resposta em menos de 2 segundos e evita processar o mesmo evento
duas vezes, mesmo com dois pods concorrentes recebendo a mesma reentrega.
6.15 Tabela devotionals #
Conteúdo editorial. Exatamente um devocional por dia de calendário.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
slug |
varchar(90) |
não | — | Identificador legível para URL do painel. Ex.: 2026-08-25-a-forca-do-silencio. |
scheduled_for |
date |
não | — | Dia de calendário (America/Sao_Paulo) em que o devocional é enviado. |
title |
varchar(120) |
não | — | Título. Vai no parâmetro {{1}} do template de convite. |
bible_reference |
varchar(80) |
não | — | Ex.: Salmos 46:10. |
bible_text |
text |
não | — | Texto do versículo. |
bible_version |
varchar(40) |
não | 'ALMEIDA_1911' |
Versão usada, de lista fechada. A sigla ARC é proibida como código e como atribuição: no mercado brasileiro ela identifica a Almeida Revista e Corrigida em edição revisada por editora ativa, que é obra protegida, e não a edição de 1911 em domínio público. Valores aceitos: ALMEIDA_1911 e BIBLIA_LIVRE. |
reflection_md |
text |
não | — | Reflexão em Markdown restrito (parágrafos, ênfase, listas). Sem HTML. |
prayer |
text |
não | — | Oração de encerramento. |
teaser |
varchar(300) |
não | — | Resumo de uma linha, de 40 a 300 caracteres. Vai no parâmetro {{2}} do template. Sem quebra de linha, sem tabulação, sem 4+ espaços seguidos. |
auto_publish |
boolean |
não | false |
Quando verdadeiro, o devocional passa sozinho de AUDIO_READY para PUBLISHED no verificador de prontidão das 05:00, sem clique do editor. |
is_evergreen |
boolean |
não | false |
Marca conteúdo sem data, elegível para ser usado como reserva quando o devocional do dia não estiver pronto. |
last_used_as_fallback_at |
timestamptz |
sim | NULL |
Última vez que este devocional foi usado como reserva. O motor escolhe sempre o evergreen usado há mais tempo, para não repetir o mesmo texto. |
status |
DevotionalStatus |
não | 'DRAFT' |
Estado editorial. |
author_admin_id |
char(26) |
sim | NULL |
Autor. |
approved_by_admin_id |
char(26) |
sim | NULL |
Quem aprovou para publicação. |
approved_at |
timestamptz |
sim | NULL |
— |
published_at |
timestamptz |
sim | NULL |
Momento em que entrou em PUBLISHED. |
sent_at |
timestamptz |
sim | NULL |
Momento em que o lote de envio terminou. |
cover_image_key |
text |
sim | NULL |
Chave no storage da arte usada no vídeo de fallback. |
narration_char_count |
integer |
sim | NULL |
Caracteres do roteiro de narração. Estima custo de TTS. |
estimated_audio_seconds |
integer |
sim | NULL |
Duração estimada antes da geração. |
internal_notes |
text |
sim | NULL |
Notas internas do editor, de 0 a 1.000 caracteres. Nunca enviada ao assinante nem incluída em exportação de dados. Preenchida no formulário do editor (Seção 15.2.1), aceita pela importação em CSV (15.5.5) e entra na lista de campos versionados de devotional_revisions.changed_fields (15.6.2). Sem índice. |
tags |
text[] |
não | '{}' |
Temas. Ex.: {fe,ansiedade}. |
version |
integer |
não | 1 |
Versionamento otimista (6.1.4). |
deleted_at |
timestamptz |
sim | NULL |
Soft delete. |
created_at |
timestamptz |
não | now() |
— |
updated_at |
timestamptz |
não | now() |
— |
6.15.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
devotionals_pkey |
PRIMARY KEY (id) |
— |
uq_devotionals_scheduled_for |
UNIQUE (scheduled_for) WHERE deleted_at IS NULL |
Regra "um por dia" no banco. Duas tentativas concorrentes de agendar o mesmo dia: uma vence, a outra recebe DEVOTIONAL_DATE_TAKEN. |
uq_devotionals_slug |
UNIQUE (slug) WHERE deleted_at IS NULL |
URL estável do acervo. |
ix_devotionals_status_date |
(status, scheduled_for) |
Calendário editorial e busca do devocional publicável do dia. |
ix_devotionals_ready_for_audio |
(scheduled_for) WHERE status = 'READY' AND deleted_at IS NULL |
Fila de geração de áudio, sem varrer a tabela. |
ix_devotionals_title_trgm |
GIN (title gin_trgm_ops) |
Busca por trecho de título no painel administrativo, tolerante a erro de digitação. |
ix_devotionals_tags |
GIN (tags) |
Filtro por tema no acervo. |
ix_devotionals_evergreen |
(last_used_as_fallback_at NULLS FIRST) WHERE is_evergreen = true AND status IN ('PUBLISHED','SENT') AND deleted_at IS NULL |
Escolhe a reserva do dia: o evergreen publicado usado há mais tempo, em uma leitura de índice. |
6.15.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
author_admin_id |
admin_users(id) |
SET NULL |
CASCADE |
approved_by_admin_id |
admin_users(id) |
SET NULL |
CASCADE |
SET NULL porque o conteúdo sobrevive à saída do autor. A autoria histórica fica
preservada em devotional_revisions e em admin_audit_log.
6.15.3 Constraints CHECK #
ALTER TABLE devotionals
ADD CONSTRAINT chk_dev_teaser_len CHECK (char_length(teaser) BETWEEN 40 AND 300),
ADD CONSTRAINT chk_dev_bible_version
CHECK (bible_version IN ('ALMEIDA_1911','BIBLIA_LIVRE')),
ADD CONSTRAINT chk_dev_teaser_no_newline CHECK (teaser !~ '[\n\r\t]'),
ADD CONSTRAINT chk_dev_teaser_no_runs CHECK (teaser !~ '[ ]{4,}'),
ADD CONSTRAINT chk_dev_title_len CHECK (char_length(btrim(title)) BETWEEN 3 AND 120),
ADD CONSTRAINT chk_dev_slug_shape CHECK (slug ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]{3,}$'),
ADD CONSTRAINT chk_dev_reflection_len CHECK (char_length(reflection_md) BETWEEN 200 AND 6000),
ADD CONSTRAINT chk_dev_prayer_len CHECK (char_length(prayer) BETWEEN 40 AND 1200),
ADD CONSTRAINT chk_dev_internal_notes_len
CHECK (internal_notes IS NULL OR char_length(internal_notes) <= 1000),
ADD CONSTRAINT chk_dev_version CHECK (version >= 1),
ADD CONSTRAINT chk_dev_published_fields
CHECK (status NOT IN ('PUBLISHED','SENT') OR published_at IS NOT NULL),
ADD CONSTRAINT chk_dev_sent_fields CHECK (status <> 'SENT' OR sent_at IS NOT NULL),
ADD CONSTRAINT chk_dev_approved_pair
CHECK ((approved_by_admin_id IS NULL) = (approved_at IS NULL));Os três CHECKs de teaser existem porque o parâmetro de template do WhatsApp rejeita
quebra de linha, tabulação e sequências de 4 ou mais espaços. Barrar isso no banco elimina
a classe inteira de falha 132xxx em produção.
O mínimo de 40 caracteres do teaser é o mesmo exigido pelo editor (Seção 15.2.2). Os
dois valores precisam coincidir: com o mínimo do banco em 20 e o do editor em 40, uma
importação por rota administrativa passaria com 25 caracteres e o editor bloquearia em 39,
produzindo um teaser que o produto aceita mas ninguém consegue corrigir na interface.
O tamanho da reflexão tem três camadas, e elas não se contradizem: 3.500 caracteres é o
aviso do editor, o bloqueio real acontece quando o pacote montado passa de 4096 caracteres
(as duas regras são da Seção 15.2.3), e os 6.000 do CHECK são o teto físico da coluna,
que existe para que uma importação em massa não falhe no banco antes de a validação
editorial explicar o problema. A mensagem de erro apresentada ao editor cita sempre
3.500, que é o número acionável.
6.15.4 Exemplo de linha válida #
{
"id": "01K3F8R3T5DEVOTIONAL000001",
"slug": "2026-08-25-a-forca-do-silencio",
"scheduledFor": "2026-08-25",
"title": "A força do silêncio",
"bibleReference": "Salmos 46:10",
"bibleText": "Aquietai-vos e sabei que eu sou Deus.",
"bibleVersion": "ALMEIDA_1911",
"teaser": "Deus não precisa de barulho para agir. Hoje, aprenda a ouvir no silêncio.",
"status": "PUBLISHED",
"tags": ["fe", "ansiedade"],
"version": 4
}O teaser tem 74 caracteres, acima do mínimo de 40, sem quebra de linha e sem espaços
repetidos. Passa nos três CHECKs e cabe no parâmetro {{2}}.
6.16 Tabela devotional_revisions #
Snapshot completo de cada versão salva de um devocional. Permite ver quem mudou o quê e restaurar conteúdo.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
devotional_id |
char(26) |
não | — | Devocional versionado. |
revision_number |
integer |
não | — | Sequencial por devocional, começando em 1. |
changed_by_admin_id |
char(26) |
sim | NULL |
Autor da alteração. |
snapshot |
jsonb |
não | — | Cópia integral dos campos de conteúdo após a alteração. |
changed_fields |
text[] |
não | '{}' |
Nomes dos campos que mudaram nesta revisão. |
diff_summary |
text |
sim | NULL |
Resumo legível. Ex.: título e reflexão alterados. |
origin |
varchar(20) |
não | 'EDIT' |
Origem da revisão: EDIT (salvamento no editor), IMPORT (importação em massa), RESTORE (restauração de revisão anterior), SYSTEM (alteração feita por job, como o preenchimento de contagem de caracteres). |
restored_from_revision |
integer |
sim | NULL |
Número da revisão que originou esta restauração. Preenchida apenas nas revisões criadas por POST /api/admin/devotionals/{id}/revisions/{rid}/restorations (Seção 15.6.3); nula em revisão de edição normal. Sem índice. |
status_before |
DevotionalStatus |
sim | NULL |
— |
status_after |
DevotionalStatus |
não | — | — |
created_at |
timestamptz |
não | now() |
— |
6.16.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
devotional_revisions_pkey |
PRIMARY KEY (id) |
— |
uq_dev_rev_number |
UNIQUE (devotional_id, revision_number) |
Garante a sequência sem buracos e sem duplicidade sob concorrência. |
ix_dev_rev_devotional_time |
(devotional_id, created_at DESC) |
Histórico exibido no painel. |
ix_dev_rev_admin |
(changed_by_admin_id, created_at DESC) WHERE changed_by_admin_id IS NOT NULL |
"O que este editor alterou?" |
6.16.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
devotional_id |
devotionals(id) |
CASCADE |
CASCADE |
changed_by_admin_id |
admin_users(id) |
SET NULL |
CASCADE |
6.16.3 Constraints CHECK #
ALTER TABLE devotional_revisions
ADD CONSTRAINT chk_dev_rev_number CHECK (revision_number >= 1),
ADD CONSTRAINT chk_dev_rev_snapshot CHECK (jsonb_typeof(snapshot) = 'object'),
ADD CONSTRAINT chk_dev_rev_origin
CHECK (origin IN ('EDIT','IMPORT','RESTORE','SYSTEM')),
ADD CONSTRAINT chk_dev_rev_restored_from
CHECK (restored_from_revision IS NULL OR restored_from_revision > 0),
ADD CONSTRAINT chk_dev_rev_restore_pair
CHECK ((restored_from_revision IS NULL) OR origin = 'RESTORE');revision_number é calculado dentro da mesma transação do UPDATE otimista do
devocional, com SELECT coalesce(max(revision_number),0)+1 ... FOR UPDATE na linha do
devocional. Como o UPDATE já toma o lock da linha pai, não há corrida.
6.17 Tabela audio_assets #
Arquivos de áudio gerados por devocional. Um devocional tem tipicamente duas linhas: OGG para o WhatsApp e MP3 para o player web. O MP4 do fallback de vídeo é uma terceira linha quando gerado.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
devotional_id |
char(26) |
não | — | Devocional narrado. |
format |
AudioFormat |
não | — | OGG_OPUS, MP3 ou MP4. |
provider |
AudioProvider |
não | — | Provedor de TTS efetivamente usado. |
voice_id |
varchar(60) |
não | — | Identificador da voz no provedor. |
model |
varchar(60) |
sim | NULL |
Modelo de síntese usado. |
status |
AudioAssetStatus |
não | 'PENDING' |
Andamento da geração. |
storage_bucket |
varchar(80) |
sim | NULL |
Bucket S3-compatível. |
storage_key |
text |
sim | NULL |
Chave do objeto. Padrão devotionals/{YYYY}/{MM}/{devotionalId}/{voice}.{ext}. |
byte_size |
bigint |
sim | NULL |
Tamanho em bytes. |
duration_seconds |
integer |
sim | NULL |
Duração medida após a transcodificação. |
sample_rate |
integer |
sim | NULL |
Ex.: 48000. |
bitrate_kbps |
smallint |
sim | NULL |
Ex.: 32. |
checksum_sha256 |
char(64) |
sim | NULL |
Integridade do objeto. |
narration_script_hash |
char(64) |
sim | NULL |
sha256 do roteiro. Se o texto não mudou, não regera áudio. É o único nome desta coluna em todo o documento: não existe script_hash. |
chunk_count |
smallint |
não | 1 |
Número de partes em que o roteiro foi dividido para a chamada ao provedor. 1 quando coube em uma chamada única. Alimenta a conferência de custo por geração (Seção 16.4). |
generation_attempts |
smallint |
não | 0 |
Tentativas de geração. |
qc_failed_at |
timestamptz |
sim | NULL |
Instante da reprovação nas verificações de qualidade. Preenchida junto com status = 'FAILED' quando a causa foi verificação de qualidade, e nula quando a causa foi falha do provedor — a distinção é o que decide se o job tenta de novo (Seção 16.13) ou se para e chama o operador (Seção 27.4.4). |
last_error |
text |
sim | NULL |
Erro da última tentativa. |
char_count |
integer |
não | 0 |
Caracteres efetivamente enviados ao provedor de TTS nesta geração. É a base da conta de custo de síntese e da apuração de consumo da cota de voz. |
cost_micros |
bigint |
sim | NULL |
Custo em micros de BRL (10⁻⁶ BRL). Divisor para BRL: 1.000.000. Nunca some com colunas em centavos sem converter. |
generated_at |
timestamptz |
sim | NULL |
Conclusão. |
created_at |
timestamptz |
não | now() |
— |
updated_at |
timestamptz |
não | now() |
— |
6.17.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
audio_assets_pkey |
PRIMARY KEY (id) |
— |
uq_audio_dev_format_voice |
UNIQUE (devotional_id, format, voice_id) |
Impede geração duplicada do mesmo formato e voz para o mesmo devocional, inclusive sob retentativa concorrente. |
ix_audio_devotional |
(devotional_id, status) |
Verifica se o devocional já tem áudio pronto antes de publicar. |
ix_audio_status_pending |
(created_at) WHERE status IN ('PENDING','PROCESSING') |
Fila de geração e detecção de trabalho travado. |
ix_audio_script_hash |
(narration_script_hash) WHERE narration_script_hash IS NOT NULL |
Reaproveita áudio quando o roteiro não mudou. |
uq_audio_storage_key |
UNIQUE (storage_key) WHERE storage_key IS NOT NULL |
Impede duas linhas apontando para o mesmo objeto. |
6.17.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
devotional_id |
devotionals(id) |
CASCADE |
CASCADE |
CASCADE aqui é seguro: o hard delete de um devocional só ocorre no expurgo, e o áudio
não tem valor fora dele. O objeto no storage é removido por job de limpeza que confere
órfãos (6.33.6).
6.17.3 Constraints CHECK #
ALTER TABLE audio_assets
ADD CONSTRAINT chk_audio_ready_fields
CHECK (status <> 'READY' OR (storage_key IS NOT NULL AND byte_size IS NOT NULL
AND duration_seconds IS NOT NULL)),
ADD CONSTRAINT chk_audio_size CHECK (byte_size IS NULL OR byte_size BETWEEN 1 AND 16777216),
ADD CONSTRAINT chk_audio_duration CHECK (duration_seconds IS NULL OR duration_seconds BETWEEN 1 AND 480),
ADD CONSTRAINT chk_audio_attempts CHECK (generation_attempts BETWEEN 0 AND 20),
ADD CONSTRAINT chk_audio_checksum CHECK (checksum_sha256 IS NULL OR checksum_sha256 ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_audio_char_count CHECK (char_count >= 0),
ADD CONSTRAINT chk_audio_chunk_count CHECK (chunk_count >= 1),
ADD CONSTRAINT chk_audio_qc_failed_needs_failed
CHECK (qc_failed_at IS NULL OR status = 'FAILED'),
ADD CONSTRAINT chk_audio_cost CHECK (cost_micros IS NULL OR cost_micros >= 0);Os dois tetos são os da plataforma de mensageria, e o banco os aplica para transformar um erro remoto de envio em um erro local de geração, detectado antes do disparo: 16.777.216 bytes (16 MB) e 480 segundos (8 minutos). Áudio substituído manualmente por um administrador segue exatamente as mesmas regras do áudio gerado — não existe caminho de upload com limite maior, porque o destino é o mesmo e o WhatsApp recusaria do mesmo jeito.
Falha de geração não cria estado de devocional. Quando o provedor falha, a linha de
audio_assets fica em status = 'FAILED' e o devocional permanece em AUDIO_PENDING;
a guarda de publicação impede publicar nesse estado e o motor de envio trata o devocional
como não pronto, acionando a reserva. Não existe, e não pode ser criado, um estado
AUDIO_FAILED em DevotionalStatus.
6.18 Tabela whatsapp_templates #
Registro local dos templates submetidos à Meta, com estado de aprovação e categoria efetiva. Sem esta tabela, o sistema não sabe se pode disparar.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
name |
varchar(80) |
não | — | Nome do template na Meta. Ex.: devocional_diario_v1. |
language |
varchar(10) |
não | 'pt_BR' |
Código de idioma no formato da Meta. |
category |
TemplateCategory |
não | — | Categoria submetida. |
effective_category |
TemplateCategory |
sim | NULL |
Categoria que a Meta efetivamente aplicou. Pode divergir da submetida. |
status |
TemplateStatus |
não | 'DRAFT' |
Estado na Meta. |
meta_template_id |
varchar(40) |
sim | NULL |
Identificador do template na Meta. |
components |
jsonb |
não | — | Estrutura completa de componentes, como enviada e como devolvida. |
body_text |
text |
não | — | Corpo com marcadores {{n}}, para conferência local. |
param_count |
smallint |
não | 0 |
Quantidade de parâmetros do corpo. |
header_type |
varchar(20) |
sim | NULL |
TEXT, IMAGE, VIDEO, DOCUMENT ou nulo. Nunca AUDIO — a Meta não aceita. |
quality_score |
varchar(20) |
sim | NULL |
GREEN, YELLOW, RED ou UNKNOWN. |
rejected_reason |
text |
sim | NULL |
Motivo da rejeição. |
version |
integer |
não | 1 |
Versionamento otimista. |
is_current |
boolean |
não | false |
Marca a linha vigente para uso em produção. |
submitted_at |
timestamptz |
sim | NULL |
— |
approved_at |
timestamptz |
sim | NULL |
— |
last_synced_at |
timestamptz |
sim | NULL |
Última sincronização com a Meta. |
created_at |
timestamptz |
não | now() |
— |
updated_at |
timestamptz |
não | now() |
— |
6.18.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
whatsapp_templates_pkey |
PRIMARY KEY (id) |
— |
uq_wa_templates_name_lang |
UNIQUE (name, language) |
A Meta trata o par nome+idioma como identidade. |
uq_wa_templates_meta_id |
UNIQUE (meta_template_id) WHERE meta_template_id IS NOT NULL |
Resolve o webhook de atualização de template. |
ix_wa_templates_current |
(name) WHERE is_current = true |
Busca o template vigente por nome no caminho quente de envio. |
ix_wa_templates_status |
(status) |
Alerta de template pausado ou rejeitado. |
6.18.2 Constraints CHECK #
ALTER TABLE whatsapp_templates
ADD CONSTRAINT chk_wa_tpl_name_shape CHECK (name ~ '^[a-z0-9_]{1,512}$'),
ADD CONSTRAINT chk_wa_tpl_header_not_audio
CHECK (header_type IS NULL OR header_type IN ('TEXT','IMAGE','VIDEO','DOCUMENT','LOCATION')),
ADD CONSTRAINT chk_wa_tpl_body_len CHECK (char_length(body_text) <= 1024),
ADD CONSTRAINT chk_wa_tpl_param_count CHECK (param_count BETWEEN 0 AND 10),
ADD CONSTRAINT chk_wa_tpl_approved_fields
CHECK (status <> 'APPROVED' OR (meta_template_id IS NOT NULL AND approved_at IS NOT NULL)),
ADD CONSTRAINT chk_wa_tpl_current_requires_approved
CHECK (is_current = false OR status = 'APPROVED'),
ADD CONSTRAINT chk_wa_tpl_quality
CHECK (quality_score IS NULL OR quality_score IN ('GREEN','YELLOW','RED','UNKNOWN')),
ADD CONSTRAINT chk_wa_tpl_components CHECK (jsonb_typeof(components) = 'object');chk_wa_tpl_header_not_audio e chk_wa_tpl_body_len codificam duas restrições reais da
plataforma: cabeçalho de template não aceita áudio, e o corpo tem teto de 1024 caracteres.
Ambas são a razão do desenho de entrega em duas etapas descrito na Seção 18.
Regra adicional garantida na aplicação: no máximo uma linha com is_current = true por
name. Não é índice único porque a troca de versão faz false na antiga e true na
nova dentro da mesma transação, e um índice único parcial sobre (name) WHERE is_current
bloquearia a ordem inversa. A transação sempre desmarca antes de marcar, e o índice
ix_wa_templates_current torna a verificação instantânea.
6.19 Tabela message_logs #
Registro de toda mensagem trocada com o WhatsApp, nas duas direções. É a maior tabela do sistema e a única particionada.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
created_at |
timestamptz |
não | now() |
Chave de partição. Faz parte da PK. |
subscriber_id |
char(26) |
sim | NULL |
Assinante. Nulo quando o número não foi resolvido. |
direction |
MessageDirection |
não | — | OUTBOUND ou INBOUND. |
kind |
MessageKind |
não | — | Tipo da mensagem. |
status |
MessageStatus |
não | 'QUEUED' |
Estado de entrega. |
devotional_id |
char(26) |
sim | NULL |
Devocional relacionado. |
delivery_attempt_id |
char(26) |
sim | NULL |
Tentativa de entrega que gerou a mensagem. |
template_id |
char(26) |
sim | NULL |
Template usado, quando kind = 'TEMPLATE'. |
template_name |
varchar(80) |
sim | NULL |
Nome do template na Meta, denormalizado para relatório. É o único nome desta coluna: não existe message_logs.template_key. Não confundir com message_key, logo abaixo, que é outra coisa — aquela identifica a mensagem do catálogo editorial da Seção 19, esta identifica o template aprovado pela Meta. |
wamid |
varchar(128) |
sim | NULL |
Identificador da mensagem na Meta. |
message_key |
varchar(60) |
sim | NULL |
Chave canônica da mensagem de catálogo que originou o envio. Ex.: optout.confirmed, pause.started, welcome.first. É por ela que se mede quais mensagens do catálogo da Seção 19 são realmente disparadas. |
phone_e164 |
text |
sim | NULL |
Destino ou origem em E.164, como envelope cifrado (6.1.8). Anulável por exigência da LGPD: a anonimização do titular zera esta coluna, e uma coluna NOT NULL tornaria a eliminação impossível na maior tabela do sistema. |
phone_hmac |
char(64) |
sim | NULL |
HMAC do destino. Permite reconstruir o histórico de um número em investigação sem decifrar nada, e é zerado junto na anonimização. |
wa_id |
text |
sim | NULL |
Identificador da Meta correspondente, cifrado. Zerado na anonimização. |
media_id |
varchar(80) |
sim | NULL |
Identificador de mídia da Meta usado no envio. |
payload_excerpt |
varchar(240) |
sim | NULL |
Primeiros 240 caracteres do conteúdo, para diagnóstico. Nunca o corpo inteiro. Zerado na anonimização: pode conter o nome do titular em parâmetro de template e texto que ele mesmo digitou. |
error_code |
varchar(20) |
sim | NULL |
Código de erro da Meta. Ex.: 131047. |
error_title |
varchar(160) |
sim | NULL |
Título do erro. |
error_details |
text |
sim | NULL |
Detalhe do erro. |
conversation_id |
varchar(80) |
sim | NULL |
Identificador da conversa faturável. |
conversation_category |
varchar(20) |
sim | NULL |
UTILITY, MARKETING, AUTHENTICATION, SERVICE. É o único nome desta coluna: billed_category e pricing_category não existem. |
pricing_model |
varchar(20) |
sim | NULL |
Modelo de cobrança informado pela Meta. |
is_billable |
boolean |
não | false |
Se a mensagem gerou custo. Nome único; não existe billable. |
cost_micros |
bigint |
sim | NULL |
Custo estimado em micros de BRL. Divisor para BRL: 1.000.000. Não existe cost_cents nesta tabela, e somar esta coluna como se fosse centavos erra o resultado por um fator de 10.000. |
sent_at |
timestamptz |
sim | NULL |
Instante em que a Meta aceitou a mensagem. É o carimbo de aceite; não existe accepted_at. |
delivered_at |
timestamptz |
sim | NULL |
— |
read_at |
timestamptz |
sim | NULL |
— |
failed_at |
timestamptz |
sim | NULL |
— |
late_open_days |
smallint |
sim | NULL |
Distância em dias entre a data do devocional entregue e o dia em que o assinante tocou no botão. Nula quando a abertura foi no mesmo dia, que é o caso comum. Alimenta a métrica de abertura tardia da Seção 21 e é a coluna que sustenta o caso de borda 2 da Seção 17.14. Sem índice: a métrica é apurada no rollup diário, não em consulta interativa. |
request_id |
char(26) |
sim | NULL |
ULID de correlação com o log da aplicação. |
Sem updated_at: a linha muda apenas por progressão de status, e cada progressão tem
timestamp próprio, que é informação mais útil.
6.19.1 Particionamento #
CREATE TABLE message_logs (
...
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);Uma partição por mês, nomeada message_logs_YYYY_MM. Detalhes de criação automática,
retenção e destacamento estão em 6.33.3.
A chave primária é composta porque o Postgres exige que a chave de partição faça parte de qualquer índice único da tabela particionada. Consequências, todas assumidas:
- Nenhuma tabela declara FK para
message_logs. Referências são lógicas (otp_codes.delivery_message_log_id,delivery_attempts.message_log_id). - Índices únicos precisam incluir
created_at. Veruq_message_logs_wamidabaixo. - Buscar por
idsemcreated_atfaz varredura em todas as partições. Por isso todo acesso por identificador na aplicação carrega também a data, que é extraível do próprio ULID.
6.19.2 Índices #
Todos criados em cada partição, herdados da definição na tabela pai.
| Índice | Definição | Justificativa |
|---|---|---|
message_logs_pkey |
PRIMARY KEY (id, created_at) |
Exigência do particionamento. |
uq_message_logs_wamid |
UNIQUE (wamid, created_at) WHERE wamid IS NOT NULL |
Idempotência de status: a Meta reentrega webhooks de status. A unicidade impede duplicar a mensagem. Inclui created_at por exigência do particionamento. |
ix_message_logs_subscriber_time |
(subscriber_id, created_at DESC) WHERE subscriber_id IS NOT NULL |
Histórico de mensagens do assinante no suporte. |
ix_message_logs_attempt |
(delivery_attempt_id) WHERE delivery_attempt_id IS NOT NULL |
Liga a mensagem à tentativa de entrega. |
ix_message_logs_status_time |
(status, created_at DESC) |
Taxa de entrega diária e detecção de pico de falhas. |
ix_message_logs_error_code |
(error_code, created_at DESC) WHERE error_code IS NOT NULL |
Agrupa falhas por código da Meta para acionar o runbook correspondente. |
ix_message_logs_devotional |
(devotional_id, created_at DESC) WHERE devotional_id IS NOT NULL |
Relatório por devocional. |
ix_message_logs_conversation |
(conversation_id) WHERE conversation_id IS NOT NULL |
Consolidação de custo por conversa. |
ix_message_logs_phone_hmac |
(phone_hmac, created_at DESC) WHERE phone_hmac IS NOT NULL |
Reconstrói o histórico de um número em investigação, sem decifrar e sem varrer partição. |
ix_message_logs_message_key |
(message_key, created_at DESC) WHERE message_key IS NOT NULL |
Volume por mensagem do catálogo, para saber quais textos realmente saem. |
6.19.3 Constraints CHECK #
ALTER TABLE message_logs
ADD CONSTRAINT chk_msg_phone_envelope
CHECK (phone_e164 IS NULL OR phone_e164 ~ '^v[0-9]+:'),
ADD CONSTRAINT chk_msg_phone_hmac
CHECK (phone_hmac IS NULL OR phone_hmac ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_msg_wa_id_envelope
CHECK (wa_id IS NULL OR wa_id ~ '^v[0-9]+:'),
ADD CONSTRAINT chk_msg_message_key_shape
CHECK (message_key IS NULL OR message_key ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$'),
ADD CONSTRAINT chk_msg_template_needs_name
CHECK (kind <> 'TEMPLATE' OR direction = 'INBOUND' OR template_name IS NOT NULL),
ADD CONSTRAINT chk_msg_failed_needs_code
CHECK (status <> 'FAILED' OR error_code IS NOT NULL),
ADD CONSTRAINT chk_msg_status_timeline
CHECK ((delivered_at IS NULL OR sent_at IS NULL OR delivered_at >= sent_at)
AND (read_at IS NULL OR delivered_at IS NULL OR read_at >= delivered_at)),
ADD CONSTRAINT chk_msg_cost CHECK (cost_micros IS NULL OR cost_micros >= 0),
ADD CONSTRAINT chk_msg_late_open_days
CHECK (late_open_days IS NULL OR late_open_days > 0),
ADD CONSTRAINT chk_msg_excerpt_len CHECK (payload_excerpt IS NULL OR char_length(payload_excerpt) <= 240);Não há CHECK de formato E.164 aqui pelo mesmo motivo de 6.3.3: a coluna guarda um
envelope cifrado. E phone_e164 é anulável nesta tabela, ao contrário do que a
intuição sugere para um registro de transporte. O motivo é jurídico e concreto: a retenção
de message_logs é de 18 meses, muito mais longa do que o prazo de eliminação de 30 dias
do titular. Se a coluna fosse NOT NULL, a anonimização por pedido de eliminação não
conseguiria zerá-la, e centenas de linhas com o telefone completo, o horário de leitura de
cada devocional e trechos do conteúdo religioso sobreviveriam por mais de um ano depois de
o painel ter confirmado "conta anonimizada". A eliminação precisa alcançar todas as
tabelas que guardam identificador pessoal, e esta é a maior delas.
6.19.4 Ausência de chaves estrangeiras #
message_logs não declara nenhuma FK de saída, mesmo tendo subscriber_id e
devotional_id. Três motivos:
- Cada
INSERTnuma tabela com FK faz uma verificação na tabela referenciada. No pico de 05:40 são até 12.000 inserções em poucos minutos. Eliminar duas verificações por linha é ganho real. - Destacar (
DETACH) e arquivar partições antigas é mais simples sem FKs de saída. - A integridade referencial aqui não é crítica: um log apontando para um assinante anonimizado continua sendo um log válido.
A consistência é garantida pela aplicação, que só grava identificadores que acabou de ler. Um job semanal de conferência conta órfãos e alerta se passar de 0,1% das linhas do mês.
6.20 Tabela delivery_attempts #
Unidade de trabalho do motor de envio diário. Cada linha é uma etapa de entrega para um assinante em uma data. É aqui que mora a idempotência do envio.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
idempotency_key |
varchar(120) |
não | — | send:{subscriberId}:{devotionalDate}:{step}. |
send_batch_id |
char(26) |
sim | NULL |
Lote de planejamento. |
subscriber_id |
char(26) |
não | — | Destinatário. |
devotional_id |
char(26) |
não | — | Conteúdo enviado. |
devotional_date |
date |
não | — | Data do devocional, denormalizada para a chave de idempotência e para os índices. |
step |
DeliveryStep |
não | — | Etapa: convite por template, texto completo, áudio, fechamento ou fallback de vídeo. |
status |
DeliveryAttemptStatus |
não | 'PLANNED' |
Estado da tentativa. |
tier_at_send |
SubscriberTier |
não | — | Tier registrado no planejamento das 05:40. É registro histórico, nunca autoridade (ver 6.20.3). |
tier_at_send_effective |
SubscriberTier |
sim | NULL |
Tier relido por chave primária imediatamente antes da chamada ao provedor. É este valor que decide o que sai. Nulo enquanto o item não foi disparado. |
downgraded_at |
timestamptz |
sim | NULL |
Preenchido quando a revalidação no disparo encontrou tier = 'FREE' em item planejado como PAID e rebaixou o pacote na hora. |
origin |
varchar(20) |
não | 'DAILY_BATCH' |
Origem da tentativa: DAILY_BATCH, MANUAL_RESEND, WELCOME_BACKFILL, ADMIN_RETRY. |
steps_completed |
smallint |
não | 0 |
Quantas etapas do pacote deste assinante já concluíram com sucesso no dia. Permite retomar de onde parou sem reenviar o que já saiu. |
audio_status |
varchar(20) |
não | 'NOT_APPLICABLE' |
Estado do áudio para esta tentativa: NOT_APPLICABLE, PENDING, SENT, SKIPPED_DOWNGRADE, FAILED. |
manual_resend_count |
smallint |
não | 0 |
Reenvios manuais já feitos para este par assinante/dia. Limitado pela regra de reenvio da Seção 14. |
attempt_count |
smallint |
não | 0 |
Tentativas executadas. |
max_attempts |
smallint |
não | 5 |
Teto de tentativas. |
next_retry_at |
timestamptz |
sim | NULL |
Próxima tentativa. É o único nome desta coluna em todo o documento: não existe next_attempt_at. |
locked_at |
timestamptz |
sim | NULL |
Instante em que um worker reivindicou o item em claimAttempt (Seção 18.9). Um item em IN_FLIGHT com locked_at mais velho que 60 segundos é considerado abandonado e recolhido pela varredura de reparo das 09:00 (Seção 18.15). Limpo no término, com sucesso ou falha. Sem esta coluna a varredura de reparo não tem como ser escrita, e ela é o mecanismo que impede envio duplicado depois de um reinício do Redis. |
last_error_code |
varchar(20) |
sim | NULL |
Código de erro da Meta na última falha. |
last_error_message |
text |
sim | NULL |
Mensagem correspondente. |
deferred_reason |
varchar(60) |
sim | NULL |
outside_window, rate_limited, ecosystem_throttle, quality_hold. |
message_log_id |
char(26) |
sim | NULL |
Mensagem gerada. Referência lógica (ver 6.19.4). |
planned_at |
timestamptz |
não | now() |
Momento do planejamento. |
started_at |
timestamptz |
sim | NULL |
Início da execução. |
completed_at |
timestamptz |
sim | NULL |
Conclusão, com sucesso ou falha definitiva. |
created_at |
timestamptz |
não | now() |
— |
updated_at |
timestamptz |
não | now() |
— |
6.20.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
delivery_attempts_pkey |
PRIMARY KEY (id) |
— |
uq_delivery_idempotency_key |
UNIQUE (idempotency_key) |
Idempotência do envio. Dois planejamentos concorrentes do mesmo dia produzem colisão, não duplicidade. |
uq_delivery_sub_date_step |
UNIQUE (subscriber_id, devotional_date, step) |
Mesma garantia expressa em colunas, útil para consultas e para o ON CONFLICT do planejador. |
ix_delivery_pending |
(next_retry_at, id) WHERE status IN ('PLANNED','DEFERRED','FAILED') AND attempt_count < max_attempts |
Fila de execução. Índice parcial mantém a árvore com milhares de linhas mesmo com milhões na tabela. |
ix_delivery_batch_status |
(send_batch_id, status) WHERE send_batch_id IS NOT NULL |
Contadores de progresso do lote. |
ix_delivery_date_status |
(devotional_date, status) |
Relatório diário de entrega. |
ix_delivery_subscriber_date |
(subscriber_id, devotional_date DESC) |
"O que este assinante recebeu?" no suporte. |
ix_delivery_error_code |
(last_error_code, devotional_date) WHERE last_error_code IS NOT NULL |
Agrupa falhas por causa. |
ix_delivery_locked |
(locked_at) WHERE status = 'IN_FLIGHT' |
Varredura de reparo das 09:00: localiza itens reivindicados e abandonados sem varrer 4 milhões de linhas. |
6.20.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
subscriber_id |
subscribers(id) |
CASCADE |
CASCADE |
devotional_id |
devotionals(id) |
RESTRICT |
CASCADE |
send_batch_id |
send_batches(id) |
SET NULL |
CASCADE |
RESTRICT em devotional_id impede apagar de fato um devocional que já foi enviado. O
soft delete continua permitido e é o caminho normal.
6.20.3 Constraints CHECK #
ALTER TABLE delivery_attempts
ADD CONSTRAINT chk_delivery_idem_shape
CHECK (idempotency_key ~ '^send:[0-9A-HJKMNP-TV-Z]{26}:[0-9]{4}-[0-9]{2}-[0-9]{2}:[A-Z_]{3,20}$'),
ADD CONSTRAINT chk_delivery_attempts_bounds
CHECK (attempt_count >= 0 AND max_attempts BETWEEN 1 AND 10 AND attempt_count <= max_attempts),
ADD CONSTRAINT chk_delivery_completed
CHECK (status NOT IN ('SUCCEEDED','FAILED','SKIPPED',
'SKIPPED_OPTED_OUT','SKIPPED_INELIGIBLE')
OR completed_at IS NOT NULL),
ADD CONSTRAINT chk_delivery_deferred_reason
CHECK (status <> 'DEFERRED' OR deferred_reason IS NOT NULL),
ADD CONSTRAINT chk_delivery_audio_step_paid
CHECK (step <> 'AUDIO' OR tier_at_send = 'PAID'),
ADD CONSTRAINT chk_delivery_audio_step_paid_effective
CHECK (step <> 'AUDIO'
OR status <> 'SUCCEEDED'
OR tier_at_send_effective = 'PAID'),
ADD CONSTRAINT chk_delivery_downgrade_pair
CHECK (downgraded_at IS NULL
OR (tier_at_send = 'PAID' AND tier_at_send_effective = 'FREE')),
ADD CONSTRAINT chk_delivery_origin
CHECK (origin IN ('DAILY_BATCH','MANUAL_RESEND','WELCOME_BACKFILL','ADMIN_RETRY')),
ADD CONSTRAINT chk_delivery_audio_status
CHECK (audio_status IN ('NOT_APPLICABLE','PENDING','SENT',
'SKIPPED_DOWNGRADE','FAILED')),
ADD CONSTRAINT chk_delivery_steps_completed
CHECK (steps_completed BETWEEN 0 AND 5),
ADD CONSTRAINT chk_delivery_manual_resends
CHECK (manual_resend_count BETWEEN 0 AND 10),
ADD CONSTRAINT chk_delivery_timeline
CHECK (started_at IS NULL OR started_at >= planned_at),
ADD CONSTRAINT chk_delivery_locked_only_in_flight
CHECK (locked_at IS NULL OR status = 'IN_FLIGHT');Os dois CHECKs de áudio codificam no banco a regra de entitlement mais importante do produto — etapa de áudio só existe para tier pago — e o fazem nos dois momentos em que ela pode ser violada:
chk_delivery_audio_step_paidprotege o planejamento. Se um bug tentar planejar áudio para um assinante FREE às 05:40, a transação falha em vez de enviar.chk_delivery_audio_step_paid_effectiveprotege o disparo, e é o que faltava. O tier é reavaliado no instante do envio de cada mensagem, nunca no planejamento do lote: um assinante que perdeu o acesso pago entre 05:40 e 06:00 não recebe áudio. Sem esse segundo CHECK, o congelamento das 05:40 concederia, na prática, um dia de carência a todo inadimplente — exatamente a carência que a regra do produto proíbe. A revalidação é uma leitura por chave primária, abaixo de 1 ms, sobre um lote de no máximo 12.400 itens, e o procedimento completo (o que é relido, em que ordem, e o que acontece comopt_out_at,blocked_atedeleted_at) está na Seção 18.5.
chk_delivery_downgrade_pair garante que downgraded_at só exista no caso que ele
descreve: planejado PAID, disparado FREE. Assim o relatório de revogações no meio do
lote é uma contagem exata, não uma heurística.
6.20.4 O planejamento idempotente, na prática #
INSERT INTO delivery_attempts
(id, idempotency_key, send_batch_id, subscriber_id, devotional_id,
devotional_date, step, status, tier_at_send, planned_at)
SELECT
$1, 'send:' || s.id || ':' || $2::text || ':' || $3,
$4, s.id, $5, $2::date, $3::"DeliveryStep", 'PLANNED', s.tier, now()
FROM subscribers s
WHERE s.id = $6
ON CONFLICT (idempotency_key) DO NOTHING;Se o job de planejamento das 05:40 rodar duas vezes (falha de rede, reinício do worker, operador executando manualmente), a segunda execução insere zero linhas. Nenhum assinante recebe o devocional duas vezes. Essa é a única garantia que impede o pior incidente possível deste produto.
6.21 Tabela inbound_messages #
Mensagens recebidas dos assinantes, com a intenção já classificada. Alimenta a abertura da janela de atendimento, o opt-out por palavra-chave e o atalho de leitura imediata.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
wamid |
varchar(128) |
não | — | Identificador da mensagem na Meta. É o único nome desta coluna; não existe wa_message_id. |
subscriber_id |
char(26) |
sim | NULL |
Assinante resolvido. Nulo quando o número é desconhecido. |
wa_id |
text |
não | — | Identificador da Meta do remetente, como envelope cifrado (6.1.8). |
wa_id_hmac |
char(64) |
não | — | HMAC do wa_id. É por esta coluna que o webhook de entrada resolve o assinante, em uma única leitura. |
phone_e164 |
text |
sim | NULL |
Normalização em E.164, quando possível, cifrada. É o nome desta coluna em todo o documento; não existe inbound_messages.from. Zerada na anonimização. |
phone_hmac |
char(64) |
sim | NULL |
HMAC do telefone normalizado. Zerado na anonimização. |
kind |
MessageKind |
não | — | Tipo recebido. |
text_body |
text |
sim | NULL |
Texto, truncado em 2000 caracteres. É o nome desta coluna; não existe inbound_messages.body. Zerado na anonimização, porque é conteúdo escrito pelo próprio titular. |
support_protocol |
varchar(20) |
sim | NULL |
Número de protocolo gerado quando a mensagem abre atendimento humano. Ex.: PD-2026-000431. É o que o assinante cita ao voltar a falar com o suporte. |
button_payload |
varchar(60) |
sim | NULL |
Payload do botão. Ex.: OPEN_DEVOTIONAL. |
interactive_id |
varchar(60) |
sim | NULL |
Identificador da opção interativa. |
media_id |
varchar(80) |
sim | NULL |
Mídia recebida. Não é baixada no MVP. |
intent |
InboundIntent |
não | 'UNKNOWN' |
Intenção classificada por regra determinística. |
intent_confidence |
smallint |
não | 100 |
0–100. Sempre 100 no MVP, porque a classificação é por regra, não por modelo. |
raw |
jsonb |
não | — | Objeto de mensagem original. |
is_handled |
boolean |
não | false |
Se já foi processado. |
handled_at |
timestamptz |
sim | NULL |
— |
handling_error |
text |
sim | NULL |
— |
received_at |
timestamptz |
não | now() |
Timestamp informado pela Meta. |
created_at |
timestamptz |
não | now() |
Chegada no nosso lado. |
6.21.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
inbound_messages_pkey |
PRIMARY KEY (id) |
— |
uq_inbound_wamid |
UNIQUE (wamid) |
Idempotência de entrada. A Meta reentrega webhooks; a mesma mensagem nunca abre a janela duas vezes nem dispara dois opt-outs. |
ix_inbound_unhandled |
(created_at) WHERE is_handled = false |
Fila de processamento. |
ix_inbound_subscriber_time |
(subscriber_id, received_at DESC) WHERE subscriber_id IS NOT NULL |
Conversa do assinante no suporte. |
ix_inbound_wa_id_time |
(wa_id_hmac, received_at DESC) |
Resolve remetente ainda não vinculado, sem decifrar. |
ix_inbound_intent_time |
(intent, received_at DESC) |
Volume de opt-out e de aberturas por dia. |
ix_inbound_support_protocol |
(support_protocol) WHERE support_protocol IS NOT NULL |
Busca por protocolo no atendimento. |
6.21.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
subscriber_id |
subscribers(id) |
SET NULL |
CASCADE |
SET NULL porque a mensagem recebida é fato histórico do canal e vale mesmo sem
assinante vinculado.
6.21.3 Constraints CHECK #
ALTER TABLE inbound_messages
ADD CONSTRAINT chk_inbound_text_len CHECK (text_body IS NULL OR char_length(text_body) <= 2000),
ADD CONSTRAINT chk_inbound_confidence CHECK (intent_confidence BETWEEN 0 AND 100),
ADD CONSTRAINT chk_inbound_handled_pair CHECK ((is_handled = false) OR (handled_at IS NOT NULL)),
ADD CONSTRAINT chk_inbound_raw_object CHECK (jsonb_typeof(raw) = 'object'),
ADD CONSTRAINT chk_inbound_wa_id_envelope CHECK (wa_id ~ '^v[0-9]+:'),
ADD CONSTRAINT chk_inbound_wa_id_hmac CHECK (wa_id_hmac ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_inbound_phone_envelope
CHECK (phone_e164 IS NULL OR phone_e164 ~ '^v[0-9]+:'),
ADD CONSTRAINT chk_inbound_phone_hmac
CHECK (phone_hmac IS NULL OR phone_hmac ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_inbound_support_protocol
CHECK (support_protocol IS NULL OR support_protocol ~ '^PD-[0-9]{4}-[0-9]{6}$');O raw da Meta é guardado como recebido, e é a única cópia bruta do que o assinante
escreveu. Ele é redigido no mesmo passo da anonimização: o campo de texto dentro do JSON é
substituído por null, preservando a estrutura para diagnóstico de protocolo e removendo
o conteúdo pessoal.
6.22 Tabela send_batches #
Um lote por dia de envio. Agrega contadores, marca início e fim, e garante que o planejamento do dia aconteça uma única vez.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
idempotency_key |
varchar(80) |
não | — | plan:{devotionalDate} no lote diário; retry:{parentBatchId} no reprocessamento. Duas formas, e apenas duas. |
devotional_id |
char(26) |
não | — | Devocional do dia. |
devotional_date |
date |
não | — | Dia de calendário. |
status |
SendBatchStatus |
não | 'PLANNING' |
Estado do lote. HALTED_BY_GUARD é o lote que a contenção automática parou sozinha por taxa de falha acima do limiar; a retomada é sempre humana. |
batch_kind |
varchar(20) |
não | 'DAILY' |
Natureza do lote: DAILY (envio diário regular), BACKFILL (retroativo de boas-vindas), RESEND (reenvio em massa autorizado), RETRY (reprocessamento de um lote anterior), TEST (lote de homologação). Esta coluna é criada aqui, na Seção 6, e é a única fonte do conceito: não existe send_batches.kind, e a seção que descreve o reprocessamento (15.8.4) usa este nome. |
parent_batch_id |
char(26) |
sim | NULL |
Lote de origem, quando este lote é um reprocessamento (batch_kind = 'RETRY'). Existe para que as métricas do lote original fiquem intactas: o reprocessamento não soma nos contadores de quem falhou (Seção 15.8.4). |
canceled_by |
char(26) |
sim | NULL |
Administrador que cancelou o lote em andamento (Seção 15.8.5). |
canceled_reason |
text |
sim | NULL |
Motivo do cancelamento, de no mínimo 10 caracteres e obrigatório na rota. Cancelar um envio diário é decisão com consequência para milhares de pessoas, e a justificativa é o que permite auditá-la depois. |
tier_limit_at_plan |
integer |
sim | NULL |
Teto de mensagens da conta na plataforma no momento do planejamento do lote (Seção 18.3.3). Congelado aqui porque a Meta muda o teto sem aviso, e a análise posterior de um lote truncado precisa do valor vigente naquela hora, não do atual. |
template_used |
varchar(80) |
sim | NULL |
Template predominante do lote. |
truncated_count |
integer |
não | 0 |
Itens cujo texto precisou ser truncado para caber no limite da mensagem. Acima de zero, é sinal editorial: o devocional do dia está longo demais. |
fallback_used |
boolean |
não | false |
Se o lote usou o devocional de reserva em vez do agendado. |
fallback_reason |
varchar(40) |
sim | NULL |
Por que a reserva foi usada: no_devotional, audio_missing, audio_failed, template_not_approved, media_expired. |
halted_error_code |
varchar(20) |
sim | NULL |
Código de erro dominante que motivou a parada automática, quando status = 'HALTED_BY_GUARD'. |
planned_count |
integer |
não | 0 |
Tentativas planejadas. |
queued_count |
integer |
não | 0 |
Tentativas enfileiradas. |
sent_count |
integer |
não | 0 |
Mensagens aceitas pela Meta. |
delivered_count |
integer |
não | 0 |
Confirmadas como entregues. |
read_count |
integer |
não | 0 |
Confirmadas como lidas. |
failed_count |
integer |
não | 0 |
Falhas definitivas. |
skipped_count |
integer |
não | 0 |
Pulados por regra (sem opt-in, opt-out, bloqueado). |
deferred_count |
integer |
não | 0 |
Adiados para o dia seguinte. |
free_count |
integer |
não | 0 |
Alvos com tier FREE. |
paid_count |
integer |
não | 0 |
Alvos com tier PAID. |
started_at |
timestamptz |
sim | NULL |
— |
finished_at |
timestamptz |
sim | NULL |
— |
duration_ms |
integer |
sim | NULL |
Duração total. Comparado à meta de 20 minutos. |
planned_by |
varchar(40) |
não | 'scheduler' |
scheduler, admin, ops_cli. |
error |
text |
sim | NULL |
Erro fatal do lote. |
created_at |
timestamptz |
não | now() |
— |
updated_at |
timestamptz |
não | now() |
— |
6.22.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
send_batches_pkey |
PRIMARY KEY (id) |
— |
uq_send_batches_idempotency |
UNIQUE (idempotency_key) |
Um planejamento por dia. Reexecução do job das 05:40 colide e não replaneja. Vale também para o reprocessamento: dois cliques no botão de reprocessar o mesmo lote produzem a mesma chave retry:{parentBatchId} e o segundo colide. |
uq_send_batches_date |
UNIQUE (devotional_date) WHERE batch_kind = 'DAILY' |
Mesma garantia, legível em consultas. Parcial por batch_kind: a regra é "um lote diário por dia de calendário", e um reprocessamento carrega a devotional_date do lote original de propósito, para que o relatório do dia continue somando tudo o que foi tentado naquele dia. |
ix_send_batches_status |
(status, devotional_date DESC) |
Painel operacional e detecção de lote travado em RUNNING. |
ix_send_batches_parent |
(parent_batch_id) WHERE parent_batch_id IS NOT NULL |
Encontra todos os reprocessamentos de um lote original. Parcial porque quase nenhum lote é reprocessado. |
6.22.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
devotional_id |
devotionals(id) |
RESTRICT |
CASCADE |
parent_batch_id |
send_batches(id) |
SET NULL |
CASCADE |
canceled_by |
admin_users(id) |
SET NULL |
CASCADE |
6.22.3 Constraints CHECK #
ALTER TABLE send_batches
ADD CONSTRAINT chk_batch_idem_shape
CHECK (idempotency_key ~ '^plan:[0-9]{4}-[0-9]{2}-[0-9]{2}$'
OR idempotency_key ~ '^retry:[0-9A-HJKMNP-TV-Z]{26}$'),
ADD CONSTRAINT chk_batch_idem_shape_matches_kind
CHECK ((batch_kind = 'RETRY') = (idempotency_key LIKE 'retry:%')),
ADD CONSTRAINT chk_batch_counts_nonneg
CHECK (planned_count >= 0 AND queued_count >= 0 AND sent_count >= 0
AND delivered_count >= 0 AND read_count >= 0 AND failed_count >= 0
AND skipped_count >= 0 AND deferred_count >= 0),
ADD CONSTRAINT chk_batch_tier_split CHECK (free_count + paid_count <= planned_count),
ADD CONSTRAINT chk_batch_delivered_le_sent CHECK (delivered_count <= sent_count),
ADD CONSTRAINT chk_batch_read_le_delivered CHECK (read_count <= delivered_count),
ADD CONSTRAINT chk_batch_finished
CHECK (status NOT IN ('COMPLETED','FAILED','CANCELED') OR finished_at IS NOT NULL),
ADD CONSTRAINT chk_batch_duration CHECK (duration_ms IS NULL OR duration_ms >= 0),
ADD CONSTRAINT chk_batch_kind
CHECK (batch_kind IN ('DAILY','BACKFILL','RESEND','RETRY','TEST')),
ADD CONSTRAINT chk_batch_parent_only_on_retry
CHECK (parent_batch_id IS NULL OR batch_kind = 'RETRY'),
ADD CONSTRAINT chk_batch_parent_not_self
CHECK (parent_batch_id IS NULL OR parent_batch_id <> id),
ADD CONSTRAINT chk_batch_canceled_reason
CHECK ((status <> 'CANCELED')
OR (canceled_reason IS NOT NULL AND char_length(btrim(canceled_reason)) >= 10)),
ADD CONSTRAINT chk_batch_tier_limit
CHECK (tier_limit_at_plan IS NULL OR tier_limit_at_plan > 0),
ADD CONSTRAINT chk_batch_truncated CHECK (truncated_count >= 0),
ADD CONSTRAINT chk_batch_fallback_pair
CHECK ((fallback_used = false) = (fallback_reason IS NULL)),
ADD CONSTRAINT chk_batch_fallback_reason
CHECK (fallback_reason IS NULL
OR fallback_reason IN ('no_devotional','audio_missing','audio_failed',
'template_not_approved','media_expired')),
ADD CONSTRAINT chk_batch_halted_needs_code
CHECK (status <> 'HALTED_BY_GUARD' OR halted_error_code IS NOT NULL);O índice único uq_send_batches_date continua valendo por dia de calendário para o lote
diário, e por isso um lote BACKFILL ou RESEND não é uma segunda linha do mesmo
dia: ele é um lote com devotional_date própria quando cobre outro dia, ou é executado
como conjunto de delivery_attempts com origin distinta dentro do lote diário existente.
A regra "um planejamento diário por dia" não tem exceção.
O lote RETRY é o único que compartilha a devotional_date de outro lote, e é por isso
que o índice é parcial em batch_kind = 'DAILY'. Ele existe para que o reprocessamento não
contamine os contadores do lote original: parent_batch_id aponta para a origem, os
números do lote de origem ficam congelados como estavam quando ele terminou, e a taxa de
entrega histórica daquele dia continua sendo lida sem correção manual. A unicidade dele vem
de uq_send_batches_idempotency, com a chave retry:{parentBatchId} — um lote de origem
gera no máximo um reprocessamento.
Os contadores são atualizados por UPDATE ... SET sent_count = sent_count + 1, operação
atômica no Postgres. Não há corrida, mas há contenção: com 20 mensagens por segundo, são
20 atualizações por segundo na mesma linha. A decisão é aceitar: 20 escritas por segundo
em uma linha é trivial para o Postgres, e o valor em tempo real vale mais do que um
agregado calculado depois. A reconciliação final do lote recalcula os contadores a partir
de delivery_attempts e corrige eventual divergência.
6.23 Tabela settings #
Configuração de runtime editável pelo administrador. A fronteira entre isto e variável de ambiente está definida na Seção 26.2, e a lista de chaves canônicas está em 26.8.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
key |
varchar(80) |
não | — | Chave, no formato grupo.chave: minúsculas, ponto como separador, sem maiúsculas e sem hífen. Chave primária. Nome em maiúsculas é nome de variável de ambiente, nunca de setting. |
value |
jsonb |
não | — | Valor atual, sempre um escalar ou objeto JSON. |
value_type |
SettingValueType |
não | — | Tipo declarado. Valida value. |
default_value |
jsonb |
não | — | Valor de fábrica. Permite "restaurar padrão". |
description |
varchar(300) |
não | — | Explicação exibida no painel, em português. |
group_name |
varchar(40) |
não | — | Agrupamento visual. Ex.: envio, cobranca, conteudo. |
is_secret |
boolean |
não | false |
Se verdadeiro, o valor nunca é devolvido pela API nem aparece em log. |
editable_by |
Role |
não | 'ADMIN' |
Papel mínimo para editar. |
min_value |
numeric(20,4) |
sim | NULL |
Limite inferior, para tipos numéricos. |
max_value |
numeric(20,4) |
sim | NULL |
Limite superior. |
allowed_values |
jsonb |
sim | NULL |
Array de valores permitidos, quando enumerado. |
version |
integer |
não | 1 |
Versionamento otimista. |
updated_by_admin_id |
char(26) |
sim | NULL |
Último editor. |
created_at |
timestamptz |
não | now() |
— |
updated_at |
timestamptz |
não | now() |
— |
6.23.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
settings_pkey |
PRIMARY KEY (key) |
Leitura por chave, que é o único acesso quente. |
ix_settings_group |
(group_name, key) |
Montagem da tela de configuração, agrupada. |
ix_settings_updated_at |
(updated_at DESC) |
Últimas alterações no painel e invalidação de cache. |
6.23.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
updated_by_admin_id |
admin_users(id) |
SET NULL |
CASCADE |
6.23.3 Constraints CHECK #
ALTER TABLE settings
ADD CONSTRAINT chk_settings_key_shape CHECK (key ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$'),
ADD CONSTRAINT chk_settings_type_matches CHECK (
(value_type = 'STRING' AND jsonb_typeof(value) = 'string')
OR (value_type = 'INT' AND jsonb_typeof(value) = 'number')
OR (value_type = 'DECIMAL' AND jsonb_typeof(value) = 'number')
OR (value_type = 'BOOL' AND jsonb_typeof(value) = 'boolean')
OR (value_type = 'JSON' AND jsonb_typeof(value) IN ('object','array'))
),
ADD CONSTRAINT chk_settings_default_type_matches CHECK (
jsonb_typeof(default_value) = jsonb_typeof(value)
),
ADD CONSTRAINT chk_settings_bounds CHECK (
min_value IS NULL OR max_value IS NULL OR min_value <= max_value
),
ADD CONSTRAINT chk_settings_allowed_is_array CHECK (
allowed_values IS NULL OR jsonb_typeof(allowed_values) = 'array'
),
ADD CONSTRAINT chk_settings_editable_by CHECK (editable_by IN ('EDITOR','ADMIN','OWNER'));Seis colunas desta tabela são NOT NULL e não têm default utilizável: value,
value_type, default_value, description, group_name e editable_by. Isso tem uma
consequência operacional que precisa estar escrita: qualquer INSERT manual em settings
— inclusive o comando de emergência que liga o interruptor geral de envios — precisa trazer
todas as seis. Um INSERT ... ON CONFLICT DO UPDATE que traga apenas key e value
funciona quando a linha já existe e aborta quando ela não existe, ou seja, falha
exatamente no ambiente recém-provisionado em que o operador mais precisa dele. O seed de
6.32.4 cria todas as chaves canônicas justamente para que o caminho normal nunca dependa
disso.
O CHECK de tipo é a razão de value ser jsonb e não text: o banco valida o tipo, e a
API não precisa confiar no cliente. Tentativa de gravar "20" (string) numa chave INT
falha na borda com VALIDATION_ERROR e, se escapar, falha no banco.
Cache: a aplicação mantém as settings em memória com TTL de 60 segundos e invalidação
ativa via canal Redis settings:invalidate publicado no UPDATE. Isso mantém a leitura
fora do caminho quente sem exigir reinício para aplicar mudanças.
6.24 Tabela feature_flags #
Chaves booleanas de rollout, separadas de settings porque têm semântica própria
(percentual e segmentação por tier) e são consultadas em caminho quente.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
key |
varchar(60) |
não | — | Chave. Formato snake_case. Chave primária. |
description |
varchar(300) |
não | — | O que a flag liga ou desliga, em português. |
is_enabled |
boolean |
não | false |
Interruptor mestre. |
rollout_percentage |
smallint |
não | 0 |
0 a 100. Aplicado sobre um hash estável do identificador do assinante. |
target_tier |
SubscriberTier |
sim | NULL |
Restringe a flag a um tier. Nulo significa todos. |
updated_by_admin_id |
char(26) |
sim | NULL |
— |
created_at |
timestamptz |
não | now() |
— |
updated_at |
timestamptz |
não | now() |
— |
6.24.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
feature_flags_pkey |
PRIMARY KEY (key) |
Único acesso necessário. A tabela cabe inteira em cache. |
ix_feature_flags_enabled |
(is_enabled) |
Lista rápida das flags ligadas no painel operacional. |
6.24.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
updated_by_admin_id |
admin_users(id) |
SET NULL |
CASCADE |
6.24.3 Constraints CHECK #
ALTER TABLE feature_flags
ADD CONSTRAINT chk_flags_key_shape CHECK (key ~ '^[a-z][a-z0-9_]{2,59}$'),
ADD CONSTRAINT chk_flags_rollout CHECK (rollout_percentage BETWEEN 0 AND 100);Avaliação determinística, sem estado por assinante:
export function isFlagOn(
flag: { isEnabled: boolean; rolloutPercentage: number; targetTier: Tier | null },
subscriber: { id: string; tier: Tier },
): boolean {
if (!flag.isEnabled) return false;
if (flag.targetTier && flag.targetTier !== subscriber.tier) return false;
if (flag.rolloutPercentage >= 100) return true;
if (flag.rolloutPercentage <= 0) return false;
// hash estável: mesmo assinante cai sempre no mesmo bucket
const bucket = Number(BigInt('0x' + sha256Hex(flag.key + ':' + subscriber.id).slice(0, 8)) % 100n);
return bucket < flag.rolloutPercentage;
}Mesma entrada devolve sempre o mesmo resultado. Aumentar o percentual só adiciona assinantes; nunca remove quem já estava dentro.
6.25 Tabela daily_metrics #
Agregados diários pré-calculados. Existe para que o painel de métricas não varra
message_logs a cada carregamento.
A tabela é de formato longo: uma linha por combinação de dia, métrica e dimensão. Não existe, e não pode ser criada, uma versão de formato largo com uma coluna nomeada por métrica. A razão é concreta: várias métricas do catálogo da Seção 21.2 são quebradas por dimensões de cardinalidade aberta — código de erro da Meta, categoria de cobrança, motivo de churn. Em formato largo, cada valor novo de dimensão exigiria uma migration, e uma métrica nova exigiria outra. Em formato longo, exige apenas uma linha.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. Chave primária. |
metric_date |
date |
não | — | Dia de calendário (America/Sao_Paulo) a que o valor se refere. |
metric_key |
text |
não | — | Chave canônica da métrica, no formato grupo.metrica. Ex.: messages.sent, revenue.gross_cents, subscribers.total. O catálogo completo está na Seção 21.2. |
dimension |
text |
não | '' |
Recorte da métrica. String vazia, nunca NULL, quando a métrica não é quebrada. Ex.: tier=PAID, error_code=131047, reason=too_expensive. |
value |
numeric(18,6) |
sim | NULL |
Valor da métrica. Nulo quando a métrica é uma razão cujo denominador é zero. |
numerator |
numeric(18,6) |
sim | NULL |
Numerador, quando a métrica é uma razão. Permite reagregar corretamente sobre um período sem somar percentuais. |
denominator |
numeric(18,6) |
sim | NULL |
Denominador, quando a métrica é uma razão. |
rollup_version |
smallint |
não | 1 |
Versão do algoritmo que produziu a linha. Uma recomputação com versão maior sobrescreve; com versão menor, não. |
is_final |
boolean |
não | false |
Verdadeiro quando o dia está fechado e não há mais expectativa de correção. Linhas não finais são reprocessadas. |
computed_at |
timestamptz |
não | now() |
Última recomputação. |
created_at |
timestamptz |
não | now() |
— |
updated_at |
timestamptz |
não | now() |
— |
Sem chave estrangeira. A tabela é um agregado derivado: apagar a origem não invalida o número já consolidado, e uma FK aqui só criaria contenção no expurgo de partições.
Três regras de valor que evitam erro de leitura:
- Dinheiro vai em
metric_keycom sufixo explícito de unidade —revenue.gross_centsestá em centavos (divisor 100),cost.whatsapp_microsestá em micros (divisor 1.000.000). Nunca some as duas sem converter. - Razão nunca é gravada só como percentual.
numeratoredenominatorsão gravados junto, porque a média de sete percentuais diários não é o percentual da semana. dimensionusa string vazia em vez deNULLporqueNULLnunca é igual aNULLno Postgres, e um índice único com coluna nula deixaria de impedir duplicidade — que é exatamente o que ele existe para impedir.
6.25.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
daily_metrics_pkey |
PRIMARY KEY (id) |
— |
daily_metrics_unique |
UNIQUE (metric_date, metric_key, dimension) |
Idempotência do rollup. É a chave do ON CONFLICT que permite recomputar qualquer dia passado sem duplicar. |
daily_metrics_key_date_idx |
(metric_key, metric_date DESC) |
Série temporal de uma métrica, que é o acesso dominante do painel. |
daily_metrics_date_idx |
(metric_date DESC) |
Todas as métricas de um dia, para a visão geral. |
daily_metrics_not_final_idx |
(metric_date) WHERE is_final = false |
Localiza os dias que ainda precisam ser reprocessados, sem varrer a tabela. |
6.25.2 Constraints CHECK #
ALTER TABLE daily_metrics
ADD CONSTRAINT chk_metrics_date_sane CHECK (metric_date >= DATE '2026-01-01'),
ADD CONSTRAINT chk_metrics_key_shape
CHECK (metric_key ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$'),
ADD CONSTRAINT chk_metrics_dimension_not_null CHECK (dimension IS NOT NULL),
ADD CONSTRAINT chk_metrics_denominator CHECK (denominator IS NULL OR denominator >= 0),
ADD CONSTRAINT chk_metrics_ratio_pair
CHECK ((numerator IS NULL) = (denominator IS NULL)),
ADD CONSTRAINT chk_metrics_rollup_version CHECK (rollup_version >= 1);A recomputação é sempre um INSERT ... ON CONFLICT (metric_date, metric_key, dimension) DO UPDATE, condicionado a daily_metrics.is_final = false OR EXCLUDED.rollup_version > daily_metrics.rollup_version. Assim o job de agregação é idempotente, permite recomputar
qualquer dia passado sem duplicar, e não sobrescreve um dia já fechado a menos que o
algoritmo tenha mudado de versão.
O rollup roda às 03:10, sobre o dia D−1 já fechado, e reprocessa também D−2, D−3, D−4, D−7 e D−14. Não existe rollup às 23:00: o último job do dia civil fecha as entregas pendentes às 23:55, e consolidar antes disso mediria o dia antes de ele acabar, produzindo mensagens entregues, aberturas de janela e entregas pendentes sistematicamente subestimadas. O agendamento e a justificativa completa estão na Seção 21.5.
A rota pública de eventos nunca escreve nesta tabela. Ela grava eventos brutos, e é o job de rollup que os consolida aqui.
6.26 Tabela webhook_deliveries #
Registro de transporte de todo webhook, nas duas direções. Complementa
payment_events: aqui fica o nível HTTP (assinatura, latência, status), lá fica o nível
de negócio do provedor de pagamento. Para o WhatsApp, esta tabela é o único registro bruto
— não existe tabela específica para eventos da Meta.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
direction |
WebhookDirection |
não | — | INBOUND (recebemos) ou OUTBOUND (enviamos). |
source |
WebhookSource |
não | — | ASAAS ou WHATSAPP. |
endpoint |
text |
não | — | Caminho recebido ou URL chamada. |
http_method |
varchar(10) |
não | 'POST' |
— |
http_status |
smallint |
sim | NULL |
Status respondido (inbound) ou recebido (outbound). |
event_type |
varchar(80) |
sim | NULL |
Tipo extraído do corpo. |
dedup_key |
varchar(160) |
sim | NULL |
Chave de deduplicação de transporte. É o único nome desta coluna: não existe external_id. Junto com source — que também é o único nome da origem, e não provider — forma o índice único uq_webhook_dedup de 6.26.1. |
duplicate_count |
integer |
não | 0 |
Quantas vezes o mesmo evento chegou de novo, incrementado no ON CONFLICT da deduplicação. Importa porque a Meta reenvia com frequência real quando há lentidão, e um salto neste contador é o primeiro sinal de que o handler está devolvendo 200 devagar demais (Seção 17.8). |
signature_header |
text |
sim | NULL |
Valor do cabeçalho de assinatura, guardado para perícia. |
is_signature_valid |
boolean |
não | false |
Resultado da verificação em tempo constante. |
body_sha256 |
char(64) |
não | — | Hash do corpo cru. Detecta reentrega idêntica. |
body |
jsonb |
sim | NULL |
Corpo parseado. Nulo quando o corpo é inválido. |
body_raw_size |
integer |
não | 0 |
Tamanho em bytes do corpo cru. |
headers |
jsonb |
sim | NULL |
Cabeçalhos relevantes, já redigidos. |
processing_status |
WebhookProcessingStatus |
não | 'RECEIVED' |
— |
processing_result |
WebhookProcessingResult |
sim | NULL |
Desfecho detalhado do processamento. SCHEMA_REJECTED, PARSE_ERROR e PERSIST_FAILED existem porque corpo inválido nunca produz resposta não-2xx: autenticado o remetente, qualquer falha posterior responde 200 e é registrada aqui, com o corpo cru preservado para reprocessamento. Devolver 4xx ou 5xx leva o provedor a retentar e, em seguida, a desativar a assinatura do webhook — e é por esse canal que a revogação imediata de acesso pago acontece. |
attempts |
smallint |
não | 0 |
Tentativas de processamento ou de envio. |
next_retry_at |
timestamptz |
sim | NULL |
— |
last_error |
text |
sim | NULL |
— |
latency_ms |
integer |
sim | NULL |
Tempo do handler. Monitorado contra o alvo de p99 abaixo de 2 s. |
request_id |
char(26) |
sim | NULL |
Correlação. |
received_at |
timestamptz |
não | now() |
— |
processed_at |
timestamptz |
sim | NULL |
— |
created_at |
timestamptz |
não | now() |
— |
6.26.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
webhook_deliveries_pkey |
PRIMARY KEY (id) |
— |
uq_webhook_dedup |
UNIQUE (source, dedup_key) WHERE dedup_key IS NOT NULL |
Deduplicação de transporte. Para o WhatsApp, dedup_key é o wamid ou wamid:status; para o provedor de pagamento, é o identificador do evento. |
ix_webhook_source_time |
(source, received_at DESC) |
Volume por origem e diagnóstico de fila pausada. |
ix_webhook_pending |
(next_retry_at) WHERE processing_status IN ('RECEIVED','PROCESSING','FAILED') |
Fila de reprocessamento. |
ix_webhook_body_hash |
(body_sha256, received_at DESC) |
Confirma reentrega idêntica em investigação. |
ix_webhook_invalid_signature |
(received_at DESC) WHERE is_signature_valid = false |
Alerta de segurança: rajada de assinaturas inválidas indica tentativa de forjar webhook. |
ix_webhook_received_at |
(received_at) |
Expurgo de 90 dias. |
6.26.2 Constraints CHECK #
ALTER TABLE webhook_deliveries
ADD CONSTRAINT chk_wh_body_hash CHECK (body_sha256 ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_wh_http_status CHECK (http_status IS NULL OR http_status BETWEEN 100 AND 599),
ADD CONSTRAINT chk_wh_method CHECK (http_method IN ('GET','POST','PUT','PATCH','DELETE')),
ADD CONSTRAINT chk_wh_attempts CHECK (attempts BETWEEN 0 AND 50),
ADD CONSTRAINT chk_wh_duplicate_count CHECK (duplicate_count >= 0),
ADD CONSTRAINT chk_wh_latency CHECK (latency_ms IS NULL OR latency_ms >= 0),
ADD CONSTRAINT chk_wh_body_size CHECK (body_raw_size >= 0 AND body_raw_size <= 5242880),
ADD CONSTRAINT chk_wh_processed CHECK (processing_status <> 'PROCESSED' OR processed_at IS NOT NULL),
ADD CONSTRAINT chk_wh_result_terminal
CHECK (processing_status NOT IN ('PROCESSED','FAILED','IGNORED')
OR processing_result IS NOT NULL);O teto de 5 MB em body_raw_size acompanha o limite de corpo definido na Seção 7.14.
Corpo maior é rejeitado antes de chegar aqui.
Sem FKs. A tabela é um diário de transporte e precisa registrar inclusive requisições que não correspondem a nada conhecido — inclusive tentativas de forjar assinatura.
6.27 Tabela media_uploads #
Controle dos identificadores de mídia da Meta. Cada áudio é enviado à Meta uma vez por devocional, nunca por assinante, e o identificador vale 30 dias.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
devotional_id |
char(26) |
não | — | Devocional de origem. |
audio_asset_id |
char(26) |
sim | NULL |
Asset enviado. Nulo quando a mídia é a arte de cabeçalho. |
purpose |
MediaPurpose |
não | — | DAILY_AUDIO, VIDEO_FALLBACK ou TEMPLATE_HEADER. |
whatsapp_media_id |
varchar(80) |
sim | NULL |
Identificador devolvido pela Meta. |
phone_number_id |
varchar(40) |
não | — | Número remetente. O identificador de mídia é válido apenas para este número. |
mime_type |
varchar(60) |
não | — | Ex.: audio/ogg; codecs=opus. |
byte_size |
bigint |
não | — | — |
sha256 |
char(64) |
não | — | Hash do arquivo enviado. |
status |
MediaUploadStatus |
não | 'PENDING' |
— |
attempts |
smallint |
não | 0 |
Tentativas de upload. |
last_error |
text |
sim | NULL |
— |
uploaded_at |
timestamptz |
sim | NULL |
— |
expires_at |
timestamptz |
sim | NULL |
uploaded_at + 30 dias. |
last_used_at |
timestamptz |
sim | NULL |
— |
superseded_at |
timestamptz |
sim | NULL |
Instante em que este upload deixou de ser o corrente porque o arquivo foi reenviado e recebeu whatsapp_media_id novo (Seção 16.11). A linha antiga não é apagada: ela prova qual identificador de mídia foi usado em cada mensagem já enviada, e sem ela um diagnóstico de "por que este áudio não tocou?" fica sem resposta. |
use_count |
integer |
não | 0 |
Quantas mensagens usaram este identificador. |
created_at |
timestamptz |
não | now() |
— |
updated_at |
timestamptz |
não | now() |
— |
6.27.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
media_uploads_pkey |
PRIMARY KEY (id) |
— |
uq_media_asset_phone |
UNIQUE (audio_asset_id, phone_number_id, purpose) WHERE audio_asset_id IS NOT NULL |
Impede reupload por assinante. Dois workers tentando enviar o mesmo áudio colidem; um vence e o outro reaproveita. |
uq_media_whatsapp_id |
UNIQUE (whatsapp_media_id) WHERE whatsapp_media_id IS NOT NULL |
Um identificador da Meta pertence a uma linha só. |
ix_media_devotional_purpose |
(devotional_id, purpose, status) |
Busca a mídia pronta do dia no caminho quente de envio. |
ix_media_expiring |
(expires_at) WHERE status = 'UPLOADED' |
Reupload proativo antes do vencimento. |
ix_media_current |
(devotional_id, purpose) WHERE superseded_at IS NULL |
Busca o upload corrente do devocional, que é a consulta do caminho quente de envio depois de uma revalidação de mídia. |
6.27.2 Chaves estrangeiras #
| Coluna | Referencia | ON DELETE | ON UPDATE |
|---|---|---|---|
devotional_id |
devotionals(id) |
CASCADE |
CASCADE |
audio_asset_id |
audio_assets(id) |
CASCADE |
CASCADE |
6.27.3 Constraints CHECK #
ALTER TABLE media_uploads
ADD CONSTRAINT chk_media_size CHECK (byte_size BETWEEN 1 AND 16777216),
ADD CONSTRAINT chk_media_sha CHECK (sha256 ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_media_uploaded_fields
CHECK (status <> 'UPLOADED' OR (whatsapp_media_id IS NOT NULL
AND uploaded_at IS NOT NULL
AND expires_at IS NOT NULL)),
ADD CONSTRAINT chk_media_expiry CHECK (expires_at IS NULL OR uploaded_at IS NULL
OR expires_at > uploaded_at),
ADD CONSTRAINT chk_media_use_count CHECK (use_count >= 0),
ADD CONSTRAINT chk_media_superseded_after_upload
CHECK (superseded_at IS NULL OR uploaded_at IS NULL OR superseded_at >= uploaded_at),
ADD CONSTRAINT chk_media_attempts CHECK (attempts BETWEEN 0 AND 20);Exemplo numérico da economia: com 3.000 assinantes pagos, enviar áudio por assinante
significaria 3.000 uploads de ~1,5 MB por dia, ou 4,5 GB diários de tráfego de saída. Com
o upload único, é 1 upload de 1,5 MB por dia. A unicidade em uq_media_asset_phone é o
que garante estruturalmente essa economia, mesmo com bug de concorrência.
6.28 Tabela job_runs #
Execução de jobs do worker. Serve para saber se o agendador rodou, quanto demorou e o que falhou, sem depender do painel do sistema de filas.
Esta tabela é a dead-letter do sistema. Não existe fila morta separada: um job que
esgota as tentativas fica no estado failed da própria fila e grava aqui uma linha com
status = 'DEAD', job_name, queue, bull_job_id, attempt, payload, error e
error_code. O painel de operação lista os jobs mortos a partir daqui, agrupa por
error_code e oferece o botão de reprocessar, que relê payload. Nenhuma seção pode
introduzir uma fila com sufixo .dlq, e nenhuma política de retentativa pode apontar para
uma.
| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
char(26) |
não | — | ULID. |
job_name |
varchar(60) |
não | — | Nome do job, em dominio.acao. Ex.: send.plan, tts.generate, billing.reconcile. |
queue |
varchar(40) |
não | — | Fila de origem. Aceita apenas os treze nomes canônicos do catálogo de filas da Seção 18.8, sem apelido e sem variação. |
bull_job_id |
varchar(80) |
sim | NULL |
Identificador do job na fila. |
status |
JobRunStatus |
não | 'RUNNING' |
RUNNING, SUCCEEDED, FAILED ou DEAD. FAILED é uma tentativa que falhou e ainda tem retentativa; DEAD é trabalho morto, que esgotou as tentativas. |
scheduled_for |
timestamptz |
sim | NULL |
Horário previsto, quando é job repetível. |
started_at |
timestamptz |
não | now() |
— |
finished_at |
timestamptz |
sim | NULL |
— |
duration_ms |
integer |
sim | NULL |
— |
items_processed |
integer |
não | 0 |
— |
items_failed |
integer |
não | 0 |
— |
attempt |
smallint |
não | 1 |
Número da tentativa. |
payload |
jsonb |
sim | NULL |
Argumentos do job, com os campos sensíveis já redigidos pelas regras da Seção 23.1.4. É o que permite reprocessar um job morto sem reconstruir os argumentos à mão: sem esta coluna, ops queue:dead:retry (Seção 25.11) não tem de onde tirá-los. Sem índice. |
result |
jsonb |
sim | NULL |
Resumo estruturado do resultado. |
error |
text |
sim | NULL |
Mensagem de erro legível, já redigida pelas regras da Seção 23.1.4, com stack truncada em 4000 caracteres. É o único nome desta coluna em todo o documento: não existe job_runs.error_message, e as Seções 16, 18 e 27 usam error ao listar os campos gravados na linha DEAD. |
error_code |
varchar(60) |
sim | NULL |
Código curto e agrupável da falha, distinto da mensagem. É por ele que ops queue:dead:list --group-by=code agrupa o trabalho morto: mil linhas com a mesma causa viram uma. |
request_id |
char(26) |
sim | NULL |
Correlação. |
created_at |
timestamptz |
não | now() |
— |
6.28.1 Índices #
| Índice | Definição | Justificativa |
|---|---|---|
job_runs_pkey |
PRIMARY KEY (id) |
— |
uq_job_runs_scheduled |
UNIQUE (job_name, scheduled_for) WHERE scheduled_for IS NOT NULL |
Impede execução dupla de job repetível. Se dois workers pegarem o mesmo disparo do agendador, um insere e o outro falha e desiste. |
ix_job_runs_name_time |
(job_name, started_at DESC) |
Histórico e detecção de job que parou de rodar. |
ix_job_runs_failed |
(started_at DESC) WHERE status = 'FAILED' |
Painel de falhas recentes. |
ix_job_runs_dead |
(queue, started_at DESC) WHERE status = 'DEAD' |
Fila de trabalho morto. Sustenta a tela de reprocessamento e os alertas por volume de mortos por fila. |
ix_job_runs_dead_code |
(queue, error_code) WHERE status = 'DEAD' |
Agrupamento por causa em ops queue:dead:list --group-by=code, sem varrer a tabela. |
ix_job_runs_running_stale |
(started_at) WHERE status = 'RUNNING' |
Detecta jobs travados: RUNNING há mais de 30 minutos vira alerta. |
6.28.2 Constraints CHECK #
ALTER TABLE job_runs
ADD CONSTRAINT chk_job_name_shape CHECK (job_name ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$'),
ADD CONSTRAINT chk_job_counts CHECK (items_processed >= 0 AND items_failed >= 0),
ADD CONSTRAINT chk_job_attempt CHECK (attempt BETWEEN 1 AND 20),
ADD CONSTRAINT chk_job_queue_shape CHECK (queue ~ '^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$'),
ADD CONSTRAINT chk_job_queue_not_dlq CHECK (queue NOT LIKE '%.dlq'),
ADD CONSTRAINT chk_job_finished
CHECK (status = 'RUNNING' OR finished_at IS NOT NULL),
ADD CONSTRAINT chk_job_failed_needs_error
CHECK (status NOT IN ('FAILED','DEAD') OR error IS NOT NULL),
ADD CONSTRAINT chk_job_dead_needs_code
CHECK (status <> 'DEAD' OR error_code IS NOT NULL),
ADD CONSTRAINT chk_job_error_code_shape
CHECK (error_code IS NULL OR error_code ~ '^[A-Z][A-Z0-9_]{2,59}$'),
ADD CONSTRAINT chk_job_payload_object
CHECK (payload IS NULL OR jsonb_typeof(payload) = 'object'),
ADD CONSTRAINT chk_job_error_len CHECK (error IS NULL OR char_length(error) <= 4000);chk_job_queue_not_dlq é uma trava deliberada e um pouco incomum: ela existe para que a
decisão "não há fila morta separada" seja imposta pelo banco, e não apenas escrita em
prosa. Se alguém implementar uma fila send.dispatch.dlq, o primeiro registro de execução
falha e a divergência aparece imediatamente, em vez de virar um segundo sistema de
trabalho morto que ninguém monitora.
6.29 schema.prisma completo #
Arquivo prisma/schema.prisma do pacote de banco. É copiável e válido.
Limitação assumida e tratada: o Prisma não representa índices parciais (WHERE),
constraints CHECK, tabelas particionadas nem FKs DEFERRABLE. Esses objetos são criados
por SQL nos arquivos de migration, conforme 6.31. O fluxo de trabalho adotado é
prisma migrate dev --create-only para gerar o esqueleto, edição manual do
migration.sql para acrescentar o que falta, e prisma migrate deploy em staging e
produção. A lista de objetos não representáveis está em 6.31.9, para que o revisor saiba
que a divergência é intencional.
// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../src/generated/client"
runtime = "nodejs"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [citext, pgcrypto, pg_trgm, btree_gin, unaccent]
}
// ---------------------------------------------------------------- enums
enum SubscriberTier { FREE PAID }
enum SubscriberStatus { PENDING_VERIFICATION VERIFIED_PENDING_OPTIN ACTIVE_FREE ACTIVE_PAID PAUSED OPTED_OUT BLOCKED }
enum Role { SUBSCRIBER EDITOR ADMIN OWNER }
enum ConsentType { OPT_IN_WEB OPT_IN_WHATSAPP OPT_OUT RE_OPT_IN TERMS_ACCEPTED PRIVACY_ACCEPTED DATA_EXPORT_REQUESTED DATA_DELETION_REQUESTED PAUSE_STARTED PAUSE_ENDED SERVICE_TERMS SENSITIVE_DATA MARKETING COOKIE_CONSENT }
enum ConsentChannel { WEB WHATSAPP EMAIL ADMIN OPS_CLI }
enum SessionSubjectType { SUBSCRIBER ADMIN }
enum SessionScope { FULL RESTRICTED IMPERSONATION_READONLY }
enum OtpPurpose { LOGIN PHONE_VERIFICATION PHONE_CHANGE EMAIL_VERIFICATION }
enum OtpChannel { WHATSAPP EMAIL_LINK }
enum PlanInterval { MONTHLY YEARLY }
enum SubscriptionStatus { PENDING_PAYMENT ACTIVE CANCELED EXPIRED REFUNDED }
enum BillingType { CREDIT_CARD PIX }
enum PaymentStatus { PENDING CONFIRMED RECEIVED OVERDUE REFUNDED CHARGEBACK_REQUESTED CHARGEBACK_DISPUTE DELETED FAILED }
enum SubscriptionEventType { CREATED ACTIVATED RENEWED CANCEL_REQUESTED CANCELED EXPIRED REFUNDED CHARGEBACK REVOKED REACTIVATED PLAN_CHANGED }
enum DevotionalStatus { DRAFT READY AUDIO_PENDING AUDIO_READY PUBLISHED SENT }
enum AudioProvider { ELEVENLABS GOOGLE_TTS }
enum AudioAssetStatus { PENDING PROCESSING READY FAILED }
enum AudioFormat { OGG_OPUS MP3 MP4 }
enum TemplateCategory { UTILITY MARKETING AUTHENTICATION }
enum TemplateStatus { DRAFT PENDING APPROVED REJECTED PAUSED DISABLED }
enum MessageDirection { OUTBOUND INBOUND }
enum MessageKind { TEMPLATE TEXT AUDIO VIDEO IMAGE INTERACTIVE REACTION UNKNOWN }
enum MessageStatus { QUEUED SENT DELIVERED READ FAILED DEFERRED }
enum DeliveryStep { TEMPLATE_INVITE FULL_TEXT AUDIO CLOSING VIDEO_FALLBACK }
enum DeliveryAttemptStatus { PLANNED IN_FLIGHT SUCCEEDED FAILED SKIPPED SKIPPED_OPTED_OUT SKIPPED_INELIGIBLE DEFERRED }
enum SendBatchStatus { PLANNING PLANNED RUNNING HALTED_BY_GUARD COMPLETED FAILED CANCELED }
enum WebhookSource { ASAAS WHATSAPP }
enum WebhookDirection { INBOUND OUTBOUND }
enum WebhookProcessingStatus { RECEIVED PROCESSING PROCESSED FAILED IGNORED }
enum WebhookProcessingResult { OK SCHEMA_REJECTED PARSE_ERROR PERSIST_FAILED UNKNOWN_EVENT DUPLICATE }
enum MediaUploadStatus { PENDING UPLOADED EXPIRED FAILED }
enum MediaPurpose { DAILY_AUDIO VIDEO_FALLBACK TEMPLATE_HEADER }
enum JobRunStatus { RUNNING SUCCEEDED FAILED DEAD }
enum SettingValueType { STRING INT BOOL JSON DECIMAL }
enum InboundIntent { OPT_OUT OPT_IN OPEN_DEVOTIONAL SNOOZE HELP SUPPORT UNKNOWN }
// ---------------------------------------------------------------- identidade
model Subscriber {
id String @id @db.Char(26)
// phoneE164, waId e email guardam envelope cifrado (v1:<iv>:<ct>:<tag>).
// A unicidade e toda busca por igualdade vivem nas colunas _hmac. Ver 6.1.8.
phoneE164 String @map("phone_e164") @db.Text
phoneHmac String @unique @map("phone_hmac") @db.Char(64)
waId String? @map("wa_id") @db.Text
waIdHmac String? @unique @map("wa_id_hmac") @db.Char(64)
waIdChangedAt DateTime? @map("wa_id_changed_at") @db.Timestamptz(3)
displayName String? @map("display_name") @db.VarChar(120)
email String? @db.Text
emailHmac String? @unique @map("email_hmac") @db.Char(64)
emailVerifiedAt DateTime? @map("email_verified_at") @db.Timestamptz(3)
phoneVerifiedAt DateTime? @map("phone_verified_at") @db.Timestamptz(3)
tier SubscriberTier @default(FREE)
status SubscriberStatus @default(PENDING_VERIFICATION)
locale String @default("pt-BR") @db.VarChar(10)
timezone String @default("America/Sao_Paulo") @db.VarChar(40)
optInConfirmedAt DateTime? @map("opt_in_confirmed_at") @db.Timestamptz(3)
optOutAt DateTime? @map("opt_out_at") @db.Timestamptz(3)
// optOutReason guarda palavra-chave E origem. Nao existe opt_out_source. Ver 6.3.
optOutReason String? @map("opt_out_reason") @db.VarChar(80)
optOutConfirmationSentAt DateTime? @map("opt_out_confirmation_sent_at") @db.Timestamptz(3)
emailMarketingConsentAt DateTime? @map("email_marketing_consent_at") @db.Timestamptz(3)
billingBlockedAt DateTime? @map("billing_blocked_at") @db.Timestamptz(3)
serviceWindowExpiresAt DateTime? @map("service_window_expires_at") @db.Timestamptz(3)
lastInboundAt DateTime? @map("last_inbound_at") @db.Timestamptz(3)
lastDeliveredAt DateTime? @map("last_delivered_at") @db.Timestamptz(3)
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(3)
consecutiveWindowMisses Int @default(0) @map("consecutive_window_misses") @db.SmallInt
undeliveredDays Int @default(0) @map("undelivered_days") @db.SmallInt
undeliverableCount Int @default(0) @map("undeliverable_count") @db.SmallInt
unrecognizedRepliesInWindow Int @default(0) @map("unrecognized_replies_in_window") @db.SmallInt
blockedAt DateTime? @map("blocked_at") @db.Timestamptz(3)
blockedReason String? @map("blocked_reason") @db.VarChar(120)
pausedUntil DateTime? @map("paused_until") @db.Timestamptz(3)
courtesyUntil DateTime? @map("courtesy_until") @db.Timestamptz(3)
humanHandoffUntil DateTime? @map("human_handoff_until") @db.Timestamptz(3)
autoreplySuppressedUntil DateTime? @map("autoreply_suppressed_until") @db.Timestamptz(3)
marketingBlockedAt DateTime? @map("marketing_blocked_at") @db.Timestamptz(3)
welcomeBackfillSentAt DateTime? @map("welcome_backfill_sent_at") @db.Timestamptz(3)
asaasCustomerId String? @unique @map("asaas_customer_id") @db.VarChar(32)
source String @default("landing") @db.VarChar(40)
currentSubscriptionId String? @map("current_subscription_id") @db.Char(26)
anonymizedAt DateTime? @map("anonymized_at") @db.Timestamptz(3)
fullyAnonymizedAt DateTime? @map("fully_anonymized_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
profile SubscriberProfile?
currentSubscription Subscription? @relation("CurrentSubscription", fields: [currentSubscriptionId], references: [id], onDelete: SetNull, onUpdate: Cascade)
subscriptions Subscription[] @relation("SubscriberSubscriptions")
payments Payment[]
subscriptionEvents SubscriptionEvent[]
consentEvents ConsentEvent[]
sessions Session[]
otpCodes OtpCode[]
unsubscribeTokens UnsubscribeToken[]
deliveryAttempts DeliveryAttempt[]
inboundMessages InboundMessage[]
@@index([tier, status], map: "ix_subscribers_tier_status")
@@index([serviceWindowExpiresAt], map: "ix_subscribers_service_window")
@@index([currentSubscriptionId], map: "ix_subscribers_current_subscription")
@@index([createdAt(sort: Desc)], map: "ix_subscribers_created_at")
@@index([consecutiveWindowMisses], map: "ix_subscribers_window_misses")
@@index([pausedUntil], map: "ix_subscribers_paused_until")
@@index([courtesyUntil], map: "ix_subscribers_courtesy_until")
@@index([lastInboundAt, lastLoginAt], map: "ix_subscribers_dormant")
@@index([billingBlockedAt], map: "ix_subscribers_billing_blocked")
@@map("subscribers")
}
model SubscriberProfile {
subscriberId String @id @map("subscriber_id") @db.Char(26)
fullName String? @map("full_name") @db.VarChar(160)
// cpf guarda envelope cifrado. cpfHmac NAO e unico, por decisao explicita (6.4.1).
cpf String? @db.Text
cpfLast4 String? @map("cpf_last4") @db.Char(4)
cpfHmac String? @map("cpf_hmac") @db.Char(64)
birthDate DateTime? @map("birth_date") @db.Date
city String? @db.VarChar(120)
state String? @db.Char(2)
churchName String? @map("church_name") @db.VarChar(160)
preferredVoice String? @map("preferred_voice") @db.VarChar(60)
utmSource String? @map("utm_source") @db.VarChar(120)
utmMedium String? @map("utm_medium") @db.VarChar(120)
utmCampaign String? @map("utm_campaign") @db.VarChar(120)
referrer String? @db.Text
signupIp String? @map("signup_ip") @db.Inet
signupUserAgent String? @map("signup_user_agent") @db.Text
adminNotes String? @map("admin_notes") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([utmCampaign], map: "ix_subscriber_profiles_utm")
@@index([cpfHmac], map: "ix_subscriber_profiles_cpf_hmac")
@@map("subscriber_profiles")
}
model ConsentEvent {
id String @id @db.Char(26)
subscriberId String @map("subscriber_id") @db.Char(26)
type ConsentType
channel ConsentChannel
granted Boolean
policyVersion String @map("policy_version") @db.VarChar(20)
consentText String @map("consent_text") @db.Text
consentTextHash String @map("consent_text_hash") @db.Char(64)
ip String? @db.Inet
userAgent String? @map("user_agent") @db.Text
evidence Json? @db.JsonB
occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict, onUpdate: Cascade)
@@index([subscriberId, occurredAt(sort: Desc)], map: "ix_consent_events_subscriber")
@@index([type, occurredAt(sort: Desc)], map: "ix_consent_events_type_time")
@@index([policyVersion], map: "ix_consent_events_policy_version")
@@map("consent_events")
}
// ---------------------------------------------------------------- autenticação
model Session {
id String @id @db.Char(26)
subjectType SessionSubjectType @map("subject_type")
subscriberId String? @map("subscriber_id") @db.Char(26)
adminUserId String? @map("admin_user_id") @db.Char(26)
role Role
scope SessionScope @default(FULL)
refreshTokenHash String @unique @map("refresh_token_hash") @db.Char(64)
parentSessionId String? @map("parent_session_id") @db.Char(26)
impersonatedByAdminId String? @map("impersonated_by_admin_id") @db.Char(26)
impersonationReason String? @map("impersonation_reason") @db.Text
ip String? @db.Inet
userAgent String? @map("user_agent") @db.Text
deviceFingerprint String? @map("device_fingerprint") @db.Char(64)
issuedAt DateTime @default(now()) @map("issued_at") @db.Timestamptz(3)
lastSeenAt DateTime @default(now()) @map("last_seen_at") @db.Timestamptz(3)
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
revokedReason String? @map("revoked_reason") @db.VarChar(60)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
subscriber Subscriber? @relation(fields: [subscriberId], references: [id], onDelete: Cascade, onUpdate: Cascade)
adminUser AdminUser? @relation("AdminSessions", fields: [adminUserId], references: [id], onDelete: Cascade, onUpdate: Cascade)
parentSession Session? @relation("SessionRotation", fields: [parentSessionId], references: [id], onDelete: SetNull, onUpdate: Cascade)
childSessions Session[] @relation("SessionRotation")
impersonatedBy AdminUser? @relation("AdminImpersonations", fields: [impersonatedByAdminId], references: [id], onDelete: SetNull, onUpdate: Cascade)
@@index([subscriberId, expiresAt(sort: Desc)], map: "ix_sessions_subscriber")
@@index([adminUserId, expiresAt(sort: Desc)], map: "ix_sessions_admin")
@@index([expiresAt], map: "ix_sessions_expires_at")
@@index([parentSessionId], map: "ix_sessions_parent")
@@index([impersonatedByAdminId, issuedAt(sort: Desc)], map: "ix_sessions_impersonation")
@@map("sessions")
}
model OtpCode {
id String @id @db.Char(26)
purpose OtpPurpose
channel OtpChannel
phoneE164 String? @map("phone_e164") @db.Text
phoneHmac String? @map("phone_hmac") @db.Char(64)
email String? @db.Text
emailHmac String? @map("email_hmac") @db.Char(64)
subscriberId String? @map("subscriber_id") @db.Char(26)
codeHash String @map("code_hash") @db.Char(64)
attempts Int @default(0) @db.SmallInt
maxAttempts Int @default(5) @map("max_attempts") @db.SmallInt
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
consumedAt DateTime? @map("consumed_at") @db.Timestamptz(3)
invalidatedAt DateTime? @map("invalidated_at") @db.Timestamptz(3)
requestIp String? @map("request_ip") @db.Inet
deliveryMessageLogId String? @map("delivery_message_log_id") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
subscriber Subscriber? @relation(fields: [subscriberId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([phoneHmac, createdAt(sort: Desc)], map: "ix_otp_codes_phone")
@@index([emailHmac, createdAt(sort: Desc)], map: "ix_otp_codes_email")
@@index([requestIp, createdAt(sort: Desc)], map: "ix_otp_codes_ip_window")
@@index([expiresAt], map: "ix_otp_codes_expires_at")
@@index([subscriberId, createdAt(sort: Desc)], map: "ix_otp_codes_subscriber")
@@map("otp_codes")
}
/// 28a e ultima tabela da lista fechada de 6.1.7. Definicao completa em 6.7.4.
model UnsubscribeToken {
id String @id @db.Char(26)
subscriberId String @map("subscriber_id") @db.Char(26)
tokenHash String @unique @map("token_hash") @db.Char(64)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
usedAt DateTime? @map("used_at") @db.Timestamptz(3)
subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([subscriberId, createdAt(sort: Desc)], map: "ix_unsubscribe_tokens_subscriber")
@@index([expiresAt], map: "ix_unsubscribe_tokens_expiring")
@@map("unsubscribe_tokens")
}
model AdminUser {
id String @id @db.Char(26)
email String @db.Citext
name String @db.VarChar(120)
passwordHash String @map("password_hash") @db.Text
role Role @default(EDITOR)
totpSecretEncrypted Bytes? @map("totp_secret_encrypted")
totpEnrolledAt DateTime? @map("totp_enrolled_at") @db.Timestamptz(3)
totpRecoveryCodes Json? @map("totp_recovery_codes") @db.JsonB
failedLoginCount Int @default(0) @map("failed_login_count") @db.SmallInt
lockedUntil DateTime? @map("locked_until") @db.Timestamptz(3)
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(3)
lastLoginIp String? @map("last_login_ip") @db.Inet
passwordChangedAt DateTime @default(now()) @map("password_changed_at") @db.Timestamptz(3)
mustChangePassword Boolean @default(false) @map("must_change_password")
createdByAdminId String? @map("created_by_admin_id") @db.Char(26)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
createdBy AdminUser? @relation("AdminCreatedBy", fields: [createdByAdminId], references: [id], onDelete: SetNull, onUpdate: Cascade)
createdAdmins AdminUser[] @relation("AdminCreatedBy")
sessions Session[] @relation("AdminSessions")
impersonations Session[] @relation("AdminImpersonations")
auditEntries AdminAuditLog[]
trustedDevices AdminTrustedDevice[]
authoredDevotionals Devotional[] @relation("DevotionalAuthor")
approvedDevotionals Devotional[] @relation("DevotionalApprover")
canceledSendBatches SendBatch[] @relation("BatchCanceledBy")
devotionalRevisions DevotionalRevision[]
updatedSettings Setting[]
updatedFeatureFlags FeatureFlag[]
@@index([role], map: "ix_admin_users_role")
@@index([lockedUntil], map: "ix_admin_users_locked")
@@map("admin_users")
}
model AdminTrustedDevice {
id String @id @db.Char(26)
adminUserId String @map("admin_user_id") @db.Char(26)
tokenHash String @unique @map("token_hash") @db.Char(64)
label String? @db.VarChar(80)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
lastUsedAt DateTime? @map("last_used_at") @db.Timestamptz(3)
adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([adminUserId, expiresAt(sort: Desc)], map: "ix_admin_trusted_admin")
@@index([expiresAt], map: "ix_admin_trusted_expires")
@@map("admin_trusted_devices")
}
/// Esquema unico da trilha administrativa. Ver 6.9: nao existem actor_admin_id,
/// actor_id, target_type, target_id, before_json, after_json, actor_email,
/// created_at_utc nem created_at_brt.
model AdminAuditLog {
id String @id @db.Char(26)
adminUserId String? @map("admin_user_id") @db.Char(26)
actorType String @default("ADMIN") @map("actor_type") @db.VarChar(20)
actorRole String @map("actor_role") @db.VarChar(20)
action String @db.VarChar(80)
entityType String? @map("entity_type") @db.VarChar(60)
entityId String? @map("entity_id") @db.Char(26)
before Json? @db.JsonB
after Json? @db.JsonB
changedFields String[] @default([]) @map("changed_fields")
metadata Json? @db.JsonB
recordHash String @map("record_hash") @db.Char(64)
reason String? @db.Text
ip String? @db.Inet
userAgent String? @map("user_agent") @db.Text
requestId String? @map("request_id") @db.Char(26)
sessionId String? @map("session_id") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
adminUser AdminUser? @relation(fields: [adminUserId], references: [id], onDelete: Restrict, onUpdate: Cascade)
@@index([adminUserId, createdAt(sort: Desc)], map: "ix_audit_admin_time")
@@index([entityType, entityId, createdAt(sort: Desc)], map: "ix_audit_entity")
@@index([action, createdAt(sort: Desc)], map: "ix_audit_action_time")
@@index([requestId], map: "ix_audit_request_id")
@@index([actorType, createdAt(sort: Desc)], map: "ix_audit_actor_type_time")
@@index([changedFields], type: Gin, map: "ix_audit_changed_fields")
@@map("admin_audit_log")
}
// ---------------------------------------------------------------- cobrança
model Plan {
id String @id @db.Char(26)
code String @unique @db.VarChar(40)
name String @db.VarChar(80)
description String? @db.VarChar(200)
interval PlanInterval
amountCents Int @map("amount_cents")
currency String @default("BRL") @db.Char(3)
tierGranted SubscriberTier @default(PAID) @map("tier_granted")
asaasCycle String @map("asaas_cycle") @db.VarChar(20)
isActive Boolean @default(true) @map("is_active")
sortOrder Int @default(0) @map("sort_order") @db.SmallInt
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
subscriptions Subscription[] @relation("SubscriptionPlan")
pendingSubscriptions Subscription[] @relation("SubscriptionPendingPlan")
@@index([isActive, sortOrder], map: "ix_plans_active_order")
@@map("plans")
}
model Subscription {
id String @id @db.Char(26)
subscriberId String @map("subscriber_id") @db.Char(26)
planId String @map("plan_id") @db.Char(26)
status SubscriptionStatus @default(PENDING_PAYMENT)
billingType BillingType @map("billing_type")
amountCents Int @map("amount_cents")
currency String @default("BRL") @db.Char(3)
asaasCustomerId String? @map("asaas_customer_id") @db.VarChar(40)
asaasSubscriptionId String? @unique @map("asaas_subscription_id") @db.VarChar(40)
asaasCardToken String? @map("asaas_card_token") @db.VarChar(64)
cardBrand String? @map("card_brand") @db.VarChar(20)
cardLast4 String? @map("card_last4") @db.Char(4)
cardExpMonth Int? @map("card_exp_month") @db.SmallInt
cardExpYear Int? @map("card_exp_year") @db.SmallInt
pendingPlanId String? @map("pending_plan_id") @db.Char(26)
pendingPlanEffectiveAt DateTime? @map("pending_plan_effective_at") @db.Timestamptz(3)
// cancelAtPeriodEnd e o sinalizador (qualquer cancelamento voluntario e o opt-out);
// billingSuspendedAt e o carimbo, preenchido SO quando a origem foi o opt-out. Ver 6.11.
cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end")
billingSuspendedAt DateTime? @map("billing_suspended_at") @db.Timestamptz(3)
endReason String? @map("end_reason") @db.VarChar(40)
startedAt DateTime? @map("started_at") @db.Timestamptz(3)
currentPeriodStart DateTime? @map("current_period_start") @db.Timestamptz(3)
currentPeriodEnd DateTime? @map("current_period_end") @db.Timestamptz(3)
nextDueDate DateTime? @map("next_due_date") @db.Date
cancelRequestedAt DateTime? @map("cancel_requested_at") @db.Timestamptz(3)
canceledAt DateTime? @map("canceled_at") @db.Timestamptz(3)
cancelReason String? @map("cancel_reason") @db.VarChar(120)
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
expiredAt DateTime? @map("expired_at") @db.Timestamptz(3)
refundedAt DateTime? @map("refunded_at") @db.Timestamptz(3)
lastPaymentId String? @map("last_payment_id") @db.Char(26)
reconciledAt DateTime? @map("reconciled_at") @db.Timestamptz(3)
metadata Json? @db.JsonB
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
subscriber Subscriber @relation("SubscriberSubscriptions", fields: [subscriberId], references: [id], onDelete: Restrict, onUpdate: Cascade)
plan Plan @relation("SubscriptionPlan", fields: [planId], references: [id], onDelete: Restrict, onUpdate: Cascade)
pendingPlan Plan? @relation("SubscriptionPendingPlan", fields: [pendingPlanId], references: [id], onDelete: Restrict, onUpdate: Cascade)
lastPayment Payment? @relation("LastPayment", fields: [lastPaymentId], references: [id], onDelete: SetNull, onUpdate: Cascade)
payments Payment[] @relation("SubscriptionPayments")
events SubscriptionEvent[]
paymentEvents PaymentEvent[]
currentOf Subscriber[] @relation("CurrentSubscription")
@@index([subscriberId, createdAt(sort: Desc)], map: "ix_subscriptions_subscriber_time")
@@index([status, currentPeriodEnd], map: "ix_subscriptions_status_period_end")
@@index([nextDueDate], map: "ix_subscriptions_next_due")
@@index([reconciledAt], map: "ix_subscriptions_reconcile")
@@index([planId], map: "ix_subscriptions_plan")
@@index([billingSuspendedAt], map: "ix_subscriptions_billing_suspended")
@@index([currentPeriodEnd], map: "ix_subscriptions_cancel_at_period_end")
@@map("subscriptions")
}
model SubscriptionEvent {
id String @id @db.Char(26)
subscriptionId String @map("subscription_id") @db.Char(26)
subscriberId String @map("subscriber_id") @db.Char(26)
type SubscriptionEventType
fromStatus SubscriptionStatus? @map("from_status")
toStatus SubscriptionStatus @map("to_status")
actor String @db.VarChar(40)
actorId String? @map("actor_id") @db.Char(26)
paymentId String? @map("payment_id") @db.Char(26)
paymentEventId String? @map("payment_event_id") @db.Char(26)
cancelReason String? @map("cancel_reason") @db.VarChar(40)
cancelComment String? @map("cancel_comment") @db.VarChar(500)
reason String? @db.Text
metadata Json? @db.JsonB
occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade, onUpdate: Cascade)
subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict, onUpdate: Cascade)
payment Payment? @relation(fields: [paymentId], references: [id], onDelete: SetNull, onUpdate: Cascade)
paymentEvent PaymentEvent? @relation(fields: [paymentEventId], references: [id], onDelete: SetNull, onUpdate: Cascade)
@@index([subscriptionId, occurredAt(sort: Desc)], map: "ix_sub_events_subscription")
@@index([subscriberId, occurredAt(sort: Desc)], map: "ix_sub_events_subscriber")
@@index([type, occurredAt(sort: Desc)], map: "ix_sub_events_type_time")
@@index([paymentId], map: "ix_sub_events_payment")
@@index([cancelReason, occurredAt(sort: Desc)], map: "ix_sub_events_cancel_reason")
@@map("subscription_events")
}
model Payment {
id String @id @db.Char(26)
subscriptionId String @map("subscription_id") @db.Char(26)
subscriberId String @map("subscriber_id") @db.Char(26)
asaasPaymentId String @unique @map("asaas_payment_id") @db.VarChar(40)
status PaymentStatus @default(PENDING)
billingType BillingType @map("billing_type")
amountCents Int @map("amount_cents")
netAmountCents Int? @map("net_amount_cents")
feeCents Int @default(0) @map("fee_cents")
refundedAmountCents Int @default(0) @map("refunded_amount_cents")
currency String @default("BRL") @db.Char(3)
dueDate DateTime @map("due_date") @db.Date
confirmedAt DateTime? @map("confirmed_at") @db.Timestamptz(3)
receivedAt DateTime? @map("received_at") @db.Timestamptz(3)
creditDate DateTime? @map("credit_date") @db.Date
invoiceUrl String? @map("invoice_url") @db.Text
receiptUrl String? @map("receipt_url") @db.Text
chargebackStage String? @map("chargeback_stage") @db.VarChar(20)
pixPayload String? @map("pix_payload") @db.Text
pixQrCodeBase64 String? @map("pix_qr_code_base64") @db.Text
pixExpiresAt DateTime? @map("pix_expires_at") @db.Timestamptz(3)
cardBrand String? @map("card_brand") @db.VarChar(20)
cardLast4 String? @map("card_last4") @db.Char(4)
failureCode String? @map("failure_code") @db.VarChar(40)
failureMessage String? @map("failure_message") @db.Text
description String? @db.VarChar(200)
reconciledAt DateTime? @map("reconciled_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
subscription Subscription @relation("SubscriptionPayments", fields: [subscriptionId], references: [id], onDelete: Restrict, onUpdate: Cascade)
subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict, onUpdate: Cascade)
lastPaymentOf Subscription[] @relation("LastPayment")
paymentEvents PaymentEvent[]
subscriptionEvents SubscriptionEvent[]
@@index([subscriptionId, createdAt(sort: Desc)], map: "ix_payments_subscription_time")
@@index([subscriberId, createdAt(sort: Desc)], map: "ix_payments_subscriber_time")
@@index([status, dueDate], map: "ix_payments_status_due")
@@index([confirmedAt(sort: Desc)], map: "ix_payments_confirmed_at")
@@index([pixExpiresAt], map: "ix_payments_pix_expiring")
@@map("payments")
}
model PaymentEvent {
id String @id @db.Char(26)
asaasEventId String @unique @map("asaas_event_id") @db.VarChar(60)
eventType String @map("event_type") @db.VarChar(60)
asaasPaymentId String? @map("asaas_payment_id") @db.VarChar(40)
asaasSubscriptionId String? @map("asaas_subscription_id") @db.VarChar(40)
paymentId String? @map("payment_id") @db.Char(26)
subscriptionId String? @map("subscription_id") @db.Char(26)
payload Json @db.JsonB
signatureValid Boolean @map("signature_valid")
processingStatus WebhookProcessingStatus @default(RECEIVED) @map("processing_status")
attempts Int @default(0) @db.SmallInt
lastError String? @map("last_error") @db.Text
nextRetryAt DateTime? @map("next_retry_at") @db.Timestamptz(3)
processedAt DateTime? @map("processed_at") @db.Timestamptz(3)
receivedAt DateTime @default(now()) @map("received_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
payment Payment? @relation(fields: [paymentId], references: [id], onDelete: SetNull, onUpdate: Cascade)
subscription Subscription? @relation(fields: [subscriptionId], references: [id], onDelete: SetNull, onUpdate: Cascade)
subscriptionEvents SubscriptionEvent[]
@@index([nextRetryAt], map: "ix_payment_events_pending")
@@index([asaasPaymentId], map: "ix_payment_events_asaas_payment")
@@index([eventType, receivedAt(sort: Desc)], map: "ix_payment_events_type_time")
@@index([receivedAt], map: "ix_payment_events_received_at")
@@map("payment_events")
}
// ---------------------------------------------------------------- editorial e mídia
model Devotional {
id String @id @db.Char(26)
slug String @unique @db.VarChar(90)
scheduledFor DateTime @unique @map("scheduled_for") @db.Date
title String @db.VarChar(120)
bibleReference String @map("bible_reference") @db.VarChar(80)
bibleText String @map("bible_text") @db.Text
bibleVersion String @default("ALMEIDA_1911") @map("bible_version") @db.VarChar(40)
reflectionMd String @map("reflection_md") @db.Text
prayer String @db.Text
teaser String @db.VarChar(300)
status DevotionalStatus @default(DRAFT)
authorAdminId String? @map("author_admin_id") @db.Char(26)
approvedByAdminId String? @map("approved_by_admin_id") @db.Char(26)
approvedAt DateTime? @map("approved_at") @db.Timestamptz(3)
publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
sentAt DateTime? @map("sent_at") @db.Timestamptz(3)
coverImageKey String? @map("cover_image_key") @db.Text
narrationCharCount Int? @map("narration_char_count")
estimatedAudioSeconds Int? @map("estimated_audio_seconds")
internalNotes String? @map("internal_notes") @db.Text
autoPublish Boolean @default(false) @map("auto_publish")
isEvergreen Boolean @default(false) @map("is_evergreen")
lastUsedAsFallbackAt DateTime? @map("last_used_as_fallback_at") @db.Timestamptz(3)
tags String[] @default([])
version Int @default(1)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
author AdminUser? @relation("DevotionalAuthor", fields: [authorAdminId], references: [id], onDelete: SetNull, onUpdate: Cascade)
approvedBy AdminUser? @relation("DevotionalApprover", fields: [approvedByAdminId], references: [id], onDelete: SetNull, onUpdate: Cascade)
revisions DevotionalRevision[]
audioAssets AudioAsset[]
mediaUploads MediaUpload[]
deliveryAttempts DeliveryAttempt[]
sendBatches SendBatch[]
@@index([status, scheduledFor], map: "ix_devotionals_status_date")
@@index([tags], type: Gin, map: "ix_devotionals_tags")
@@index([isEvergreen, lastUsedAsFallbackAt], map: "ix_devotionals_evergreen")
@@map("devotionals")
}
model DevotionalRevision {
id String @id @db.Char(26)
devotionalId String @map("devotional_id") @db.Char(26)
revisionNumber Int @map("revision_number")
changedByAdminId String? @map("changed_by_admin_id") @db.Char(26)
snapshot Json @db.JsonB
changedFields String[] @default([]) @map("changed_fields")
diffSummary String? @map("diff_summary") @db.Text
origin String @default("EDIT") @db.VarChar(20)
restoredFromRevision Int? @map("restored_from_revision")
statusBefore DevotionalStatus? @map("status_before")
statusAfter DevotionalStatus @map("status_after")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
devotional Devotional @relation(fields: [devotionalId], references: [id], onDelete: Cascade, onUpdate: Cascade)
changedBy AdminUser? @relation(fields: [changedByAdminId], references: [id], onDelete: SetNull, onUpdate: Cascade)
@@unique([devotionalId, revisionNumber], map: "uq_dev_rev_number")
@@index([devotionalId, createdAt(sort: Desc)], map: "ix_dev_rev_devotional_time")
@@index([changedByAdminId, createdAt(sort: Desc)], map: "ix_dev_rev_admin")
@@map("devotional_revisions")
}
model AudioAsset {
id String @id @db.Char(26)
devotionalId String @map("devotional_id") @db.Char(26)
format AudioFormat
provider AudioProvider
voiceId String @map("voice_id") @db.VarChar(60)
model String? @db.VarChar(60)
status AudioAssetStatus @default(PENDING)
storageBucket String? @map("storage_bucket") @db.VarChar(80)
storageKey String? @unique @map("storage_key") @db.Text
byteSize BigInt? @map("byte_size")
durationSeconds Int? @map("duration_seconds")
sampleRate Int? @map("sample_rate")
bitrateKbps Int? @map("bitrate_kbps") @db.SmallInt
checksumSha256 String? @map("checksum_sha256") @db.Char(64)
// narrationScriptHash e o unico nome deste campo. Nao existe script_hash. Ver 6.17.
narrationScriptHash String? @map("narration_script_hash") @db.Char(64)
chunkCount Int @default(1) @map("chunk_count") @db.SmallInt
generationAttempts Int @default(0) @map("generation_attempts") @db.SmallInt
qcFailedAt DateTime? @map("qc_failed_at") @db.Timestamptz(3)
lastError String? @map("last_error") @db.Text
charCount Int @default(0) @map("char_count")
costMicros BigInt? @map("cost_micros")
generatedAt DateTime? @map("generated_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
devotional Devotional @relation(fields: [devotionalId], references: [id], onDelete: Cascade, onUpdate: Cascade)
mediaUploads MediaUpload[]
@@unique([devotionalId, format, voiceId], map: "uq_audio_dev_format_voice")
@@index([devotionalId, status], map: "ix_audio_devotional")
@@index([createdAt], map: "ix_audio_status_pending")
@@index([narrationScriptHash], map: "ix_audio_script_hash")
@@map("audio_assets")
}
model MediaUpload {
id String @id @db.Char(26)
devotionalId String @map("devotional_id") @db.Char(26)
audioAssetId String? @map("audio_asset_id") @db.Char(26)
purpose MediaPurpose
whatsappMediaId String? @unique @map("whatsapp_media_id") @db.VarChar(80)
phoneNumberId String @map("phone_number_id") @db.VarChar(40)
mimeType String @map("mime_type") @db.VarChar(60)
byteSize BigInt @map("byte_size")
sha256 String @db.Char(64)
status MediaUploadStatus @default(PENDING)
attempts Int @default(0) @db.SmallInt
lastError String? @map("last_error") @db.Text
uploadedAt DateTime? @map("uploaded_at") @db.Timestamptz(3)
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
lastUsedAt DateTime? @map("last_used_at") @db.Timestamptz(3)
supersededAt DateTime? @map("superseded_at") @db.Timestamptz(3)
useCount Int @default(0) @map("use_count")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
devotional Devotional @relation(fields: [devotionalId], references: [id], onDelete: Cascade, onUpdate: Cascade)
audioAsset AudioAsset? @relation(fields: [audioAssetId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@unique([audioAssetId, phoneNumberId, purpose], map: "uq_media_asset_phone")
@@index([devotionalId, purpose, status], map: "ix_media_devotional_purpose")
@@index([expiresAt], map: "ix_media_expiring")
@@index([devotionalId, purpose], map: "ix_media_current")
@@map("media_uploads")
}
// ---------------------------------------------------------------- mensageria e envio
model WhatsappTemplate {
id String @id @db.Char(26)
name String @db.VarChar(80)
language String @default("pt_BR") @db.VarChar(10)
category TemplateCategory
effectiveCategory TemplateCategory? @map("effective_category")
status TemplateStatus @default(DRAFT)
metaTemplateId String? @unique @map("meta_template_id") @db.VarChar(40)
components Json @db.JsonB
bodyText String @map("body_text") @db.Text
paramCount Int @default(0) @map("param_count") @db.SmallInt
headerType String? @map("header_type") @db.VarChar(20)
qualityScore String? @map("quality_score") @db.VarChar(20)
rejectedReason String? @map("rejected_reason") @db.Text
version Int @default(1)
isCurrent Boolean @default(false) @map("is_current")
submittedAt DateTime? @map("submitted_at") @db.Timestamptz(3)
approvedAt DateTime? @map("approved_at") @db.Timestamptz(3)
lastSyncedAt DateTime? @map("last_synced_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@unique([name, language], map: "uq_wa_templates_name_lang")
@@index([name], map: "ix_wa_templates_current")
@@index([status], map: "ix_wa_templates_status")
@@map("whatsapp_templates")
}
/// Tabela particionada por RANGE (created_at). Sem FKs de saída — ver Seção 6.19.4.
model MessageLog {
id String @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
subscriberId String? @map("subscriber_id") @db.Char(26)
direction MessageDirection
kind MessageKind
status MessageStatus @default(QUEUED)
devotionalId String? @map("devotional_id") @db.Char(26)
deliveryAttemptId String? @map("delivery_attempt_id") @db.Char(26)
templateId String? @map("template_id") @db.Char(26)
templateName String? @map("template_name") @db.VarChar(80)
wamid String? @db.VarChar(128)
messageKey String? @map("message_key") @db.VarChar(60)
// phoneE164 e waId sao envelope cifrado e ANULAVEIS: a anonimizacao por LGPD zera as
// duas colunas, e a retencao aqui e de 18 meses. Ver 6.19.3.
phoneE164 String? @map("phone_e164") @db.Text
phoneHmac String? @map("phone_hmac") @db.Char(64)
waId String? @map("wa_id") @db.Text
mediaId String? @map("media_id") @db.VarChar(80)
payloadExcerpt String? @map("payload_excerpt") @db.VarChar(240)
errorCode String? @map("error_code") @db.VarChar(20)
errorTitle String? @map("error_title") @db.VarChar(160)
errorDetails String? @map("error_details") @db.Text
conversationId String? @map("conversation_id") @db.VarChar(80)
conversationCategory String? @map("conversation_category") @db.VarChar(20)
pricingModel String? @map("pricing_model") @db.VarChar(20)
isBillable Boolean @default(false) @map("is_billable")
costMicros BigInt? @map("cost_micros")
sentAt DateTime? @map("sent_at") @db.Timestamptz(3)
deliveredAt DateTime? @map("delivered_at") @db.Timestamptz(3)
readAt DateTime? @map("read_at") @db.Timestamptz(3)
failedAt DateTime? @map("failed_at") @db.Timestamptz(3)
lateOpenDays Int? @map("late_open_days") @db.SmallInt
requestId String? @map("request_id") @db.Char(26)
@@id([id, createdAt])
@@unique([wamid, createdAt], map: "uq_message_logs_wamid")
@@index([subscriberId, createdAt(sort: Desc)], map: "ix_message_logs_subscriber_time")
@@index([deliveryAttemptId], map: "ix_message_logs_attempt")
@@index([status, createdAt(sort: Desc)], map: "ix_message_logs_status_time")
@@index([errorCode, createdAt(sort: Desc)], map: "ix_message_logs_error_code")
@@index([devotionalId, createdAt(sort: Desc)], map: "ix_message_logs_devotional")
@@index([conversationId], map: "ix_message_logs_conversation")
@@index([phoneHmac, createdAt(sort: Desc)], map: "ix_message_logs_phone_hmac")
@@index([messageKey, createdAt(sort: Desc)], map: "ix_message_logs_message_key")
@@map("message_logs")
}
model DeliveryAttempt {
id String @id @db.Char(26)
idempotencyKey String @unique @map("idempotency_key") @db.VarChar(120)
sendBatchId String? @map("send_batch_id") @db.Char(26)
subscriberId String @map("subscriber_id") @db.Char(26)
devotionalId String @map("devotional_id") @db.Char(26)
devotionalDate DateTime @map("devotional_date") @db.Date
step DeliveryStep
status DeliveryAttemptStatus @default(PLANNED)
tierAtSend SubscriberTier @map("tier_at_send")
tierAtSendEffective SubscriberTier? @map("tier_at_send_effective")
downgradedAt DateTime? @map("downgraded_at") @db.Timestamptz(3)
origin String @default("DAILY_BATCH") @db.VarChar(20)
stepsCompleted Int @default(0) @map("steps_completed") @db.SmallInt
audioStatus String @default("NOT_APPLICABLE") @map("audio_status") @db.VarChar(20)
manualResendCount Int @default(0) @map("manual_resend_count") @db.SmallInt
attemptCount Int @default(0) @map("attempt_count") @db.SmallInt
maxAttempts Int @default(5) @map("max_attempts") @db.SmallInt
// nextRetryAt e o unico nome deste campo. Nao existe next_attempt_at. Ver 6.20.
nextRetryAt DateTime? @map("next_retry_at") @db.Timestamptz(3)
lockedAt DateTime? @map("locked_at") @db.Timestamptz(3)
lastErrorCode String? @map("last_error_code") @db.VarChar(20)
lastErrorMessage String? @map("last_error_message") @db.Text
deferredReason String? @map("deferred_reason") @db.VarChar(60)
messageLogId String? @map("message_log_id") @db.Char(26)
plannedAt DateTime @default(now()) @map("planned_at") @db.Timestamptz(3)
startedAt DateTime? @map("started_at") @db.Timestamptz(3)
completedAt DateTime? @map("completed_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Cascade, onUpdate: Cascade)
devotional Devotional @relation(fields: [devotionalId], references: [id], onDelete: Restrict, onUpdate: Cascade)
sendBatch SendBatch? @relation(fields: [sendBatchId], references: [id], onDelete: SetNull, onUpdate: Cascade)
@@unique([subscriberId, devotionalDate, step], map: "uq_delivery_sub_date_step")
@@index([nextRetryAt, id], map: "ix_delivery_pending")
@@index([sendBatchId, status], map: "ix_delivery_batch_status")
@@index([devotionalDate, status], map: "ix_delivery_date_status")
@@index([subscriberId, devotionalDate(sort: Desc)], map: "ix_delivery_subscriber_date")
@@index([lastErrorCode, devotionalDate], map: "ix_delivery_error_code")
@@index([downgradedAt], map: "ix_delivery_downgraded")
@@index([lockedAt], map: "ix_delivery_locked")
@@map("delivery_attempts")
}
model InboundMessage {
id String @id @db.Char(26)
wamid String @unique @db.VarChar(128)
subscriberId String? @map("subscriber_id") @db.Char(26)
waId String @map("wa_id") @db.Text
waIdHmac String @map("wa_id_hmac") @db.Char(64)
phoneE164 String? @map("phone_e164") @db.Text
phoneHmac String? @map("phone_hmac") @db.Char(64)
kind MessageKind
textBody String? @map("text_body") @db.Text
supportProtocol String? @map("support_protocol") @db.VarChar(20)
buttonPayload String? @map("button_payload") @db.VarChar(60)
interactiveId String? @map("interactive_id") @db.VarChar(60)
mediaId String? @map("media_id") @db.VarChar(80)
intent InboundIntent @default(UNKNOWN)
intentConfidence Int @default(100) @map("intent_confidence") @db.SmallInt
raw Json @db.JsonB
isHandled Boolean @default(false) @map("is_handled")
handledAt DateTime? @map("handled_at") @db.Timestamptz(3)
handlingError String? @map("handling_error") @db.Text
receivedAt DateTime @default(now()) @map("received_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
subscriber Subscriber? @relation(fields: [subscriberId], references: [id], onDelete: SetNull, onUpdate: Cascade)
@@index([createdAt], map: "ix_inbound_unhandled")
@@index([subscriberId, receivedAt(sort: Desc)], map: "ix_inbound_subscriber_time")
@@index([waIdHmac, receivedAt(sort: Desc)], map: "ix_inbound_wa_id_time")
@@index([intent, receivedAt(sort: Desc)], map: "ix_inbound_intent_time")
@@index([supportProtocol], map: "ix_inbound_support_protocol")
@@map("inbound_messages")
}
model SendBatch {
id String @id @db.Char(26)
idempotencyKey String @unique @map("idempotency_key") @db.VarChar(80)
devotionalId String @map("devotional_id") @db.Char(26)
// A unicidade de devotionalDate e PARCIAL (batch_kind = 'DAILY') e vive em M10, nao
// aqui: o Prisma nao representa indice parcial. Ver 6.22.1 e 6.31.2.
devotionalDate DateTime @map("devotional_date") @db.Date
status SendBatchStatus @default(PLANNING)
// batchKind e o unico nome deste campo. Nao existe send_batches.kind. Ver 6.22.
batchKind String @default("DAILY") @map("batch_kind") @db.VarChar(20)
parentBatchId String? @map("parent_batch_id") @db.Char(26)
canceledBy String? @map("canceled_by") @db.Char(26)
canceledReason String? @map("canceled_reason") @db.Text
tierLimitAtPlan Int? @map("tier_limit_at_plan")
templateUsed String? @map("template_used") @db.VarChar(80)
truncatedCount Int @default(0) @map("truncated_count")
fallbackUsed Boolean @default(false) @map("fallback_used")
fallbackReason String? @map("fallback_reason") @db.VarChar(40)
haltedErrorCode String? @map("halted_error_code") @db.VarChar(20)
plannedCount Int @default(0) @map("planned_count")
queuedCount Int @default(0) @map("queued_count")
sentCount Int @default(0) @map("sent_count")
deliveredCount Int @default(0) @map("delivered_count")
readCount Int @default(0) @map("read_count")
failedCount Int @default(0) @map("failed_count")
skippedCount Int @default(0) @map("skipped_count")
deferredCount Int @default(0) @map("deferred_count")
freeCount Int @default(0) @map("free_count")
paidCount Int @default(0) @map("paid_count")
startedAt DateTime? @map("started_at") @db.Timestamptz(3)
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
durationMs Int? @map("duration_ms")
plannedBy String @default("scheduler") @map("planned_by") @db.VarChar(40)
error String? @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
devotional Devotional @relation(fields: [devotionalId], references: [id], onDelete: Restrict, onUpdate: Cascade)
parentBatch SendBatch? @relation("BatchRetry", fields: [parentBatchId], references: [id], onDelete: SetNull, onUpdate: Cascade)
retryBatches SendBatch[] @relation("BatchRetry")
canceledByAdmin AdminUser? @relation("BatchCanceledBy", fields: [canceledBy], references: [id], onDelete: SetNull, onUpdate: Cascade)
deliveryAttempts DeliveryAttempt[]
@@index([status, devotionalDate(sort: Desc)], map: "ix_send_batches_status")
@@index([parentBatchId], map: "ix_send_batches_parent")
@@map("send_batches")
}
// ---------------------------------------------------------------- config, métricas, operação
model Setting {
key String @id @db.VarChar(80)
value Json @db.JsonB
valueType SettingValueType @map("value_type")
defaultValue Json @map("default_value") @db.JsonB
description String @db.VarChar(300)
groupName String @map("group_name") @db.VarChar(40)
isSecret Boolean @default(false) @map("is_secret")
editableBy Role @default(ADMIN) @map("editable_by")
minValue Decimal? @map("min_value") @db.Decimal(20, 4)
maxValue Decimal? @map("max_value") @db.Decimal(20, 4)
allowedValues Json? @map("allowed_values") @db.JsonB
version Int @default(1)
updatedByAdminId String? @map("updated_by_admin_id") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
updatedBy AdminUser? @relation(fields: [updatedByAdminId], references: [id], onDelete: SetNull, onUpdate: Cascade)
@@index([groupName, key], map: "ix_settings_group")
@@index([updatedAt(sort: Desc)], map: "ix_settings_updated_at")
@@map("settings")
}
model FeatureFlag {
key String @id @db.VarChar(60)
description String @db.VarChar(300)
isEnabled Boolean @default(false) @map("is_enabled")
rolloutPercentage Int @default(0) @map("rollout_percentage") @db.SmallInt
targetTier SubscriberTier? @map("target_tier")
updatedByAdminId String? @map("updated_by_admin_id") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
updatedBy AdminUser? @relation(fields: [updatedByAdminId], references: [id], onDelete: SetNull, onUpdate: Cascade)
@@index([isEnabled], map: "ix_feature_flags_enabled")
@@map("feature_flags")
}
/// Formato longo: uma linha por (dia, metrica, dimensao). Ver 6.25.
/// Nao existe versao de formato largo com uma coluna por metrica.
model DailyMetric {
id String @id @db.Char(26)
metricDate DateTime @map("metric_date") @db.Date
metricKey String @map("metric_key") @db.Text
dimension String @default("") @db.Text
value Decimal? @db.Decimal(18, 6)
numerator Decimal? @db.Decimal(18, 6)
denominator Decimal? @db.Decimal(18, 6)
rollupVersion Int @default(1) @map("rollup_version") @db.SmallInt
isFinal Boolean @default(false) @map("is_final")
computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@unique([metricDate, metricKey, dimension], map: "daily_metrics_unique")
@@index([metricKey, metricDate(sort: Desc)], map: "daily_metrics_key_date_idx")
@@index([metricDate(sort: Desc)], map: "daily_metrics_date_idx")
@@index([metricDate], map: "daily_metrics_not_final_idx")
@@map("daily_metrics")
}
model WebhookDelivery {
id String @id @db.Char(26)
direction WebhookDirection
source WebhookSource
endpoint String @db.Text
httpMethod String @default("POST") @map("http_method") @db.VarChar(10)
httpStatus Int? @map("http_status") @db.SmallInt
eventType String? @map("event_type") @db.VarChar(80)
// dedupKey e source sao os unicos nomes destes campos. Nao existem external_id
// nem provider nesta tabela. Ver 6.26.
dedupKey String? @map("dedup_key") @db.VarChar(160)
duplicateCount Int @default(0) @map("duplicate_count")
signatureHeader String? @map("signature_header") @db.Text
isSignatureValid Boolean @default(false) @map("is_signature_valid")
bodySha256 String @map("body_sha256") @db.Char(64)
body Json? @db.JsonB
bodyRawSize Int @default(0) @map("body_raw_size")
headers Json? @db.JsonB
processingStatus WebhookProcessingStatus @default(RECEIVED) @map("processing_status")
processingResult WebhookProcessingResult? @map("processing_result")
attempts Int @default(0) @db.SmallInt
nextRetryAt DateTime? @map("next_retry_at") @db.Timestamptz(3)
lastError String? @map("last_error") @db.Text
latencyMs Int? @map("latency_ms")
requestId String? @map("request_id") @db.Char(26)
receivedAt DateTime @default(now()) @map("received_at") @db.Timestamptz(3)
processedAt DateTime? @map("processed_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
@@unique([source, dedupKey], map: "uq_webhook_dedup")
@@index([source, receivedAt(sort: Desc)], map: "ix_webhook_source_time")
@@index([nextRetryAt], map: "ix_webhook_pending")
@@index([bodySha256, receivedAt(sort: Desc)], map: "ix_webhook_body_hash")
@@index([receivedAt], map: "ix_webhook_received_at")
@@map("webhook_deliveries")
}
model JobRun {
id String @id @db.Char(26)
jobName String @map("job_name") @db.VarChar(60)
queue String @db.VarChar(40)
bullJobId String? @map("bull_job_id") @db.VarChar(80)
status JobRunStatus @default(RUNNING)
scheduledFor DateTime? @map("scheduled_for") @db.Timestamptz(3)
startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz(3)
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
durationMs Int? @map("duration_ms")
itemsProcessed Int @default(0) @map("items_processed")
itemsFailed Int @default(0) @map("items_failed")
attempt Int @default(1) @db.SmallInt
payload Json? @db.JsonB
result Json? @db.JsonB
// error e o unico nome da mensagem de falha. Nao existe error_message. Ver 6.28.
error String? @db.Text
errorCode String? @map("error_code") @db.VarChar(60)
requestId String? @map("request_id") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
@@unique([jobName, scheduledFor], map: "uq_job_runs_scheduled")
@@index([jobName, startedAt(sort: Desc)], map: "ix_job_runs_name_time")
@@index([startedAt(sort: Desc)], map: "ix_job_runs_failed")
@@index([queue, startedAt(sort: Desc)], map: "ix_job_runs_dead")
@@index([queue, errorCode], map: "ix_job_runs_dead_code")
@@index([startedAt], map: "ix_job_runs_running_stale")
@@map("job_runs")
}6.30 DDL SQL das tabelas críticas #
Trechos de SQL que o Prisma não gera e que precisam existir literalmente nos arquivos de migration. As demais tabelas são criadas pelo Prisma a partir de 6.29.
6.30.1 subscribers — índices parciais e CHECKs #
-- Identidade e unicidade vivem nas colunas de indice cego, nunca nas cifradas.
CREATE UNIQUE INDEX uq_subscribers_phone_hmac
ON subscribers (phone_hmac) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uq_subscribers_wa_id_hmac
ON subscribers (wa_id_hmac) WHERE wa_id_hmac IS NOT NULL;
CREATE UNIQUE INDEX uq_subscribers_email_hmac
ON subscribers (email_hmac) WHERE email_hmac IS NOT NULL AND deleted_at IS NULL;
CREATE UNIQUE INDEX uq_subscribers_asaas_customer
ON subscribers (asaas_customer_id) WHERE asaas_customer_id IS NOT NULL;
CREATE INDEX ix_subscribers_send_eligible
ON subscribers (tier, status)
WHERE opt_in_confirmed_at IS NOT NULL
AND opt_out_at IS NULL
AND deleted_at IS NULL
AND blocked_at IS NULL;
CREATE INDEX ix_subscribers_window_misses
ON subscribers (consecutive_window_misses)
WHERE tier = 'PAID' AND consecutive_window_misses >= 3;
CREATE INDEX ix_subscribers_paused_until
ON subscribers (paused_until) WHERE paused_until IS NOT NULL;
CREATE INDEX ix_subscribers_courtesy_until
ON subscribers (courtesy_until) WHERE courtesy_until IS NOT NULL;
CREATE INDEX ix_subscribers_dormant
ON subscribers (last_inbound_at NULLS FIRST, last_login_at NULLS FIRST)
WHERE deleted_at IS NULL;
CREATE INDEX ix_subscribers_billing_blocked
ON subscribers (billing_blocked_at) WHERE billing_blocked_at IS NOT NULL;
ALTER TABLE subscribers
ADD CONSTRAINT chk_subscribers_id_ulid CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
-- Nao existe CHECK de formato E.164 aqui: a coluna guarda envelope cifrado e a
-- validacao de formato e do Zod, antes de cifrar (6.1.8).
ADD CONSTRAINT chk_subscribers_phone_envelope CHECK (phone_e164 ~ '^v[0-9]+:'),
ADD CONSTRAINT chk_subscribers_phone_hmac CHECK (phone_hmac ~ '^[0-9a-f]{64}$'),
ADD CONSTRAINT chk_subscribers_wa_id_envelope
CHECK (wa_id IS NULL OR wa_id ~ '^v[0-9]+:'),
ADD CONSTRAINT chk_subscribers_wa_id_pair
CHECK ((wa_id IS NULL) = (wa_id_hmac IS NULL)),
ADD CONSTRAINT chk_subscribers_email_envelope
CHECK (email IS NULL OR email ~ '^v[0-9]+:'),
ADD CONSTRAINT chk_subscribers_email_pair
CHECK ((email IS NULL) = (email_hmac IS NULL)),
ADD CONSTRAINT chk_subscribers_status_optout
CHECK ((status = 'OPTED_OUT') = (opt_out_at IS NOT NULL
AND blocked_at IS NULL
AND deleted_at IS NULL)),
ADD CONSTRAINT chk_subscribers_status_blocked
CHECK ((status = 'BLOCKED') = (blocked_at IS NOT NULL AND deleted_at IS NULL)),
ADD CONSTRAINT chk_subscribers_status_paused
CHECK (status <> 'PAUSED' OR paused_until IS NOT NULL),
ADD CONSTRAINT chk_subscribers_status_tier_matches
CHECK ((status <> 'ACTIVE_PAID' OR tier = 'PAID')
AND (status <> 'ACTIVE_FREE' OR tier = 'FREE'));
ALTER TABLE subscribers
DROP CONSTRAINT subscribers_current_subscription_id_fkey,
ADD CONSTRAINT subscribers_current_subscription_id_fkey
FOREIGN KEY (current_subscription_id) REFERENCES subscriptions(id)
ON DELETE SET NULL ON UPDATE CASCADE
DEFERRABLE INITIALLY DEFERRED;
-- subscriber_profiles: o indice de CPF e deliberadamente NAO unico (6.4.1).
CREATE INDEX ix_subscriber_profiles_cpf_hmac
ON subscriber_profiles (cpf_hmac) WHERE cpf_hmac IS NOT NULL;
ALTER TABLE subscriber_profiles
ADD CONSTRAINT chk_profiles_cpf_envelope CHECK (cpf IS NULL OR cpf ~ '^v[0-9]+:'),
ADD CONSTRAINT chk_profiles_cpf_pair CHECK ((cpf IS NULL) = (cpf_hmac IS NULL));As três colunas cifradas de subscribers e a de subscriber_profiles nascem assim na
primeira migration que cria as tabelas. Não são endurecimento posterior. Adiá-las para
um marco de segurança obrigaria a reescrever, depois, toda consulta por telefone escrita
até lá — e é justamente esse tipo de retrabalho que a decisão evita.
6.30.2 message_logs — criação particionada #
O Prisma cria a tabela como comum. A migration substitui a definição antes de qualquer dado existir.
DROP TABLE IF EXISTS message_logs;
CREATE TABLE message_logs (
id char(26) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
subscriber_id char(26),
direction "MessageDirection" NOT NULL,
kind "MessageKind" NOT NULL,
status "MessageStatus" NOT NULL DEFAULT 'QUEUED',
devotional_id char(26),
delivery_attempt_id char(26),
template_id char(26),
template_name varchar(80),
wamid varchar(128),
message_key varchar(60),
-- phone_e164 e wa_id sao envelope cifrado e ANULAVEIS: a anonimizacao por LGPD
-- precisa zera-las, e a retencao desta tabela e de 18 meses (6.19.3).
phone_e164 text,
phone_hmac char(64),
wa_id text,
media_id varchar(80),
payload_excerpt varchar(240),
error_code varchar(20),
error_title varchar(160),
error_details text,
conversation_id varchar(80),
conversation_category varchar(20),
pricing_model varchar(20),
is_billable boolean NOT NULL DEFAULT false,
cost_micros bigint,
sent_at timestamptz,
delivered_at timestamptz,
read_at timestamptz,
failed_at timestamptz,
late_open_days smallint,
request_id char(26),
CONSTRAINT message_logs_pkey PRIMARY KEY (id, created_at),
CONSTRAINT chk_msg_phone_envelope CHECK (phone_e164 IS NULL OR phone_e164 ~ '^v[0-9]+:'),
CONSTRAINT chk_msg_phone_hmac CHECK (phone_hmac IS NULL OR phone_hmac ~ '^[0-9a-f]{64}$'),
CONSTRAINT chk_msg_wa_id_envelope CHECK (wa_id IS NULL OR wa_id ~ '^v[0-9]+:'),
CONSTRAINT chk_msg_failed_needs_code CHECK (status <> 'FAILED' OR error_code IS NOT NULL),
CONSTRAINT chk_msg_cost CHECK (cost_micros IS NULL OR cost_micros >= 0)
) PARTITION BY RANGE (created_at);
CREATE UNIQUE INDEX uq_message_logs_wamid
ON message_logs (wamid, created_at) WHERE wamid IS NOT NULL;
CREATE INDEX ix_message_logs_subscriber_time
ON message_logs (subscriber_id, created_at DESC) WHERE subscriber_id IS NOT NULL;
CREATE INDEX ix_message_logs_status_time ON message_logs (status, created_at DESC);
CREATE INDEX ix_message_logs_error_code ON message_logs (error_code, created_at DESC)
WHERE error_code IS NOT NULL;
CREATE INDEX ix_message_logs_attempt ON message_logs (delivery_attempt_id)
WHERE delivery_attempt_id IS NOT NULL;
CREATE INDEX ix_message_logs_phone_hmac ON message_logs (phone_hmac, created_at DESC)
WHERE phone_hmac IS NOT NULL;
CREATE INDEX ix_message_logs_message_key ON message_logs (message_key, created_at DESC)
WHERE message_key IS NOT NULL;
-- partição de escape: nada fica sem lugar se o job de criação falhar
CREATE TABLE message_logs_default PARTITION OF message_logs DEFAULT;6.30.3 Função de criação automática de partições #
CREATE OR REPLACE FUNCTION ensure_message_logs_partition(target date)
RETURNS text
LANGUAGE plpgsql
AS $$
DECLARE
start_ts timestamptz := date_trunc('month', target)::timestamptz;
end_ts timestamptz := (date_trunc('month', target) + interval '1 month')::timestamptz;
part text := format('message_logs_%s', to_char(start_ts, 'YYYY_MM'));
BEGIN
IF to_regclass(part) IS NOT NULL THEN
RETURN part || ' (já existia)';
END IF;
EXECUTE format(
'CREATE TABLE %I PARTITION OF message_logs FOR VALUES FROM (%L) TO (%L)',
part, start_ts, end_ts);
RETURN part || ' (criada)';
END;
$$;O job db.ensure_partitions roda diariamente às 03:00 e chama a função para o mês
corrente e para os dois meses seguintes. Dois meses de folga garantem que uma falha de
job por vários dias não derrube a escrita. A partição DEFAULT é a rede de segurança:
linha que caia nela dispara alerta imediato.
6.30.4 delivery_attempts — índices que sustentam o motor de envio #
CREATE INDEX ix_delivery_pending
ON delivery_attempts (next_retry_at NULLS FIRST, id)
WHERE status IN ('PLANNED','DEFERRED','FAILED') AND attempt_count < max_attempts;
CREATE INDEX ix_delivery_downgraded
ON delivery_attempts (downgraded_at) WHERE downgraded_at IS NOT NULL;
-- Sustenta a varredura de reparo das 09:00: itens reivindicados e abandonados.
CREATE INDEX ix_delivery_locked
ON delivery_attempts (locked_at) WHERE status = 'IN_FLIGHT';
ALTER TABLE delivery_attempts
-- Guarda do planejamento.
ADD CONSTRAINT chk_delivery_audio_step_paid
CHECK (step <> 'AUDIO' OR tier_at_send = 'PAID'),
-- Guarda do disparo: o tier e relido no instante do envio, nunca so no planejamento.
ADD CONSTRAINT chk_delivery_audio_step_paid_effective
CHECK (step <> 'AUDIO' OR status <> 'SUCCEEDED' OR tier_at_send_effective = 'PAID'),
ADD CONSTRAINT chk_delivery_downgrade_pair
CHECK (downgraded_at IS NULL
OR (tier_at_send = 'PAID' AND tier_at_send_effective = 'FREE'));Com 4 milhões de linhas na tabela e poucos milhares pendentes, ix_delivery_pending
ocupa alguns megabytes em vez de centenas. O índice parcial é o que torna a fila viável em
um único servidor.
6.30.5 subscriptions — a regra "uma assinatura viva por assinante" #
CREATE UNIQUE INDEX uq_subscriptions_one_live_per_subscriber
ON subscriptions (subscriber_id)
WHERE status IN ('PENDING_PAYMENT','ACTIVE');
CREATE INDEX ix_subscriptions_cancel_at_period_end
ON subscriptions (current_period_end) WHERE cancel_at_period_end = true;
-- Um lote DIARIO por dia de calendario. O lote RETRY compartilha a data de proposito,
-- e a unicidade dele vem de uq_send_batches_idempotency (6.22.1).
CREATE UNIQUE INDEX uq_send_batches_date
ON send_batches (devotional_date) WHERE batch_kind = 'DAILY';
CREATE INDEX ix_send_batches_parent
ON send_batches (parent_batch_id) WHERE parent_batch_id IS NOT NULL;Duas abas de checkout abertas ao mesmo tempo produzem duas requisições. A segunda viola
este índice, o repositório traduz o erro 23505 do Postgres para
SUBSCRIPTION_ALREADY_ACTIVE e o cliente é redirecionado para a assinatura existente.
Nenhuma cobrança duplicada é criada no provedor porque a criação remota só acontece depois
do INSERT local bem-sucedido, dentro da mesma transação lógica.
6.30.6 Proteção append-only #
O gatilho de imutabilidade não pode ser um gatilho cego. Se ele recusar toda escrita, ele impede também as duas operações que a própria lei obriga: a anonimização por pedido de eliminação, que precisa zerar IP e user-agent, e o expurgo por retenção, que precisa apagar linhas fora do prazo de 5 anos. Um controle de integridade que impede o cumprimento de um direito do titular não é um controle: é um defeito com aparência de rigor.
A solução também não é desabilitar o gatilho na transação. ALTER TABLE ... DISABLE TRIGGER exige ser dono da tabela, o que o papel de privacidade não é, e abriria uma janela
em que qualquer escrita passa. O gatilho nunca é desabilitado. Em vez disso, ele
reconhece dois papéis nomeados e verifica, coluna a coluna, que a operação é exatamente a
permitida — o conteúdo probatório do consentimento continua imutável mesmo para eles.
CREATE ROLE privacy_operator LOGIN;
CREATE ROLE retention_operator LOGIN;
CREATE OR REPLACE FUNCTION consent_events_immutable()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
-- Excecao 1: eliminacao por LGPD, restrita a zerar identificadores de rede.
IF TG_OP = 'UPDATE' AND current_user = 'privacy_operator'
AND NEW.id = OLD.id AND NEW.subscriber_id = OLD.subscriber_id
AND NEW.type = OLD.type AND NEW.granted = OLD.granted
AND NEW.policy_version = OLD.policy_version
AND NEW.consent_text_hash = OLD.consent_text_hash
AND NEW.occurred_at = OLD.occurred_at AND NEW.created_at = OLD.created_at
AND NEW.ip IS NULL AND NEW.user_agent IS NULL THEN
RETURN NEW;
END IF;
-- Excecao 2: expurgo por retencao, restrito a linhas fora do prazo legal.
IF TG_OP = 'DELETE' AND current_user = 'retention_operator'
AND OLD.created_at < now() - interval '5 years' THEN
RETURN OLD;
END IF;
RAISE EXCEPTION 'tabela % é append-only: % não permitido', TG_TABLE_NAME, TG_OP
USING ERRCODE = '42501';
END;
$$;
CREATE TRIGGER trg_consent_events_append_only
BEFORE UPDATE OR DELETE ON consent_events
FOR EACH ROW EXECUTE FUNCTION consent_events_immutable();
-- Mesmo padrao, mesmos dois papeis, para a trilha administrativa.
CREATE OR REPLACE FUNCTION admin_audit_log_immutable()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'UPDATE' AND current_user = 'privacy_operator'
AND NEW.id = OLD.id
-- IS NOT DISTINCT FROM porque admin_user_id e anulavel (acao do sistema ou do
-- proprio titular): com "=" a comparacao daria NULL e a excecao nunca valeria.
AND NEW.admin_user_id IS NOT DISTINCT FROM OLD.admin_user_id
AND NEW.actor_type = OLD.actor_type AND NEW.actor_role = OLD.actor_role
AND NEW.action = OLD.action AND NEW.created_at = OLD.created_at
AND NEW.record_hash = OLD.record_hash
AND NEW.ip IS NULL AND NEW.user_agent IS NULL THEN
RETURN NEW;
END IF;
IF TG_OP = 'DELETE' AND current_user = 'retention_operator'
AND OLD.created_at < now() - interval '5 years' THEN
RETURN OLD;
END IF;
RAISE EXCEPTION 'tabela % é append-only: % não permitido', TG_TABLE_NAME, TG_OP
USING ERRCODE = '42501';
END;
$$;
CREATE TRIGGER trg_admin_audit_append_only
BEFORE UPDATE OR DELETE ON admin_audit_log
FOR EACH ROW EXECUTE FUNCTION admin_audit_log_immutable();
REVOKE UPDATE, DELETE ON consent_events FROM palavra_diaria_app;
REVOKE UPDATE, DELETE ON admin_audit_log FROM palavra_diaria_app;
GRANT INSERT, SELECT ON consent_events TO palavra_diaria_app;
GRANT INSERT, SELECT ON admin_audit_log TO palavra_diaria_app;
GRANT SELECT, UPDATE ON consent_events, admin_audit_log TO privacy_operator;
GRANT SELECT, DELETE ON consent_events, admin_audit_log TO retention_operator;Dois mecanismos redundantes de propósito: o REVOKE protege contra o caminho normal, o
gatilho protege contra um GRANT acidental em manutenção futura. As duas exceções são
caminhos privilegiados explícitos e auditados: cada uma exige um papel nomeado próprio,
usado por um único job, e cada execução grava a ação correspondente em admin_audit_log
(subscriber.anonymized e o expurgo de retenção). Um teste de integração executa a
anonimização e o expurgo de ponta a ponta contra um banco com o gatilho ativo, e falha se
qualquer um lançar exceção — e falha também se um UPDATE que altere type, granted,
consent_text_hash ou occurred_at for aceito com o papel privacy_operator.
6.31 Ordem de migrations #
Migrations aplicadas por prisma migrate deploy no start do contêiner web, com lock
distribuído (Seção 25). A numeração é a ordem cronológica dos diretórios em
prisma/migrations/.
| Migration | Nome | Conteúdo |
|---|---|---|
| M1 | init_extensions |
CREATE EXTENSION para citext, pgcrypto, pg_trgm, btree_gin, unaccent. Criação dos papéis palavra_diaria_app, palavra_diaria_owner, privacy_operator e retention_operator. |
| M2 | init_enums |
Todos os 35 tipos enum de 6.1.6. Isolado por causa da restrição do Postgres a ALTER TYPE ... ADD VALUE na mesma transação. |
| M3 | identity_tables |
subscribers, subscriber_profiles, consent_events, já com as colunas cifradas e as colunas de índice cego (phone_hmac, wa_id_hmac, email_hmac, cpf_hmac). FK circular de subscribers fica sem criar. |
| M4 | auth_tables |
admin_users, sessions, otp_codes, unsubscribe_tokens, admin_audit_log, admin_trusted_devices. |
| M5 | billing_tables |
plans, subscriptions, subscription_events, payments, payment_events. Cria a FK circular subscribers.current_subscription_id como DEFERRABLE INITIALLY DEFERRED e a FK subscriptions.last_payment_id. |
| M6 | editorial_tables |
devotionals, devotional_revisions, audio_assets, media_uploads. |
| M7 | messaging_tables |
whatsapp_templates, inbound_messages, send_batches, delivery_attempts. |
| M8 | message_logs_partitioned |
Recria message_logs como tabela particionada (6.30.2), cria ensure_message_logs_partition() (6.30.3), a partição DEFAULT e as partições do mês corrente e dos dois seguintes. |
| M9 | ops_tables |
settings, feature_flags, daily_metrics, webhook_deliveries, job_runs. |
| M10 | partial_indexes |
Todos os índices parciais listados em 6.3.1 a 6.28.1 que o Prisma não representa. |
| M11 | check_constraints |
Todos os CHECK das subseções 6.3.3 a 6.28.2, incluindo os chk_<tabela>_id_ulid. |
| M12 | append_only_guards |
Funções consent_events_immutable() e admin_audit_log_immutable(), os dois triggers, os REVOKE e os GRANT dos papéis privacy_operator e retention_operator (6.30.6). |
| M13 | grants |
GRANT SELECT, INSERT, UPDATE, DELETE nas tabelas mutáveis para palavra_diaria_app; GRANT SELECT, INSERT nas append-only; GRANT USAGE no schema; e — obrigatoriamente — ALTER DEFAULT PRIVILEGES para que toda tabela criada por migration futura nasça acessível (ver abaixo). |
| M14 | seed_reference_data |
Dados de referência obrigatórios: planos, settings, feature flags, templates. Escrito como INSERT ... ON CONFLICT DO NOTHING para ser reaplicável. Conteúdo em 6.32. |
| M15 | retention_functions |
Funções purge_expired_sessions(), purge_expired_otp_codes(), anonymize_deleted_subscribers(), detach_old_message_partitions() (6.33). |
O bloco de M13 precisa, obrigatoriamente, das três últimas linhas:
GRANT ALL PRIVILEGES ON SCHEMA public TO palavra_diaria_owner;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO palavra_diaria_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO palavra_diaria_app;
-- Sem isto, toda tabela criada por uma migration futura nasce inacessivel a aplicacao.
ALTER DEFAULT PRIVILEGES FOR ROLE palavra_diaria_owner IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO palavra_diaria_app;
ALTER DEFAULT PRIVILEGES FOR ROLE palavra_diaria_owner IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO palavra_diaria_app;GRANT ... ON ALL TABLES concede privilégio apenas às tabelas existentes no momento do
comando. Sem o ALTER DEFAULT PRIVILEGES, a próxima migration que criar uma tabela a
deixa sem privilégio para a aplicação, o deploy é declarado bem-sucedido pelo healthcheck —
que só consulta as tabelas antigas — e a falha aparece horas depois, como permission denied for table, sem se parecer com um problema de permissão. Por isso a verificação
pós-migration de pnpm db:verify passa a incluir: nenhuma tabela do schema public pode
existir sem SELECT para palavra_diaria_app, percorrendo information_schema.tables
contra has_table_privilege. Uma falha aqui bloqueia a troca de tráfego.
6.31.1 Regras de migration #
- Nenhuma migration destrutiva sem migration de compatibilidade antes. Remover coluna exige duas releases: a primeira para de escrever, a segunda remove.
- Índice em tabela grande usa
CREATE INDEX CONCURRENTLY. Isso não roda dentro de transação, então essas migrations ficam em arquivo próprio, marcado no cabeçalho, e omigration.sqldesativa a transação implícita do Prisma. - Adicionar valor a enum é migration isolada. Nunca acompanha uso desse valor.
- Toda migration é testada com
prisma migrate diffcontra o banco de staging antes de ir para produção. - Rollback é sempre para frente. Não existe arquivo
down. Reverter é escrever a migration seguinte que desfaz.
6.31.2 Objetos deliberadamente fora do schema.prisma #
Para que o revisor não trate a divergência como erro:
| Objeto | Onde vive | Motivo |
|---|---|---|
Índices parciais (WHERE) |
M10 | Prisma não suporta. |
Constraints CHECK |
M11 | Prisma não suporta. |
Particionamento de message_logs |
M8 | Prisma não suporta. |
FKs DEFERRABLE |
M5 | Prisma não suporta. |
| Triggers e funções PL/pgSQL | M8, M12, M15 | Prisma não suporta. |
GRANT e REVOKE |
M1, M12, M13 | Fora do escopo do ORM. |
O comando pnpm db:verify (parte do ops) roda um conjunto de asserções SQL que confirma
que todos esses objetos existem no banco alvo. Ele faz parte do healthcheck de deploy: se
faltar um índice parcial, o deploy falha em vez de degradar silenciosamente.
6.32 Seeds obrigatórios #
Executados por pnpm --filter @palavra-diaria/db seed, idempotentes, seguros para rodar
em qualquer ambiente. Em produção, o seed só cria o que não existe; nunca sobrescreve.
6.32.1 Planos #
INSERT INTO plans (id, code, name, description, interval, amount_cents, currency,
tier_granted, asaas_cycle, is_active, sort_order)
VALUES
('01K000000000000000PLANMTH', 'plan_monthly', 'Mensal',
'Devocional em texto e áudio todos os dias.', 'MONTHLY', 1990, 'BRL',
'PAID', 'MONTHLY', true, 1),
('01K000000000000000PLANANL', 'plan_annual', 'Anual',
'Doze meses de devocional diário com dois meses de desconto.', 'YEARLY', 19900, 'BRL',
'PAID', 'YEARLY', true, 2)
ON CONFLICT (code) DO NOTHING;R$ 19,90 por mês e R$ 199,00 por ano. O anual equivale a R$ 16,58 por mês, ou 16,7% de desconto — número interno; o rótulo exibido ao público é arredondado para baixo.
O seed cria duas linhas, e apenas duas. Não existe linha plan_free: ser gratuito é a
ausência de assinatura vigente (6.10). Se a página de vendas precisar mostrar um plano
gratuito na tabela comparativa, o item é montado pelo handler.
6.32.2 Administrador inicial #
Criado apenas quando não existe nenhuma conta com papel OWNER. A senha vem de variável
de ambiente (Seção 26.3.11) e a conta nasce com troca obrigatória no primeiro acesso e sem
TOTP enrolado — o enrolamento é imposto no primeiro login (Seção 8.6).
// packages/db/src/seeds/admin.ts
// A verificacao vem PRIMEIRO. BOOTSTRAP_ADMIN_PASSWORD so e lida dentro do caminho que
// cria o primeiro proprietario, nunca no topo do modulo.
const ownerExists = await prisma.adminUser.findFirst({
where: { role: 'OWNER', deletedAt: null },
select: { id: true },
});
if (ownerExists) {
logger.info('owner already exists, skipping bootstrap');
} else {
const bootstrapEmail = requireEnv('BOOTSTRAP_ADMIN_EMAIL');
const bootstrapPassword = requireEnv('BOOTSTRAP_ADMIN_PASSWORD');
await prisma.adminUser.create({
data: {
id: ulid(),
email: bootstrapEmail,
name: 'Administrador',
passwordHash: await argon2.hash(bootstrapPassword, ARGON2_OPTIONS), // ver Seção 8.5
role: 'OWNER',
mustChangePassword: true,
// totpEnrolledAt fica nulo: o enrolamento e imposto no primeiro login.
},
});
logger.info({ email: bootstrapEmail }, 'bootstrap owner created');
}A ordem importa e não é estética. Se a variável fosse lida no topo do módulo, remover
BOOTSTRAP_ADMIN_PASSWORD do ambiente — que é justamente o que o checklist de implantação
manda fazer depois do primeiro acesso — faria todo deploy posterior falhar, porque o
seed é reexecutado e é declarado idempotente e seguro. O operador então reporia a senha
original para destravar o contêiner, e ela ficaria no ambiente para sempre, agora com uma
conta OWNER ativa correspondente. Lendo a variável dentro do ramo de criação, a ausência
dela com proprietário já criado é o estado normal e não produz erro.
Três regras completam o desenho:
- O valor precisa ter no mínimo 24 caracteres e é rejeitado pelo schema de validação de ambiente se constar da lista de senhas comuns ou for igual ao exemplo publicado.
- A conta nasce com
must_change_password = trueetotp_enrolled_atnulo, e a sessão emitida no primeiro acesso é restrita às rotas de troca de senha e de enrolamento de segundo fator. - O processo recusa iniciar se o ambiente for produção, existir ao menos um
OWNERcom TOTP enrolado, eBOOTSTRAP_ADMIN_PASSWORDcontinuar definida. A remoção deixa de ser um item de checklist que alguém pode esquecer e passa a ser imposta pelo boot.
O seed nunca grava a senha em log, nem mesmo mascarada.
6.32.3 Templates do WhatsApp #
Oito templates. O seed cria as oito linhas locais com status = 'DRAFT'; a submissão à
Meta é feita pelo comando pnpm ops templates:submit, e o retorno atualiza
meta_template_id, status e effective_category. Os components semeados são
idênticos aos definidos na seção dona da integração com o WhatsApp: divergir aqui
significa submeter à Meta um template diferente do que o motor de envio espera preencher.
name |
category |
header_type |
param_count |
Uso |
|---|---|---|---|---|
devocional_diario_v1 |
UTILITY |
TEXT |
2 | Convite diário. {{1}} título, {{2}} teaser. |
devocional_diario_video_v1 |
UTILITY |
VIDEO |
2 | Fallback para quem não abre a janela há 3 dias. |
codigo_acesso_v1 |
AUTHENTICATION |
— | 1 | OTP de login. {{1}} código de 6 dígitos. |
boas_vindas_v1 |
UTILITY |
TEXT |
1 | Confirmação de opt-in. {{1}} primeiro nome. |
lembrete_pagamento_v1 |
UTILITY |
TEXT |
3 | Lembrete de vencimento D-3, D-1 e D0. {{1}} nome, {{2}} data, {{3}} valor. Tem um botão de URL. |
pagamento_confirmado_v1 |
UTILITY |
TEXT |
3 | Confirmação de pagamento. {{1}} nome, {{2}} valor, {{3}} fim do período. |
acesso_encerrado_v1 |
UTILITY |
TEXT |
1 | Aviso de fim do acesso pago. {{1}} primeiro nome. |
reativacao_v1 |
MARKETING |
— | 2 | Convite de retorno a quem cancelou. {{1}} nome, {{2}} oferta. |
param_count conta apenas os parâmetros do corpo. Parâmetros de botão de URL são
numerados em sequência própria pela Meta e não entram nesta contagem — por isso
lembrete_pagamento_v1 tem param_count = 3 e, ainda assim, um parâmetro de URL.
INSERT INTO whatsapp_templates
(id, name, language, category, status, body_text, param_count, header_type, components, is_current)
VALUES
('01K000000000000000TPLDIA1', 'devocional_diario_v1', 'pt_BR', 'UTILITY', 'DRAFT',
'Hoje: {{1}}. {{2}}', 2, 'TEXT',
'{"components":[
{"type":"HEADER","format":"TEXT","text":"Devocional de hoje"},
{"type":"BODY","text":"Hoje: {{1}}. {{2}}",
"example":{"body_text":[["A força do silêncio","Deus não precisa de barulho para agir."]]}},
{"type":"FOOTER","text":"Responda SAIR para cancelar"},
{"type":"BUTTONS","buttons":[
{"type":"QUICK_REPLY","text":"Ler e ouvir agora"},
{"type":"QUICK_REPLY","text":"Depois"}]}]}'::jsonb,
false),
('01K000000000000000TPLVID1', 'devocional_diario_video_v1', 'pt_BR', 'UTILITY', 'DRAFT',
'Hoje: {{1}}. {{2}}', 2, 'VIDEO',
'{"components":[
{"type":"HEADER","format":"VIDEO"},
{"type":"BODY","text":"Hoje: {{1}}. {{2}}"},
{"type":"FOOTER","text":"Responda SAIR para cancelar"}]}'::jsonb,
false),
('01K000000000000000TPLOTP1', 'codigo_acesso_v1', 'pt_BR', 'AUTHENTICATION', 'DRAFT',
'Seu código de acesso é {{1}}. Ele expira em 10 minutos.', 1, NULL,
'{"components":[
{"type":"BODY","text":"Seu código de acesso é {{1}}. Ele expira em 10 minutos."},
{"type":"BUTTONS","buttons":[{"type":"OTP","otp_type":"COPY_CODE","text":"Copiar código"}]}]}'::jsonb,
false),
('01K000000000000000TPLWEL1', 'boas_vindas_v1', 'pt_BR', 'UTILITY', 'DRAFT',
'Olá, {{1}}! Sua inscrição foi confirmada. Responda SIM para começar a receber.', 1, 'TEXT',
'{"components":[
{"type":"HEADER","format":"TEXT","text":"Bem-vindo"},
{"type":"BODY","text":"Olá, {{1}}! Sua inscrição foi confirmada. Responda SIM para começar a receber."},
{"type":"BUTTONS","buttons":[{"type":"QUICK_REPLY","text":"Confirmar inscrição"}]}]}'::jsonb,
false),
('01K000000000000000TPLLEM1', 'lembrete_pagamento_v1', 'pt_BR', 'UTILITY', 'DRAFT',
'Olá, {{1}}. Sua assinatura da Palavra Diária vence em {{2}}.
Valor: {{3}}
Pague pelo link abaixo para manter o áudio diário ativo.', 3, 'TEXT',
'{"components":[
{"type":"HEADER","format":"TEXT","text":"Lembrete de pagamento"},
{"type":"BODY","text":"Olá, {{1}}. Sua assinatura da Palavra Diária vence em {{2}}.\n\nValor: {{3}}\n\nPague pelo link abaixo para manter o áudio diário ativo.",
"example":{"body_text":[["Ana","25/09/2026","R$ 19,90"]]}},
{"type":"FOOTER","text":"Responda SAIR para cancelar os envios"},
{"type":"BUTTONS","buttons":[
{"type":"URL","text":"Pagar agora","url":"https://palavradiaria.com.br/app/assinatura/{{1}}",
"example":["https://palavradiaria.com.br/app/assinatura/pay_01K3F8R5W7"]}]}]}'::jsonb,
false),
('01K000000000000000TPLPAG1', 'pagamento_confirmado_v1', 'pt_BR', 'UTILITY', 'DRAFT',
'Pagamento confirmado, {{1}}. Recebemos {{2}} e seu acesso está garantido até {{3}}.', 3, 'TEXT',
'{"components":[
{"type":"HEADER","format":"TEXT","text":"Pagamento confirmado"},
{"type":"BODY","text":"Pagamento confirmado, {{1}}. Recebemos {{2}} e seu acesso está garantido até {{3}}.",
"example":{"body_text":[["Ana","R$ 19,90","25/10/2026"]]}},
{"type":"FOOTER","text":"Obrigado por caminhar com a gente"}]}'::jsonb,
false),
('01K000000000000000TPLENC1', 'acesso_encerrado_v1', 'pt_BR', 'UTILITY', 'DRAFT',
'Olá, {{1}}. Seu acesso ao áudio diário foi encerrado. Você continua recebendo o devocional em texto aos domingos.', 1, 'TEXT',
'{"components":[
{"type":"HEADER","format":"TEXT","text":"Acesso encerrado"},
{"type":"BODY","text":"Olá, {{1}}. Seu acesso ao áudio diário foi encerrado. Você continua recebendo o devocional em texto aos domingos.",
"example":{"body_text":[["Ana"]]}},
{"type":"FOOTER","text":"Responda SAIR para parar de receber"}]}'::jsonb,
false),
('01K000000000000000TPLREA1', 'reativacao_v1', 'pt_BR', 'MARKETING', 'DRAFT',
'Sentimos sua falta, {{1}}. {{2}}', 2, NULL,
'{"components":[
{"type":"BODY","text":"Sentimos sua falta, {{1}}. {{2}}",
"example":{"body_text":[["Ana","Volte a receber o devocional em áudio todos os dias."]]}},
{"type":"FOOTER","text":"Responda SAIR para não receber mais"},
{"type":"BUTTONS","buttons":[
{"type":"QUICK_REPLY","text":"Quero voltar"},
{"type":"QUICK_REPLY","text":"Não, obrigado"}]}]}'::jsonb,
false)
ON CONFLICT (name, language) DO NOTHING;O cabeçalho de devocional_diario_v1 é TEXT estático, não parametrizado, porque
cabeçalho de texto com parâmetro tem limite de 60 caracteres e nenhuma vantagem aqui.
header_type é NULL para codigo_acesso_v1 e para reativacao_v1 por motivos
diferentes: templates de autenticação não aceitam cabeçalho, e o de reativação
simplesmente não usa. Nos dois casos NULL significa "sem cabeçalho", e não "a definir".
6.32.4 Settings de runtime #
São 35 chaves canônicas, todas em grupo.chave, descritas em 26.8. O seed insere
todas, com o valor de fábrica e com as seis colunas NOT NULL preenchidas em cada
linha. Isso não é zelo excessivo: uma chave citada em outra seção e ausente do seed produz
leitura nula em produção, e um INSERT de emergência que tente criá-la sem as seis colunas
aborta (6.23.3). Toda chave citada em qualquer seção precisa existir aqui, e nenhuma seção
pode citar chave que não esteja nesta lista.
INSERT INTO settings (key, value, value_type, default_value, description, group_name,
is_secret, editable_by, min_value, max_value)
VALUES
('send.daily_hour_local', '6', 'INT', '6',
'Hora local (America/Sao_Paulo) do envio diário.', 'envio', false, 'ADMIN', 0, 23),
('send.plan_offset_minutes', '20', 'INT', '20',
'Minutos de antecedência do planejamento em relação ao envio.', 'envio', false, 'ADMIN', 5, 120),
('send.free_tier_weekday', '0', 'INT', '0',
'Dia da semana do envio do plano gratuito. 0 = domingo.', 'envio', false, 'ADMIN', 0, 6),
('send.rate_per_second', '20', 'INT', '20',
'Mensagens por segundo enviadas ao WhatsApp.', 'envio', false, 'ADMIN', 1, 80),
('send.max_attempts', '5', 'INT', '5',
'Tentativas máximas por etapa de entrega.', 'envio', false, 'ADMIN', 1, 10),
('send.window_miss_threshold', '3', 'INT', '3',
'Dias sem abrir a janela antes de usar o template de vídeo.', 'envio', false, 'ADMIN', 1, 14),
('send.optout_keywords',
'["SAIR","PARAR","PARE","CANCELAR","STOP","DESCADASTRAR","REMOVER"]', 'JSON',
'["SAIR","PARAR","PARE","CANCELAR","STOP","DESCADASTRAR","REMOVER"]',
'Palavras que encerram os envios. Comparadas sobre a mensagem inteira normalizada.', 'envio', false, 'ADMIN', NULL, NULL),
('send.optin_keywords',
'["VOLTAR","RETORNAR","QUERO VOLTAR","REATIVAR"]', 'JSON',
'["VOLTAR","RETORNAR","QUERO VOLTAR","REATIVAR"]',
'Palavras que reativam os envios após um opt-out.', 'envio', false, 'ADMIN', NULL, NULL),
('content.teaser_max_length', '300', 'INT', '300',
'Tamanho máximo do teaser enviado no template.', 'conteudo', false, 'EDITOR', 50, 300),
('content.bible_version_default', '"ALMEIDA_1911"', 'STRING', '"ALMEIDA_1911"',
'Versão bíblica padrão de novos devocionais.', 'conteudo', false, 'EDITOR', NULL, NULL),
('content.allowed_bible_versions', '["ALMEIDA_1911","BIBLIA_LIVRE"]', 'JSON',
'["ALMEIDA_1911","BIBLIA_LIVRE"]',
'Lista fechada de versões bíblicas aceitas. A sigla ARC é proibida: identifica edição protegida.', 'conteudo', false, 'OWNER', NULL, NULL),
('content.bible_source_provenance', '{}', 'JSON', '{}',
'Origem verificável de cada versão importada: nome da fonte, endereço, data e responsável.', 'conteudo', false, 'OWNER', NULL, NULL),
('content.editorial_lock_minutes', '15', 'INT', '15',
'Minutos de bloqueio editorial de um devocional aberto por outro editor.', 'conteudo', false, 'ADMIN', 1, 120),
('resend.free_daily_limit', '1', 'INT', '1',
'Reenvios manuais por dia no plano gratuito.', 'entitlements', false, 'ADMIN', 0, 10),
('resend.paid_daily_limit', '3', 'INT', '3',
'Reenvios manuais por dia no plano pago.', 'entitlements', false, 'ADMIN', 0, 10),
('archive.free_days', '7', 'INT', '7',
'Dias de acervo visíveis no painel para o plano gratuito.', 'entitlements', false, 'ADMIN', 1, 90),
('billing.reminder_days', '[3,1,0]', 'JSON', '[3,1,0]',
'Dias antes do vencimento em que o lembrete de cobrança é enviado.', 'cobranca', false, 'ADMIN', NULL, NULL),
('billing.reconcile_batch_size', '200', 'INT', '200',
'Assinaturas conferidas por execução da reconciliação diária.', 'cobranca', false, 'ADMIN', 10, 2000),
('support.email', '"suporte@palavradiaria.com.br"', 'STRING', '"suporte@palavradiaria.com.br"',
'E-mail de suporte exibido ao assinante.', 'geral', false, 'ADMIN', NULL, NULL),
('support.whatsapp_reply_text',
'"Recebemos sua mensagem. Nossa equipe responde em até um dia útil."', 'STRING',
'"Recebemos sua mensagem. Nossa equipe responde em até um dia útil."',
'Resposta automática enviada quando a mensagem abre atendimento humano.', 'geral', false, 'ADMIN', NULL, NULL),
('privacy.dpo_email', '"privacidade@palavradiaria.com.br"', 'STRING', '"privacidade@palavradiaria.com.br"',
'E-mail do encarregado de dados, exigido pela LGPD.', 'geral', false, 'OWNER', NULL, NULL),
('privacy.policy_version', '"2026-08-01"', 'STRING', '"2026-08-01"',
'Versão vigente do texto de consentimento.', 'geral', false, 'OWNER', NULL, NULL),
('privacy.message_log_retention_months', '18', 'INT', '18',
'Meses de retenção do registro de mensagens. Valor único; nenhuma outra seção o redefine.', 'geral', false, 'OWNER', 6, 60),
('privacy.deletion_grace_days', '7', 'INT', '7',
'Dias entre o pedido de eliminação e a execução, para permitir arrependimento.', 'geral', false, 'OWNER', 0, 30),
('privacy.legal_entity',
'{"razaoSocial":"","cnpj":"","endereco":""}', 'JSON',
'{"razaoSocial":"","cnpj":"","endereco":""}',
'Dados da pessoa jurídica controladora, exibidos nos Termos e na Política de Privacidade.', 'geral', false, 'OWNER', NULL, NULL),
('security.blocked_email_domains', '[]', 'JSON', '[]',
'Domínios de e-mail recusados no cadastro.', 'seguranca', false, 'ADMIN', NULL, NULL),
('security.mobile_carrier_ranges', '[]', 'JSON', '[]',
'Faixas de IP de operadoras móveis brasileiras. Isenta CGNAT da pontuação por IP repetido.', 'seguranca', false, 'ADMIN', NULL, NULL),
('ops.kill_switch',
'{"enabled":false,"reason":null,"activatedBy":null,"activatedAt":null}', 'JSON',
'{"enabled":false,"reason":null,"activatedBy":null,"activatedAt":null}',
'Interruptor geral de envio. Quando ligado, nenhuma mensagem sai.', 'operacao', false, 'OWNER', NULL, NULL),
('ops.maintenance_mode', 'false', 'BOOL', 'false',
'Bloqueia rotas de escrita e exibe aviso de manutenção.', 'operacao', false, 'OWNER', NULL, NULL),
('ops.alert_email', '"alertas@palavradiaria.com.br"', 'STRING', '"alertas@palavradiaria.com.br"',
'Destino dos alertas operacionais.', 'operacao', false, 'ADMIN', NULL, NULL),
('ops.cost_alert_threshold_cents', '0', 'INT', '0',
'Custo diário, em centavos de BRL, acima do qual o alerta de custo dispara. Zero desliga o alerta.', 'operacao', false, 'OWNER', 0, 100000000),
('ops.infra_cost_monthly_cents', '0', 'INT', '0',
'Custo mensal de infraestrutura, em centavos de BRL, usado no rateio do painel de custos.', 'operacao', false, 'OWNER', 0, 100000000),
('ops.cost_per_subscriber_target_cents', '0', 'INT', '0',
'Meta de custo por assinante, em centavos de BRL.', 'operacao', false, 'OWNER', 0, 100000000),
('ops.debug_subscriber_ids', '[]', 'JSON', '[]',
'Assinantes com log em nível de depuração. Máximo de 20 itens.', 'operacao', false, 'ADMIN', NULL, NULL),
('ops.debug_paths', '[]', 'JSON', '[]',
'Caminhos de API com log em nível de depuração.', 'operacao', false, 'ADMIN', NULL, NULL),
('tts.cost_per_1k_chars_micros', '0', 'INT', '0',
'Custo de síntese de voz por mil caracteres, em micros de BRL, conforme a tabela vigente do provedor primário (Seção 16.4). Alimenta audio_assets.cost_micros e o painel de custos da Seção 21. Zero significa "ainda não informado" e faz o painel exibir o custo como indisponível, nunca como zero.', 'conteudo', false, 'OWNER', 0, 100000000)
ON CONFLICT (key) DO NOTHING;Duas observações sobre chaves específicas:
- O interruptor geral de envios é
ops.kill_switch, e éJSON, não booleano. Ele guarda também o motivo, quem ligou e quando, porque um interruptor acionado às 06:03 sem registro de quem o acionou e por quê é uma investigação que começa no escuro. Não existesend.pause_all. - Valores monetários de configuração vão em centavos, com sufixo
_cents. As chaves de custo (ops.infra_cost_monthly_cents,ops.cost_per_subscriber_target_cents,ops.cost_alert_threshold_cents) são divididas por 100 na apresentação. Não existe chave com sufixo_brl: um número em reais dentro desettingsseria o único ponto do sistema com dinheiro fracionário, e a primeira soma com uma coluna em centavos erraria por 100.
6.32.5 Feature flags #
INSERT INTO feature_flags (key, description, is_enabled, rollout_percentage, target_tier)
VALUES
('video_fallback_enabled', 'Usa o template de vídeo para quem não abre a janela há 3 dias.', true, 100, 'PAID'),
('window_shortcut_enabled', 'Pula o template quando a janela de 24h já está aberta.', true, 100, NULL),
('tts_google_fallback', 'Permite cair para o provedor secundário de voz após 3 falhas.', true, 100, NULL),
('web_player_enabled', 'Exibe o player de áudio no painel do assinante.', true, 100, 'PAID'),
('pix_checkout_enabled', 'Oferece PIX como forma de pagamento no checkout.', true, 100, NULL),
('card_checkout_enabled', 'Oferece cartão de crédito no checkout.', true, 100, NULL),
('email_fallback_login', 'Permite login por magic link quando há e-mail verificado.', true, 100, NULL),
('admin_impersonation', 'Permite que o suporte acesse o painel como o assinante.', true, 100, NULL),
('metrics_dashboard', 'Exibe o painel de métricas para papéis administrativos.', true, 100, NULL),
('snooze_button_enabled', 'Inclui o botão Depois no template de convite diário.', true, 100, NULL),
('reengagement_campaign', 'Envia mensagem de reengajamento a quem não abre há 14 dias.', false, 0, NULL),
('audio_speed_control', 'Controle de velocidade no player web.', false, 0, 'PAID'),
('audio_human_review', 'Exige revisão humana do áudio antes de o devocional ficar AUDIO_READY.', false, 0, NULL)
ON CONFLICT (key) DO NOTHING;São treze flags. Efeito de cada uma e critério de desligamento estão em 26.9.
As chaves de feature_flags são snake_case sem prefixo de grupo, ao contrário das de
settings. A diferença é proposital e o CHECK de 6.24.3 a impõe: setting é configuração
permanente e agrupada por assunto, flag é interruptor temporário de rollout que se espera
remover. Uma flag citada em outra seção com prefixo — content.require_audio_human_review,
por exemplo — não existe; o nome correto é audio_human_review.
6.32.6 Seeds de desenvolvimento #
Executados apenas quando NODE_ENV !== 'production', por pnpm --filter @palavra-diaria/db seed:dev:
- 30 devocionais, de
hoje - 7ahoje + 22, com status coerente (SENTno passado,PUBLISHEDhoje,READYeDRAFTno futuro); - 50 assinantes: 35 FREE e 15 PAID, com telefones no bloco de teste
+5511900000001em diante, todos comopt_in_confirmed_atpreenchido; - 15 assinaturas ativas distribuídas entre os dois planos, com pagamentos confirmados;
- 3 assinantes em estados de borda: um com opt-out, um bloqueado, um com soft delete;
- 200 linhas de
message_logsdistribuídas nos últimos 7 dias, com mistura de status; daily_metricsde 7 dias, em formato longo — cerca de 350 linhas, uma por dia, métrica e dimensão —, para o painel não abrir vazio.
Os telefones do seed de desenvolvimento passam pelo mesmo caminho de cifra e de índice cego da produção. Gravar telefone em claro em desenvolvimento faria as consultas de desenvolvimento divergirem das de produção, que é exatamente o tipo de diferença que só aparece no primeiro deploy.
Nenhum seed de desenvolvimento roda em produção. A verificação é explícita e falha o
comando com mensagem clara se NODE_ENV === 'production'.
6.33 Retenção, arquivamento e particionamento #
6.33.1 Política por tabela #
| Tabela | Retenção on-line | Depois | Justificativa |
|---|---|---|---|
subscribers |
indefinida | anonimização 30 dias após pedido de eliminação | LGPD; a linha permanece para preservar integridade referencial. |
subscriber_profiles |
indefinida | campos sensíveis zerados junto com a anonimização | LGPD. |
consent_events |
5 anos | exportação para arquivo assinado, depois exclusão | Prova de consentimento; obrigação legal. |
sessions |
30 dias após expires_at |
exclusão física | Sem valor após vencer. |
otp_codes |
24 h após expires_at |
exclusão física | Sem valor após vencer; reduz superfície de dado sensível. Expurgo horário, não diário. |
unsubscribe_tokens |
30 dias após expires_at |
exclusão física | Credencial de uso único, sem valor probatório; a prova do descadastro fica em consent_events (6.7.4). Expurgado também na hora em que o opt-out é gravado. |
admin_users |
indefinida | soft delete apenas | Trilha de auditoria depende. |
admin_audit_log |
5 anos | exportação e exclusão | Auditoria. |
plans |
indefinida | — | Volume irrelevante. |
subscriptions |
indefinida | — | Obrigação fiscal. |
subscription_events |
5 anos | exportação e exclusão | Auditoria financeira. |
payments |
5 anos | exportação e exclusão | Obrigação fiscal. |
payment_events |
5 anos | exportação e exclusão | Obrigação fiscal. |
devotionals |
indefinida | — | É o produto. |
devotional_revisions |
2 anos | mantém apenas a primeira e a última revisão de cada devocional | Volume sem valor proporcional. |
audio_assets |
indefinida no banco | objeto no storage migra para classe fria após 90 dias | Custo de armazenamento. |
whatsapp_templates |
indefinida | — | Volume irrelevante. |
message_logs |
18 meses | partição destacada, exportada em Parquet, depois removida | Volume; 18 meses cobrem qualquer disputa de entrega. O valor é dirigido pela chave privacy.message_log_retention_months e não é digitado em dois lugares. |
admin_trusted_devices |
30 dias após expires_at |
exclusão física | Cookie de dispositivo vencido não tem uso. |
delivery_attempts |
18 meses | exclusão em lote por devotional_date |
Acompanha message_logs. |
inbound_messages |
18 meses | exclusão em lote | Acompanha message_logs. |
send_batches |
indefinida | — | 365 linhas por ano. |
settings |
indefinida | — | Configuração. |
feature_flags |
indefinida | — | Configuração. |
daily_metrics |
indefinida | — | Agregado sem dado pessoal; é a memória histórica do produto. |
webhook_deliveries |
90 dias | exclusão física | Diário de transporte; perde valor rápido. Os fatos de negócio já estão em payment_events e inbound_messages. |
media_uploads |
12 meses | exclusão física | Identificadores vencem em 30 dias. |
job_runs |
180 dias | exclusão física | Diagnóstico operacional recente. |
6.33.2 Job de retenção #
retention.purge, diário às 03:30, uma transação por tabela, com teto de 50.000 linhas
por execução para não travar o banco. Se atingir o teto, reagenda para 10 minutos depois.
CREATE OR REPLACE FUNCTION purge_expired_sessions(batch_limit int DEFAULT 50000)
RETURNS int LANGUAGE plpgsql AS $$
DECLARE deleted int;
BEGIN
WITH doomed AS (
SELECT id FROM sessions
WHERE expires_at < now() - interval '30 days'
ORDER BY expires_at
LIMIT batch_limit
FOR UPDATE SKIP LOCKED
)
DELETE FROM sessions s USING doomed d WHERE s.id = d.id;
GET DIAGNOSTICS deleted = ROW_COUNT;
RETURN deleted;
END;
$$;FOR UPDATE SKIP LOCKED evita que o expurgo brigue com uma sessão sendo usada naquele
instante. O mesmo padrão vale para otp_codes, unsubscribe_tokens, webhook_deliveries,
job_runs e admin_trusted_devices.
otp_codes é a exceção de cadência: o expurgo dele roda de hora em hora, apagando
códigos que venceram há mais de 24 horas, e não uma vez por dia junto com os demais. Um
código de acesso é dado de autenticação, e mantê-lo por dias depois de perder a validade
só aumenta a superfície de um vazamento sem oferecer nada em troca.
O expurgo por retenção de consent_events e de admin_audit_log roda com o papel
retention_operator, cuja exceção está codificada dentro do próprio gatilho append-only
(6.30.6). Nenhum gatilho é desabilitado em momento algum.
6.33.3 Particionamento de message_logs #
- Estratégia:
RANGEporcreated_at, uma partição por mês. - Nome:
message_logs_YYYY_MM. - Criação: job
db.ensure_partitionsàs 03:00, criando o mês corrente e os dois seguintes (6.30.3). - Retenção: partições com mais de 18 meses são destacadas, exportadas e removidas.
CREATE OR REPLACE FUNCTION detach_old_message_partitions(keep_months int DEFAULT 18)
RETURNS TABLE(partition_name text, action text)
LANGUAGE plpgsql AS $$
DECLARE r record; cutoff date := date_trunc('month', now())::date - (keep_months || ' months')::interval;
BEGIN
FOR r IN
SELECT c.relname
FROM pg_class c
JOIN pg_inherits i ON i.inhrelid = c.oid
JOIN pg_class p ON p.oid = i.inhparent
WHERE p.relname = 'message_logs'
AND c.relname ~ '^message_logs_[0-9]{4}_[0-9]{2}$'
AND to_date(right(c.relname, 7), 'YYYY_MM') < cutoff
LOOP
EXECUTE format('ALTER TABLE message_logs DETACH PARTITION %I CONCURRENTLY', r.relname);
partition_name := r.relname; action := 'detached'; RETURN NEXT;
END LOOP;
END;
$$;Destacar é instantâneo e reversível. A remoção definitiva (DROP TABLE) só acontece
depois que o job de exportação confirmar o arquivo Parquet no storage e registrar o
checksum. Sequência mensal, dia 5 às 04:00:
detach_old_message_partitions(18)— destaca as partições vencidas;- exportação para
archive/message_logs/message_logs_YYYY_MM.parquet; - verificação de checksum e contagem de linhas contra a partição;
DROP TABLE message_logs_YYYY_MM;- registro em
job_runscom o número de linhas arquivadas.
Se o passo 3 falhar, o processo para e alerta. A partição destacada continua no disco, íntegra e consultável por nome.
6.33.4 Por que delivery_attempts não é particionada #
4 milhões de linhas em 18 meses. É volume alto, mas o acesso é quase todo pelo índice
parcial ix_delivery_pending, que enxerga só os pendentes, ou por
(devotional_date, status), que já filtra bem. O custo operacional do particionamento
(criar partição, tratar FKs, migrar) não se paga nessa escala. A decisão é revisada se a
tabela passar de 20 milhões de linhas, o que aconteceria por volta de 50.000 assinantes —
e o gatilho de revisão é um alerta automático sobre pg_total_relation_size.
O expurgo usa exclusão em lote por data:
DELETE FROM delivery_attempts
WHERE devotional_date < CURRENT_DATE - interval '18 months'
AND id IN (SELECT id FROM delivery_attempts
WHERE devotional_date < CURRENT_DATE - interval '18 months'
LIMIT 50000);6.33.5 Anonimização por LGPD #
A operação tem dois estágios, com regimes jurídicos diferentes, e a distinção não é semântica. Chamar o estágio 1 de anonimização levaria a equipe a excluir essas pessoas da contagem de titulares afetados em um incidente, que é exatamente o erro que a Autoridade procura.
Estágio 1 — pseudonimização. Roda até 30 dias após o pedido. Todos os identificadores
diretos saem, mas cpf/cpf_hmac e os identificadores do provedor de pagamento permanecem
enquanto durar a retenção fiscal. Nesse estágio a linha continua sendo dado pessoal,
permanece no escopo da LGPD, conta na apuração de qualquer incidente e está sujeita ao mesmo
controle de acesso das linhas ativas.
Estágio 2 — anonimização. Roda 5 anos após o último pagamento, quando a retenção fiscal vence. Zera CPF e os identificadores que recuperariam o cadastro completo no provedor.
Regra de alcance, obrigatória. A anonimização percorre todas as tabelas que
contenham telefone, wa_id, e-mail, CPF ou conteúdo de mensagem do titular, e não apenas
subscribers. A lista é derivada da coluna, não da tabela: qualquer coluna cujo nome
esteja em ('phone_e164','phone_hmac','wa_id','wa_id_hmac','email','email_hmac','cpf', 'cpf_hmac','payload_excerpt','text_body') e cuja linha se relacione ao assinante é zerada.
Nenhuma tabela pode reter telefone, wa_id, CPF ou e-mail em claro depois disso.
CREATE OR REPLACE FUNCTION anonymize_subscriber(target_id char(26))
RETURNS void LANGUAGE plpgsql AS $$
BEGIN
-- Estagio 1: pseudonimizacao. Executado pelo papel privacy_operator.
UPDATE subscribers
SET phone_e164 = 'v1:anonymized:' || substr(target_id, 1, 12),
phone_hmac = encode(digest('anonymized:' || target_id, 'sha256'), 'hex'),
wa_id = NULL,
wa_id_hmac = NULL,
email = NULL,
email_hmac = NULL,
display_name = NULL,
anonymized_at = now(),
updated_at = now()
WHERE id = target_id AND deleted_at IS NOT NULL;
UPDATE subscriber_profiles
SET full_name = NULL, birth_date = NULL, city = NULL, state = NULL,
church_name = NULL, signup_ip = NULL, signup_user_agent = NULL,
referrer = NULL, admin_notes = NULL, updated_at = now()
WHERE subscriber_id = target_id;
-- cpf, cpf_hmac e cpf_last4 permanecem ate o estagio 2: retencao fiscal.
UPDATE message_logs
SET phone_e164 = NULL, phone_hmac = NULL, wa_id = NULL, payload_excerpt = NULL
WHERE subscriber_id = target_id;
UPDATE inbound_messages
SET phone_e164 = NULL, phone_hmac = NULL,
wa_id = 'v1:anonymized', wa_id_hmac = encode(digest('anonymized:' || target_id, 'sha256'), 'hex'),
text_body = NULL, raw = '{"anonymized":true}'::jsonb
WHERE subscriber_id = target_id;
UPDATE otp_codes
SET phone_e164 = NULL, phone_hmac = NULL, email = NULL, email_hmac = NULL
WHERE subscriber_id = target_id;
-- Credenciais vivas de descadastro perdem a funcao e sao apagadas, nao anonimizadas:
-- a tabela guarda apenas hashes, e um token vivo apontando para conta anonimizada e
-- um segredo sem dono (6.7.4).
DELETE FROM unsubscribe_tokens WHERE subscriber_id = target_id;
-- Identificadores de rede na trilha de consentimento: caminho privilegiado do
-- gatilho append-only (6.30.6). O conteudo probatorio nao e tocado.
UPDATE consent_events SET ip = NULL, user_agent = NULL
WHERE subscriber_id = target_id;
END;
$$;
CREATE OR REPLACE FUNCTION fully_anonymize_subscriber(target_id char(26))
RETURNS void LANGUAGE plpgsql AS $$
BEGIN
-- Estagio 2: so depois de vencida a retencao fiscal de 5 anos.
UPDATE subscriber_profiles
SET cpf = NULL, cpf_hmac = NULL, cpf_last4 = NULL, updated_at = now()
WHERE subscriber_id = target_id;
UPDATE subscribers
SET asaas_customer_id = NULL, fully_anonymized_at = now(), updated_at = now()
WHERE id = target_id;
UPDATE subscriptions SET asaas_customer_id = NULL, asaas_subscription_id = NULL,
asaas_card_token = NULL, card_brand = NULL, card_last4 = NULL,
card_exp_month = NULL, card_exp_year = NULL, updated_at = now()
WHERE subscriber_id = target_id;
UPDATE payments SET invoice_url = NULL, receipt_url = NULL,
pix_payload = NULL, pix_qr_code_base64 = NULL,
card_brand = NULL, card_last4 = NULL, updated_at = now()
WHERE subscriber_id = target_id;
END;
$$;O telefone é substituído por um marcador derivado do próprio identificador, o que preserva
a unicidade do índice cego sem guardar o número. As linhas de consent_events, payments,
payment_events e subscriptions não são apagadas: são obrigação legal e ficam pelos 5
anos, agora ligadas apenas ao identificador interno. Essa distinção é a razão de
consent_events.subscriber_id ser RESTRICT e não CASCADE.
message_logs é a tabela que mais importa aqui, e é a que costuma ser esquecida. A
retenção dela é de 18 meses, muito mais longa que o prazo de eliminação. Sem o UPDATE
acima, centenas de linhas com o telefone completo, o horário de leitura de cada devocional e
trechos do conteúdo religioso sobreviveriam mais de um ano depois de o painel ter confirmado
"conta anonimizada" — e, em um incidente, a pessoa continuaria identificável enquanto a
comunicação à Autoridade a teria excluído da contagem de afetados.
Um teste de integração cria um assinante, gera tráfego em todas as tabelas, executa a
anonimização e falha se uma busca pelo phone_hmac, pelo wa_id_hmac ou pelo email_hmac
originais devolver qualquer linha em qualquer tabela. O job retention.enforce executa o
estágio 2 diariamente sobre as linhas cuja retenção fiscal venceu e registra a transição em
admin_audit_log com action = 'subscriber.fully_anonymized'.
6.33.6 Limpeza do storage #
Job semanal storage.gc, domingos às 05:00:
- lista objetos sob
devotionals/no bucket; - compara com
audio_assets.storage_key; - objetos sem linha correspondente há mais de 7 dias são removidos;
- linhas com
storage_keyapontando para objeto inexistente marcamstatus = 'FAILED'e disparam alerta.
A janela de 7 dias evita apagar objeto recém-enviado cuja linha ainda não foi confirmada.
6.34 VACUUM, autovacuum e manutenção de índices #
6.34.1 Tabelas de alto volume e seu perfil de escrita #
| Tabela | Perfil | Problema resultante |
|---|---|---|
message_logs |
inserção em rajada, depois 2 a 3 UPDATE de status por linha |
Bloat moderado por linha morta de UPDATE. |
delivery_attempts |
inserção em rajada, depois 1 a 5 UPDATE por linha |
Bloat alto; é a tabela mais atualizada do sistema. |
subscribers |
UPDATE diário de service_window_expires_at e last_delivered_at |
Bloat contínuo em tabela pequena, o que degrada o índice de elegibilidade. |
send_batches |
dezenas de UPDATE por segundo na mesma linha |
Bloat concentrado; a linha do dia acumula centenas de versões mortas. |
webhook_deliveries |
inserção alta, UPDATE único |
Bloat baixo. |
6.34.2 Parâmetros por tabela #
-- muito atualizada: vacuum bem mais agressivo que o padrão de 20%
ALTER TABLE delivery_attempts SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 1000,
autovacuum_analyze_scale_factor = 0.01,
autovacuum_vacuum_cost_limit = 2000
);
-- pequena mas atualizada todo dia: gatilho por número absoluto
ALTER TABLE subscribers SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_vacuum_threshold = 500,
fillfactor = 85
);
-- linha única muito atualizada: fillfactor deixa espaço para HOT update
ALTER TABLE send_batches SET (fillfactor = 70, autovacuum_vacuum_threshold = 100);
-- inserção pura na partição corrente
ALTER TABLE message_logs SET (
autovacuum_vacuum_scale_factor = 0.1,
autovacuum_analyze_scale_factor = 0.02
);fillfactor = 85 em subscribers reserva 15% de espaço em cada página para que o
UPDATE diário caiba na mesma página. Isso ativa o HOT update, que não toca os índices e
elimina a maior fonte de bloat da tabela. send_batches usa 70% porque a mesma linha é
atualizada centenas de vezes na mesma hora.
6.34.3 Manutenção programada #
| Quando | O quê | Comando |
|---|---|---|
| Diário 04:15 | ANALYZE das tabelas quentes |
ANALYZE subscribers, delivery_attempts, send_batches; |
| Semanal, domingo 04:30 | REINDEX CONCURRENTLY do índice de fila |
REINDEX INDEX CONCURRENTLY ix_delivery_pending; |
| Mensal, dia 3 às 04:45 | VACUUM (ANALYZE) na partição do mês anterior |
VACUUM (ANALYZE) message_logs_YYYY_MM; |
| Mensal, dia 3 às 05:00 | ALTER INDEX ... SET (fillfactor) e reindex de índices com bloat > 30% |
script pnpm ops db:reindex-bloated |
| Trimestral | Revisão de índices não usados | consulta em 6.34.4 |
Índice parcial de fila é o que mais sofre: linhas entram e saem do índice o tempo todo,
porque a condição status IN ('PLANNED','DEFERRED','FAILED') deixa de valer assim que a
entrega conclui. Reindexar semanalmente mantém a árvore compacta. CONCURRENTLY evita
bloqueio de escrita.
6.34.4 Detecção de índice inútil #
SELECT s.relname AS tabela, s.indexrelname AS indice,
s.idx_scan AS varreduras,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS tamanho
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan < 50
AND NOT i.indisunique
AND pg_relation_size(s.indexrelid) > 10 * 1024 * 1024
ORDER BY pg_relation_size(s.indexrelid) DESC;Índice não único, maior que 10 MB e com menos de 50 varreduras em um trimestre é candidato a remoção. A decisão é registrada como migration, com o motivo no comentário. Índices únicos nunca entram na lista: eles existem por correção, não por desempenho.
6.34.5 Alertas de banco #
| Alerta | Condição | Ação |
|---|---|---|
| Bloat crítico | n_dead_tup > 500000 em qualquer tabela |
VACUUM manual e revisão dos parâmetros. |
| Partição faltando | linha inserida em message_logs_default |
Executar ensure_message_logs_partition() imediatamente. |
| Transação longa | consulta ativa há mais de 5 minutos | Investigar; transação longa impede o autovacuum de limpar. |
| Conexões | uso acima de 80% de max_connections |
Revisar o pool da aplicação. |
| Crescimento | pg_database_size acima de 80% do disco |
Antecipar o arquivamento de partições. |
| Replicação de backup | último backup com mais de 26 horas | Verificar o job de backup. |
6.35 Consultas frequentes e planos de índice #
As oito operações que mais rodam. Cada uma com o índice que a atende.
6.35.1 Selecionar destinatários do envio diário #
Roda uma vez por dia, às 05:40, e é a consulta mais importante do sistema.
SELECT s.id, s.phone_e164, s.wa_id, s.tier,
(s.service_window_expires_at > now()) AS window_open
FROM subscribers s
WHERE s.opt_in_confirmed_at IS NOT NULL
AND s.opt_out_at IS NULL
AND s.deleted_at IS NULL
AND s.blocked_at IS NULL
AND (s.tier = 'PAID' OR EXTRACT(DOW FROM (now() AT TIME ZONE 'America/Sao_Paulo')) = 0)
ORDER BY s.id
LIMIT 1000 OFFSET 0;Índice: ix_subscribers_send_eligible (parcial em (tier, status)).
Plano: Index Scan sobre o índice parcial. O predicado do índice já elimina quem não
tem opt-in, quem saiu e quem foi excluído, então o planejador só percorre o conjunto
elegível. Em 10.000 assinantes com 8.500 elegíveis, é varredura de índice de ~8.500
entradas em vez de Seq Scan em 10.000 linhas largas.
Nota de paginação: o planejador percorre em blocos de 1.000 usando cursor por id
(WHERE id > :lastId), não OFFSET, para manter o custo constante.
6.35.2 Buscar o próximo lote de tentativas pendentes #
Roda continuamente durante a janela de envio, várias vezes por segundo.
UPDATE delivery_attempts
SET status = 'IN_FLIGHT', started_at = now(), attempt_count = attempt_count + 1,
updated_at = now()
WHERE id IN (
SELECT id FROM delivery_attempts
WHERE status IN ('PLANNED','DEFERRED','FAILED')
AND attempt_count < max_attempts
AND (next_retry_at IS NULL OR next_retry_at <= now())
ORDER BY next_retry_at NULLS FIRST, id
LIMIT 200
FOR UPDATE SKIP LOCKED
)
RETURNING id, subscriber_id, devotional_id, step, tier_at_send;Índice: ix_delivery_pending (parcial em (next_retry_at NULLS FIRST, id)).
Plano: Index Scan no índice parcial, limitado a 200 linhas, com SKIP LOCKED.
Vários workers rodam a mesma consulta sem conflito: cada um pega um conjunto disjunto.
Nenhum trabalho é processado duas vezes, nenhum worker espera por lock.
Esta consulta não decide o que sai. Ela desenfileira trabalho; ela não autoriza
conteúdo. tier_at_send volta no RETURNING como informação histórica, e o motor
precisa reler o estado do assinante por chave primária imediatamente antes de chamar o
provedor:
SELECT tier, opt_out_at, blocked_at, deleted_at, paused_until
FROM subscribers
WHERE id = $1;Índice: chave primária. Custo abaixo de 1 ms, sobre um lote de no máximo 12.400 itens.
A ordem de decisão é a da Seção 18.5: deleted_at ou blocked_at preenchido encerra o item
como SKIPPED_INELIGIBLE, sem chamada ao provedor; opt_out_at preenchido encerra como
SKIPPED_OPTED_OUT; tier = 'FREE' em item planejado como PAID rebaixa o pacote na
hora, grava downgraded_at e tier_at_send_effective = 'FREE', e nunca envia o áudio.
O motivo é a regra de produto mais dura que existe aqui: revogação é imediata, sem carência. O planejamento acontece às 05:40 e o disparo começa às 06:00, com varreduras de acompanhamento até 23:55. Se o pacote fosse decidido apenas pelo tier congelado às 05:40, todo assinante cujo pagamento vencesse nesse intervalo ganharia de graça o dia da revogação — que é exatamente a carência de um dia proibida por escrito. Uma leitura por chave primária é o preço de tornar a regra verdadeira também dentro dos 15 minutos de disparo.
6.35.3 Resolver o assinante a partir de um webhook de entrada #
Roda a cada mensagem recebida.
SELECT id, tier, opt_out_at, blocked_at, service_window_expires_at
FROM subscribers
WHERE wa_id_hmac = $1 AND deleted_at IS NULL
LIMIT 1;O parâmetro $1 é blindIndex(waId), calculado na aplicação. Nunca se compara a coluna
cifrada: a cifra é aleatorizada por nonce, então dois envelopes do mesmo wa_id são bytes
diferentes e a igualdade jamais casaria.
Índice: uq_subscribers_wa_id_hmac (único parcial).
Plano: Index Scan com uma leitura. Se não encontrar, a aplicação tenta em ordem o
HMAC do phone_e164 exato (índice uq_subscribers_phone_hmac), o HMAC da variante sem nono
dígito e o HMAC da variante com nono dígito. As variantes são geradas antes de calcular
o HMAC, porque o índice cego não tolera aproximação: cada variante é uma chave própria.
Detalhe da regra em 6.36.1.
6.35.4 Registrar status de mensagem vindo do webhook #
Roda 2 a 3 vezes por mensagem enviada — o maior volume de escrita do sistema.
UPDATE message_logs
SET status = $3,
delivered_at = CASE WHEN $3 = 'DELIVERED' THEN $4 ELSE delivered_at END,
read_at = CASE WHEN $3 = 'READ' THEN $4 ELSE read_at END,
failed_at = CASE WHEN $3 = 'FAILED' THEN $4 ELSE failed_at END,
error_code = COALESCE($5, error_code)
WHERE wamid = $1
AND created_at >= $2::timestamptz - interval '3 days'
AND created_at < $2::timestamptz + interval '1 day';Índice: uq_message_logs_wamid em (wamid, created_at).
Plano: partition pruning pela faixa de created_at, depois Index Scan no índice
único. A faixa é obrigatória: sem ela o Postgres varre todas as partições. A janela de
3 dias para trás cobre a reentrega atrasada de status; a Meta pode reenviar status de uma
mensagem enviada dias antes.
6.35.5 Acervo do assinante no painel #
SELECT d.id, d.slug, d.title, d.scheduled_for, d.teaser,
a.storage_key AS audio_key, a.duration_seconds
FROM devotionals d
LEFT JOIN audio_assets a
ON a.devotional_id = d.id AND a.format = 'MP3' AND a.status = 'READY'
WHERE d.status IN ('PUBLISHED','SENT')
AND d.deleted_at IS NULL
AND d.scheduled_for <= CURRENT_DATE
AND ($1::text = 'PAID' OR d.scheduled_for > CURRENT_DATE - interval '7 days')
AND ($2::date IS NULL OR d.scheduled_for < $2::date)
ORDER BY d.scheduled_for DESC
LIMIT 21;Índices: ix_devotionals_status_date na tabela principal e ix_audio_devotional na
junção.
Plano: Index Scan Backward em (status, scheduled_for) com limite de 21 (20 itens
mais um para saber se há próxima página), depois Nested Loop com Index Scan em
audio_assets. O corte de 7 dias para tier FREE é aplicado no mesmo predicado, o que
mantém o índice utilizável. $2 é o cursor: a data do último item da página anterior.
6.35.6 Extrato de cobranças do assinante #
SELECT p.id, p.status, p.billing_type, p.amount_cents, p.fee_cents, p.due_date,
coalesce(p.confirmed_at, p.received_at) AS paid_at,
p.invoice_url, p.receipt_url
FROM payments p
WHERE p.subscriber_id = $1
AND ($2::char(26) IS NULL OR p.id < $2)
ORDER BY p.created_at DESC, p.id DESC
LIMIT 21;Índice: ix_payments_subscriber_time em (subscriber_id, created_at DESC).
Plano: Index Scan direto. O cursor por id funciona como desempate porque o ULID é
monotônico: ordenar por id DESC equivale a ordenar por criação.
Duas notas que valem para toda lista deste documento:
LIMIT 21para uma página de 20. Busca-se uma linha a mais para saber se existe próxima página, o que evita uma consultaCOUNTseparada — em tabela grande, oCOUNTcusta mais do que a própria página.- Nenhuma resposta paginada devolve
total. A paginação é por cursor, e contagens agregadas vêm dos endpoints de métricas, que leemdaily_metrics. O campopaid_atdoSELECTacima é um apelido calculado, não uma coluna:payments.paid_atnão existe.
6.35.7 Detectar divergência entre tier e assinatura #
Roda na reconciliação diária das 04:00.
SELECT s.id AS subscriber_id, s.tier AS tier_atual,
sub.status AS status_assinatura, sub.current_period_end
FROM subscribers s
LEFT JOIN subscriptions sub ON sub.id = s.current_subscription_id
WHERE s.deleted_at IS NULL
AND (
(s.tier = 'PAID' AND (sub.id IS NULL
OR sub.status <> 'ACTIVE'
OR sub.current_period_end < now()))
OR (s.tier = 'FREE' AND sub.status = 'ACTIVE'
AND sub.current_period_end > now())
);Índices: ix_subscribers_current_subscription e a chave primária de subscriptions.
Plano: Seq Scan em subscribers com Nested Loop e Index Scan em
subscriptions. Varredura sequencial é aceitável aqui: são 10.000 linhas, uma vez por
dia, fora do horário de pico. O resultado esperado é zero linhas; qualquer linha vira
alerta e correção automática registrada em subscription_events com
actor = 'reconciliation'.
6.35.8 Métricas do dia para o painel #
Série temporal de uma métrica, que é o acesso dominante do painel:
SELECT metric_date, value, numerator, denominator
FROM daily_metrics
WHERE metric_key = $1
AND dimension = $2 -- string vazia quando a metrica nao e quebrada
AND metric_date BETWEEN $3 AND $4
ORDER BY metric_date;Índice: daily_metrics_key_date_idx em (metric_key, metric_date DESC).
Plano: Index Scan sobre uma faixa. Para 90 dias, são 90 linhas.
Todas as métricas de um dia, para a visão geral:
SELECT metric_key, dimension, value, numerator, denominator
FROM daily_metrics
WHERE metric_date = $1
ORDER BY metric_key, dimension;Índice: daily_metrics_date_idx em (metric_date DESC).
É por isso que a tabela existe: a alternativa seria agregar milhões de linhas de
message_logs a cada carregamento do painel, o que levaria segundos em vez de
milissegundos.
Uma armadilha que o formato longo evita e que vale enunciar. Para agregar uma razão
sobre um período, some numerator e denominator e divida no fim — nunca tire a média
dos value diários. A taxa de entrega da semana é sum(numerator) / sum(denominator), não
avg(value): um dia com 10 envios e um dia com 10.000 pesam igual na média e não deveriam.
É exatamente para tornar essa conta possível que as duas colunas são gravadas junto do
resultado.
6.36 Regras transversais obrigatórias #
6.36.1 phone_e164 versus wa_id #
O problema é concreto: no Brasil, celulares têm nove dígitos após o DDD, mas a plataforma
do WhatsApp historicamente identifica alguns números sem o nono dígito. O wa_id
devolvido pode não ser igual ao número cadastrado.
| Aspecto | phone_e164 |
wa_id |
|---|---|---|
| Origem | Digitado pelo usuário, normalizado por libphonenumber-js com região BR |
Devolvido pela plataforma no primeiro contato |
| Formato em claro | +5511987654321 (com +, com nono dígito) |
551187654321 (sem +, pode não ter nono dígito) |
| Formato no banco | Envelope cifrado v1:<iv>:<ct>:<tag> |
Envelope cifrado, mesmo formato |
| Preenchimento | Sempre, no cadastro | Só no primeiro contato bem-sucedido |
| Coluna de busca | phone_hmac |
wa_id_hmac |
| Índice | UNIQUE (phone_hmac) WHERE deleted_at IS NULL |
UNIQUE (wa_id_hmac) WHERE wa_id_hmac IS NOT NULL |
| Papel | Identidade canônica do assinante | Chave de resolução do canal |
A normalização do telefone tem uma única fonte: normalizePhoneBR() de
packages/core/src/phone.ts, cuja regra completa (nono dígito, DDD, DDI) está na Seção
11.4. Nenhuma outra seção reimplementa a regra, e nenhuma outra função produz um
phone_e164.
Ordem de resolução no webhook de entrada, implementada no mesmo módulo:
export async function resolveSubscriber(waId: string): Promise<Subscriber | null> {
// 1. wa_id exato — o caminho normal depois do primeiro contato.
// A busca e sempre pelo indice cego; a coluna cifrada nunca e comparada.
const byWaId = await repo.findByWaIdHmac(blindIndex(waId));
if (byWaId) return byWaId;
const e164 = '+' + waId;
// 2. phone_e164 exato
const exact = await repo.findByPhoneHmac(blindIndex(e164));
if (exact) return await repo.attachWaId(exact.id, waId);
// 3. variante SEM o nono dígito (+5511987654321 -> +551187654321)
const without = dropNinthDigit(e164);
if (without) {
const found = await repo.findByPhoneHmac(blindIndex(without));
if (found) return await repo.attachWaId(found.id, waId);
}
// 4. variante COM o nono dígito (+551187654321 -> +5511987654321)
const withNine = addNinthDigit(e164);
if (withNine) {
const found = await repo.findByPhoneHmac(blindIndex(withNine));
if (found) return await repo.attachWaId(found.id, waId);
}
return null; // número desconhecido: registra em inbound_messages sem subscriber_id
}Cada variante é normalizada antes de virar HMAC. Índice cego não tolera aproximação:
+5511987654321 e +551187654321 produzem hashes sem nenhuma relação entre si, então as
quatro tentativas são quatro chaves distintas, e não uma busca tolerante.
attachWaId grava o wa_id cifrado e o wa_id_hmac na primeira resolução bem-sucedida, de
modo que as próximas mensagens caem no passo 1. Se o INSERT violar o índice único parcial
— situação real quando dois cadastros diferentes apontam para o mesmo número na plataforma —
a operação registra alerta WA_ID_CONFLICT, não vincula, e o suporte decide qual conta
mantém o canal. Nenhum dado é sobrescrito automaticamente.
Troca de número e chip reciclado. Números celulares brasileiros são reemitidos pelas
operadoras, e a posse do número é o único fator de autenticação do assinante. Por isso,
qualquer mudança do wa_id associado a um telefone — attachWaId sobre uma linha que
já tinha wa_id diferente, ou troca de número solicitada pelo titular — dispara, na mesma
transação:
wa_id_changed_at = now();- revogação de todas as sessões daquele assinante, com
revoked_reason = 'wa_id_changed'; - exigência de nova verificação por código antes de liberar qualquer dado pessoal.
Nunca se entrega conta, CPF ou histórico a um número apenas porque ele respondeu no
WhatsApp. O caso concreto é direto: alguém cancela a linha, a operadora a reemite meses
depois, o novo dono recebe o devocional, pede o código no painel, recebe no próprio aparelho
e entraria como a titular anterior — com acesso ao CPF dela, ao valor e à data de cada
cobrança e a 18 meses de mensagens. A revogação de sessões fecha a porta de dentro; a
sessão RESTRICTED por dormência (Seção 8.15.1.1) fecha a de fora.
Números com DDI diferente de 55 são aceitos pelo tipo da coluna mas rejeitados no cadastro
com UNSUPPORTED_COUNTRY_CODE (Seção 7.11). A coluna não restringe o DDI para não exigir
migration quando o produto abrir outros países.
6.36.2 Idempotência: as quatro chaves #
| Operação | Tabela | Chave | Índice | O que impede |
|---|---|---|---|---|
| Planejar o dia | send_batches |
plan:{devotionalDate}, ou retry:{parentBatchId} no reprocessamento |
uq_send_batches_idempotency |
Dois planejamentos do mesmo dia, e dois reprocessamentos do mesmo lote. |
| Enviar uma etapa | delivery_attempts |
send:{subscriberId}:{devotionalDate}:{step} |
uq_delivery_idempotency_key |
O mesmo assinante receber a mesma etapa duas vezes. |
| Processar cobrança | payment_events |
asaas_event_id |
uq_payment_events_asaas_event_id |
O mesmo evento de pagamento alterar a assinatura duas vezes. |
| Processar entrada | inbound_messages |
wamid |
uq_inbound_wamid |
A mesma mensagem abrir a janela ou disparar opt-out duas vezes. |
Todas usam o mesmo padrão: INSERT ... ON CONFLICT DO NOTHING seguido de verificação de
linhas afetadas. Zero linhas significa "já foi feito", e o caminho de sucesso é retornado
sem efeito colateral. Nunca ON CONFLICT DO UPDATE, porque atualizar uma reentrega
reabriria a possibilidade de efeito duplicado.
Exemplo de reentrega do webhook de pagamento, o cenário mais crítico:
12:00:01 Provedor envia PAYMENT_CONFIRMED (evt_abc123)
12:00:01 INSERT em payment_events → 1 linha → enfileira processamento
12:00:02 Resposta 200 em 340 ms
12:00:03 Worker processa: subscription ACTIVE, subscriber tier PAID
12:00:45 Provedor não registrou o 200 (timeout de rede) e reenvia evt_abc123
12:00:45 INSERT em payment_events → 0 linhas (conflito)
12:00:45 Resposta 200 com {"received":true,"duplicate":true} em 12 ms
Nenhum reprocessamento. Assinatura permanece ACTIVE, uma única vez.6.36.3 Append-only: consent_events e admin_audit_log #
Duas tabelas nunca aceitam UPDATE nem DELETE pela aplicação:
consent_events— é a prova jurídica de consentimento. Alterar destrói a defesa.admin_audit_log— é a trilha de auditoria. Alterar destrói a confiança nela.
Proteção em três camadas:
- Permissão. O papel
palavra_diaria_apptem apenasSELECTeINSERT(M13). - Trigger.
consent_events_immutable()eadmin_audit_log_immutable()levantam exceção mesmo se a permissão for concedida por engano (6.30.6). - Modelo. Nenhuma das duas tem
updated_atoudeleted_at, então não existe caminho de código que pareça legítimo.
Correção de dado errado é feita por novo evento que descreve a correção, nunca por
alteração do anterior. Exemplo: consentimento registrado com o canal errado gera uma nova
linha do mesmo tipo, com evidence explicando a correção e referência ao id original.
Duas exceções, e apenas duas, estão codificadas dentro do próprio gatilho — nenhuma delas desabilita nada:
| Exceção | Papel | O que pode fazer | O que continua imutável |
|---|---|---|---|
| Eliminação por LGPD | privacy_operator |
UPDATE que zera apenas ip e user_agent |
Em consent_events: type, granted, policy_version, consent_text_hash, occurred_at, created_at. Em admin_audit_log: admin_user_id, actor_type, actor_role, action, record_hash, created_at |
| Expurgo por retenção | retention_operator |
DELETE de linhas com mais de 5 anos |
Qualquer linha dentro do prazo legal |
A primeira exceção existe porque um controle de integridade não pode impedir o cumprimento
de um direito do titular. Um gatilho que recusasse todo UPDATE faria a transação de
anonimização reverter por inteiro — inclusive a parte já aplicada em subscribers — e a
eliminação nunca aconteceria para titular nenhum, com o prazo legal de 30 dias vencendo sem
que ninguém percebesse. O caminho é privilegiado, explícito, restrito coluna a coluna e
auditado: cada execução grava subscriber.anonymized em admin_audit_log.
Nenhum gatilho é desabilitado em momento algum. Desabilitar exigiria ser dono da tabela — o que esses papéis não são — e abriria uma janela em que qualquer escrita passa.
6.36.4 Soft delete: apenas três tabelas #
| Tabela | Coluna | Efeito | Quem filtra |
|---|---|---|---|
subscribers |
deleted_at |
Some das listas, para de receber, não pode logar | Todos os índices relevantes são parciais com WHERE deleted_at IS NULL |
devotionals |
deleted_at |
Some do calendário e do acervo, libera a data para novo devocional | uq_devotionals_scheduled_for é parcial |
admin_users |
deleted_at |
Não faz login, some da lista, libera o e-mail | uq_admin_users_email é parcial |
As outras 25 tabelas usam hard delete ou retenção por tempo. A razão de limitar o soft delete é direta: cada tabela com soft delete adiciona um predicado que toda consulta precisa lembrar de aplicar, e esquecer um deles é um vazamento silencioso de dado excluído. Três tabelas é o número que dá para revisar; vinte e oito, não.
deleted_at não tem valor correspondente em SubscriberStatus. Exclusão é uma coluna,
não um estado: o enum descreve o que o assinante é no serviço, e a exclusão o retira do
serviço inteiro. Os índices parciais de 6.3.1 já embutem WHERE deleted_at IS NULL, então
a consulta correta é também a consulta rápida.
Regra de código obrigatória: todo acesso a essas três tabelas passa por um repositório em
packages/db que aplica o filtro por padrão. Ler linhas excluídas exige um método
explícito com sufixo IncludingDeleted, e cada uso desse método é justificado em
comentário. O teste de arquitetura de pnpm test:arch falha se prisma.subscriber for
usado diretamente fora do repositório.
6.36.5 O que nunca é persistido #
Lista fechada, verificada por revisão de código e por teste automatizado que varre o schema em busca de nomes proibidos:
| Dado | Por quê |
|---|---|
| Número completo de cartão, CVV, validade | Escopo de PCI; a tokenização é do provedor. Só bandeira e 4 últimos dígitos (6.13). |
| Código OTP em claro | Só o hash com pepper (6.7). |
| Refresh token em claro | Só o hash (6.6). |
| Segredo TOTP em claro | Cifrado com AES-256-GCM (6.8). |
| CPF em claro | Envelope cifrado, mais índice cego para detecção e 4 últimos dígitos para conferência (6.4). |
| Telefone em claro | Envelope cifrado, mais phone_hmac para busca (6.3). Vale em subscribers, otp_codes, message_logs e inbound_messages. |
wa_id em claro |
Envelope cifrado, mais wa_id_hmac para resolução do canal (6.3). |
| E-mail de assinante em claro | Envelope cifrado, mais email_hmac (6.3). O e-mail de administrador continua em citext: não é dado de titular e é o login do painel. |
| Senha de administrador | Só o hash argon2id (6.8). |
| Cookie de dispositivo confiável em claro | Só o sha256 em admin_trusted_devices.token_hash (6.8.4). |
| Corpo completo de mensagem enviada | Só um trecho de 240 caracteres em message_logs.payload_excerpt, zerado na anonimização. |
Cabeçalho Authorization de webhook |
webhook_deliveries.headers é redigido antes de gravar. |
| URL assinada de mídia | É credencial, não endereço: quem tem a URL tem o arquivo. Nenhuma coluna e nenhum log a guardam; a referência é sempre a chave do objeto, sem host e sem assinatura. |
7. Design de API — Contratos, Erros e Padrões #
Esta seção é a dona do envelope de resposta, do catálogo de códigos de erro, da paginação, do versionamento, do rate limiting e das convenções de rota. Toda outra seção que descreve um endpoint herda estas regras e referencia esta seção em vez de repeti-las.
O que não está aqui: o comportamento de negócio de cada rota. Isso pertence às seções donas de cada domínio, listadas no inventário de 7.16.
7.1 Princípios #
- Uma forma de resposta. Toda rota
/api/*devolve o mesmo envelope, sucesso ou erro. O cliente escreve um único parser. - Erro é dado, não texto. O cliente decide pelo
code, nunca pelamessage. Amessageé para o ser humano. - Idempotência explícita. Toda rota que cria recurso ou cobra dinheiro aceita
Idempotency-Keye se comporta de forma previsível em repetição. - Nada de segredo em resposta de erro. Erro nunca revela existência de recurso alheio, stack trace, nome de tabela, SQL ou valor de variável de ambiente.
- Validação na borda. Todo corpo, query e parâmetro de rota passa por um schema Zod antes de tocar a camada de domínio. O tipo do handler é derivado do schema, não declarado à mão.
- Compatível para frente. Adicionar campo à resposta nunca quebra o cliente. Remover ou renomear campo exige versão nova (7.2.4).
7.2 Convenções de rota e versionamento #
7.2.1 Forma das rotas #
| Regra | Exemplo correto | Exemplo errado |
|---|---|---|
kebab-case |
/api/admin/whatsapp-templates |
/api/admin/whatsappTemplates |
| Substantivo no plural | /api/signups |
/api/signup |
| Sem verbo no caminho | POST /api/signups |
POST /api/create-subscription |
| Sub-recurso aninhado em no máximo dois níveis | /api/admin/devotionals/{id}/revisions/{rid}/restorations |
/api/admin/devotionals/{id}/revisions/{rid}/diffs/{did}/lines |
| Singleton com nome próprio | /api/me/subscription |
/api/subscriptions/me |
| Ação sem forma de recurso vira sub-recurso de ação | POST /api/admin/devotionals/{id}/status-changes |
POST /api/publish-devotional |
Ações que não são CRUD são expostas como POST em sub-recurso, nunca como verbo no
caminho. É a exceção deliberada à regra "sem verbo": modelar "publicar" como PATCH de
status esconde a operação e impede validação específica. Duas formas, e apenas duas:
- Ação administrativa ou sobre recurso de terceiro vira sub-recurso substantivo no
plural (
/cancellations,/resends,/reactivations,/status-changes,/restorations,/impersonations,/retries), nunca verbo (/cancel,/resend,/publish,/retry). - Ação sobre a própria conta do assinante fica sob
/api/me/com nome de ação no singular:POST /api/me/opt-out,POST /api/me/pause,POST /api/me/deletion-request,POST /api/me/subscription/cancel. Ali o sujeito é único e vem da sessão, então não existe coleção a nomear — pluralizar produziria/api/me/opt-outspara um recurso que nunca tem mais de um item vivo.
Sub-recurso aninhado vai a no máximo dois níveis. O terceiro nível exige recurso de topo próprio, com identificador estável: quando o caminho precisa de três saltos para chegar ao objeto, o objeto merece existir sozinho.
O terceiro nível também é o ponto em que o cursor de paginação (7.8) deixa de ser derivável de uma única chave de ordenação, o que é a razão técnica do limite.
7.2.2 Prefixos #
| Prefixo | Uso | Autenticação |
|---|---|---|
/api/public/* |
Dados abertos: planos, devocional de demonstração | Nenhuma |
/api/auth/* |
Login, OTP, desafio de segundo fator, listagem e revogação de sessão | Varia por rota |
/api/signups/* |
Cadastro em andamento, antes de existir sessão | Nenhuma ou cookie de cadastro |
/api/me/* |
Recursos e ações da conta do assinante autenticado, inclusive assinatura, cobranças e opt-out | Sessão SUBSCRIBER |
/api/devotionals/* |
Acervo e conteúdo do dia, sob sessão de assinante | Sessão SUBSCRIBER |
/api/checkout/* |
Tokenização de cartão e passos de contratação | Sessão SUBSCRIBER |
/api/admin/* |
Painel administrativo | Sessão EDITOR, ADMIN ou OWNER |
/api/webhooks/* |
Entrada de provedores externos | Assinatura ou token, nunca sessão |
/api/internal/* |
Saúde e métricas | Rede interna ou token de operação |
Todo caminho /api/* do produto cai em um destes prefixos. Não existe rota sem prefixo
declarado: um prefixo novo exige linha nova nesta tabela, com a autenticação exigida
escrita ao lado, porque é esta tabela que o teste de arquitetura percorre para decidir qual
guarda cada rota precisa carregar.
Os prefixos /api/devotionals/* e /api/checkout/* são de assinante autenticado, e não de
recurso público, apesar de não começarem com /api/me/. Eles existem porque o recurso tem
identificador próprio no caminho — o devocional, o token de cartão — e ali a guarda de
propriedade de 8.12 é obrigatória. Não existem os prefixos /api/sessions/*,
/api/subscriptions/* nem /api/payments/*: a sessão vive sob /api/auth/*, e a
assinatura e as cobranças do titular vivem sob /api/me/*, porque o sujeito é único e vem
da sessão (7.16).
/api/me/* nunca aceita identificador de outro assinante. O identificador vem da sessão.
Isso elimina uma classe inteira de falha de autorização por design — não existe parâmetro
para adulterar.
7.2.3 Métodos #
| Método | Semântica | Idempotente | Corpo |
|---|---|---|---|
GET |
Leitura | Sim | Não |
POST |
Criação ou ação | Não por padrão; sim com Idempotency-Key |
Sim |
PATCH |
Atualização parcial | Sim | Sim |
PUT |
Substituição total | Sim | Sim |
DELETE |
Remoção ou desativação | Sim | Não |
PUT é usado apenas em settings e em preferências, onde o recurso é um documento
completo. Em todo o resto, PATCH.
7.2.4 Versionamento #
A API é interna ao produto: o único cliente é o front do próprio monorepo. Por isso não há
prefixo de versão no caminho (/api/v1/...). Front e back sobem juntos no mesmo deploy.
Duas exceções, ambas com regra explícita:
- Webhooks de entrada têm caminho versionado (
/api/webhooks/asaas,/api/webhooks/whatsapp). Mudança incompatível cria caminho novo com sufixo-v2, e o antigo continua respondendo por no mínimo 90 dias, porque o provedor externo não sobe junto com a gente. - Mudança incompatível em rota consumida pelo front durante uma janela de deploy parcial é tratada por expansão e contração: primeiro adiciona o campo novo mantendo o antigo, depois o front passa a usar o novo, depois o antigo é removido em release separada. Nunca as três coisas no mesmo deploy.
O header X-Api-Build devolve o SHA curto do commit em toda resposta. É o que permite
diagnosticar "o front está falando com uma versão antiga do back".
7.3 Envelope de sucesso #
Toda resposta 2xx de /api/* tem exatamente esta forma:
{
"data": { "id": "sub_01K3F8QZ7MHV2N9R4B6T0XYZAB", "tier": "PAID" },
"meta": {
"requestId": "req_01K3F8RMN3QWERTYUIOPASDFGH",
"timestamp": "2026-08-25T09:00:00.000Z"
}
}Regras:
dataé sempre presente em2xx. Para lista, é um array. Para operação sem retorno útil, é um objeto com o resultado ({"ok": true}), nuncanull.meta.requestIdemeta.timestampsão obrigatórios em toda resposta.- Nenhum campo fora de
dataemeta. Nada desuccess: true— o status HTTP já diz. 204 No Contentnão é usado. Toda resposta tem corpo, para que o cliente sempre tenha orequestIddisponível para suporte.
Resposta de lista acrescenta os campos de paginação em meta (7.8):
{
"data": [
{ "id": "dev_01K3F8R3T5...", "title": "A força do silêncio", "scheduledFor": "2026-08-25" },
{ "id": "dev_01K3F8R2S4...", "title": "Quando a espera ensina", "scheduledFor": "2026-08-24" }
],
"meta": {
"requestId": "req_01K3F8RMN3QWERTYUIOPASDFGH",
"timestamp": "2026-08-25T09:00:00.000Z",
"nextCursor": "eyJrIjoiMjAyNi0wOC0yNCIsImkiOiJkZXZfMDFLM0Y4UjJTNCJ9",
"hasMore": true,
"limit": 20
}
}Implementação única, usada por todos os handlers:
// apps/web/src/lib/api/envelope.ts
import { ulid } from 'ulid';
export type Envelope<T> = { data: T; meta: Meta };
export type Meta = {
requestId: string;
timestamp: string;
nextCursor?: string | null;
hasMore?: boolean;
limit?: number;
};
export function ok<T>(data: T, requestId: string, extra: Partial<Meta> = {}): Response {
const body: Envelope<T> = {
data,
meta: { requestId, timestamp: new Date().toISOString(), ...extra },
};
return Response.json(body, {
status: 200,
headers: { 'X-Request-Id': requestId, 'Cache-Control': 'no-store' },
});
}7.4 Envelope de erro #
Toda resposta 4xx e 5xx tem exatamente esta forma:
{
"error": {
"code": "SUBSCRIBER_NOT_FOUND",
"message": "Assinante não encontrado.",
"details": [{ "field": "phone", "issue": "invalid_format" }]
},
"meta": {
"requestId": "req_01K3F8RMN3QWERTYUIOPASDFGH",
"timestamp": "2026-08-25T09:00:00.000Z"
}
}| Campo | Tipo | Obrigatório | Regra |
|---|---|---|---|
error.code |
string |
Sim | SCREAMING_SNAKE_CASE. Estável para sempre. Catálogo em 7.11. |
error.message |
string |
Sim | Português do Brasil, seguro para exibir ao usuário final. Nunca contém identificador interno, SQL, caminho de arquivo ou nome de tabela. |
error.details |
array |
Não | Lista de { field, issue }. Presente em erros de validação e em conflitos com causa específica. |
error.retryAfterSeconds |
number |
Não | Presente em 429 e em 503. Segundos até a próxima tentativa útil. |
details[].issue usa um vocabulário fechado, em inglês, para que o front possa mapear
para texto próprio quando quiser: required, invalid_format, too_short, too_long,
out_of_range, not_unique, already_in_use, unsupported_value, mismatch,
expired, not_allowed.
Erro de validação com múltiplos campos:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Alguns campos precisam de correção.",
"details": [
{ "field": "phone", "issue": "invalid_format" },
{ "field": "cpfCnpj", "issue": "invalid_format" },
{ "field": "acceptedTerms", "issue": "required" }
]
},
"meta": { "requestId": "req_01K3F8RMN3QWERTYUIOPASDFGH", "timestamp": "2026-08-25T09:00:00.000Z" }
}O handler central converte qualquer exceção não tratada em INTERNAL_ERROR com status
500 e mensagem genérica. O erro real vai para o log com o mesmo requestId, nunca para
a resposta.
// apps/web/src/lib/api/errors.ts
export class AppError extends Error {
constructor(
readonly code: ErrorCode,
readonly status: number,
message: string,
readonly details?: ErrorDetail[],
readonly retryAfterSeconds?: number,
) { super(message); }
}
export function fail(err: unknown, requestId: string, log: Logger): Response {
if (err instanceof AppError) {
log.warn({ requestId, code: err.code, status: err.status }, 'api error');
return Response.json(
{ error: { code: err.code, message: err.message, details: err.details,
retryAfterSeconds: err.retryAfterSeconds },
meta: { requestId, timestamp: new Date().toISOString() } },
{ status: err.status,
headers: { 'X-Request-Id': requestId, 'Cache-Control': 'no-store' } },
);
}
if (err instanceof ZodError) return fail(fromZod(err), requestId, log);
log.error({ requestId, err }, 'unhandled error'); // stack só no log
return fail(
new AppError('INTERNAL_ERROR', 500, 'Ocorreu um erro inesperado. Tente novamente.'),
requestId, log,
);
}A classe de erro da aplicação chama-se AppError em todo o documento e em todo o código,
sem exceção. Ela vive em packages/core/src/errors.ts, junto do catálogo de 7.11, e é a
única exceção que o invólucro withApi (8.11.3) converte em resposta estruturada. O nome
ApiError não existe: uma segunda classe com o mesmo papel faria metade dos catch
do código deixar de reconhecer o erro e cair no caminho de INTERNAL_ERROR.
7.5 Status HTTP por categoria #
| Status | Quando | Códigos típicos |
|---|---|---|
200 OK |
Sucesso de leitura, atualização ou ação | — |
201 Created |
Criação de recurso. Inclui header Location |
— |
400 Bad Request |
Corpo malformado, JSON inválido, parâmetro sem sentido sintático | INVALID_JSON, INVALID_CURSOR |
401 Unauthorized |
Sem sessão, sessão expirada ou credencial inválida | UNAUTHENTICATED, SESSION_EXPIRED |
403 Forbidden |
Autenticado, mas sem permissão | FORBIDDEN, INSUFFICIENT_ROLE |
404 Not Found |
Recurso inexistente ou existente e não pertencente ao solicitante | SUBSCRIBER_NOT_FOUND, NOT_FOUND |
409 Conflict |
Estado atual do recurso impede a operação | SUBSCRIPTION_ALREADY_ACTIVE, DEVOTIONAL_DATE_TAKEN |
410 Gone |
Recurso existiu e foi removido de forma permanente | SUBSCRIBER_DELETED |
413 Payload Too Large |
Corpo acima do limite de 7.14 | PAYLOAD_TOO_LARGE |
415 Unsupported Media Type |
Content-Type não é application/json |
UNSUPPORTED_MEDIA_TYPE |
422 Unprocessable Entity |
Sintaxe correta, semântica inválida ou regra de negócio violada | VALIDATION_ERROR, INVALID_PHONE_NUMBER |
429 Too Many Requests |
Rate limit | RATE_LIMITED |
500 Internal Server Error |
Falha não esperada | INTERNAL_ERROR |
502 Bad Gateway |
Provedor externo devolveu resposta inválida | PAYMENT_PROVIDER_ERROR |
503 Service Unavailable |
Dependência indisponível ou modo manutenção | SERVICE_UNAVAILABLE, MAINTENANCE_MODE |
504 Gateway Timeout |
Provedor externo estourou o tempo | UPSTREAM_TIMEOUT |
Distinção entre 400 e 422, aplicada sem exceção: 400 é falha de sintaxe (não deu
para interpretar); 422 é falha de semântica (interpretei, mas não posso aceitar).
{"limit": "vinte"} é 422 porque o JSON é válido e o campo existe; {"limit": é 400.
404 é deliberadamente usado tanto para "não existe" quanto para "existe mas não é seu".
A justificativa está em 8.12: distinguir os dois casos vaza a existência do recurso.
7.6 requestId e correlação #
- Formato: ULID com prefixo
req_. Exemplo:req_01K3F8RMN3QWERTYUIOPASDFGH. - Gerado no middleware, antes de qualquer outra coisa.
- Se o cliente enviar
X-Request-Idcom um ULID válido, ele é reaproveitado; caso contrário, é gerado. Valor inválido é descartado em silêncio e substituído. - Ecoado no header
X-Request-Idde toda resposta, inclusive de erro. - Presente em
meta.requestIdde toda resposta. - Presente em todas as linhas de log da requisição, e propagado para jobs enfileirados
durante ela (
job_runs.request_id) e para chamadas a provedores externos.
// apps/web/src/middleware.ts
const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
export function resolveRequestId(header: string | null): string {
const raw = header?.startsWith('req_') ? header.slice(4) : header;
return raw && ULID_RE.test(raw) ? `req_${raw}` : `req_${ulid()}`;
}Reaproveitar o identificador do cliente é o que permite ao assinante mandar um print da tela de erro para o suporte e o suporte achar a linha exata no log. Aceitar apenas ULID válido impede que um cliente injete conteúdo arbitrário no log.
7.7 Idempotência via Idempotency-Key #
7.7.1 Onde é exigido #
| Rota | Header | Comportamento sem o header |
|---|---|---|
POST /api/me/subscription |
Obrigatório | 400 IDEMPOTENCY_KEY_REQUIRED |
POST /api/me/subscription/cancel |
Obrigatório | 400 IDEMPOTENCY_KEY_REQUIRED |
POST /api/me/subscription/plan-change |
Obrigatório | 400 IDEMPOTENCY_KEY_REQUIRED |
POST /api/devotionals/{devotionalId}/resends |
Obrigatório | 400 IDEMPOTENCY_KEY_REQUIRED |
POST /api/admin/subscribers/{id}/courtesy |
Obrigatório | 400 IDEMPOTENCY_KEY_REQUIRED |
POST /api/admin/batches |
Obrigatório | 400 IDEMPOTENCY_KEY_REQUIRED |
POST /api/admin/batches/{batchId}/retries |
Obrigatório | 400 IDEMPOTENCY_KEY_REQUIRED |
POST /api/admin/devotionals/{id}/status-changes |
Opcional | Executa normalmente |
POST /api/auth/otp/request |
Opcional | Executa normalmente |
Demais POST |
Opcional | Executa normalmente |
A regra: exigido em toda rota que gasta dinheiro, envia mensagem ao assinante ou cria recurso caro de desfazer.
As sete rotas marcadas como obrigatórias propagam escrita para fora do nosso banco — para
o provedor de pagamento, para a plataforma de mensagens, ou para os dois. Nelas, a ausência
do header é erro de programação do cliente e devolve 400 IDEMPOTENCY_KEY_REQUIRED antes
de qualquer efeito; a repetição da mesma chave devolve a resposta gravada com
Idempotent-Replay: true; e a repetição com chave nova depois de a operação já ter
concluído devolve o conflito de estado da própria rota (por exemplo,
409 SUBSCRIPTION_NOT_ACTIVE no cancelamento), sem chamar o provedor de novo. Nenhuma
seção de domínio pode declarar o header como opcional em qualquer uma delas.
7.7.2 Formato e armazenamento #
- Formato aceito: 16 a 128 caracteres de
[A-Za-z0-9_-]. Recomendado: um ULID gerado pelo cliente por intenção do usuário, não por tentativa de requisição. - Armazenamento: Redis, chave
idem:{scope}:{subjectId}:{sha256(idempotencyKey)}, ondescopeé o nome da rota. - TTL: 24 horas.
- O escopo inclui o identificador do sujeito autenticado. Duas pessoas usando a mesma chave por acaso não colidem.
O TTL de 24 horas cobre o duplo clique do usuário e a retentativa automática do cliente,
não a reexecução operacional. A idempotência de operações reprocessadas dias depois — um
webhook reenviado manualmente em D+3, um lote reprocessado pelo painel — é garantida por
chave persistida em banco, e não pelo Redis: payment_events.asaas_event_id,
delivery_attempts.idempotency_key e send_batches.idempotency_key. O operador que
reprocessa não deve contar com a camada HTTP, porque a chave já expirou muito antes.
7.7.3 Máquina de estados do replay #
Requisição chega com Idempotency-Key
│
▼
SET idem:{...} = {state:"in_progress", fingerprint} NX EX 86400
│
├── OK (chave nova) ──► executa o handler
│ ├── sucesso: SET {state:"done", status, body} EX 86400 → resposta
│ └── erro 5xx: DEL da chave → resposta de erro
│ (erro 4xx determinístico é gravado como "done")
│
└── já existia
├── fingerprint do corpo DIFERENTE ──► 422 IDEMPOTENCY_KEY_REUSED
├── state = "in_progress" ──► 409 IDEMPOTENCY_IN_PROGRESS
└── state = "done" ──► repete status e corpo gravados,
com header Idempotent-Replay: true
e requestId NOVO em metafingerprint é o sha256 do corpo canonicalizado (chaves ordenadas, espaços removidos).
Reusar a mesma chave com corpo diferente é erro do cliente e é reportado como tal — nunca
executa a operação nova.
Erro 5xx apaga a chave para que o cliente possa tentar de novo com a mesma chave. Erro
4xx determinístico é gravado, porque repetir a mesma requisição inválida deve dar o
mesmo resultado sem custo.
7.7.4 Exemplo completo #
# 1ª chamada
curl -X POST https://app.palavradiaria.com.br/api/me/subscription \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: 01K3F8T9Q2XKLMNOPQRSTUVWXY' \
-H 'Cookie: __Host-session=...' \
-d '{"planCode":"plan_monthly","billingType":"PIX","cpfCnpj":"12345678909"}'
# HTTP/1.1 201 Created
# Location: /api/me/subscription
# X-Request-Id: req_01K3F8T9R3AAAAAAAAAAAAAAAA
# 2ª chamada, idêntica (o usuário clicou duas vezes)
# HTTP/1.1 201 Created
# Idempotent-Replay: true
# X-Request-Id: req_01K3F8T9S4BBBBBBBBBBBBBBBB <- novo, corpo idêntico
# Nenhuma cobrança adicional foi criada no provedor.
# 3ª chamada, mesma chave, corpo diferente
# HTTP/1.1 422 Unprocessable Entity
# {"error":{"code":"IDEMPOTENCY_KEY_REUSED",
# "message":"Esta chave de idempotência já foi usada com outros dados."}}A idempotência de nível HTTP em 7.7 é uma camada de conveniência. A garantia dura está
no banco, nos índices únicos de 6.36.2. Se o Redis cair inteiro, nenhuma cobrança
duplicada acontece — o índice uq_subscriptions_one_live_per_subscriber continua
impedindo.
7.8 Paginação por cursor #
Nunca offset. Nunca page. Sempre cursor.
7.8.1 Contrato #
| Parâmetro | Tipo | Padrão | Limite | Descrição |
|---|---|---|---|---|
limit |
inteiro | 20 | 1 a 100 | Itens por página. |
cursor |
string opaca | ausente | — | Posição devolvida em meta.nextCursor. |
Resposta em meta: nextCursor (string ou null), hasMore (booleano), limit
(o valor efetivamente aplicado).
Nenhuma resposta paginada devolve total, page, perPage ou totalPages. O tipo
Meta de 7.3 tem exatamente os cinco campos declarados ali e nenhum a mais. Contagem
agregada é responsabilidade das rotas de métricas da Seção 21.9, que a servem de
daily_metrics já consolidado. Uma seção de domínio que precise exibir "412 devocionais"
em um contador de tela chama a rota de métricas, nunca acrescenta um campo à paginação.
O motivo é de custo, não de estética: devolver total exige um COUNT separado sobre o
mesmo predicado, que em tabela grande custa mais do que a página inteira e piora
exatamente na hora em que a base cresce.
7.8.2 Formato do cursor #
O cursor é um objeto JSON serializado em Base64URL sem padding. É opaco por contrato: o cliente não decodifica, não constrói e não interpreta.
type Cursor = {
v: 1; // versão do formato
k: string | number; // valor da chave de ordenação primária do último item
i: string; // id do último item — desempate
};
export function encodeCursor(c: Cursor): string {
return Buffer.from(JSON.stringify(c)).toString('base64url');
}
export function decodeCursor(raw: string): Cursor {
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
} catch {
throw new AppError('INVALID_CURSOR', 400, 'Cursor de paginação inválido.');
}
const result = cursorSchema.safeParse(parsed);
if (!result.success) {
throw new AppError('INVALID_CURSOR', 400, 'Cursor de paginação inválido.');
}
return result.data;
}Cursor de versão desconhecida (v diferente de 1) também devolve INVALID_CURSOR. Isso
permite trocar o formato no futuro sem que um cursor antigo produza resultado errado.
7.8.3 Estabilidade #
A ordenação é sempre por um par (chave, id), nunca por chave sozinha:
WHERE (scheduled_for, id) < ($cursorKey, $cursorId)
ORDER BY scheduled_for DESC, id DESC
LIMIT $limit + 1O id como desempate garante ordem total. Sem ele, dois devocionais na mesma data
poderiam aparecer duas vezes ou sumir entre páginas.
Busca-se limit + 1 linhas. Se voltarem limit + 1, hasMore = true e a última é
descartada. Isso evita uma consulta COUNT separada, que em tabela grande custa mais que
a própria página.
Garantias explícitas do cursor:
- Item inserido depois de a página 1 ser buscada não aparece em páginas seguintes se ficaria antes do cursor. Isso é correto: a paginação é um instantâneo do ponto de corte.
- Item removido entre páginas não causa erro nem página curta anômala.
- Cursor não expira. Um cursor de ontem funciona hoje, apontando para a mesma posição lógica. Se o item que gerou o cursor foi excluído, a comparação por par ainda funciona, porque compara valores, não existência.
7.8.4 Chave de ordenação por recurso #
| Recurso | Chave primária de ordenação | Direção padrão |
|---|---|---|
/api/devotionals |
scheduled_for |
DESC |
/api/me/payments |
created_at |
DESC |
/api/me/subscription/history |
occurred_at |
DESC |
/api/admin/subscribers |
created_at |
DESC |
/api/admin/devotionals |
scheduled_for |
DESC |
/api/admin/cancellations |
created_at |
DESC |
/api/admin/audit-logs |
created_at |
DESC |
/api/admin/batches |
devotional_date |
DESC |
7.9 Ordenação, filtros e busca #
7.9.1 Ordenação #
Parâmetro sort, valor no formato campo:direção. Apenas campos explicitamente
permitidos por rota; qualquer outro devolve UNSUPPORTED_SORT_FIELD.
GET /api/admin/subscribers?sort=createdAt:desc&limit=50Mudar a ordenação invalida o cursor: nextCursor só é válido para a mesma combinação
de sort e filtros. O cursor carrega o hash dos parâmetros de consulta e a validação
rejeita uso cruzado com INVALID_CURSOR.
7.9.2 Filtros #
Filtro é sempre um parâmetro nomeado, tipado por Zod. Não existe sintaxe genérica de
consulta (nada de ?filter[status][eq]=ACTIVE), porque isso vira um mecanismo de consulta
não indexável.
| Padrão | Exemplo | Regra |
|---|---|---|
| Igualdade em enum | ?status=ACTIVE |
Valor precisa estar no enum, senão INVALID_ENUM_VALUE |
| Múltiplos valores | ?status=ACTIVE,PENDING_PAYMENT |
Lista separada por vírgula, máximo 10 valores |
| Faixa de data | ?from=2026-08-01&to=2026-08-31 |
Ambos date. from maior que to devolve INVALID_DATE_RANGE |
| Booleano | ?hasAudio=true |
Só true ou false |
| Busca textual | ?q=silêncio |
Mínimo 2, máximo 60 caracteres |
Toda combinação de filtro exposta corresponde a um índice existente da Seção 6. Filtro sem índice não é exposto — essa é a regra que impede a API de virar um gerador de varredura sequencial.
Faixa de data tem teto de 366 dias. Pedido maior devolve INVALID_DATE_RANGE com
details: [{ "field": "to", "issue": "out_of_range" }].
7.10 Formatos canônicos #
| Tipo | Formato na API | Exemplo | Observação |
|---|---|---|---|
| Instante | ISO 8601 com Z, milissegundos |
"2026-08-25T09:00:00.000Z" |
Sempre UTC. Nunca offset local. |
| Data de calendário | YYYY-MM-DD |
"2026-08-25" |
Interpretada em America/Sao_Paulo. |
| Dinheiro | inteiro em centavos, campo com sufixo Cents |
"amountCents": 1990 |
R$ 19,90. Nunca float, nunca string. |
| Custo unitário muito pequeno | inteiro em milionésimos de BRL, campo com sufixo Micros |
"costMicros": 4200 |
R$ 0,0042. Custo por mensagem e por caractere. |
| Moeda | ISO 4217 | "currency": "BRL" |
Sempre acompanha o valor. |
| Telefone | E.164 | "phone": "+5511987654321" |
Nunca formatado com parênteses ou traço. |
| Identificador | ULID com prefixo | "id": "sub_01K3F8QZ7M..." |
Ver 6.1.3. |
| Enum | SCREAMING_SNAKE_CASE |
"status": "ACTIVE" |
Igual ao banco. |
| Percentual | inteiro em pontos-base, sufixo Bp |
"deliveryRateBp": 9743 |
97,43%. |
| Duração | inteiro em segundos, sufixo Seconds |
"durationSeconds": 214 |
— |
| Booleano | true/false |
— | Nunca 0/1, nunca "true". |
| Campos JSON | camelCase |
"scheduledFor" |
Conversão de snake_case na borda. |
Por que centavos inteiros: 19.90 em ponto flutuante binário não é exatamente 19,90.
Somar 12 mensalidades produz erro acumulado. Com inteiros, 1990 × 12 = 23880 é exato.
Por que duas unidades: uma mensagem custa fração de centavo, e arredondar cada uma para
centavo zeraria o custo de dezenas de milhares de envios. Valor cobrado do assinante vai
sempre em *Cents (divisor para BRL: 100); custo unitário de mensagem, de caractere ou
de mídia vai sempre em *Micros (divisor para BRL: 1.000.000). As duas unidades nunca
entram na mesma fórmula sem conversão explícita — misturá-las erra o resultado por um fator
de 10.000, e o erro é silencioso porque os dois lados são inteiros plausíveis.
Por que UTC no wire: o servidor não adivinha o fuso do cliente. A conversão para
America/Sao_Paulo acontece no componente de apresentação, com date-fns-tz, uma única
vez. date puro (scheduledFor) não leva hora nem fuso, porque "o devocional de 25 de
agosto" é um fato de calendário, não um instante.
Locale: fixo em pt-BR. O header Accept-Language é ignorado. Toda mensagem de erro e
todo texto ao usuário sai em português do Brasil.
7.11 Catálogo completo de códigos de erro #
code é contrato. Uma vez publicado, nunca muda de significado nem é removido. Código
descontinuado é mantido e deixa de ser emitido.
7.11.1 Autenticação e sessão #
code |
HTTP | Mensagem exibida | Quando ocorre | O que o cliente faz |
|---|---|---|---|---|
UNAUTHENTICATED |
401 | Faça login para continuar. | Rota protegida sem cookie de sessão válido | Redireciona para o login |
SESSION_EXPIRED |
401 | Sua sessão expirou. Entre novamente. | JWT vencido e refresh também vencido | Redireciona para o login |
SESSION_REVOKED |
401 | Sua sessão foi encerrada. Entre novamente. | sessions.revoked_at preenchido |
Limpa estado local e redireciona |
SESSION_REUSE_DETECTED |
401 | Detectamos uso indevido da sessão. Entre novamente. | Refresh token já rotacionado foi reapresentado | Limpa tudo; a família de sessões foi revogada |
INVALID_CREDENTIALS |
401 | E-mail ou senha incorretos. | Login de admin com e-mail ou senha errados | Mostra erro no formulário; não diz qual campo |
ACCOUNT_LOCKED |
403 | Conta bloqueada por tentativas seguidas. Tente mais tarde. | locked_until > now() |
Mostra o tempo restante |
TOTP_REQUIRED |
401 | Informe o código do aplicativo autenticador. | Login de admin passou na senha e falta o segundo fator | Exibe o campo de TOTP |
TOTP_INVALID |
401 | Código do autenticador inválido. | TOTP errado ou fora da janela | Permite nova tentativa |
TOTP_ENROLLMENT_REQUIRED |
403 | Configure o autenticador antes de continuar. | Admin sem TOTP enrolado | Redireciona para o enrolamento |
TOTP_ALREADY_ENROLLED |
409 | O autenticador já está configurado. | Tentativa de enrolar duas vezes | Ignora |
RECOVERY_CODE_INVALID |
401 | Código de recuperação inválido ou já usado. | Código de recuperação errado ou consumido | Permite nova tentativa |
PASSWORD_POLICY_VIOLATION |
422 | A senha não atende aos requisitos mínimos. | Senha fraca na troca | Exibe os requisitos; details traz o que falhou |
PASSWORD_REUSE |
422 | Escolha uma senha diferente da atual. | Nova senha igual à anterior | Pede outra |
PASSWORD_CHANGE_REQUIRED |
403 | Troque sua senha para continuar. | must_change_password = true |
Redireciona para a troca |
OTP_INVALID |
401 | Código incorreto. | Código do WhatsApp errado | Permite nova tentativa; details traz as restantes |
OTP_EXPIRED |
410 | Este código expirou. Peça um novo. | Passaram-se mais de 10 minutos desde a emissão | Habilita o botão de reenvio sem pedir o código de novo |
OTP_MAX_ATTEMPTS |
429 | Muitas tentativas. Peça um novo código. | 5 tentativas erradas contra o mesmo código | Invalida o código e oferece reenvio |
OTP_ATTEMPTS_EXCEEDED |
429 | Muitas tentativas de verificação. Tente mais tarde. | Teto de 30 verificações por hora para o mesmo IP (8.2.1) | Aguarda retryAfterSeconds |
OTP_ALREADY_USED |
409 | Este código já foi utilizado. | Código consumido | Oferece novo envio |
OTP_RESEND_TOO_SOON |
429 | Aguarde antes de pedir outro código. | Reenvio antes de 60 segundos | Exibe o contador; usa retryAfterSeconds |
OTP_COOLDOWN |
429 | Aguarde alguns instantes antes de pedir outro código. | Intervalo mínimo entre pedidos de código ainda não decorrido, medido pelo fluxo de cadastro (Seção 11.12.3). Mesmo status de OTP_RESEND_TOO_SOON, do qual difere apenas pela origem: aquele é do reenvio explícito, este é do pedido inicial repetido |
Exibe o contador; usa retryAfterSeconds |
OTP_SEND_LIMIT_REACHED |
429 | Limite de envios atingido. Tente em 1 hora. | 3 pedidos na mesma hora para o número, contados antes de qualquer consulta ao banco | Oferece o login por e-mail |
OTP_RATE_LIMITED |
429 | Muitas solicitações de código. Tente mais tarde. | Teto diário de 8 pedidos em 24 h para o número (8.2.1) | Oferece o login por e-mail |
OTP_DELIVERY_FAILED |
502 | Não conseguimos enviar o código pelo WhatsApp. | Falha ao entregar o template de autenticação | Oferece o login por e-mail |
SERVICE_BUSY |
503 | Estamos com muitos pedidos agora. Tente em instantes. | Cota global de envio de código esgotada (8.2.1) | Aguarda retryAfterSeconds |
REVERIFICATION_REQUIRED |
403 | Faz tempo que você não aparece. Confirme por e-mail ou fale com a gente. | Sessão restrita por dormência (8.15.1.1) pediu rota de dado pessoal | Oferece o link por e-mail ou o contato do suporte |
CSRF_INVALID |
403 | Não foi possível concluir a ação. Recarregue a página e tente de novo. | Header X-CSRF-Token ausente ou diferente do cookie __Host-csrf (7.13.2) |
Recarrega e refaz a ação |
TOTP_ENROLLMENT_TARGET_FORBIDDEN |
403 | O autenticador só pode ser configurado para a própria conta. | Tentativa de enrolar segundo fator de outra conta (8.6.2) | Esconde a ação |
MAGIC_LINK_INVALID |
401 | Link inválido. | Token do e-mail não confere | Pede novo link |
MAGIC_LINK_EXPIRED |
401 | Este link expirou. Peça um novo. | Passaram-se mais de 20 minutos | Pede novo link |
EMAIL_NOT_VERIFIED |
403 | Verifique seu e-mail para usar esta opção. | Fallback de e-mail sem email_verified_at |
Oferece só o OTP do WhatsApp |
IMPERSONATION_NOT_ALLOWED |
403 | Você não tem permissão para acessar como assinante. | Papel abaixo de ADMIN tentou impersonar |
Esconde a ação da interface |
IMPERSONATION_EXPIRED |
401 | A sessão de suporte expirou. | Sessão de impersonação passou de 30 minutos | Volta ao painel administrativo |
IMPERSONATION_REASON_REQUIRED |
422 | Informe o motivo do acesso de suporte. | Impersonação sem justificativa de 10+ caracteres | Exige o campo |
OTP_EXPIRED é 410 Gone em todo o documento, nunca 401. O 410 distingue código
expirado — recurso que existiu e não existe mais — de código inválido (401 OTP_INVALID),
e essa distinção é o que permite ao cliente habilitar o botão de reenvio sem ambiguidade:
com 401 ele não sabe se deve pedir outro código ou se a pessoa apenas digitou errado.
Nenhuma seção pode reafirmar OTP_EXPIRED com outro status.
7.11.2 Autorização #
code |
HTTP | Mensagem exibida | Quando ocorre | O que o cliente faz |
|---|---|---|---|---|
FORBIDDEN |
403 | Você não tem permissão para esta ação. | Guarda de autorização genérica negou | Esconde a ação |
INSUFFICIENT_ROLE |
403 | Seu perfil não permite esta operação. | Papel abaixo do exigido pela rota | Esconde a ação |
FORBIDDEN_ROLE |
403 | Seu perfil não permite esta operação. | Grafia usada pelas guardas de papel das Seções 3, 13, 20 e 21; mesmo efeito de INSUFFICIENT_ROLE, que é o código preferido em rota nova |
Esconde a ação |
OWNER_REQUIRED |
403 | Apenas o proprietário da conta pode fazer isso. | Ação restrita a OWNER |
Esconde a ação |
RESOURCE_NOT_OWNED |
404 | Não encontrado. | Assinante tentou acessar recurso de outro | Trata como inexistente |
ADMIN_SELF_MODIFICATION_FORBIDDEN |
409 | Você não pode alterar o próprio perfil por aqui. | Admin tentou mudar o próprio papel ou se desativar | Desabilita a ação sobre si |
CANNOT_MODIFY_SELF |
409 | Você não pode executar esta ação sobre a sua própria conta. | Grafia usada pelas rotas de administração de contas da Seção 15; mesmo efeito de ADMIN_SELF_MODIFICATION_FORBIDDEN, que é o código preferido em rota nova |
Desabilita a ação sobre si |
LAST_OWNER_PROTECTED |
409 | É preciso manter ao menos um proprietário ativo. | Rebaixar ou desativar o último OWNER |
Bloqueia a ação |
LAST_OWNER |
409 | É preciso manter ao menos um proprietário ativo. | Grafia curta usada pelas rotas de administração de contas; mesmo efeito de LAST_OWNER_PROTECTED, que é o código preferido em rota nova |
Bloqueia a ação |
ENTITLEMENT_REQUIRED |
403 | Este recurso é exclusivo do plano pago. | Guarda de entitlement negou antes do handler, sem sequer localizar o recurso | Oferece o upgrade |
MAINTENANCE_MODE |
503 | Estamos em manutenção. Volte em instantes. | ops.maintenance_mode = true e a rota escreve |
Exibe aviso; leitura continua |
FEATURE_DISABLED |
403 | Este recurso está indisponível no momento. | Feature flag desligada | Esconde a funcionalidade |
7.11.3 Validação e protocolo #
code |
HTTP | Mensagem exibida | Quando ocorre | O que o cliente faz |
|---|---|---|---|---|
VALIDATION_ERROR |
422 | Alguns campos precisam de correção. | Schema Zod rejeitou o corpo | Marca os campos de details |
INVALID_JSON |
400 | Não foi possível ler os dados enviados. | Corpo não é JSON válido | Erro de programação; reporta |
UNSUPPORTED_MEDIA_TYPE |
415 | Formato de envio não suportado. | Content-Type não é application/json |
Corrige o header |
PAYLOAD_TOO_LARGE |
413 | O conteúdo enviado é grande demais. | Corpo acima do limite de 7.14 | Reduz o conteúdo |
METHOD_NOT_ALLOWED |
405 | Operação não suportada. | Método inexistente na rota | Erro de programação |
NOT_FOUND |
404 | Não encontrado. | Rota ou recurso genérico inexistente | Mostra tela de não encontrado |
MISSING_REQUIRED_FIELD |
422 | Preencha os campos obrigatórios. | Campo obrigatório ausente | Marca os campos |
INVALID_ENUM_VALUE |
422 | Valor não permitido para este campo. | Valor fora do enum | Marca o campo |
INVALID_CURSOR |
400 | Não foi possível carregar a próxima página. | Cursor corrompido, de outra ordenação ou de versão desconhecida | Recarrega do início |
INVALID_LIMIT |
422 | Quantidade por página fora do permitido. | limit fora de 1 a 100 |
Usa o padrão |
LIMIT_OUT_OF_RANGE |
422 | Quantidade por página fora do permitido. | Grafia usada pelas rotas de listagem administrativa; mesmo efeito de INVALID_LIMIT, que é o código preferido em rota nova |
Usa o padrão |
INVALID_DATE_RANGE |
422 | Período informado é inválido. | from > to ou faixa acima de 366 dias |
Corrige o período |
RANGE_TOO_LARGE |
422 | O período pedido é longo demais. | Faixa de data acima do teto específico da rota, menor que os 366 dias gerais — relatórios e exportações | Reduz o período |
UNSUPPORTED_SORT_FIELD |
422 | Não é possível ordenar por este campo. | sort com campo não permitido |
Usa a ordenação padrão |
INVALID_PHONE_NUMBER |
422 | Número de WhatsApp inválido. | Não normaliza para E.164 | Marca o campo |
UNSUPPORTED_COUNTRY_CODE |
422 | No momento atendemos apenas números do Brasil. | DDI diferente de 55 | Exibe a mensagem |
INVALID_CPF_CNPJ |
422 | CPF inválido. | Dígito verificador incorreto ou já em uso | Marca o campo |
TAX_ID_INVALID |
422 | CPF inválido. | Grafia usada pelas rotas de cobrança; mesmo efeito de INVALID_CPF_CNPJ, que é o código preferido em rota nova |
Marca o campo |
INVALID_EMAIL |
422 | E-mail inválido. | Formato incorreto | Marca o campo |
INVALID_ID_FORMAT |
422 | Identificador inválido. | ULID malformado ou prefixo errado | Erro de programação |
INVALID_ENCODING |
422 | Não foi possível ler o arquivo enviado. | Arquivo de importação que não é UTF-8 válido | Pede reenvio em UTF-8 |
TERMS_NOT_ACCEPTED |
422 | É preciso aceitar os termos para continuar. | Checkbox de consentimento ausente | Marca o campo |
CAPTCHA_FAILED |
422 | Não conseguimos confirmar que você não é um robô. | Token do verificador anti-bot ausente, expirado ou recusado na conferência com o provedor | Reinicia o verificador e permite nova tentativa |
BOT_CHECK_FAILED |
422 | Não conseguimos confirmar que você não é um robô. | Grafia usada pelo cadastro e pelo formulário de contato; mesmo efeito de CAPTCHA_FAILED, que é o código preferido em rota nova |
Reinicia o verificador e permite nova tentativa |
HONEYPOT_TRIGGERED |
422 | Não foi possível concluir o envio. | Campo-armadilha invisível preenchido, o que só um preenchedor automático faz. A mensagem ao usuário nunca revela a causa | Exibe o erro genérico; nunca explica a armadilha |
CSRF_ORIGIN_MISMATCH |
403 | Não foi possível concluir a ação. Recarregue a página e tente de novo. | Header Origin ausente ou fora da lista de origens próprias em método não seguro (7.13.2, camada 2). Distingue-se de CSRF_INVALID, que é a falha do token double-submit |
Recarrega e refaz a ação |
PHONE_NOT_ALLOWED |
422 | Não atendemos este número. | Prefixo do telefone na lista fechada de security.blocked_phone_prefixes |
Marca o campo e oferece o suporte |
INVALID_STATUS_FILTER |
422 | Filtro de situação inválido. | Valor de ?status= fora do enum da entidade listada, em rota administrativa |
Usa o filtro padrão |
STALE_WRITE |
409 | O registro mudou desde que você abriu a tela. Recarregue e tente de novo. | Escrita com version ou carimbo de leitura desatualizado, detectada fora do fluxo de edição de devocional (Seções 15.2.6 e 15.12.3) |
Recarrega o recurso e mostra a diferença |
CONFIRMATION_REQUIRED |
422 | Confirme a operação para continuar. | Ação destrutiva enviada sem o campo de confirmação explícita | Exibe o passo de confirmação |
CONFIRMATION_MISMATCH |
422 | O texto de confirmação não confere. | Texto digitado diferente do exigido na confirmação | Marca o campo |
PHONE_CONFIRMATION_MISMATCH |
422 | Os dois números informados não são iguais. | Campo de confirmação de telefone diferente do campo principal | Marca os dois campos |
REASON_REQUIRED |
422 | Informe o motivo desta ação. | Ação administrativa auditada sem reason de 10+ caracteres |
Exige o campo |
OVERRIDE_REASON_REQUIRED |
422 | Informe o motivo para ignorar a validação. | Operação que ultrapassa uma trava de segurança sem justificativa registrada | Exige o campo |
FILE_TOO_LARGE |
413 | O arquivo enviado é grande demais. | Upload acima do limite da rota (7.14) | Reduz o arquivo |
FILE_CONTENT_MISMATCH |
422 | O conteúdo do arquivo não corresponde à extensão. | Número mágico do arquivo diverge do Content-Type declarado |
Recusa o upload |
FILE_DIMENSIONS_EXCEEDED |
422 | A imagem excede as dimensões permitidas. | Largura ou altura acima do teto da rota de capa | Redimensiona |
IMPORT_LIMIT |
413 | O arquivo tem linhas demais para uma importação. | Importação acima do teto de linhas por lote | Divide o arquivo |
TOO_MANY_ROWS |
413 | O arquivo tem linhas demais. | Grafia usada pela importação editorial da Seção 15; mesmo efeito de IMPORT_LIMIT, que é o código preferido em rota nova |
Divide o arquivo |
MISSING_COLUMN |
422 | O arquivo não tem todas as colunas obrigatórias. | Cabeçalho do CSV de importação sem uma coluna exigida; details traz o nome de cada coluna faltante |
Corrige o cabeçalho e reenvia |
IMPORT_ABORTED |
409 | A importação foi interrompida e nada foi gravado. | Erro em qualquer linha do lote com a importação em modo tudo-ou-nada; a transação inteira é desfeita | Exibe o relatório de linhas com problema |
ILLEGAL_STATE_TRANSITION |
409 | Não é possível mudar para este estado agora. | Transição fora da máquina de estados, em qualquer entidade que tenha uma | Recarrega e exibe as transições válidas |
ILLEGAL_STEP |
409 | Este passo não pode ser executado agora. | Passo de fluxo em várias etapas executado fora de ordem | Volta ao passo correto |
7.11.4 Assinantes, consentimento e conta #
code |
HTTP | Mensagem exibida | Quando ocorre | O que o cliente faz |
|---|---|---|---|---|
SUBSCRIBER_NOT_FOUND |
404 | Assinante não encontrado. | Identificador ou telefone inexistente | Mostra não encontrado |
SUBSCRIBER_ALREADY_EXISTS |
409 | Este número já está cadastrado. | Cadastro com telefone existente | Oferece login |
PHONE_ALREADY_REGISTERED |
409 | Este número já está cadastrado. | Grafia usada pelo fluxo de cadastro da Seção 11; mesmo efeito de SUBSCRIBER_ALREADY_EXISTS, que é o código preferido em rota nova |
Oferece login |
SUBSCRIBER_DELETED |
410 | Esta conta foi excluída. | Conta com deleted_at |
Oferece cadastro novo |
SUBSCRIBER_BLOCKED |
403 | Não conseguimos enviar mensagens para este número. | blocked_at preenchido, em rota já autenticada. Nunca no pedido de código, onde revelaria a existência do cadastro (8.2.4) |
Instrui a contatar o suporte |
SUBSCRIBER_OPTED_OUT |
409 | Este assinante pediu para não receber mensagens. | Ação administrativa ou de envio contra assinante com opt_out_at preenchido |
Bloqueia a ação e explica |
PHONE_ALREADY_IN_USE |
409 | Este número pertence a outra conta. | Troca de número para um já usado | Marca o campo |
EMAIL_ALREADY_IN_USE |
409 | Este e-mail pertence a outra conta. | E-mail duplicado | Marca o campo |
PHONE_CHANGE_NOT_ALLOWED |
403 | Não é possível trocar o número por aqui. | Troca de número fora do fluxo verificado | Direciona ao suporte |
PHONE_CHANGE_NOT_FOUND |
404 | Pedido de troca de número não encontrado. | Identificador de troca inexistente ou já concluído | Reinicia o fluxo |
PHONE_CHANGE_LIMIT |
429 | Você já trocou de número recentemente. | Teto de trocas de número na janela definida pela Seção 20 | Exibe quando poderá tentar |
PHONE_CORRECTION_LIMIT |
429 | Você já corrigiu o número várias vezes. | Teto de correções de telefone dentro do mesmo cadastro | Direciona ao suporte |
SIGNUP_NOT_FOUND |
404 | Não encontramos seu cadastro em andamento. | Cookie de cadastro ausente, expirado ou apontando para cadastro concluído | Reinicia o cadastro |
SIGNUP_BLOCKED |
422 | Não foi possível concluir o cadastro. Fale com a gente. | Pontuação anti-abuso acima do limiar de recusa | Exibe o contato humano, nunca o motivo detalhado |
WELCOME_RESEND_COOLDOWN |
429 | Aguarde para reenviar a mensagem de boas-vindas. | Reenvio antes do intervalo mínimo | Exibe o contador |
WELCOME_RESEND_LIMIT |
429 | Limite de reenvios da mensagem de boas-vindas atingido. | Teto de reenvios por cadastro | Direciona ao suporte |
CONSENT_REQUIRED |
422 | É preciso autorizar o tratamento para continuar. | Consentimento específico ausente na requisição | Marca o campo |
CONSENT_VERSION_MISMATCH |
409 | Os termos foram atualizados. Leia e confirme de novo. | Versão de texto enviada diferente da vigente | Recarrega o texto e pede nova confirmação |
OPT_IN_REQUIRED |
403 | Confirme sua inscrição no WhatsApp para continuar. | Ação que exige opt_in_confirmed_at |
Reenvia a confirmação |
OPT_IN_ALREADY_CONFIRMED |
409 | Sua inscrição já está confirmada. | Confirmação repetida | Segue para o painel |
ALREADY_OPTED_OUT |
409 | Você já cancelou o recebimento das mensagens. | Opt-out repetido | Oferece reativação |
NOT_OPTED_OUT |
409 | Você já está recebendo as mensagens. | Reativação sem opt-out anterior | Ignora |
UNSUBSCRIBE_TOKEN_INVALID |
400 | Este link de descadastro não é válido. | Token do link público de saída malformado, expirado ou já usado | Oferece o descadastro pelo painel |
SUBSCRIBER_NOT_OPTED_IN |
409 | Este assinante ainda não confirmou o recebimento no WhatsApp. | Ação administrativa ou de envio contra assinante com opt_in_confirmed_at nulo (Seção 13) |
Bloqueia a ação e oferece reenviar a confirmação |
PHONE_PENDING_DELETION |
409 | Este número está em processo de exclusão. | Cadastro ou troca de número para um telefone cuja conta pediu eliminação e ainda está na janela de 30 dias (Seção 20.12) | Explica o prazo e oferece cancelar a exclusão |
PAUSE_LIMIT_EXCEEDED |
429 | Você já usou suas pausas deste período. | Teto de pausas na janela definida pela Seção 20 | Exibe quando poderá pausar de novo |
PAUSE_DAYS_OUT_OF_RANGE |
422 | Escolha um número de dias dentro do permitido. | days fora da faixa aceita pela pausa (Seção 20.12.3) |
Marca o campo com a faixa válida |
PAUSE_NOT_ACTIVE |
409 | Você não tem uma pausa em andamento. | Encerramento de pausa sem paused_until vigente (Seção 20.12.4) |
Ignora e atualiza a tela |
DELETION_NOT_REQUESTED |
409 | Não há pedido de exclusão em andamento. | Confirmação ou cancelamento de exclusão sem pedido aberto (Seção 20.12.9) | Atualiza a tela |
DELETION_WINDOW_CLOSED |
409 | O prazo para desfazer a exclusão terminou. | Tentativa de cancelar a eliminação depois da janela de arrependimento (Seção 15) | Explica o prazo e direciona ao suporte |
EXPORT_TIMEOUT |
504 | Sua exportação demorou mais do que o esperado. | Montagem do pacote de portabilidade estourou o tempo máximo do job (Seção 21) | Oferece pedir de novo mais tarde |
EXPORT_ALREADY_IN_PROGRESS |
409 | Já existe uma exportação em andamento. | Segundo pedido de exportação em 24 h | Exibe o pedido atual |
EXPORT_NOT_FOUND |
404 | Exportação não encontrada. | Identificador inexistente, expirado ou de outro titular | Oferece pedir uma nova |
EXPORT_RATE_LIMITED |
429 | Limite de exportações do mês atingido. | Teto de pedidos de portabilidade por assinante por mês | Exibe quando poderá pedir de novo |
EXPORT_TOO_LARGE |
413 | Sua exportação ficou grande demais para o download direto. | Pacote acima do teto de tamanho de download | Direciona ao suporte para entrega alternativa |
DELETION_ALREADY_REQUESTED |
409 | A exclusão da sua conta já foi solicitada. | Pedido repetido | Exibe a data prevista |
7.11.5 Devocionais e editorial #
code |
HTTP | Mensagem exibida | Quando ocorre | O que o cliente faz |
|---|---|---|---|---|
DEVOTIONAL_NOT_FOUND |
404 | Devocional não encontrado. | Identificador inexistente ou excluído | Mostra não encontrado |
DEVOTIONAL_DATE_TAKEN |
409 | Já existe um devocional para esta data. | Violação de uq_devotionals_scheduled_for |
Oferece abrir o existente |
DATE_ALREADY_TAKEN |
409 | Já existe um devocional para esta data. | Grafia usada pelo editor e pela importação da Seção 15; mesmo efeito de DEVOTIONAL_DATE_TAKEN, que é o código preferido em rota nova |
Oferece abrir o existente |
DEVOTIONAL_INVALID_TRANSITION |
409 | Não é possível mudar para este status agora. | Transição fora da máquina de estados | Exibe as transições válidas |
DEVOTIONAL_NOT_PUBLISHED |
403 | Este devocional ainda não está disponível. | Assinante pediu conteúdo não publicado | Mostra o acervo |
DEVOTIONAL_ALREADY_SENT |
409 | Este devocional já foi enviado e não pode ser alterado. | Edição de devocional com status SENT |
Bloqueia a edição |
DEVOTIONAL_MISSING_AUDIO |
409 | Gere o áudio antes de publicar. | Publicação sem áudio pronto | Dispara a geração |
DEVOTIONAL_LOCKED |
409 | Outro editor está trabalhando neste devocional. | Lock editorial de 10 minutos ativo | Exibe quem está editando |
UNPUBLISH_REQUIRED |
409 | Despublique o devocional antes desta operação. | Edição estrutural ou exclusão pedida sobre devocional publicado | Oferece o passo de despublicar |
REVISION_NOT_FOUND |
404 | Revisão não encontrada. | Identificador de revisão inexistente ou de outro devocional | Recarrega a lista de revisões |
TEASER_TOO_LONG |
422 | O resumo excede 300 caracteres. | teaser maior que o limite do template |
Marca o campo com o contador |
TEASER_TOO_SHORT |
422 | O resumo precisa de pelo menos 40 caracteres. | teaser abaixo do mínimo exigido pelo editor e pelo banco (Seção 6.15.3) |
Marca o campo com o contador |
BIBLE_VERSION_NOT_ALLOWED |
422 | Versão bíblica não permitida. | Valor de bibleVersion fora da lista fechada ALMEIDA_1911 e BIBLIA_LIVRE (Seção 22.10.3) |
Marca o campo e exibe a lista permitida |
TEASER_INVALID_CHARS |
422 | O resumo não pode ter quebras de linha nem espaços repetidos. | Violação da restrição do parâmetro de template | Marca o campo |
TEASER_INVALID |
422 | O resumo não atende às regras de formato. | Grafia genérica usada pelo editor da Seção 15; cobre tamanho e caracteres proibidos, detalhados em details |
Marca o campo |
REFLECTION_TOO_LONG |
422 | A reflexão ultrapassa o limite da mensagem. | Texto final passaria de 4096 caracteres | Exibe o contador |
MESSAGE_TOO_LONG |
422 | A mensagem montada ultrapassa o limite da plataforma. | Corpo final de qualquer mensagem de saída acima do teto de caracteres da plataforma | Encurta o conteúdo |
AUDIO_NOT_FOUND |
404 | Áudio não encontrado. | Nenhum audio_assets pronto para o devocional pedido |
Dispara a geração ou mostra não encontrado |
AUDIO_REQUIRED |
422 | Este devocional precisa de áudio para seguir. | Operação que exige áudio pronto e não o encontrou | Dispara a geração |
AUDIO_TOO_LONG |
422 | O áudio excede a duração máxima permitida. | Áudio acima de 480 s (8 minutos), o teto da plataforma de mensageria | Encurta a narração |
REVISION_CONFLICT |
409 | Alguém alterou este devocional. Recarregue e tente de novo. | version divergente |
Recarrega e mostra a diferença |
OPTIMISTIC_LOCK_FAILED |
409 | O registro mudou. Recarregue e tente de novo. | Falha de versionamento otimista genérico | Recarrega |
ARCHIVE_LIMIT_REACHED |
403 | Assine para acessar o acervo completo. | Assinante FREE pediu conteúdo com mais de 7 dias | Oferece o upgrade |
7.11.6 Planos, assinaturas e entitlements #
code |
HTTP | Mensagem exibida | Quando ocorre | O que o cliente faz |
|---|---|---|---|---|
PLAN_NOT_FOUND |
404 | Plano não encontrado. | planCode inexistente |
Recarrega a grade |
PLAN_INACTIVE |
409 | Este plano não está mais disponível. | is_active = false |
Mostra os planos vigentes |
SUBSCRIPTION_NOT_FOUND |
404 | Assinatura não encontrada. | Sem assinatura vigente | Oferece contratar |
SUBSCRIPTION_ALREADY_ACTIVE |
409 | Você já tem uma assinatura ativa. | Violação de uq_subscriptions_one_live_per_subscriber |
Leva à assinatura existente |
SUBSCRIPTION_NOT_ACTIVE |
409 | Sua assinatura não está ativa. | Ação que exige assinatura viva | Oferece reativar |
SUBSCRIPTION_PENDING_PAYMENT |
409 | Estamos aguardando a confirmação do pagamento. | Nova contratação com uma pendente | Mostra o PIX pendente |
ALREADY_CANCELED |
409 | Esta assinatura já foi cancelada. | Cancelamento repetido | Exibe a data de término |
PLAN_CHANGE_NOT_ALLOWED |
409 | Não é possível trocar de plano agora. | Troca de plano fora da janela permitida | Explica a regra |
PLAN_CHANGE_ALREADY_PENDING |
409 | Já existe uma troca de plano agendada. | pending_plan_id preenchido e ainda não aplicado |
Exibe a troca agendada e oferece cancelá-la |
PLAN_CHANGE_SAME_PLAN |
422 | Escolha um plano diferente do atual. | Troca agendada para o mesmo plano vigente; é a mesma regra do chk_subs_pending_plan_differs (Seção 6.11.3) |
Marca o campo e mostra os outros planos |
PLAN_CHANGE_NOT_PENDING |
409 | Não há troca de plano agendada para cancelar. | DELETE da troca de plano sem pending_plan_id preenchido |
Atualiza a tela |
REACTIVATION_WINDOW_CLOSED |
409 | O prazo para reativar esta assinatura terminou. | Reativação pedida depois dos 30 dias que se seguem ao cancelamento ou ao opt-out (Seção 14.10.10) | Oferece contratar de novo |
CANCELLATION_REASON_REQUIRED |
422 | Escolha um motivo para o cancelamento. | Cancelamento sem cancelReason da lista fechada |
Exige a escolha |
CANCELLATION_COMMENT_REQUIRED |
422 | Conte um pouco mais sobre o motivo. | Motivo "outro" escolhido sem comentário livre | Exige o campo |
RETENTION_OFFER_ALREADY_SHOWN |
409 | Esta oferta já foi apresentada. | Segundo registro de exibição da oferta de retenção no mesmo fluxo | Segue para o cancelamento |
BILLING_ACKNOWLEDGEMENT_REQUIRED |
422 | Confirme que entendeu como funciona a cobrança. | Contratação sem o aceite explícito do aviso de cobrança recorrente | Marca o campo |
COURTESY_LIMIT_EXCEEDED |
429 | Limite de cortesias concedidas atingido. | Teto de concessões de cortesia na janela do operador | Bloqueia e exige aprovação de OWNER |
COURTESY_REASON_REQUIRED |
422 | Informe o motivo da cortesia. | Concessão de cortesia sem justificativa registrada | Exige o campo |
COURTESY_DAYS_OUT_OF_RANGE |
422 | Escolha um número de dias dentro do permitido. | days fora da faixa aceita pela concessão de cortesia (Seção 13.11.5) |
Marca o campo com a faixa válida |
COURTESY_NOT_ACTIVE |
409 | Este assinante não tem cortesia em andamento. | Revogação de cortesia com courtesy_until nulo ou já vencido |
Atualiza a ficha |
ENTITLEMENT_DENIED |
403 | Este recurso é exclusivo do plano pago. | Assinante FREE pediu recurso PAID, verificado dentro do handler | Oferece o upgrade |
TIER_UPGRADE_REQUIRED |
403 | Assine para usar este recurso. | Igual ao anterior, em contexto de interface | Abre o checkout |
AUDIO_REQUIRES_PAID_PLAN |
403 | O áudio é exclusivo do plano pago. | Pedido de credencial de áudio por assinante FREE; é a especialização de ENTITLEMENT_DENIED para o único diferencial do plano pago (Seção 13) |
Oferece o upgrade |
NO_DEVOTIONAL_FOR_TODAY_ON_FREE_TIER |
404 | Hoje não há devocional no plano gratuito. Volte no próximo domingo. | Assinante FREE pediu GET /api/devotionals/today em dia que o regime semanal não cobre (Seção 3.4) |
Exibe o próximo dia coberto e oferece o upgrade |
RESEND_LIMIT_REACHED |
429 | Você atingiu o limite de reenvios de hoje. | 1 reenvio no FREE, 3 no PAID | Mostra quando reseta |
7.11.7 Pagamentos #
code |
HTTP | Mensagem exibida | Quando ocorre | O que o cliente faz |
|---|---|---|---|---|
PAYMENT_NOT_FOUND |
404 | Cobrança não encontrada. | Identificador inexistente | Mostra não encontrado |
PAYMENT_PROVIDER_ERROR |
502 | Não foi possível concluir o pagamento. Tente novamente. | Provedor devolveu erro tratável | Permite nova tentativa |
ASAAS_ERROR |
502 | Não foi possível concluir o pagamento. Tente novamente. | Grafia usada pela integração da Seção 12; mesmo efeito de PAYMENT_PROVIDER_ERROR, que é o código preferido em rota nova |
Permite nova tentativa |
CARD_BILLING_BLOCKED |
409 | Não é possível usar cartão nesta assinatura agora. | Cobrança por cartão suspensa para o assinante após recusas seguidas ou contestação — subscribers.billing_blocked_at preenchido (Seção 6.3) |
Oferece PIX |
TAX_ID_MUST_BE_CPF |
422 | Informe um CPF. | Documento informado é um CNPJ, e o produto só aceita pessoa física no checkout (Seção 12.7) | Marca o campo |
TAX_ID_INVALID_LENGTH |
422 | O CPF precisa ter 11 dígitos. | Quantidade de dígitos diferente de 11 depois de removida a formatação | Marca o campo |
TAX_ID_REJECTED_BY_PROVIDER |
422 | O provedor de pagamento recusou este CPF. | Documento sintaticamente válido e recusado na criação do cliente no provedor | Pede conferência do documento e oferece o suporte |
BILLING_TYPE_NOT_PIX |
409 | Esta operação só vale para cobranças por PIX. | Pedido do código PIX em assinatura cujo billing_type é CREDIT_CARD (Seção 12.7.5) |
Esconde a ação |
BILLING_TYPE_NOT_CARD |
409 | Esta operação só vale para cobranças por cartão. | Troca de cartão em assinatura cujo billing_type é PIX (Seção 13.13.5) |
Esconde a ação |
PIX_CODE_UNAVAILABLE |
409 | O código PIX desta cobrança ainda não está disponível. | Cobrança criada e QR Code ainda não emitido pelo provedor | Tenta de novo em instantes |
PAYMENT_ALREADY_PAID |
409 | Este pagamento já foi confirmado. | Grafia usada pelas rotas de cobrança da Seção 12; mesmo efeito de PAYMENT_ALREADY_CONFIRMED, que é o código preferido em rota nova |
Leva ao painel |
PAYMENT_PROVIDER_UNAVAILABLE |
503 | O sistema de pagamentos está indisponível. Tente em instantes. | Provedor fora do ar ou circuit breaker aberto | Nova tentativa com retryAfterSeconds |
CARD_DECLINED |
422 | Cartão recusado. Verifique os dados ou use outro cartão. | Recusa do emissor | Oferece novo cartão ou PIX |
CARD_TOKEN_INVALID |
422 | Não foi possível validar o cartão. | Token de cartão inválido ou expirado | Refaz a tokenização |
PIX_QR_UNAVAILABLE |
502 | Não foi possível gerar o código PIX. | Falha ao obter o QR Code | Nova tentativa |
PIX_EXPIRED |
409 | Este código PIX expirou. Gere um novo. | QR Code vencido | Gera novo código |
PAYMENT_ALREADY_CONFIRMED |
409 | Este pagamento já foi confirmado. | Tentativa de pagar duas vezes | Leva ao painel |
CHECKOUT_SESSION_EXPIRED |
409 | Sua sessão de checkout expirou. Recomece. | Checkout parado por mais de 30 minutos | Reinicia o fluxo |
CUSTOMER_CREATE_FAILED |
502 | Não foi possível registrar seus dados de cobrança. | Falha ao criar cliente no provedor | Nova tentativa; verifica CPF |
REFUND_NOT_ALLOWED |
409 | Este pagamento não pode ser estornado. | Fora da janela ou já estornado | Direciona ao suporte |
DUPLICATE_PAYMENT |
409 | Já existe uma cobrança em aberto. | Segunda cobrança para o mesmo ciclo | Mostra a existente |
7.11.8 Mídia, áudio e envio #
code |
HTTP | Mensagem exibida | Quando ocorre | O que o cliente faz |
|---|---|---|---|---|
AUDIO_NOT_READY |
409 | O áudio ainda está sendo gerado. | Áudio pedido antes de READY |
Mostra o progresso e tenta depois |
AUDIO_GENERATION_FAILED |
500 | Não foi possível gerar o áudio. | Falha nos dois provedores de voz | Permite nova tentativa manual |
TTS_PROVIDER_UNAVAILABLE |
503 | O serviço de voz está indisponível. | Ambos os provedores fora | Reagenda a geração |
TTS_RATE_LIMITED |
429 | O serviço de voz atingiu o limite de uso. | Provedor de TTS devolveu limite de taxa ou cota de caracteres esgotada (Seção 16) | Reagenda com retryAfterSeconds e alerta a operação |
MEDIA_TOO_LARGE |
422 | O arquivo excede o limite permitido. | Mídia acima de 16 MB | Reduz a qualidade |
MEDIA_UPLOAD_FAILED |
502 | Falha ao enviar a mídia. | Erro na API de mídia da plataforma | Nova tentativa |
MEDIA_ID_EXPIRED |
409 | A mídia expirou e precisa ser reenviada. | Identificador com mais de 30 dias | Dispara reupload |
SIGNED_URL_EXPIRED |
403 | O link de áudio expirou. Recarregue a página. | URL assinada com mais de 15 minutos | Pede nova URL |
UNSUPPORTED_AUDIO_FORMAT |
422 | Formato de áudio não suportado. | Formato fora do permitido | Erro de programação |
SEND_LIMIT_REACHED |
429 | Limite diário de envios atingido. | Teto operacional do dia | Aguarda o próximo dia |
OUTSIDE_SERVICE_WINDOW |
409 | Não é possível enviar esta mensagem agora. | Free-form fora da janela de 24 h | Usa template |
TEMPLATE_NOT_APPROVED |
409 | O modelo de mensagem ainda não foi aprovado. | Template sem APPROVED |
Bloqueia o envio e alerta |
TEMPLATE_PARAM_INVALID |
422 | Os dados do modelo de mensagem são inválidos. | Parâmetro com quebra de linha ou longo demais | Corrige o teaser |
TEMPLATE_PARAM_SEQUENCE |
422 | Os marcadores do modelo estão fora de sequência. | Corpo do template com {{n}} que pula número ou começa fora de {{1}}, o que a Meta recusa na submissão |
Corrige a numeração dos marcadores |
TEMPLATE_NAME_TAKEN |
409 | Já existe um modelo com este nome neste idioma. | Violação de uq_wa_templates_name_lang (Seção 6.18.1) |
Oferece abrir o existente |
TEMPLATE_IN_USE |
409 | Este modelo está em uso e não pode ser removido. | Exclusão de template marcado is_current ou referenciado por lote em andamento |
Oferece despromover antes de remover |
SENDING_PAUSED |
409 | O envio de mensagens está pausado. | ops.kill_switch ligado ou contenção automática ativa; nenhuma mensagem sai enquanto durar (Seções 14.9 e 14.10.6) |
Exibe o aviso e reagenda a ação |
TOO_MANY_RECIPIENTS |
422 | O envio tem destinatários demais. | Lote manual acima do teto de destinatários por disparo | Divide o envio |
WHATSAPP_PROVIDER_ERROR |
502 | Falha ao enviar a mensagem. | Erro genérico da plataforma | Retenta com backoff |
WHATSAPP_SEND_FAILED |
502 | Falha ao enviar a mensagem. | Grafia usada pelas Seções 17 e 18; mesmo efeito de WHATSAPP_PROVIDER_ERROR, que é o código preferido em rota nova |
Retenta com backoff |
WHATSAPP_UNAVAILABLE |
503 | O envio de mensagens está indisponível. | Plataforma fora do ar ou disjuntor aberto | Reagenda com retryAfterSeconds |
META_API_ERROR |
502 | Falha ao falar com a plataforma de mensagens. | Erro da API de gestão — template, mídia ou número — fora do caminho de envio | Registra e alerta |
WHATSAPP_RATE_LIMITED |
429 | Limite de envio da plataforma atingido. | Erro 130429 |
Reduz a taxa e reagenda |
MESSAGE_UNDELIVERABLE |
422 | Não foi possível entregar a mensagem para este número. | Erro 131026 |
Marca o assinante e alerta |
SEND_BATCH_ALREADY_RUNNING |
409 | O envio deste dia já está em andamento. | Violação de uq_send_batches_idempotency |
Mostra o lote em andamento |
BATCH_ALREADY_STARTED |
409 | Este lote já começou e não pode mais ser alterado. | Alteração de composição de lote já em disparo | Oferece pausar em vez de alterar |
BATCH_NOT_FOUND |
404 | Lote de envio não encontrado. | Identificador de lote inexistente | Mostra não encontrado |
BATCH_NOT_RUNNING |
409 | Este lote não está em andamento. | Cancelamento de lote cujo status não é RUNNING (Seção 15.12.13) |
Atualiza a ficha do lote |
BATCH_STILL_RUNNING |
409 | Este lote ainda está em andamento. | Reprocessamento pedido antes de o lote original terminar (Seção 15.12.12) | Espera o término e reabilita a ação |
RETRY_WINDOW_CLOSED |
409 | O prazo para reprocessar este lote terminou. | Reprocessamento de lote fora da janela permitida; depois dela o reenvio vira um envio novo | Explica a regra e oferece um lote novo |
NO_ELIGIBLE_RECIPIENTS |
409 | Nenhum assinante elegível para este envio. | Planejamento sem alvos | Informa e não cria lote |
7.11.9 Webhooks #
code |
HTTP | Mensagem exibida | Quando ocorre | O que o cliente faz |
|---|---|---|---|---|
WEBHOOK_SIGNATURE_INVALID |
401 | Assinatura inválida. | HMAC do corpo não confere | Nada; alerta de segurança é disparado |
WEBHOOK_TOKEN_INVALID |
401 | Token inválido. | Token do webhook de pagamento não confere | Idem |
WEBHOOK_PAYLOAD_INVALID |
200 | — | Corpo não é JSON ou não bate com o schema, depois de o remetente já ter sido autenticado | Registra em webhook_deliveries.processing_result e responde 200 com corpo vazio |
WEBHOOK_EVENT_DUPLICATE |
200 | — | Reentrega já processada | Resposta de sucesso com duplicate: true |
UNKNOWN_EVENT |
200 | — | Tipo de evento que o remetente autenticado enviou e que nenhum handler trata. Registra em webhook_deliveries.processing_result com o valor UNKNOWN_EVENT e responde 200, pela mesma regra de 7.15.1 |
Nada; alimenta o painel de eventos não tratados |
WEBHOOK_VERIFICATION_FAILED |
403 | Verificação falhou. | hub.verify_token errado no handshake GET de registro |
Corrige a configuração |
WEBHOOK_SOURCE_UNKNOWN |
404 | Origem desconhecida. | Caminho de webhook inexistente | Nada |
WEBHOOK_EVENT_DUPLICATE e WEBHOOK_PAYLOAD_INVALID são os dois códigos do catálogo
emitidos com status 200. Eles aparecem dentro de data, não de error, porque nenhum
dos dois é falha do remetente: duplicidade é o comportamento esperado da reentrega, e corpo
malformado é problema nosso de interpretação, que precisa ser corrigido do nosso lado.
Nos dois casos o provedor precisa ver sucesso para não pausar nem desativar a assinatura do
webhook — a regra completa está em 7.15.1.
7.11.10 Rate limit, idempotência e infraestrutura #
code |
HTTP | Mensagem exibida | Quando ocorre | O que o cliente faz |
|---|---|---|---|---|
RATE_LIMITED |
429 | Muitas requisições. Aguarde um instante. | Estouro do limite da rota | Aguarda retryAfterSeconds |
IP_RATE_LIMITED |
429 | Muitas requisições deste endereço. | Estouro do limite por IP | Aguarda |
ACTION_RATE_LIMITED |
429 | Você repetiu esta ação vezes demais. | Estouro do limite de uma ação de negócio específica, contado por sujeito e não por rota | Aguarda retryAfterSeconds |
SETTING_NOT_FOUND |
404 | Chave de configuração não encontrada. | Chave fora das declaradas no catálogo de settings (Seção 26.8.1) |
Recarrega a lista |
IMMUTABLE_SETTING |
409 | Esta configuração não pode ser alterada em execução. | Escrita em chave marcada como imutável no catálogo de settings; o valor só muda por migration (Seção 15) |
Esconde o campo de edição |
OVERRIDE_NOT_ALLOWED |
403 | Esta trava não pode ser ignorada. | Pedido de sobreposição de uma verificação de segurança que não admite justificativa, ao contrário das cobertas por OVERRIDE_REASON_REQUIRED (Seção 15) |
Esconde a ação |
UNKNOWN_METRIC_KEY |
422 | Métrica desconhecida. | metricKey fora do catálogo de métricas da Seção 21 |
Recarrega a lista de métricas |
FLAG_NOT_FOUND |
404 | Feature flag não encontrada. | Chave fora das declaradas em feature_flags (Seção 26.9) |
Recarrega a lista |
IDEMPOTENCY_KEY_REQUIRED |
400 | Requisição inválida. | Rota que exige Idempotency-Key sem o header |
Erro de programação |
IDEMPOTENCY_KEY_REUSED |
422 | Esta operação já foi feita com outros dados. | Mesma chave, corpo diferente | Gera nova chave |
IDEMPOTENCY_IN_PROGRESS |
409 | Estamos processando sua solicitação. | Requisição concorrente com a mesma chave | Aguarda e consulta |
CONFLICT |
409 | O estado atual impede esta operação. | Conflito genérico não coberto por código específico | Recarrega |
INTERNAL_ERROR |
500 | Ocorreu um erro inesperado. Tente novamente. | Exceção não tratada | Nova tentativa; reporta o requestId |
SERVICE_UNAVAILABLE |
503 | Serviço temporariamente indisponível. | Dependência crítica fora | Nova tentativa com backoff |
DATABASE_UNAVAILABLE |
503 | Serviço temporariamente indisponível. | Pool de conexões esgotado ou banco fora | Nova tentativa |
CACHE_UNAVAILABLE |
503 | Serviço temporariamente indisponível. | Redis fora e a rota depende dele | Nova tentativa |
UPSTREAM_TIMEOUT |
504 | O serviço demorou para responder. | Provedor externo estourou o tempo | Nova tentativa |
EMAIL_SEND_FAILED |
502 | Não foi possível enviar o e-mail. | Falha no provedor de e-mail | Nova tentativa |
EMAIL_PROVIDER_ERROR |
502 | Não foi possível enviar o e-mail. | Grafia usada pela integração de e-mail da Seção 9; mesmo efeito de EMAIL_SEND_FAILED, que é o código preferido em rota nova |
Nova tentativa |
STORAGE_UNAVAILABLE |
503 | Não foi possível acessar o arquivo. | Storage fora | Nova tentativa |
7.11.11 Propriedade do catálogo #
Total: 252 códigos. Esta subseção é a única dona do catálogo. Nenhuma outra seção
pode introduzir, renomear ou mudar o status HTTP de um código de erro: um código novo entra
aqui primeiro, e só depois é usado por uma seção de domínio. Um teste do pipeline extrai
toda string passada a apiError(...) e a new AppError(...) no código, compara com as
chaves de ERROR_CATALOG e falha se encontrar qualquer código não catalogado — e falha
também se encontrar um código catalogado e nunca emitido por rota alguma, que é sinal de
código morto.
Duas grafias que aparecem em texto de seções de domínio não são códigos e não entram no catálogo. As formas canônicas são:
| Grafia incorreta | Código canônico |
|---|---|
OPTIN_REQUIRED |
OPT_IN_REQUIRED |
CSRF_TOKEN_INVALID |
CSRF_INVALID |
Onde o catálogo traz duas entradas com o mesmo efeito — CANNOT_MODIFY_SELF e
ADMIN_SELF_MODIFICATION_FORBIDDEN, LAST_OWNER e LAST_OWNER_PROTECTED,
LIMIT_OUT_OF_RANGE e INVALID_LIMIT, TAX_ID_INVALID e INVALID_CPF_CNPJ,
DATE_ALREADY_TAKEN e DEVOTIONAL_DATE_TAKEN, PHONE_ALREADY_REGISTERED e
SUBSCRIBER_ALREADY_EXISTS, PAYMENT_ALREADY_PAID e PAYMENT_ALREADY_CONFIRMED,
ASAAS_ERROR e PAYMENT_PROVIDER_ERROR, WHATSAPP_SEND_FAILED e
WHATSAPP_PROVIDER_ERROR, FORBIDDEN_ROLE e INSUFFICIENT_ROLE, BOT_CHECK_FAILED e
CAPTCHA_FAILED, TOO_MANY_ROWS e IMPORT_LIMIT, EMAIL_PROVIDER_ERROR e
EMAIL_SEND_FAILED — as duas continuam válidas e catalogadas, porque code é
contrato e um código publicado nunca é removido. A coluna "Quando ocorre" indica qual é o
preferido em rota nova. Rota existente não é reescrita só para trocar de grafia.
Três códigos exigem status que não é o intuitivo e que nenhuma seção pode reafirmar de
outra forma: FORBIDDEN_ROLE é sempre 403, OTP_COOLDOWN é sempre 429 e
STALE_WRITE é sempre 409. OTP_COOLDOWN não é 403 porque o pedido é legítimo e
apenas cedo demais; STALE_WRITE não é 422 porque o corpo enviado está correto — o que
mudou foi o estado no servidor.
O tipo TypeScript é derivado do próprio catálogo, o que impede emitir código não catalogado:
// packages/core/src/errors/catalog.ts
export const ERROR_CATALOG = {
UNAUTHENTICATED: { status: 401, message: 'Faça login para continuar.' },
SESSION_EXPIRED: { status: 401, message: 'Sua sessão expirou. Entre novamente.' },
VALIDATION_ERROR: { status: 422, message: 'Alguns campos precisam de correção.' },
OTP_EXPIRED: { status: 410, message: 'Este código expirou. Peça um novo.' },
// ... todas as entradas de 7.11.1 a 7.11.10
} as const satisfies Record<string, { status: number; message: string }>;
export type ErrorCode = keyof typeof ERROR_CATALOG;
export function apiError(code: ErrorCode, details?: ErrorDetail[]): AppError {
const entry = ERROR_CATALOG[code];
return new AppError(code, entry.status, entry.message, details);
}Um teste automatizado garante que cada code do catálogo aparece na documentação e que
nenhum throw new AppError no código usa string literal fora do catálogo.
7.12 Rate limiting #
7.12.1 Algoritmo #
Token bucket em Redis, executado como script Lua para ser atômico. Cada balde tem capacidade (rajada) e taxa de recarga (sustentado).
-- packages/core/src/ratelimit/token-bucket.lua
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2]) -- tokens por segundo
local now = tonumber(ARGV[3]) -- epoch em milissegundos
local cost = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(bucket[1]) or capacity
local ts = tonumber(bucket[2]) or now
tokens = math.min(capacity, tokens + ((now - ts) / 1000) * refill)
local allowed = tokens >= cost
if allowed then tokens = tokens - cost end
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, math.ceil((capacity / refill) * 1000) + 1000)
local retry_after = 0
if not allowed then retry_after = math.ceil(((cost - tokens) / refill)) end
return { allowed and 1 or 0, math.floor(tokens), retry_after }Token bucket, e não janela fixa, porque janela fixa permite o dobro do limite na virada (fim de uma janela mais início da seguinte) e porque o balde absorve rajada legítima — carregar o painel dispara 6 requisições em 200 ms e isso é normal.
7.12.2 Limites por rota #
identidade é a chave do balde: ip, phone, subscriber (identificador da sessão) ou
admin.
| Rota | Identidade | Capacidade | Recarga | Motivo |
|---|---|---|---|---|
POST /api/auth/otp/request |
phone |
3 | 3/hora | Cada envio custa uma mensagem de autenticação |
POST /api/auth/otp/request |
phone |
8 | 8/24 h | Teto diário; devolve OTP_RATE_LIMITED |
POST /api/auth/otp/request |
ip |
10 | 10/hora | Impede varredura de números |
POST /api/auth/otp/verify |
phone |
10 | 10/hora | Complementa o limite de 5 tentativas por código |
POST /api/auth/otp/verify |
ip |
30 | 30/hora | Impede varredura de códigos entre números; devolve OTP_ATTEMPTS_EXCEEDED |
POST /api/auth/email/request |
ip |
5 | 5/hora | Custo de e-mail e antiabuso |
POST /api/auth/admin/login |
ip |
10 | 5/hora | Antiforça bruta |
POST /api/auth/admin/login |
email |
5 | 5/hora | Complementa o bloqueio de conta |
POST /api/signups |
ip |
5 | 5/hora | Antiabuso de cadastro |
POST /api/public/unsubscribe |
ip |
10 | 10/hora | Rota pública que aceita token opaco; o limite impede varredura de tokens |
POST /api/me/subscription |
subscriber |
5 | 5/hora | Evita cobrança acidental repetida |
POST /api/devotionals/{devotionalId}/resends |
subscriber |
3 | 3/dia | Limite de entitlement (Seção 13) |
GET /api/me/** |
subscriber |
120 | 60/minuto | Navegação normal |
GET /api/admin/** |
admin |
300 | 120/minuto | Painel administrativo é mais intenso |
POST/PATCH /api/admin/** |
admin |
60 | 30/minuto | Escrita administrativa |
POST /api/admin/batches |
admin |
3 | 3/hora | Disparo manual é operação séria |
GET /api/public/** |
ip |
60 | 30/minuto | Landing page |
POST /api/webhooks/** |
ip |
600 | 300/minuto | Alto por desenho; o provedor faz rajada |
| Global, qualquer rota | ip |
600 | 300/minuto | Rede de segurança |
O limite de /api/devotionals/{devotionalId}/resends é o limite máximo do tier pago. O limite real
por tier (1 no FREE, 3 no PAID) é uma regra de negócio verificada no handler, que devolve
RESEND_LIMIT_REACHED. Rate limit e entitlement são coisas distintas e ambos existem: o
primeiro protege a infraestrutura, o segundo protege o modelo de negócio.
Webhooks têm limite generoso porque o provedor de pagamento pausa a fila em caso de falha.
Devolver 429 para ele seria pior do que aceitar a rajada. O limite existe só como
proteção contra inundação.
7.12.3 Headers #
Toda resposta de rota com limite carrega:
RateLimit-Limit: 120
RateLimit-Remaining: 117
RateLimit-Reset: 43
RateLimit-Policy: 120;w=60RateLimit-Reset é em segundos, relativo. Resposta 429 acrescenta Retry-After com o
mesmo valor, para clientes que só entendem esse header:
HTTP/1.1 429 Too Many Requests
Retry-After: 43
RateLimit-Limit: 120
RateLimit-Remaining: 0
RateLimit-Reset: 43
Content-Type: application/json
{
"error": {
"code": "RATE_LIMITED",
"message": "Muitas requisições. Aguarde um instante.",
"retryAfterSeconds": 43
},
"meta": { "requestId": "req_01K3F8TV5...", "timestamp": "2026-08-25T09:00:00.000Z" }
}7.12.4 Falha do Redis #
Se o Redis estiver indisponível, o limitador permite a requisição e registra
ratelimit_bypass no log com nível warn. Decisão explícita: derrubar o produto inteiro
porque o limitador caiu é pior do que ficar temporariamente sem proteção de taxa. As rotas
de autenticação são a exceção — sem Redis, elas devolvem SERVICE_UNAVAILABLE, porque ali
a proteção contra força bruta é mais importante que a disponibilidade.
7.13 CORS, CSRF e cabeçalhos de segurança #
7.13.1 CORS #
O front é servido do mesmo domínio da API. Não há cliente cross-origin legítimo. Portanto:
/api/me/*,/api/admin/*,/api/auth/*: CORS não habilitado. Requisição cross-origin é bloqueada pelo navegador. Sem headerAccess-Control-Allow-Origin./api/public/*:Access-Control-Allow-Origincom lista fechada (ALLOWED_ORIGINS, Seção 26.3.7), métodosGET, OPTIONS, sem credenciais./api/webhooks/*: CORS irrelevante; a chamada não vem de navegador.
Origem não permitida recebe 403 sem header de CORS. Nada de Access-Control-Allow-Origin: *
em rota que usa cookie — a combinação é rejeitada pelo próprio navegador e mascara o erro.
7.13.2 CSRF #
Defesa em três camadas:
SameSite=Laxno cookie de sessão. Bloqueia o envio do cookie emPOSTcross-site, que é o vetor principal.- Verificação de
Origin. TodoPOST,PATCH,PUTeDELETEem rota autenticada compara o headerOrigincom a lista de origens próprias. Ausente ou divergente devolve403 FORBIDDEN. Requisição semOriginsó é aceita em rota de webhook. - Token double-submit em rotas destrutivas. Cancelar assinatura, excluir conta,
excluir devocional e alterar papel de admin exigem o header
X-CSRF-Tokenigual ao cookie__Host-csrf(que é legível por script, ao contrário do cookie de sessão), com comparação em tempo constante.
O cookie de CSRF tem um único nome em todo o documento e em todo o código:
__Host-csrf. O nome csrf-token não existe. Um emissor que grave um nome e um
verificador que leia outro produz 403 em toda requisição que muda estado — cadastro,
checkout, cancelamento, opt-out e o painel administrativo inteiro —, e nenhum teste
unitário pega, porque os testes injetam o par diretamente.
export function assertSameOrigin(req: Request): void {
if (SAFE_METHODS.has(req.method)) return;
const origin = req.headers.get('origin');
if (!origin || !ALLOWED_APP_ORIGINS.includes(origin)) {
throw apiError('FORBIDDEN');
}
}
export function assertCsrf(req: Request): void {
if (SAFE_METHODS.has(req.method)) return;
const cookieToken = readCookie(req, '__Host-csrf'); // nome único; ver 8.8.3
const headerToken = req.headers.get('x-csrf-token');
if (!cookieToken || !headerToken) throw apiError('CSRF_INVALID');
const a = Buffer.from(cookieToken, 'utf8');
const b = Buffer.from(headerToken, 'utf8');
if (a.length !== b.length || !timingSafeEqual(a, b)) throw apiError('CSRF_INVALID');
}A falha de CSRF devolve 403 CSRF_INVALID, e não 403 FORBIDDEN genérico: o cliente
precisa distinguir "sua sessão não permite isso" de "recarregue a página e tente de novo",
porque só o segundo caso tem uma ação útil para o usuário.
7.13.3 Cabeçalhos de resposta #
Aplicados a todas as respostas pelo middleware e reforçados no proxy reverso:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{NONCE}' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob: https://*.r2.cloudflarestorage.com https://media.palavradiaria.com.br; connect-src 'self' https://challenges.cloudflare.com https://media.palavradiaria.com.br; frame-src 'self' https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
X-Frame-Options: DENYRotas /api/* acrescentam Cache-Control: no-store sem exceção. Nenhuma resposta de API
é cacheável — dados de assinante em cache de proxy é vazamento.
media-src inclui o domínio do storage e o domínio de mídia porque o player web consome
URLs assinadas. connect-src precisa incluir o mesmo domínio de mídia: o player faz
fetch da URL assinada antes de criar o blob, e media-src governa o elemento <audio>,
não a requisição. Sem os dois, o áudio — que é o único diferencial do plano pago — não toca
em navegador nenhum.
frame-src é declarada explicitamente porque default-src 'self' não basta para o
verificador anti-bot: o widget do Cloudflare Turnstile roda dentro de um iframe servido
por https://challenges.cloudflare.com, e o mesmo host precisa constar de script-src e
de connect-src. Esse host é também a razão de o cadastro funcionar: sem ele, o widget
nunca renderiza, o token nunca é preenchido e o POST de cadastro devolve
422 VALIDATION_ERROR para todo visitante.
style-src permite unsafe-inline por exigência do runtime de estilos do framework de UI.
script-src não permite: scripts inline são bloqueados, e o nonce por requisição é
usado onde o framework precisa e para carregar o script do verificador anti-bot.
Esta é a política de linha de base. A Seção 22.3.1 é a dona da política completa, com as
variantes por tipo de página, e nenhuma delas pode ser mais permissiva do que a linha de
base acima em object-src, base-uri, form-action e frame-ancestors.
7.14 Limites de corpo, timeouts e uploads #
| Escopo | Limite | Erro ao exceder |
|---|---|---|
| Corpo JSON, rotas gerais | 256 KB | 413 PAYLOAD_TOO_LARGE |
Corpo JSON, /api/admin/devotionals |
1 MB | 413 PAYLOAD_TOO_LARGE |
| Corpo de webhook | 512 KB, medido antes da leitura | 413 PAYLOAD_TOO_LARGE |
| Upload de imagem de capa | 8 MB | 413 PAYLOAD_TOO_LARGE |
| Profundidade de JSON | 20 níveis | 422 VALIDATION_ERROR |
| Itens em array de entrada | 500 | 422 VALIDATION_ERROR |
| Tamanho de string em campo de texto livre | 10.000 caracteres | 422 VALIDATION_ERROR |
| Quantidade de headers | 60 | 431 do proxy reverso |
| Tamanho da URL | 4 KB | 414 do proxy reverso |
Timeouts:
| Operação | Timeout | Comportamento ao estourar |
|---|---|---|
| Handler HTTP (total) | 25 s | 504 UPSTREAM_TIMEOUT |
| Consulta ao banco | 8 s | DATABASE_UNAVAILABLE, com statement_timeout no Postgres |
| Comando Redis | 1 s | Degrada conforme 7.12.4 |
| Chamada ao provedor de pagamento | 10 s | PAYMENT_PROVIDER_UNAVAILABLE |
| Chamada à plataforma de mensagens | 15 s | WHATSAPP_PROVIDER_ERROR, com retentativa na fila |
| Chamada ao provedor de voz | 120 s | Cai para o provedor secundário |
| Upload de mídia | 60 s | MEDIA_UPLOAD_FAILED, com retentativa |
| Handler de webhook | 2 s | Registra e responde 200 mesmo assim |
O handler de webhook é o caso especial: o alvo é p99 abaixo de 2 segundos porque tanto o
provedor de pagamento quanto a plataforma de mensagens penalizam handler lento — um pausa
a fila, o outro degrada a qualidade do número. Por isso o handler só persiste e
enfileira. Se a persistência ultrapassar 2 segundos, ele responde 200 mesmo assim e
registra webhook_slow_path no log, porque um 200 tardio é melhor do que um 500.
7.15 Convenções de webhook #
7.15.1 Entrada — regra geral #
Todo handler de webhook segue exatamente esta sequência, sem exceção:
0. Mede o tamanho declarado do corpo ANTES de ler. Acima de 512 KB (7.14) → 413.
É o único caminho de 4xx depois do passo 2.
1. Lê o corpo CRU (bytes), antes de qualquer parse.
2. Verifica a assinatura ou o token sobre os bytes crus, em tempo constante.
Falha → registra em webhook_deliveries com is_signature_valid = false
→ responde 401 e dispara alerta se houver rajada.
É a ÚNICA condição que autoriza 401.
3. Faz o parse do JSON e valida o schema.
Falha → grava webhook_deliveries.processing_result = 'PARSE_ERROR' ou
'SCHEMA_REJECTED', guarda o corpo cru para reprocessamento,
e responde 200 com corpo vazio.
4. Calcula dedup_key e persiste (INSERT ... ON CONFLICT DO NOTHING).
Conflito → responde 200 com { "received": true, "duplicate": true }.
Falha de banco ou de fila → processing_result = 'PERSIST_FAILED' → 200.
5. Enfileira o processamento no worker.
6. Responde 200 em menos de 2 segundos.
O processamento de negócio NUNCA acontece dentro do handler HTTP.Verificar sobre os bytes crus, e não sobre o objeto reserializado, é obrigatório: qualquer reserialização altera a ordem de chaves ou o escape de caracteres e quebra o HMAC.
Corpo inválido nunca produz resposta não-2xx. Autenticado o remetente, qualquer falha
posterior — corpo que não é JSON, schema reprovado, evento desconhecido, indisponibilidade
do banco ou da fila — responde 200 com corpo vazio e persiste o ocorrido. O alerta
webhook_payload_rejected (severidade alta, plantonista) dispara acima de 3 ocorrências em
15 minutos, e o alerta webhook_silence dispara quando nenhum evento de cobrança é
processado com sucesso por 90 minutos entre 08:00 e 22:00.
O motivo é operacional e vale mais do que a pureza do protocolo: provedores de pagamento e
a plataforma de mensagens tratam resposta não-2xx como falha de entrega, retentam e,
depois de uma sequência de falhas, desativam a assinatura do webhook. Perder o canal de
eventos é catastroficamente pior do que engolir um corpo malformado, porque é por ele que a
revogação imediata de acesso pago acontece — sem ele, inadimplente segue recebendo conteúdo
pago por dias e o único detector é a reconciliação diária.
7.15.2 Verificação de assinatura #
Plataforma de mensagens — HMAC SHA-256 do corpo cru, chave é o segredo do aplicativo:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyMetaSignature(rawBody: Buffer, header: string | null, secret: string): boolean {
if (!header?.startsWith('sha256=')) return false;
const expected = createHmac('sha256', secret).update(rawBody).digest();
const received = Buffer.from(header.slice(7), 'hex');
return expected.length === received.length && timingSafeEqual(expected, received);
}Provedor de pagamento — token estático em header, comparado em tempo constante:
export function verifyPaymentToken(header: string | null, expected: string): boolean {
if (!header) return false;
const a = Buffer.from(header, 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}timingSafeEqual exige buffers do mesmo tamanho, por isso a checagem de comprimento vem
antes. Comparar com === vazaria informação pelo tempo de execução.
7.15.3 Handshake de verificação #
A plataforma de mensagens exige um GET de verificação ao registrar o webhook:
GET /api/webhooks/whatsapp?hub.mode=subscribe&hub.verify_token=<token>&hub.challenge=1158201444Resposta: 200 com o valor de hub.challenge em texto puro, sem envelope JSON. É a
única rota de todo o sistema que não usa o envelope da Seção 7.3, porque o protocolo
externo exige. Token incorreto devolve 403 com WEBHOOK_VERIFICATION_FAILED.
O hub.challenge só é ecoado se casar com ^[0-9]{1,32}$, e a comparação do token é feita
em tempo constante. O desafio da plataforma é sempre numérico e curto; validar o formato
antes de ecoar impede que o endpoint sirva de refletor de conteúdo arbitrário a partir do
nosso domínio caso o token de verificação vaze em um log de integração contínua.
Este GET de handshake é a única exceção à regra do passo 2 de 7.15.1: aqui o 403 é
correto, porque não há entrega de evento a preservar — é o registro do webhook que está
sendo negociado, e o provedor precisa da recusa para não concluir um registro inválido.
7.15.4 Webhooks de saída #
O produto não expõe webhooks públicos para terceiros. webhook_deliveries.direction = 'OUTBOUND' cobre um caso só: notificação operacional para um endpoint interno de alertas.
| Aspecto | Regra |
|---|---|
| Destino | Uma única URL, em OPS_WEBHOOK_URL (Seção 26.3.10) |
| Assinatura | HMAC SHA-256 do corpo, header X-Signature-256, chave em OPS_WEBHOOK_SECRET |
| Retentativa | 5 tentativas com backoff exponencial: 1 s, 4 s, 16 s, 64 s, 256 s |
| Timeout | 5 s por tentativa |
| Sucesso | Qualquer 2xx |
| Desistência | Após a 5ª falha, marca FAILED e registra; não retenta mais |
| Corpo | {"event": "<nome>", "occurredAt": "<ISO>", "data": {...}} |
Se o endpoint de alertas estiver fora, o alerta também vai para o log com nível
error. Alerta nunca depende de um único canal.
7.16 Inventário mestre de rotas #
Esta subseção é a única dona da lista de rotas HTTP do produto. Nenhuma outra seção introduz, renomeia ou remove rota: uma rota nova entra aqui primeiro e só depois é especificada em detalhe pela seção dona. O comportamento de negócio de cada linha está na seção indicada na última coluna; onde este inventário e uma seção de domínio divergirem no caminho, este inventário vence.
Regras de nome, aplicadas sem exceção (7.2.1):
- Coleções são substantivos no plural em
kebab-case(/api/signups,/api/admin/whatsapp-templates,/api/me/phone-changes). - Ações que não são CRUD sobre recurso de terceiro ou sobre recurso administrativo viram
sub-recurso substantivo no plural (
/status-changes,/retries,/cancellations,/resends,/restorations,/synchronizations,/exports). - Ações sobre a própria conta do assinante ficam sob
/api/me/, com nome de ação no singular (/api/me/opt-out,/api/me/pause,/api/me/deletion-request,/api/me/subscription/cancel), porque ali o sujeito é único e vem da sessão. - Sub-recurso aninhado vai a no máximo dois níveis.
- Nenhum verbo no caminho de topo.
POST /api/create-subscriptionePOST /api/publish-devotionalsão as formas erradas de 7.2.1 e não existem.
Coluna Autenticação: Não = rota pública; Cookie de sessão = __Host-session
(assinante) ou __Host-admin-session (administração), conforme a Seção 8; formas próprias
estão nomeadas na linha. Toda mutação autenticada por cookie exige também o header
X-CSRF-Token casado com o cookie __Host-csrf (7.13.2).
Coluna Papel mínimo: papel exigido pela guarda de autorização (Seção 3.8). — = rota
sem exigência de papel.
7.16.1 Públicas #
| Método | Caminho | Autenticação | Papel mínimo | Seção que especifica em detalhe |
|---|---|---|---|---|
GET |
/api/public/plans |
Não | — | 9.16.2 |
GET |
/api/public/config |
Não | — | 9.16.5 |
GET |
/api/public/devotional-preview |
Não | — | 9.16.1 |
POST |
/api/public/events |
Não | — | 9.16.3 |
POST |
/api/public/contact |
Não (exige token Turnstile) | — | 9.16.4 |
POST |
/api/public/unsubscribe |
Token opaco de descadastro no corpo | — | 20.12.10 |
POST |
/api/csp-report |
Não | — | 22.3.1 |
7.16.2 Cadastro e opt-in #
| Método | Caminho | Autenticação | Papel mínimo | Seção que especifica em detalhe |
|---|---|---|---|---|
POST |
/api/signups |
Não | — | 11.12.1 |
POST |
/api/signups/verifications |
Não (é a rota que cria a sessão) | — | 11.12.2 |
POST |
/api/signups/otp-resends |
Não | — | 11.12.3 |
GET |
/api/signups/current |
Cookie de sessão ou signupId em query, só em PENDING_VERIFICATION |
— | 11.12.4 |
POST |
/api/signups/phone-corrections |
Não (exige signupId em PENDING_VERIFICATION) |
— | 11.12.5 |
POST |
/api/signups/welcome-resends |
Cookie de sessão | SUBSCRIBER |
11.12.6 |
7.16.3 Autenticação e sessões #
| Método | Caminho | Autenticação | Papel mínimo | Seção que especifica em detalhe |
|---|---|---|---|---|
POST |
/api/auth/otp/request |
Não | — | 8.2.3 |
POST |
/api/auth/otp/verify |
Não | — | 8.2.5 |
POST |
/api/auth/email/request |
Não | — | 8.3.1 |
GET |
/api/auth/email/verify |
Token de link mágico em query | — | 8.3.2 |
POST |
/api/auth/admin/login |
Não | — | 8.4.1 |
POST |
/api/auth/admin/totp |
Cookie __Host-auth-challenge |
— | 8.4.2 |
POST |
/api/auth/admin/totp/enroll |
Cookie de sessão | EDITOR |
8.6.2 |
POST |
/api/auth/admin/totp/confirm |
Cookie de sessão | EDITOR |
8.6.3 |
POST |
/api/auth/admin/recovery |
Link mágico + senha + TOTP | — | 8.6.4 |
POST |
/api/auth/refresh |
Cookie __Host-refresh |
— | 8.8.4 |
POST |
/api/auth/logout |
Cookie de sessão | — | 8.9.3 |
POST |
/api/auth/logout-all |
Cookie de sessão | — | 8.9.4 |
GET |
/api/auth/sessions |
Cookie de sessão | — | 8.9.1 |
DELETE |
/api/auth/sessions/{id} |
Cookie de sessão | — | 8.9.2 |
7.16.4 Assinante — conta e preferências #
| Método | Caminho | Autenticação | Papel mínimo | Seção que especifica em detalhe |
|---|---|---|---|---|
GET |
/api/me |
Cookie de sessão | SUBSCRIBER |
14.10.1 |
PATCH |
/api/me |
Cookie de sessão | SUBSCRIBER |
14.10.11 |
PUT |
/api/me/preferences |
Cookie de sessão | SUBSCRIBER |
14.10.12 |
GET |
/api/me/entitlements |
Cookie de sessão | SUBSCRIBER |
13.13.2 |
GET |
/api/me/consents |
Cookie de sessão | SUBSCRIBER |
14.10.17 |
POST |
/api/me/pause |
Cookie de sessão | SUBSCRIBER |
20.12.3 |
DELETE |
/api/me/pause |
Cookie de sessão | SUBSCRIBER |
20.12.4 |
POST |
/api/me/opt-out |
Cookie de sessão | SUBSCRIBER |
20.12.1 |
POST |
/api/me/opt-in |
Cookie de sessão | SUBSCRIBER |
20.12.2 |
POST |
/api/me/phone-changes |
Cookie de sessão | SUBSCRIBER |
14.10.14 |
POST |
/api/me/phone-changes/{changeId}/current-verifications |
Cookie de sessão | SUBSCRIBER |
14.10.14 |
POST |
/api/me/phone-changes/{changeId}/new-phone |
Cookie de sessão | SUBSCRIBER |
14.10.14 |
POST |
/api/me/phone-changes/{changeId}/new-verifications |
Cookie de sessão | SUBSCRIBER |
14.10.14 |
POST |
/api/me/data-exports |
Cookie de sessão | SUBSCRIBER |
14.10.15 |
GET |
/api/me/data-exports/current |
Cookie de sessão | SUBSCRIBER |
14.10.15 |
POST |
/api/me/data-exports/current/download-links |
Cookie de sessão | SUBSCRIBER |
14.10.15 |
POST |
/api/me/deletion-request |
Cookie de sessão | SUBSCRIBER |
20.12.8 |
POST |
/api/me/deletion-request/confirm |
Cookie de sessão + código de acesso | SUBSCRIBER |
20.12.9 |
7.16.5 Assinante — assinatura e pagamentos #
| Método | Caminho | Autenticação | Papel mínimo | Seção que especifica em detalhe |
|---|---|---|---|---|
GET |
/api/me/subscription |
Cookie de sessão | SUBSCRIBER |
13.13.1 |
POST |
/api/me/subscription |
Cookie de sessão | SUBSCRIBER |
12.9.2 e 13.2.2 |
POST |
/api/me/subscription/cancel |
Cookie de sessão | SUBSCRIBER |
14.10.9 e 20.12.7 |
GET |
/api/me/subscription/cancellation-preview |
Cookie de sessão | SUBSCRIBER |
20.12.5 |
POST |
/api/me/subscription/retention-offer-shown |
Cookie de sessão | SUBSCRIBER |
20.12.6 |
POST |
/api/me/subscription/reactivate |
Cookie de sessão | SUBSCRIBER |
14.10.10 |
POST |
/api/me/subscription/plan-change |
Cookie de sessão | SUBSCRIBER |
13.8.3 |
DELETE |
/api/me/subscription/plan-change |
Cookie de sessão | SUBSCRIBER |
13.8.3 |
POST |
/api/me/subscription/card |
Cookie de sessão | SUBSCRIBER |
13.13.5 |
GET |
/api/me/subscription/pix-code |
Cookie de sessão | SUBSCRIBER |
12.7.5 |
GET |
/api/me/subscription/history |
Cookie de sessão | SUBSCRIBER |
13.13.3 |
GET |
/api/me/payments |
Cookie de sessão | SUBSCRIBER |
13.13.4 |
POST |
/api/checkout/card-tokens |
Cookie de sessão | SUBSCRIBER |
12.6.4 |
7.16.6 Assinante — acervo de devocionais #
| Método | Caminho | Autenticação | Papel mínimo | Seção que especifica em detalhe |
|---|---|---|---|---|
GET |
/api/devotionals/today |
Cookie de sessão | SUBSCRIBER |
14.10.2 |
GET |
/api/devotionals |
Cookie de sessão | SUBSCRIBER |
14.10.3 |
GET |
/api/devotionals/{devotionalId} |
Cookie de sessão | SUBSCRIBER |
14.10.4 |
POST |
/api/devotionals/{devotionalId}/media-links |
Cookie de sessão | SUBSCRIBER (entitlement PAID) |
14.10.5 |
POST |
/api/devotionals/{devotionalId}/resends |
Cookie de sessão + Idempotency-Key |
SUBSCRIBER |
14.10.6 |
7.16.7 Administração — editorial #
| Método | Caminho | Autenticação | Papel mínimo | Seção que especifica em detalhe |
|---|---|---|---|---|
GET |
/api/admin/devotionals |
Cookie de sessão | EDITOR |
15.12.1 |
POST |
/api/admin/devotionals |
Cookie de sessão | EDITOR |
15.12.2 |
PATCH |
/api/admin/devotionals/{id} |
Cookie de sessão | EDITOR |
15.12.3 |
DELETE |
/api/admin/devotionals/{id} |
Cookie de sessão | ADMIN |
15.12.3 |
POST |
/api/admin/devotionals/{id}/status-changes |
Cookie de sessão | EDITOR (DRAFT ↔ READY); ADMIN (PUBLISHED e despublicação) |
15.12.4 |
POST |
/api/admin/devotionals/{id}/reschedules |
Cookie de sessão | EDITOR |
15.12.5 |
POST |
/api/admin/devotionals/{id}/duplications |
Cookie de sessão | EDITOR |
15.12.6 |
GET |
/api/admin/devotionals/{id}/revisions |
Cookie de sessão | EDITOR |
15.12.7 |
POST |
/api/admin/devotionals/{id}/revisions/{rid}/restorations |
Cookie de sessão | EDITOR |
15.12.7 |
POST |
/api/admin/devotionals/imports |
Cookie de sessão | EDITOR |
15.12.8 |
7.16.8 Administração — assinantes e assinaturas #
| Método | Caminho | Autenticação | Papel mínimo | Seção que especifica em detalhe |
|---|---|---|---|---|
GET |
/api/admin/subscribers |
Cookie de sessão | ADMIN |
15.12.9 |
GET |
/api/admin/subscribers/{id} |
Cookie de sessão | ADMIN |
15.12.9 |
POST |
/api/admin/subscribers/{id}/actions |
Cookie de sessão | conforme a matriz de 15.7.3 | 15.12.10 |
POST |
/api/admin/subscribers/{id}/impersonations |
Cookie de sessão + TOTP validado | ADMIN |
8.13.2 |
POST |
/api/admin/subscribers/{id}/courtesy |
Cookie de sessão + TOTP validado + Idempotency-Key |
ADMIN |
13.11.5 |
DELETE |
/api/admin/subscribers/{id}/courtesy |
Cookie de sessão + TOTP validado | ADMIN |
13.11.5 |
GET |
/api/admin/subscriptions |
Cookie de sessão | EDITOR (somente leitura) |
13.13.6 |
GET |
/api/admin/cancellations |
Cookie de sessão | ADMIN |
20.12.11 |
GET |
/api/admin/cancellations/reasons |
Cookie de sessão | EDITOR |
20.12.12 |
7.16.9 Administração — envio e mensageria #
| Método | Caminho | Autenticação | Papel mínimo | Seção que especifica em detalhe |
|---|---|---|---|---|
GET |
/api/admin/batches |
Cookie de sessão | EDITOR |
15.12.11 |
POST |
/api/admin/batches |
Cookie de sessão + Idempotency-Key |
ADMIN |
15.12.11 |
GET |
/api/admin/batches/{batchId} |
Cookie de sessão | EDITOR |
15.12.11 |
POST |
/api/admin/batches/{batchId}/retries |
Cookie de sessão + Idempotency-Key |
ADMIN |
15.12.12 |
POST |
/api/admin/batches/{batchId}/cancellations |
Cookie de sessão | ADMIN |
15.12.13 |
GET |
/api/admin/whatsapp-templates |
Cookie de sessão | ADMIN |
15.12.14 |
POST |
/api/admin/whatsapp-templates |
Cookie de sessão | ADMIN |
15.12.14 |
DELETE |
/api/admin/whatsapp-templates/{id} |
Cookie de sessão | ADMIN |
15.12.14 |
POST |
/api/admin/whatsapp-templates/synchronizations |
Cookie de sessão | ADMIN |
15.12.14 |
7.16.10 Administração — configuração, métricas e auditoria #
| Método | Caminho | Autenticação | Papel mínimo | Seção que especifica em detalhe |
|---|---|---|---|---|
GET |
/api/admin/settings |
Cookie de sessão | ADMIN |
26.8.2 |
PUT |
/api/admin/settings/{key} |
Cookie de sessão | ADMIN, respeitado o editable_by da linha; OWNER quando is_secret = true |
15.12.16 |
PATCH |
/api/admin/plans/{code} |
Cookie de sessão | OWNER |
15.12.16 |
GET |
/api/admin/feature-flags |
Cookie de sessão | ADMIN |
26.9 |
PATCH |
/api/admin/feature-flags/{key} |
Cookie de sessão | ADMIN |
15.12.16 |
POST |
/api/admin/admin-users |
Cookie de sessão | ADMIN (alvo EDITOR); OWNER nos demais |
15.12.17 |
PATCH |
/api/admin/admin-users/{id} |
Cookie de sessão | ADMIN (alvo EDITOR); OWNER nos demais |
15.12.17 |
GET |
/api/admin/audit-logs |
Cookie de sessão | ADMIN |
15.12.15 |
POST |
/api/admin/audit-logs/exports |
Cookie de sessão | ADMIN |
15.12.15 |
GET |
/api/admin/metrics/series |
Cookie de sessão | ADMIN; EDITOR apenas para chaves de Conteúdo e Entrega |
21.9.1 |
GET |
/api/admin/metrics/export |
Cookie de sessão | ADMIN |
21.9.2 |
GET |
/api/admin/metrics/overview |
Cookie de sessão | ADMIN; EDITOR apenas para chaves de Conteúdo e Entrega |
21.9.3 |
7.16.11 Webhooks de entrada #
| Método | Caminho | Autenticação | Papel mínimo | Seção que especifica em detalhe |
|---|---|---|---|---|
GET |
/api/webhooks/whatsapp |
hub.verify_token (WHATSAPP_VERIFY_TOKEN) |
— | 7.15.3 |
POST |
/api/webhooks/whatsapp |
HMAC X-Hub-Signature-256 |
— | 17.8 |
POST |
/api/webhooks/asaas |
Header asaas-access-token |
— | 12.10.1 |
7.16.12 Internas e de operação #
| Método | Caminho | Autenticação | Papel mínimo | Seção que especifica em detalhe |
|---|---|---|---|---|
GET |
/api/internal/health |
Não (publicada; 60 req/min por endereço) | — | 23.6.1 |
GET |
/api/internal/ready |
Authorization: Bearer <METRICS_TOKEN>; sem token o proxy devolve 404 |
— | 23.6.2 |
GET |
/api/internal/metrics |
Authorization: Bearer <METRICS_TOKEN> |
— | 23.3 |
GET |
/api/internal/version |
Authorization: Bearer <METRICS_TOKEN>; nunca publicada |
— | 23.6.1 |
/api/internal/health é liveness: responde 200 se o processo está vivo, sem tocar
dependência. /api/internal/ready é readiness: verifica banco, Redis e filas, e responde
503 com o detalhe de qual dependência falhou. O balanceador usa ready; o supervisor de
contêiner usa health. Confundir os dois causa reinício em cascata quando o banco oscila.
7.16.13 Ganchos de teste — nunca publicados #
Existem apenas quando E2E_TEST_HOOKS=true e exigem o header x-e2e-secret igual a
E2E_HOOK_SECRET. O proxy reverso devolve 404 para todo o prefixo /api/internal/test/*
em qualquer ambiente, e o web falha ao iniciar se E2E_TEST_HOOKS=true com
NODE_ENV=production (Seção 24.7.1).
| Método | Caminho | Autenticação | Papel mínimo | Seção que especifica em detalhe |
|---|---|---|---|---|
POST |
/api/internal/test/clock |
Header x-e2e-secret |
— | 24.7.1 |
POST |
/api/internal/test/inbound |
Header x-e2e-secret |
— | 24.7.1 |
GET |
/api/internal/test/otp |
Header x-e2e-secret |
— | 24.7.1 |
POST |
/api/internal/test/run-jobs |
Header x-e2e-secret |
— | 24.7.1 |
POST |
/api/internal/test/enqueue-send |
Header x-e2e-secret |
— | 24.9 |
Total: 115 rotas, das quais 5 são ganchos de teste que não existem em produção — 110 em produção. Um teste do pipeline percorre a árvore de rotas do aplicativo, compara com esta tabela e reprova o build tanto quando encontra rota não inventariada quanto quando uma linha desta tabela não tem rota correspondente.
7.17 Geração do openapi.yaml #
7.17.1 Fonte única #
Os schemas Zod da Seção 26 e das seções de domínio são a fonte. O documento OpenAPI é gerado, nunca escrito à mão. Documentação escrita à mão diverge do código na primeira semana.
// packages/core/src/openapi/registry.ts
import { OpenAPIRegistry, OpenApiGeneratorV31 } from '@asteasolutions/zod-to-openapi';
export const registry = new OpenAPIRegistry();
registry.registerPath({
method: 'post',
path: '/api/auth/otp/request',
tags: ['Autenticação'],
summary: 'Solicita o envio de um código de acesso pelo WhatsApp',
request: {
headers: z.object({ 'Idempotency-Key': z.string().optional() }),
body: { content: { 'application/json': { schema: OtpRequestSchema } } },
},
responses: {
200: {
description: 'Código enviado ou reenviado',
content: { 'application/json': { schema: envelopeOf(OtpRequestResponseSchema) } },
},
422: errorResponse('VALIDATION_ERROR', 'INVALID_PHONE_NUMBER', 'UNSUPPORTED_COUNTRY_CODE'),
429: errorResponse('OTP_SEND_LIMIT_REACHED', 'OTP_RESEND_TOO_SOON', 'RATE_LIMITED'),
502: errorResponse('OTP_DELIVERY_FAILED'),
},
});envelopeOf() e errorResponse() são utilitários que envolvem o schema no envelope de
7.3 e 7.4, garantindo que nenhum endpoint documente uma forma de resposta diferente.
Geração: pnpm openapi:build escreve openapi.yaml na raiz do repositório. A CI roda o
comando e falha se o arquivo no repositório estiver desatualizado — o mesmo padrão de
verificação usado para o lockfile.
7.17.2 Exemplo do documento gerado #
openapi: 3.1.0
info:
title: Palavra Diária — API interna
version: 1.0.0
description: |
API interna do produto. Todas as respostas usam o envelope padrão.
Erros são identificados pelo campo error.code, estável e catalogado.
servers:
- url: https://app.palavradiaria.com.br
description: Produção
- url: https://staging.palavradiaria.com.br
description: Staging
components:
securitySchemes:
sessionCookie:
type: apiKey
in: cookie
name: __Host-session
schemas:
Meta:
type: object
required: [requestId, timestamp]
properties:
requestId: { type: string, example: req_01K3F8RMN3QWERTYUIOPASDFGH }
timestamp: { type: string, format: date-time }
nextCursor: { type: [string, 'null'] }
hasMore: { type: boolean }
limit: { type: integer, minimum: 1, maximum: 100 }
ErrorDetail:
type: object
required: [field, issue]
properties:
field: { type: string, example: phone }
issue:
type: string
enum: [required, invalid_format, too_short, too_long, out_of_range,
not_unique, already_in_use, unsupported_value, mismatch, expired, not_allowed]
ErrorEnvelope:
type: object
required: [error, meta]
properties:
error:
type: object
required: [code, message]
properties:
code: { type: string, example: OTP_EXPIRED }
message: { type: string, example: Este código expirou. Peça um novo. }
details: { type: array, items: { $ref: '#/components/schemas/ErrorDetail' } }
retryAfterSeconds: { type: integer, minimum: 0 }
meta: { $ref: '#/components/schemas/Meta' }
paths:
/api/auth/otp/request:
post:
tags: [Autenticação]
summary: Solicita o envio de um código de acesso pelo WhatsApp
parameters:
- in: header
name: Idempotency-Key
required: false
schema: { type: string, minLength: 16, maxLength: 128 }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [phone]
properties:
phone: { type: string, example: '+5511987654321' }
channel: { type: string, enum: [WHATSAPP, EMAIL_LINK], default: WHATSAPP }
responses:
'200':
description: Código enviado
headers:
X-Request-Id: { schema: { type: string } }
RateLimit-Remaining: { schema: { type: integer } }
content:
application/json:
schema:
type: object
required: [data, meta]
properties:
data:
type: object
properties:
sent: { type: boolean, example: true }
expiresInSeconds: { type: integer, example: 600 }
maskedDestination: { type: string, example: '+5511*****4321' }
meta: { $ref: '#/components/schemas/Meta' }
'429':
description: Limite de envios atingido
headers:
Retry-After: { schema: { type: integer } }
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorEnvelope' }7.17.3 Regras do documento gerado #
- Todo endpoint declara todos os códigos de erro que pode emitir. Um teste compara os códigos declarados com os efetivamente lançados nos testes de integração e falha se houver código lançado e não documentado.
- Todo exemplo usa dados fictícios coerentes: telefones do bloco
+55119000000xx, CPF123.456.789-09, e-mails@example.com. Nenhum dado real, nem em staging. - O documento é servido em
/api/internal/openapi.yaml, protegido pelo mesmo token de/api/internal/metrics. Não é público. security: [{ sessionCookie: [] }]é declarado por endpoint, não globalmente, para que as rotas públicas fiquem visivelmente sem exigência.
7.18 Cliente de referência #
O front consome a API por um único cliente tipado. Nenhum componente chama fetch direto.
// apps/web/src/lib/api/client.ts
export class ApiClientError extends Error {
constructor(
readonly code: ErrorCode,
readonly status: number,
message: string,
readonly details: ErrorDetail[] = [],
readonly requestId: string,
readonly retryAfterSeconds?: number,
) { super(message); }
}
export async function apiFetch<T>(
path: string,
init: RequestInit & { idempotencyKey?: string } = {},
): Promise<T> {
const headers = new Headers(init.headers);
headers.set('Content-Type', 'application/json');
headers.set('X-Request-Id', `req_${ulid()}`);
if (init.idempotencyKey) headers.set('Idempotency-Key', init.idempotencyKey);
const res = await fetch(path, { ...init, headers, credentials: 'same-origin' });
const body = await res.json().catch(() => null);
const requestId = res.headers.get('X-Request-Id') ?? 'desconhecido';
if (!res.ok) {
const e = body?.error;
throw new ApiClientError(
e?.code ?? 'INTERNAL_ERROR',
res.status,
e?.message ?? 'Ocorreu um erro inesperado. Tente novamente.',
e?.details ?? [],
requestId,
e?.retryAfterSeconds,
);
}
return body.data as T;
}Tratamento padronizado no cliente, aplicado por um interceptador da camada de estado de servidor:
| Situação | Comportamento |
|---|---|
401 com SESSION_EXPIRED |
Tenta POST /api/auth/refresh uma vez; se falhar, redireciona ao login |
401 com qualquer outro código |
Redireciona ao login sem tentar refresh |
403 com ENTITLEMENT_DENIED ou TIER_UPGRADE_REQUIRED |
Abre o fluxo de upgrade |
403 com MAINTENANCE_MODE |
Exibe a faixa de manutenção |
409 com OPTIMISTIC_LOCK_FAILED ou REVISION_CONFLICT |
Recarrega o recurso e mostra a diferença |
422 com VALIDATION_ERROR |
Mapeia details[].field para os campos do formulário |
429 |
Desabilita a ação por retryAfterSeconds e exibe contador |
5xx |
Retenta até 2 vezes com backoff, só em GET; exibe o requestId na mensagem |
Retentativa automática só em GET. Repetir POST automaticamente é como se cria
cobrança duplicada — a retentativa de escrita é sempre uma decisão explícita do usuário,
com a mesma Idempotency-Key da tentativa original.
8. Autenticação, Sessões e Autorização #
Todos os endpoints desta seção seguem o envelope, o catálogo de erros e as regras de rate
limiting da Seção 7. As tabelas usadas (sessions, otp_codes, admin_users,
subscribers) estão definidas na Seção 6.
8.1 Modelo de identidade e papéis #
A identidade do assinante é o telefone normalizado pela Seção 11.4, que é a única fonte
da normalização brasileira (nono dígito, DDD, DDI) e a dona da função que produz um
phone_e164. Esta seção define apenas como essa identidade vira sessão, e não reimplementa
nenhuma regra de normalização.
Como a coluna subscribers.phone_e164 é um envelope cifrado (Seção 6.3), toda busca por
telefone nesta seção é feita pela coluna de índice cego subscribers.phone_hmac. Nenhuma
consulta compara a coluna cifrada por igualdade.
8.1.1 Dois mundos separados #
| Aspecto | Assinante | Administrador |
|---|---|---|
| Identidade | Telefone em E.164 | |
| Tabela | subscribers |
admin_users |
| Fator primário | OTP de 6 dígitos no WhatsApp | Senha argon2id |
| Segundo fator | Não | TOTP obrigatório |
| Fallback | Magic link por e-mail verificado | Códigos de recuperação |
| Autocadastro | Sim, pela landing | Não. Criado por seed ou por OWNER |
| Papel | SUBSCRIBER |
EDITOR, ADMIN ou OWNER |
| TTL da sessão | 30 dias | 12 horas |
| Cookie | __Host-session |
__Host-admin-session |
| Escopo de rota | Prefixos de assinante de 7.2.2: /api/me/*, /api/devotionals/*, /api/subscriptions/*, /api/payments/*, /api/checkout/* |
/api/admin/* |
Os dois mundos não se cruzam. Uma sessão de assinante nunca acessa /api/admin/*, e uma
sessão de administrador nunca acessa rota de assinante — exceto durante impersonação
(8.13), que emite uma sessão de assinante marcada, somente leitura e auditada.
O escopo do assinante é definido pela guarda requireSubscriber, e não pelo prefixo do
caminho. Vários recursos do assinante vivem fora de /api/me/* porque têm identificador
próprio no caminho, e qualquer regra desta seção que fale em "rota de assinante" se refere à
guarda, nunca ao prefixo.
Uma pessoa que é assinante e administrador tem duas contas e dois cookies, que coexistem sem conflito porque os nomes são diferentes.
8.1.2 Papéis #
| Papel | Concedido a | Pode |
|---|---|---|
SUBSCRIBER |
Todo assinante | Ver e gerenciar apenas os próprios dados |
EDITOR |
Equipe editorial | Criar e editar devocionais, ver assinantes (somente leitura), ver métricas |
ADMIN |
Operação | Tudo de EDITOR, mais publicar, disparar envios, editar assinantes, impersonar, editar settings e flags |
OWNER |
Responsável pelo produto | Tudo de ADMIN, mais gerenciar contas administrativas, executar jobs manualmente e alterar settings restritos |
Hierarquia estritamente crescente: EDITOR < ADMIN < OWNER. Quem tem OWNER pode tudo o
que ADMIN pode. A matriz detalhada por funcionalidade está na Seção 3.
8.2 Login do assinante por OTP no WhatsApp #
8.2.1 Parâmetros canônicos #
| Parâmetro | Valor | Motivo |
|---|---|---|
| Comprimento do código | 6 dígitos numéricos | Padrão reconhecível; digitável no celular |
| Espaço de códigos | 1.000.000 (000000 a 999999) |
Com 5 tentativas, a chance de acerto é 1 em 200.000 |
| Geração | crypto.randomInt(0, 1_000_000) com zero à esquerda |
Aleatoriedade criptográfica, distribuição uniforme |
| TTL | 10 minutos | Tempo confortável para achar a mensagem sem manter janela longa |
| Tentativas por código | 5 | Depois disso o código é invalidado |
| Pedidos por número, por hora | 3 por hora | Cada envio custa uma mensagem de autenticação |
| Pedidos por número, por dia | 8 por 24 h | Teto diário além do limite horário; devolve OTP_RATE_LIMITED |
| Pedidos por IP | 10 por hora | Impede varredura de números |
| Verificações por IP | 30 por hora | Impede varredura de códigos entre números; devolve OTP_ATTEMPTS_EXCEEDED |
| Cota global do sistema | 500 por hora, com reserva de 20% para números já cadastrados | Teto de custo; devolve SERVICE_BUSY |
| Intervalo mínimo entre envios | 60 segundos | Evita reenvio por ansiedade |
| Códigos ativos simultâneos | 1 por número | Novo envio invalida o anterior |
Esta subseção é a única dona dos limites de código de acesso. As Seções 11 e 22 são réplicas informativas e referenciam esta tabela; nenhuma delas define número próprio.
Duas propriedades da tabela são obrigatórias e fáceis de perder na implementação:
- Os contadores contam pedidos, não envios. Os três limites por número e por IP são
incrementados antes de qualquer consulta ao banco, igualmente para número existente e
inexistente. Se contassem apenas envios reais, o quarto pedido se comportaria de forma
diferente nos dois casos —
429para assinante,200para não assinante — e isso sozinho seria um oráculo completo de enumeração, anulando as outras defesas de 8.2.4. - A cota global tem balde reservado. Uma cota global única é, por si, um vetor de negação de serviço: esgotada de propósito por um atacante, ela impediria o login de toda a base. Por isso 20% dos 500 por hora ficam num balde separado, consumido apenas por números já cadastrados, que o tráfego de cadastro não alcança.
8.2.2 Geração e armazenamento #
O código nunca é persistido em claro. O que vai para o banco é
sha256(pepper ‖ otpId ‖ code) em hexadecimal.
// packages/core/src/auth/otp.ts
import { randomInt, createHash, timingSafeEqual } from 'node:crypto';
import { ulid } from 'ulid';
export function generateOtp(): string {
return String(randomInt(0, 1_000_000)).padStart(6, '0');
}
export function hashOtp(otpId: string, code: string, pepper: string): string {
return createHash('sha256').update(`${pepper}${otpId}${code}`).digest('hex');
}
export function verifyOtp(otpId: string, code: string, pepper: string, stored: string): boolean {
const computed = Buffer.from(hashOtp(otpId, code, pepper), 'hex');
const expected = Buffer.from(stored, 'hex');
return computed.length === expected.length && timingSafeEqual(computed, expected);
}Três decisões, cada uma com razão própria:
- O
otpIdentra no hash como sal por linha. Sem isso, todos os códigos123456no banco teriam o mesmo hash, e um vazamento do banco revelaria os códigos por comparação. - O pepper vem de variável de ambiente (
OTP_PEPPER, Seção 26.3.6), não do banco. Um vazamento apenas do banco não permite testar códigos offline. - Não é argon2. OTP tem espaço de 10⁶ e vive 10 minutos; um hash lento aqui só atrasaria o login legítimo. A proteção real é o limite de 5 tentativas, não a lentidão do hash.
8.2.3 POST /api/auth/otp/request #
Solicita o envio de um código.
- Autenticação: nenhuma.
- Papel: nenhum.
- Idempotência:
Idempotency-Keyopcional. Sem a chave, chamadas repetidas dentro de 60 segundos devolvemOTP_RESEND_TOO_SOONem vez de enviar de novo.
export const OtpRequestSchema = z.object({
phone: z.string().trim().min(8).max(20),
channel: z.enum(['WHATSAPP', 'EMAIL_LINK']).default('WHATSAPP'),
});Resposta 200:
{
"data": {
"sent": true,
"channel": "WHATSAPP",
"expiresInSeconds": 600,
"resendAvailableInSeconds": 60,
"maskedDestination": "+5511*****4321"
},
"meta": { "requestId": "req_01K3F8V1A2...", "timestamp": "2026-08-25T09:00:00.000Z" }
}Erros possíveis:
code |
HTTP | Causa |
|---|---|---|
VALIDATION_ERROR |
422 | Corpo fora do schema |
INVALID_PHONE_NUMBER |
422 | Não normaliza para E.164 |
UNSUPPORTED_COUNTRY_CODE |
422 | DDI diferente de 55 |
OTP_RESEND_TOO_SOON |
429 | Menos de 60 s desde o último envio |
OTP_SEND_LIMIT_REACHED |
429 | 3 pedidos na última hora para o número, contados antes de qualquer consulta ao banco e independentemente de o número existir |
OTP_RATE_LIMITED |
429 | 8 pedidos nas últimas 24 h para o número, contados da mesma forma |
IP_RATE_LIMITED |
429 | 10 pedidos na última hora do mesmo IP |
SERVICE_BUSY |
503 | Cota global de 500 por hora esgotada e o número não está na reserva de cadastrados |
OTP_DELIVERY_FAILED |
502 | A plataforma recusou o template de autenticação |
SERVICE_UNAVAILABLE |
503 | Redis fora (rota de autenticação não degrada) |
Número bloqueado não produz erro próprio nesta rota: a resposta é 200, idêntica à do
caminho normal, e nenhuma mensagem é enviada. SUBSCRIBER_BLOCKED (403) aqui confirmaria
a existência do cadastro e tornaria o oráculo de enumeração ainda mais barato do que o
descrito em 8.2.4 — bastaria uma requisição por número, em vez de quatro. O código
SUBSCRIBER_BLOCKED continua existindo e é devolvido nas rotas já autenticadas, onde o
solicitante já provou ser o titular.
Efeitos colaterais: invalida códigos anteriores do mesmo número
(invalidated_at = now()), insere uma linha em otp_codes, enfileira o envio do template
de autenticação, registra a mensagem em message_logs.
8.2.4 Proteção contra enumeração de números #
A resposta é idêntica para número cadastrado e não cadastrado: sent: true, mesmo
expiresInSeconds, mesmo maskedDestination construído a partir do número informado.
Quem tem o número não descobre se ele é assinante.
Três reforços:
- Tempo constante. Para número não cadastrado, o handler faz um hash descartável de custo equivalente ao da geração real, para que a diferença de latência não vaze a informação. Alvo: variação abaixo de 15 ms entre os dois caminhos.
- Rate limit por IP antes de qualquer consulta. 10 pedidos por hora não sustentam varredura.
- Nenhuma mensagem é enviada para número não cadastrado. Nada é inserido em
otp_codes. Do lado de fora, os dois casos são indistinguíveis; do lado de dentro, não há custo nem lixo. - Os contadores contam pedidos, não envios. Os três limites de 8.2.1 — por número, por
IP e o intervalo mínimo — são incrementados antes da consulta ao banco, para número
existente e inexistente igualmente. Este é o reforço que sustenta os outros três: se
contassem apenas envios reais, o contador nunca incrementaria para número inexistente, e
a diferença de comportamento no quarto pedido (
429contra200) seria, sozinha, um oráculo completo de enumeração. Quatro requisições por número bastariam para mapear toda a base — que é, neste produto, uma base de convicção religiosa.
O maskedDestination é derivado do número enviado pelo cliente, nunca do banco. Se
viesse do banco, um número não cadastrado não teria máscara e a diferença seria o
vazamento.
O critério verificável é único e vale para os quatro reforços: dado um número cadastrado e
um não cadastrado, quatro pedidos em sequência para cada um produzem quatro respostas
idênticas em status HTTP, corpo e error.code nos dois casos.
8.2.5 POST /api/auth/otp/verify #
Verifica o código e cria a sessão.
- Autenticação: nenhuma.
- Idempotência: não aplicável. O código é de uso único por natureza; a segunda
verificação do mesmo código devolve
OTP_ALREADY_USED.
export const OtpVerifySchema = z.object({
phone: z.string().trim().min(8).max(20),
code: z.string().regex(/^[0-9]{6}$/, 'código deve ter 6 dígitos'),
deviceName: z.string().trim().max(60).optional(),
});Resposta 200:
{
"data": {
"subscriber": {
"id": "sub_01K3F8QZ7MHV2N9R4B6T0XYZAB",
"displayName": "Ana",
"tier": "PAID",
"optInConfirmed": true
},
"session": {
"expiresAt": "2026-09-24T09:00:00.000Z",
"isNewDevice": true,
"scope": "FULL"
}
},
"meta": { "requestId": "req_01K3F8V3C4...", "timestamp": "2026-08-25T09:00:00.000Z" }
}O campo session.scope vale FULL ou RESTRICTED. Ele é RESTRICTED quando a conta está
dormente, pelas regras de 8.15.1.1, e nesse caso o painel abre em modo reduzido e explica o
que falta para liberar o resto. O cliente nunca infere o escopo: ele lê este campo.
Headers de resposta: Set-Cookie com __Host-session e __Host-refresh (8.8.3).
Erros possíveis:
code |
HTTP | Causa |
|---|---|---|
VALIDATION_ERROR |
422 | Código não tem 6 dígitos |
OTP_INVALID |
401 | Código errado. details traz {"field":"code","issue":"mismatch"} |
OTP_EXPIRED |
410 | Passou de 10 minutos. 410 Gone distingue código expirado de código inválido e permite ao cliente habilitar o reenvio sem ambiguidade (7.11.1) |
OTP_ATTEMPTS_EXCEEDED |
429 | 30 verificações na última hora do mesmo IP |
OTP_MAX_ATTEMPTS |
429 | 5 tentativas erradas; o código foi invalidado |
OTP_ALREADY_USED |
409 | consumed_at preenchido |
SUBSCRIBER_NOT_FOUND |
401 | Devolvido como OTP_INVALID para não enumerar |
SUBSCRIBER_DELETED |
410 | Conta com soft delete |
SUBSCRIBER_BLOCKED |
403 | Assinante bloqueado |
A transação de verificação, em uma única unidade atômica:
BEGIN;
SELECT id, code_hash, attempts, max_attempts, expires_at, consumed_at, invalidated_at
FROM otp_codes
WHERE phone_hmac = $1 AND consumed_at IS NULL AND invalidated_at IS NULL
ORDER BY created_at DESC LIMIT 1
FOR UPDATE; -- lock impede corrida entre duas abas
-- $1 é o índice cego do telefone (Seção 6.3). A coluna cifrada nunca é comparada.
-- código errado: incrementa e, no 5º erro, invalida
UPDATE otp_codes
SET attempts = attempts + 1,
invalidated_at = CASE WHEN attempts + 1 >= max_attempts THEN now() ELSE NULL END
WHERE id = $2;
-- código certo: consome
UPDATE otp_codes SET consumed_at = now() WHERE id = $2;
COMMIT;FOR UPDATE é obrigatório. Sem ele, duas verificações simultâneas do mesmo código
poderiam ambas encontrar attempts = 4 e ambas ter uma 5ª tentativa, efetivamente dando
6 chances.
Efeitos colaterais do sucesso: consome o código, cria a sessão (8.8), grava
last_login_at, avalia se o dispositivo é novo (8.10) e, se for, envia notificação.
8.2.6 Contagem de tentativas na resposta #
OTP_INVALID traz as tentativas restantes em details:
{
"error": {
"code": "OTP_INVALID",
"message": "Código incorreto.",
"details": [{ "field": "code", "issue": "mismatch" }]
},
"meta": { "requestId": "req_01K3F8V5E6...", "timestamp": "2026-08-25T09:01:12.000Z" }
}A quantidade restante não é exposta na resposta, apenas na interface, que a calcula localmente contando as tentativas da sessão de digitação. Expor "faltam 2 tentativas" no corpo ajudaria um atacante a calibrar o ataque sem custo.
8.3 Fallback por e-mail (magic link) #
Disponível apenas para assinantes com email_verified_at preenchido. É a saída para quem
perdeu acesso ao WhatsApp (8.15.2).
8.3.1 POST /api/auth/email/request #
export const EmailLoginRequestSchema = z.object({
email: z.string().trim().toLowerCase().email().max(254),
});Resposta 200, sempre idêntica, cadastrado ou não:
{
"data": { "sent": true, "expiresInSeconds": 1200 },
"meta": { "requestId": "req_01K3F8V7G8...", "timestamp": "2026-08-25T09:00:00.000Z" }
}Erros: VALIDATION_ERROR (422), INVALID_EMAIL (422), IP_RATE_LIMITED (429),
EMAIL_SEND_FAILED (502).
EMAIL_NOT_VERIFIED não é devolvido aqui: isso revelaria que o e-mail existe mas não
está verificado. O caso é tratado em silêncio, sem envio.
Token: 32 bytes de crypto.randomBytes, codificados em Base64URL, armazenados como
sha256 em otp_codes com channel = 'EMAIL_LINK' e TTL de 20 minutos. Vinte
minutos, e não dez, porque e-mail tem latência de entrega maior que o WhatsApp.
8.3.2 GET /api/auth/email/verify #
Consome o token e cria a sessão. É um GET porque o usuário chega por clique em link.
Query: ?token=<base64url>&redirect=<caminho relativo opcional>.
Comportamento: sucesso redireciona (302) para redirect ou para /painel, com os
cookies de sessão no Set-Cookie. Falha redireciona para /entrar?erro=<code> — nunca
devolve JSON, porque o destino é um navegador em navegação de topo.
Erros mapeados para o parâmetro erro: MAGIC_LINK_INVALID, MAGIC_LINK_EXPIRED,
SUBSCRIBER_DELETED, SUBSCRIBER_BLOCKED.
redirect é validado: aceita apenas caminho relativo começando com / e sem //. Isso
fecha o vetor de redirecionamento aberto.
export function safeRedirect(raw: string | null): string {
if (!raw) return '/painel';
if (!raw.startsWith('/') || raw.startsWith('//')) return '/painel';
return raw;
}Como o link chega por e-mail e pode ser pré-carregado por scanner de segurança do provedor
de e-mail, o token só é consumido em requisição com Sec-Fetch-Mode: navigate. Requisição
de pré-carregamento não invalida o link do usuário legítimo.
8.4 Login do administrador #
Dois passos obrigatórios: senha, depois TOTP. Nunca há sessão completa após só a senha.
8.4.1 POST /api/auth/admin/login #
export const AdminLoginSchema = z.object({
email: z.string().trim().toLowerCase().email().max(254),
password: z.string().min(1).max(200),
});Resposta 200 — atenção: não cria sessão, apenas um desafio intermediário.
{
"data": {
"challengeId": "chl_01K3F8VA9B...",
"requires": "TOTP",
"expiresInSeconds": 300,
"totpEnrollmentRequired": false
},
"meta": { "requestId": "req_01K3F8VA9B...", "timestamp": "2026-08-25T09:00:00.000Z" }
}O desafio vive no Redis por 5 minutos, chave authchl:{challengeId}, valor
{adminUserId, ip, userAgentHash}. Não vira sessão. Não vira cookie de sessão — vai num
cookie temporário __Host-auth-challenge, HttpOnly, Secure, SameSite=Strict,
Path=/, TTL 5 minutos. O Path=/ não é opcional: o prefixo __Host- obriga a isso
(8.8.3), e um cookie __Host- com qualquer outro Path é descartado pelo navegador sem
erro visível.
Erros: VALIDATION_ERROR (422), INVALID_CREDENTIALS (401), ACCOUNT_LOCKED (403),
IP_RATE_LIMITED (429), SERVICE_UNAVAILABLE (503).
Regras de tempo constante, obrigatórias:
- E-mail inexistente executa
argon2.verifycontra um hash fictício fixo, para que a latência seja igual à de e-mail existente com senha errada. INVALID_CREDENTIALSé o único código para e-mail errado, senha errada ou conta desativada. Nunca "e-mail não encontrado".ACCOUNT_LOCKEDé a exceção deliberada: informar o bloqueio é mais útil ao usuário legítimo do que a informação vale para o atacante, que já sabe que está sendo barrado.
8.4.2 POST /api/auth/admin/totp #
export const AdminTotpSchema = z.object({
challengeId: z.string().regex(/^chl_[0-9A-HJKMNP-TV-Z]{26}$/),
code: z.string().regex(/^[0-9]{6}$/),
trustDevice: z.boolean().default(false),
});Resposta 200:
{
"data": {
"admin": { "id": "adm_01K3F8RHJ9...", "name": "Ana Souza", "role": "ADMIN" },
"session": { "expiresAt": "2026-08-25T21:00:00.000Z" },
"mustChangePassword": false
},
"meta": { "requestId": "req_01K3F8VC1D...", "timestamp": "2026-08-25T09:00:00.000Z" }
}Erros: VALIDATION_ERROR (422), TOTP_INVALID (401), TOTP_REQUIRED (401, desafio
inexistente ou expirado), ACCOUNT_LOCKED (403), TOTP_ENROLLMENT_REQUIRED (403),
PASSWORD_CHANGE_REQUIRED (403, com sessão criada mas restrita à rota de troca de senha).
O desafio é consumido no primeiro uso, com sucesso ou falha de TOTP. Falha exige refazer o passo da senha. Isso impede que um desafio roubado sirva de oráculo para adivinhar TOTP indefinidamente.
trustDevice: true não usa device_fingerprint. Um fingerprint derivado de cabeçalhos
é controlado pelo cliente e tem entropia baixa demais para servir de fator de autenticação:
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ... Chrome/... com
Accept-Language: pt-BR,pt;q=0.9 é a combinação de praticamente todo administrador
brasileiro em notebook, e adivinhá-la transformaria o segundo fator em opcional para quem
já obteve a senha por phishing.
O que o servidor faz é emitir um cookie próprio __Host-admin-device, HttpOnly,
Secure, SameSite=Strict, Path=/, contendo 32 bytes de crypto.randomBytes em
Base64URL, com validade de 30 dias. O valor é gravado apenas como sha256 em
admin_trusted_devices(admin_user_id, token_hash, label, created_at, expires_at, last_used_at), com índice único em token_hash. Em logins seguintes, a apresentação do
cookie válido e não expirado dispensa o TOTP; a senha continua sendo exigida sempre.
O device_fingerprint da Seção 8.10 permanece exclusivamente como sinal informativo
para a notificação de dispositivo novo, e é proibido usá-lo em qualquer decisão de
autorização — um teste de arquitetura falha se device_fingerprint for lido fora do módulo
de notificação. A lista inteira de dispositivos confiáveis é apagada em qualquer troca de
senha, em uso de código de recuperação e em logout-all.
8.5 Hash de senha — argon2id #
8.5.1 Parâmetros exatos #
// packages/core/src/auth/password.ts
import argon2 from 'argon2';
export const ARGON2_OPTIONS = {
type: argon2.argon2id,
memoryCost: 65536, // 64 MiB
timeCost: 3, // 3 iterações
parallelism: 4, // 4 threads
hashLength: 32, // 32 bytes de saída
} as const;
// saltLength padrão da biblioteca: 16 bytes, gerado por CSPRNG a cada hash
export async function hashPassword(plain: string): Promise<string> {
return argon2.hash(plain, ARGON2_OPTIONS);
}
export async function verifyPassword(hash: string, plain: string): Promise<boolean> {
try {
return await argon2.verify(hash, plain);
} catch {
return false; // hash malformado nunca vira exceção visível
}
}| Parâmetro | Valor | Justificativa |
|---|---|---|
| Variante | argon2id |
Híbrida: resiste a ataque por canal lateral e a GPU. É a variante recomendada quando não há razão específica para outra. |
memoryCost |
65536 KiB (64 MiB) | Torna o ataque em GPU caro. 64 MiB por verificação é aceitável num servidor com poucas dezenas de logins administrativos por dia. |
timeCost |
3 | Com 64 MiB, resulta em ~120 ms por verificação no hardware alvo. |
parallelism |
4 | Aproveita os núcleos disponíveis sem monopolizar o processo. |
hashLength |
32 bytes | Padrão sólido; não há ganho acima disso. |
saltLength |
16 bytes | Padrão da biblioteca; elimina tabelas pré-computadas. |
Alvo de calibragem: entre 100 ms e 250 ms por verificação na máquina de produção.
O comando pnpm ops auth:benchmark mede e avisa se sair da faixa. Abaixo de 100 ms, o
custo para o atacante é baixo demais; acima de 250 ms, o login vira lento e o servidor
fica vulnerável a esgotamento de memória por rajada de tentativas.
O hash é armazenado no formato PHC completo, que já carrega variante, versão, parâmetros e sal:
$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHR2YWx1ZQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObGIsso permite aumentar os parâmetros no futuro sem quebrar hashes antigos: argon2.verify
lê os parâmetros do próprio hash. Quando argon2.needsRehash(hash, ARGON2_OPTIONS)
devolver true em um login bem-sucedido, a senha é reescrita com os parâmetros novos, na
mesma requisição, de forma transparente.
8.5.2 Política de senha #
| Regra | Valor |
|---|---|
| Comprimento mínimo | 12 caracteres |
| Comprimento máximo | 200 caracteres |
| Composição | Sem exigência de classes de caractere |
| Lista de bloqueio | 10.000 senhas mais comuns, comparação normalizada |
| Semelhança | Rejeita senha que contenha o e-mail ou o nome da conta |
| Reuso | Rejeita senha igual à atual |
| Expiração | Nenhuma |
| Corte de espaços | Nunca. Espaço é caractere válido, inclusive nas pontas |
Comprimento em vez de composição é decisão consciente: exigir símbolo e maiúscula produz
Senha@123, que está em qualquer lista de bloqueio. Doze caracteres com verificação contra
lista de senhas comuns dá mais entropia real.
Expiração periódica também é rejeitada por decisão explícita: troca forçada produz
Senha1, Senha2, Senha3. A troca é exigida em evento concreto — comprometimento
suspeito, saída de pessoa da equipe, primeiro login.
export const PasswordSchema = z
.string()
.min(12, 'A senha precisa de pelo menos 12 caracteres.')
.max(200, 'A senha é longa demais.')
.superRefine((value, ctx) => {
const v = value.toLowerCase();
if (COMMON_PASSWORDS.has(v)) {
ctx.addIssue({ code: 'custom', params: { rule: 'not_allowed' },
message: 'Esta senha é muito comum. Escolha outra.' });
}
// e-mail completo, parte local do e-mail e nome, com 4 ou mais caracteres
for (const t of accountTokens()) {
if (t.length >= 4 && v.includes(t)) {
ctx.addIssue({ code: 'custom', params: { rule: 'mismatch' },
message: 'A senha não pode conter seu nome ou e-mail.' });
}
}
});A verificação de igualdade com a senha atual não vive no schema: ela acontece no serviço
de troca de senha, onde o hash vigente está disponível, e devolve
PASSWORD_POLICY_VIOLATION com rule: 'reuse'. O schema sozinho não tem como saber a
senha atual, e prometer a regra sem implementá-la em algum lugar deixaria a tabela acima
mentindo.
Violação devolve PASSWORD_POLICY_VIOLATION (422) com details indicando qual regra
falhou: too_short, too_long, not_allowed (lista de bloqueio), mismatch
(semelhança com dados da conta) ou reuse (igual à senha atual). As cinco regras da tabela
acima têm, cada uma, um rule correspondente — nenhuma fica declarada sem implementação.
8.6 TOTP — segundo fator obrigatório #
8.6.1 Parâmetros #
| Parâmetro | Valor |
|---|---|
| Algoritmo | HMAC-SHA1 (exigência de compatibilidade com os aplicativos autenticadores) |
| Dígitos | 6 |
| Período | 30 segundos |
| Tamanho do segredo | 20 bytes (160 bits), codificado em Base32 |
| Janela de deriva | ±1 período (aceita o código anterior, o atual e o próximo) |
| Reuso de código | Proibido. Cada código só vale uma vez por conta |
| Códigos de recuperação | 10, de 10 caracteres em Base32, uso único |
Janela de ±1 cobre relógio de celular até 30 segundos adiantado ou atrasado, que é o desvio real observado. Janela maior (±2 ou mais) triplicaria o espaço aceito sem ganho de usabilidade.
Proibição de reuso: o código aceito é gravado no Redis em
totp:used:{adminId}:{counter} com TTL de 90 segundos. Um código interceptado não pode ser
replayed dentro da mesma janela.
8.6.2 POST /api/auth/admin/totp/enroll #
Inicia o enrolamento. Exige sessão administrativa (que só existe após TOTP, exceto no primeiro acesso, quando a sessão nasce restrita à rota de enrolamento).
Resposta 200:
{
"data": {
"secret": "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP",
"otpauthUri": "otpauth://totp/Palavra%20Di%C3%A1ria:ana%40palavradiaria.com.br?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Palavra%20Di%C3%A1ria&algorithm=SHA1&digits=6&period=30",
"qrCodeDataUri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg..."
},
"meta": { "requestId": "req_01K3F8VE3F...", "timestamp": "2026-08-25T09:00:00.000Z" }
}O QR Code é gerado no servidor, como PNG em data URI. Nunca por serviço externo de geração de QR: mandar o segredo TOTP para um terceiro seria entregar o segundo fator.
Nesta etapa o segredo é guardado apenas no Redis (totp:enroll:{adminId}, TTL 10 minutos),
cifrado. Só vai para admin_users.totp_secret_encrypted depois da confirmação.
Erros: UNAUTHENTICATED (401), TOTP_ALREADY_ENROLLED (409),
TOTP_ENROLLMENT_TARGET_FORBIDDEN (403, tentativa de enrolar o segundo fator de outra
conta; o enrolamento é sempre da própria conta autenticada).
O código é TOTP_ENROLLMENT_TARGET_FORBIDDEN, e não
ADMIN_SELF_MODIFICATION_FORBIDDEN: este último significa o oposto — proibição de
modificar a si mesmo —, e um executor que implemente pelo nome acabaria permitindo enrolar
o segundo fator de outra conta, que é entrega direta do fator.
8.6.3 POST /api/auth/admin/totp/confirm #
export const TotpConfirmSchema = z.object({
code: z.string().regex(/^[0-9]{6}$/),
});Resposta 200:
{
"data": {
"enrolled": true,
"recoveryCodes": [
"K7M2-P9Q4-XR", "N3F8-T5W1-BZ", "H6J0-L2V7-CD", "R4S9-M1N6-EF", "T8U3-K7P2-GH",
"W5X1-Q4R8-JK", "Y2Z6-N9M3-LM", "A7B4-P1Q5-NP", "C3D8-T6W2-QR", "E9F5-K3L7-ST"
]
},
"meta": { "requestId": "req_01K3F8VG5H...", "timestamp": "2026-08-25T09:00:00.000Z" }
}Os códigos de recuperação são exibidos exatamente uma vez. No banco vão apenas os
hashes sha256, no formato [{"hash":"<hex>","usedAt":null}, ...]. A resposta é
no-store e o front exige confirmação explícita de "guardei os códigos" antes de sair da
tela.
Efeitos colaterais: grava totp_secret_encrypted e totp_enrolled_at, grava os hashes de
recuperação, revoga todas as outras sessões da conta, registra
admin.totp.enroll em admin_audit_log.
Erros: TOTP_INVALID (401), TOTP_REQUIRED (401, enrolamento expirado no Redis),
TOTP_ALREADY_ENROLLED (409).
8.6.4 POST /api/auth/admin/recovery #
Usa um código de recuperação no lugar do TOTP. Exige o desafio de senha já concluído.
export const RecoveryCodeSchema = z.object({
challengeId: z.string().regex(/^chl_[0-9A-HJKMNP-TV-Z]{26}$/),
code: z.string().trim().regex(/^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{2}$/),
});Efeitos: marca o código como usado (usedAt), cria a sessão administrativa, define
must_change_password = true — porque quem usou recuperação provavelmente perdeu o
dispositivo e precisa reenrolar —, revoga as demais sessões e envia e-mail de aviso ao
titular e ao endereço de alertas.
Erros: RECOVERY_CODE_INVALID (401), TOTP_REQUIRED (401), ACCOUNT_LOCKED (403).
Com menos de 3 códigos restantes, a resposta inclui remainingCodes e o painel exibe
aviso para regenerar o conjunto. Regenerar invalida todos os anteriores.
8.7 Bloqueio e desbloqueio de conta administrativa #
A escada de bloqueio incide sobre o par (conta, origem), e não sobre a conta
isoladamente. failed_login_count é mantido em Redis sob authfail:{adminId}:{ipPrefix},
onde ipPrefix é o /24 do endereço de origem, e a coluna homônima em admin_users guarda
apenas o total agregado, para fins de alerta. Uma origem bloqueada não impede o titular
legítimo de entrar de outra rede.
Duas salvaguardas fecham o restante:
- A partir de 20 falhas agregadas em 1 hora, vindas de 3 ou mais origens distintas, a conta entra em bloqueio global de 1 hora e um alerta crítico é emitido. O ataque distribuído continua sendo contido, mas com aviso e com prazo curto.
- O
OWNERsempre pode desbloquear a si mesmo por link mágico enviado ao e-mail cadastrado, que exige em seguida senha e TOTP, e é registrado emadmin_audit_log.
O motivo é concreto: o e-mail do OWNER é público — aparece no rodapé, na Política de
Privacidade e nos Termos. Sem separação por origem, qualquer pessoa que conheça esse
endereço envia dez senhas erradas por dia e tranca a operação inteira em bloqueio perpétuo
de 24 horas, e o desbloqueio passa a depender de acesso por SSH exatamente no pior momento
possível — às 06:03 de um domingo, com o lote diário falhando.
| Evento | Efeito |
|---|---|
| Falha de senha | failed_login_count = failed_login_count + 1 |
| Falha de TOTP | failed_login_count = failed_login_count + 1 |
| Falha de código de recuperação | failed_login_count = failed_login_count + 2 |
| Sucesso completo | failed_login_count = 0, locked_until = NULL |
Escada de bloqueio, por contagem de falhas consecutivas:
| Falhas | locked_until |
|---|---|
| 1 a 4 | Sem bloqueio |
| 5 | +5 minutos |
| 6 | +15 minutos |
| 7 | +1 hora |
| 8 | +6 horas |
| 9 ou mais | +24 horas, e alerta operacional |
Durante o bloqueio, qualquer tentativa devolve ACCOUNT_LOCKED sem verificar a senha.
Isso impede que o bloqueio vire um oráculo de tempo.
Desbloqueio, três caminhos:
- Automático. Passado
locked_until, a próxima tentativa é avaliada normalmente. O contador não zera: uma nova falha bloqueia de novo, no degrau seguinte. - Por outro
OWNER.PATCH /api/admin/users/{id}com{"unlock": true}. Exigereasoncom no mínimo 10 caracteres e é registrado emadmin_audit_log. - Autodesbloqueio do
OWNER. Link mágico para o e-mail cadastrado, seguido de senha e TOTP. É o caminho normal quando existe um únicoOWNER, e é auditado. - Pela linha de comando de operação.
pnpm ops admin:unlock --email <e-mail>, para o caso em que nem o e-mail está acessível. Exige acesso ao servidor.
A escada existe para tornar a força bruta inviável sem transformar um erro de digitação em bloqueio permanente. Sete tentativas erradas custam mais de uma hora ao atacante; ao usuário legítimo, custa 5 minutos na quinta tentativa.
8.8 Sessões #
8.8.1 Estrutura do JWT #
Algoritmo EdDSA (Ed25519), com a biblioteca jose na linha declarada na Seção 4.
{
"alg": "EdDSA",
"typ": "JWT",
"kid": "k1"
}{
"iss": "https://app.palavradiaria.com.br",
"aud": "palavra-diaria-web",
"sub": "sub_01K3F8QZ7MHV2N9R4B6T0XYZAB",
"jti": "ses_01K3F8VJ7K2MNPQRSTUVWXYZAB",
"typ": "SUBSCRIBER",
"role": "SUBSCRIBER",
"tier": "PAID",
"iat": 1787635200,
"exp": 1787636100,
"imp": null
}| Claim | Descrição |
|---|---|
iss |
Emissor. Validado na verificação. |
aud |
Público. palavra-diaria-web. Validado. |
sub |
Identificador público do sujeito, com prefixo. |
jti |
Identificador da sessão em sessions.id. É o que torna o token revogável. |
typ |
SUBSCRIBER ou ADMIN. |
role |
Papel congelado na emissão. |
tier |
Tier no momento da emissão. Dica, não autoridade (ver 8.15.4). |
iat / exp |
Emissão e expiração, em segundos epoch. |
imp |
Identificador do administrador que impersona, ou null. |
TTL do access token: 15 minutos, para os dois papéis. O que difere é o TTL da sessão
como um todo (30 dias para assinante, 12 horas para administrador), controlado por
sessions.expires_at e pelo refresh token.
Por que EdDSA e não HMAC: a chave pública pode ser distribuída para verificação sem permitir emissão. Isso separa quem emite (a rota de autenticação) de quem verifica (todo o resto), e permite rotacionar a chave privada sem redeploy coordenado.
// packages/core/src/auth/jwt.ts
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose';
const ISSUER = 'https://app.palavradiaria.com.br';
const AUDIENCE = 'palavra-diaria-web';
export async function signSessionToken(claims: SessionClaims): Promise<string> {
return new SignJWT(claims)
.setProtectedHeader({ alg: 'EdDSA', typ: 'JWT', kid: currentKeyId() })
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setIssuedAt()
.setExpirationTime('15m')
.sign(await importPKCS8(env.SESSION_JWT_PRIVATE_KEY, 'EdDSA'));
}
export async function verifySessionToken(token: string): Promise<SessionClaims> {
const { payload } = await jwtVerify(token, resolveKey, {
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ['EdDSA'], // lista fechada: impede troca de algoritmo
clockTolerance: 5, // 5 s de tolerância de relógio
});
return SessionClaimsSchema.parse(payload);
}algorithms: ['EdDSA'] é obrigatório e não negociável. Sem essa lista, um token com
alg: none ou alg: HS256 assinado com a chave pública poderia ser aceito.
A chave privada de assinatura de sessão tem um único nome de variável de ambiente:
SESSION_JWT_PRIVATE_KEY, e a pública é SESSION_JWT_PUBLIC_KEY. Os nomes
SESSION_JWK_PRIVATE e JWT_PRIVATE_KEY não existem em lugar nenhum — nem no código, nem
no arquivo de composição, nem nos scripts de inicialização. O guarda de boot do processo
web valida exatamente esse nome e aborta se ele faltar; um guarda que procure um nome e um
registro de variáveis que declare outro faz o contêiner não subir no primeiro deploy de
produção, e o conserto improvisado deixa duas chaves de sessão no ambiente sem que ninguém
saiba qual assina.
8.8.2 Refresh token #
- 32 bytes de
crypto.randomBytes, em Base64URL. Opaco, sem estrutura. - Armazenado apenas como
sha256emsessions.refresh_token_hash. - TTL: igual ao da sessão (30 dias para assinante, 12 horas para administrador).
- Rotacionado a cada uso. Cada refresh cria uma linha nova em
sessionscomparent_session_idapontando para a anterior, e revoga a anterior comrevoked_reason = 'rotation'.
8.8.3 Cookies #
| Cookie | Conteúdo | Flags | Max-Age |
|---|---|---|---|
__Host-session |
Access token JWT | HttpOnly; Secure; SameSite=Lax; Path=/ |
900 (15 min) |
__Host-refresh |
Refresh token opaco | HttpOnly; Secure; SameSite=Strict; Path=/ |
2592000 (assinante) / 43200 (admin) |
__Host-admin-session |
Access token JWT do admin | HttpOnly; Secure; SameSite=Lax; Path=/ |
900 |
__Host-admin-refresh |
Refresh do admin | HttpOnly; Secure; SameSite=Strict; Path=/ |
43200 |
__Host-auth-challenge |
Desafio intermediário de senha (8.4.1) | HttpOnly; Secure; SameSite=Strict; Path=/ |
300 |
__Host-admin-device |
Dispositivo confiável do admin (8.4.2) | HttpOnly; Secure; SameSite=Strict; Path=/ |
2592000 (30 dias) |
__Host-csrf |
Token CSRF, 32 bytes aleatórios em Base64URL | Secure; SameSite=Lax; Path=/ — sem HttpOnly |
igual à sessão |
O prefixo __Host- obriga o navegador a exigir Secure, Path=/ e ausência de
Domain. Isso impede que um subdomínio comprometido sobrescreva o cookie de sessão — um
ataque real e barato quando subdomínios são gerenciados por terceiros.
Os seis cookies com prefixo __Host- são emitidos com Path=/, sem exceção. Um
Set-Cookie com prefixo __Host- e Path diferente de / é rejeitado silenciosamente
por Chrome, Firefox, Safari e Edge: o cookie simplesmente não é gravado, e não há erro
visível. Se o refresh fosse emitido com Path=/api/auth, o navegador o descartaria, o JWT
de 15 minutos expiraria, POST /api/auth/refresh chegaria sem o cookie e devolveria 401 —
ou seja, todo assinante e todo administrador seria deslogado a cada 15 minutos, para
sempre. Nenhum teste unitário pega isso; só um teste com navegador real.
O refresh usa SameSite=Strict, que é onde a proteção de fato mora. A restrição de caminho
que se perde vale menos do que a proteção contra sobrescrita por subdomínio que o prefixo
garante, e o HttpOnly já impede leitura por script em qualquer rota. Um teste de
integração afirma que todo Set-Cookie emitido cujo nome comece com __Host- contém
Secure, contém Path=/ e não contém Domain.
__Host-csrf é intencionalmente legível por script: é o padrão double-submit de 7.13.2. Ele
não é credencial; sozinho não autentica nada. O nome é único em todo o documento — não
existe csrf-token nem qualquer outra grafia — e o cliente o envia de volta no header
X-CSRF-Token. Um emissor que grave um nome e um verificador que leia outro devolve 403
em toda requisição que muda estado, e o produto inteiro fica inutilizável sem que nenhum
teste unitário acuse, porque os testes injetam o par diretamente.
8.8.4 POST /api/auth/refresh #
- Autenticação: cookie
__Host-refresh. Sem corpo. - Idempotência: não. Cada chamada rotaciona. Chamadas concorrentes são tratadas em 8.8.5.
Resposta 200:
{
"data": {
"expiresAt": "2026-09-24T09:00:00.000Z",
"accessExpiresInSeconds": 900,
"role": "SUBSCRIBER",
"tier": "FREE"
},
"meta": { "requestId": "req_01K3F8VM9N...", "timestamp": "2026-08-25T09:15:00.000Z" }
}O refresh relê o estado atual do sujeito no banco: papel, tier, deleted_at,
blocked_at. É o ponto em que uma mudança de tier passa a valer no token (8.15.4).
Erros: UNAUTHENTICATED (401, sem cookie), SESSION_EXPIRED (401),
SESSION_REVOKED (401), SESSION_REUSE_DETECTED (401), SUBSCRIBER_DELETED (410),
SUBSCRIBER_BLOCKED (403), ACCOUNT_LOCKED (403).
8.8.5 Detecção de reuso de refresh token #
Um refresh token só pode ser usado uma vez. Apresentar um token já rotacionado significa uma de duas coisas: corrida de rede legítima ou token roubado. O sistema trata como roubo, que é a hipótese cara.
Sessão A ──refresh──► Sessão B ──refresh──► Sessão C (cadeia normal)
│
└── alguém apresenta o refresh de A de novo
→ A já está revoked_at com reason='rotation'
→ REVOGA A CADEIA INTEIRA (A, B, C) com reason='reuse_detected'
→ responde 401 SESSION_REUSE_DETECTED
→ registra alerta de segurança e notifica o titularA revogação percorre parent_session_id nos dois sentidos, então o atacante e o usuário
legítimo são desconectados juntos. É agressivo de propósito: preferir que o usuário refaça
o login a manter uma sessão possivelmente roubada.
Corrida legítima (duas abas fazendo refresh no mesmo instante) é absorvida por uma janela
de tolerância de 30 segundos: se a sessão foi rotacionada há menos de 30 segundos e o
device_fingerprint é o mesmo, o sistema devolve a sessão filha já criada em vez de
declarar reuso. Fora da janela, ou com device_fingerprint diferente, é reuso e a cadeia
inteira é revogada.
O endereço IP não entra nessa comparação. Em rede móvel brasileira, com CGNAT e alternância entre 4G e Wi-Fi, o IP muda entre duas requisições quase simultâneas do mesmo aparelho; exigi-lo igual transformaria um evento cotidiano — duas abas abertas, ambas percebendo o JWT expirado no mesmo segundo — em revogação da cadeia inteira, e-mail de alerta de sessão roubada e logout. Repetido todo dia para uma fração relevante da base, isso gera chamados de suporte e, pior, transforma um alerta de segurança real em ruído que ninguém mais lê. A mudança de IP dentro da janela é registrada no evento, para análise, mas não altera a decisão.
8.9 Gestão de sessões #
8.9.1 GET /api/auth/sessions #
Lista as sessões ativas do sujeito autenticado. Paginação por cursor (7.8).
{
"data": [
{
"id": "ses_01K3F8VJ7K...",
"current": true,
"deviceLabel": "Chrome em Android",
"ipMasked": "189.45.***.**",
"createdAt": "2026-08-25T09:00:00.000Z",
"lastSeenAt": "2026-08-25T09:14:00.000Z",
"expiresAt": "2026-09-24T09:00:00.000Z"
}
],
"meta": { "requestId": "req_01K3F8VP1Q...", "timestamp": "2026-08-25T09:15:00.000Z",
"nextCursor": null, "hasMore": false, "limit": 20 }
}deviceLabel é derivado do user-agent por uma função de rotulagem simples — nunca o
user-agent cru, que é ruidoso e pode conter conteúdo controlado pelo cliente. ipMasked
esconde os dois últimos octetos: mostra o suficiente para o titular reconhecer a rede sem
expor o endereço completo caso a tela seja compartilhada.
Sessões de impersonação aparecem para o assinante em uma lista separada, rotulada
Acessos do suporte, na tela Meus dados, com data, duração e a justificativa registrada
— nunca o nome do operador, que é dado do administrador. O registro é retido pelo mesmo
prazo de admin_audit_log, e não pelo prazo do log técnico.
O motivo é legal e prático ao mesmo tempo: o Art. 18 dá ao titular o direito de saber como seus dados foram tratados, e uma sessão de suporte dentro da conta dele é tratamento. Esconder isso do titular torna o direito inexequível — seis meses depois, a única resposta possível seria "não sabemos com precisão". A transparência é também o que desestimula o acesso desnecessário.
Do lado administrativo, as mesmas sessões aparecem em /api/admin/audit-logs, com o nome
do operador e a justificativa completa.
8.9.2 DELETE /api/auth/sessions/{id} #
Revoga uma sessão específica do próprio sujeito.
Resposta 200: {"data":{"revoked":true},"meta":{...}}.
Erros: UNAUTHENTICATED (401), NOT_FOUND (404, sessão de outro sujeito — nunca 403,
por 8.12), CONFLICT (409, sessão já revogada).
Revogar a sessão atual é permitido e equivale a logout.
8.9.3 POST /api/auth/logout #
Revoga apenas a sessão atual e limpa os cookies com Max-Age=0. Sempre devolve 200,
mesmo sem sessão válida — logout precisa funcionar mesmo com estado inconsistente.
8.9.4 POST /api/auth/logout-all #
Revoga todas as sessões do sujeito, inclusive a atual.
UPDATE sessions
SET revoked_at = now(), revoked_reason = 'logout_all'
WHERE subscriber_id = $1 AND revoked_at IS NULL;Também limpa a lista de dispositivos confiáveis do TOTP quando o sujeito é administrador.
Gatilhos automáticos de logout-all, sem ação do usuário:
| Evento | revoked_reason |
|---|---|
| Troca de senha do administrador | password_change |
| Enrolamento ou reenrolamento de TOTP | password_change |
| Uso de código de recuperação | password_change |
| Detecção de reuso de refresh token | reuse_detected |
| Exclusão ou bloqueio da conta | admin_revoke |
| Alteração de papel do administrador | admin_revoke |
Mudança de tier de PAID para FREE não revoga sessões. O motivo está em 8.15.4.
8.10 Detecção de dispositivo novo #
device_fingerprint é sha256 de user-agent + Accept-Language + plataforma declarada.
Não é rastreamento: é um sinal grosseiro, guardado apenas em sessions, que morre com a
sessão.
No login bem-sucedido, se não existir sessão anterior não revogada com o mesmo
device_fingerprint para aquele sujeito nos últimos 90 dias, o dispositivo é considerado
novo. Efeitos:
| Sujeito | Ação |
|---|---|
| Assinante com e-mail verificado | E-mail: "Novo acesso à sua conta", com data, dispositivo e IP mascarado |
| Assinante sem e-mail | Nenhuma notificação. Enviar pelo WhatsApp custaria uma mensagem e assustaria mais do que ajudaria |
| Administrador | E-mail ao titular e ao endereço de alertas operacionais |
A resposta de otp/verify e de admin/totp inclui session.isNewDevice, para que a
interface possa exibir um aviso discreto.
Falso positivo é esperado: atualizar o navegador muda o user-agent e gera um "dispositivo novo". O texto da notificação assume isso e é informativo, não alarmante. Nenhuma ação é bloqueada por dispositivo novo — bloquear geraria mais suporte do que segurança.
8.11 Autorização #
8.11.1 Camadas #
1. Middleware de borda → resolve requestId, aplica cabeçalhos de segurança,
verifica Origin, aplica rate limit por IP.
2. Guarda de autenticação → lê o cookie, verifica o JWT, confirma que sessions.revoked_at
é nulo e que expires_at está no futuro. Falha → 401.
3. Guarda de papel → compara o papel exigido pela rota com o da sessão. Falha → 403.
4. Guarda de propriedade → confirma que o recurso pertence ao sujeito. Falha → 404.
5. Guarda de entitlement → resolveEntitlements() decide se o tier permite. Falha → 403.
6. Handler → regra de negócio.A ordem importa. Verificar propriedade antes do papel vazaria existência de recurso para quem nem deveria estar na rota.
8.11.2 Implementação #
// apps/web/src/lib/auth/guards.ts
const ROLE_RANK: Record<Role, number> = { SUBSCRIBER: 0, EDITOR: 1, ADMIN: 2, OWNER: 3 };
export async function requireSession(req: Request): Promise<SessionContext> {
const token = readCookie(req, '__Host-session') ?? readCookie(req, '__Host-admin-session');
if (!token) throw apiError('UNAUTHENTICATED');
let claims: SessionClaims;
try {
claims = await verifySessionToken(token);
} catch (e) {
throw apiError(e instanceof JWTExpired ? 'SESSION_EXPIRED' : 'UNAUTHENTICATED');
}
// o JWT ser válido não basta: a sessão precisa estar viva no banco
const session = await db.session.findUnique({ where: { id: stripPrefix(claims.jti) } });
if (!session) throw apiError('SESSION_REVOKED');
if (session.revokedAt) throw apiError('SESSION_REVOKED');
if (session.expiresAt <= new Date()) throw apiError('SESSION_EXPIRED');
touchLastSeen(session); // no máximo 1 escrita a cada 5 minutos
return toContext(claims, session);
}
export function requireRole(ctx: SessionContext, minimum: Role): void {
if (ctx.subjectType !== 'ADMIN') throw apiError('INSUFFICIENT_ROLE');
if (ROLE_RANK[ctx.role] < ROLE_RANK[minimum]) throw apiError('INSUFFICIENT_ROLE');
}
export function requireSubscriber(ctx: SessionContext): SubscriberContext {
if (ctx.subjectType !== 'SUBSCRIBER') throw apiError('FORBIDDEN');
return ctx as SubscriberContext;
}A consulta a sessions em toda requisição autenticada é o preço da revogabilidade. Com
sessions_pkey é uma leitura por chave primária, abaixo de 1 ms, e o resultado é cacheado
no Redis por 30 segundos.
A invalidação desse cache é ativa e síncrona: toda escrita em sessions.revoked_at
apaga a chave correspondente no Redis dentro da mesma transação, antes do commit. O TTL de
30 segundos é apenas a rede de proteção para o caso de a invalidação ativa falhar, nunca o
mecanismo principal.
Nos caminhos de revogação por segurança — saída de administrador, resposta a vazamento de
segredo, detecção de reuso de refresh (8.8.5) e encerramento de impersonação (8.13) — a
operação chama revokeAndPurge(), que só retorna após confirmar a remoção das chaves.
Se o Redis estiver indisponível, a revogação falha ruidosamente em vez de suceder pela
metade. Sem isso, um administrador desligado às 14:00:00 continuaria com acesso pleno ao
painel até 14:00:30, e o procedimento de saída afirmaria revogação imediata sem entregá-la
— diferença que importa em uma apuração.
8.11.3 Composição por rota #
// apps/web/src/app/api/admin/devotionals/[id]/publish/route.ts
export const POST = withApi(async (req, { params }) => {
const ctx = await requireSession(req);
requireRole(ctx, 'ADMIN');
assertSameOrigin(req);
const devotionalId = parseId(params.id, 'dev'); // valida prefixo e ULID
const result = await publishDevotional(devotionalId, ctx);
await audit(ctx, 'devotional.publish', 'devotionals', devotionalId, { after: result });
return ok(result, ctx.requestId);
});withApi é o invólucro que resolve o requestId, aplica o rate limit, converte exceções
pelo tratamento de 7.4 e garante os cabeçalhos de resposta. Nenhum handler é exportado sem
ele — um teste de arquitetura falha se encontrar export const POST sem withApi.
8.11.4 Entitlements #
Autorização por papel e autorização por plano são coisas diferentes e ficam em guardas separadas. O papel diz quem você é; o entitlement diz o que seu plano compra.
export function requireEntitlement(
sub: SubscriberContext,
capability: Capability,
): void {
const entitlements = resolveEntitlements(sub.subscriber); // fonte única em packages/core
if (!entitlements[capability]) throw apiError('ENTITLEMENT_DENIED');
}resolveEntitlements é a única função autorizada a decidir o que cada tier pode. Nenhum
handler, componente ou consulta SQL recalcula isso localmente. O tier vem do banco, não do
claim tier do JWT (8.15.4).
8.12 Propriedade de recurso: 403 versus 404 #
Regra fechada:
| Situação | Resposta |
|---|---|
| Recurso não existe | 404 com o código específico da entidade |
| Recurso existe e pertence ao solicitante, mas a operação é proibida | 403 FORBIDDEN |
| Recurso existe e não pertence ao solicitante | 404, indistinguível do primeiro caso |
| Sem papel para acessar a rota inteira | 403 INSUFFICIENT_ROLE |
O terceiro caso é o que importa. Devolver 403 para recurso alheio confirma que ele
existe. Com identificadores ULID de 26 caracteres o risco de enumeração é baixo, mas o
princípio se mantém: a resposta não revela existência.
export async function getOwnedDevotional(id: string, sub: SubscriberContext) {
const devotional = await db.devotional.findFirst({
where: { id, deletedAt: null, status: { in: ['PUBLISHED', 'SENT'] } },
});
if (!devotional) throw apiError('DEVOTIONAL_NOT_FOUND');
const entitlements = resolveEntitlements(sub.subscriber);
const oldestVisible = entitlements.archiveDays === null
? null
: subDays(new Date(), entitlements.archiveDays);
// fora do acervo do tier: 403 com código próprio, porque aqui o upgrade resolve
if (oldestVisible && devotional.scheduledFor < oldestVisible) {
throw apiError('ARCHIVE_LIMIT_REACHED');
}
return devotional;
}Exceção deliberada e única: quando o recurso é público por natureza e a restrição é de
plano, o código é ARCHIVE_LIMIT_REACHED com 403. Ali, revelar a existência é o ponto —
é exatamente o gancho de conversão. Devocionais não são segredo entre assinantes; dados de
conta são.
Os recursos de /api/me/* nunca aceitam identificador de outro assinante porque o
identificador vem da sessão (7.2.2). A guarda de propriedade só é necessária em recursos
com identificador no caminho: devocionais, pagamentos e sessões.
8.13 Impersonação de assinante pelo administrador #
8.13.1 Regras #
| Aspecto | Regra |
|---|---|
| Papel exigido | ADMIN ou OWNER. EDITOR não pode |
| Justificativa | Obrigatória, mínimo 10 caracteres |
| Duração | 30 minutos, não renovável |
| Escopo | Somente leitura, em toda rota executável sob sessão de assinante, qualquer que seja o prefixo |
| Escritas bloqueadas | Todas. Inclui, sem se limitar a: cancelar assinatura, reativar, trocar de plano, trocar o cartão, pedir reenvio, opt-out, opt-in, pausar, exportar dados, pedir eliminação e contratar plano |
| Escritas permitidas | Nenhuma. A sessão é integralmente somente leitura |
| Marcação da sessão | scope = 'IMPERSONATION_READONLY' |
| Sessões simultâneas | 1 por administrador |
| Auditoria | Início e fim registrados. Toda requisição feita sob impersonação é registrada |
| Visibilidade | Faixa fixa no topo da interface, em vermelho, com o nome do assinante e o tempo restante |
| Bloqueio recíproco | Administrador não pode impersonar um assinante que também seja administrador |
8.13.2 POST /api/admin/subscribers/{id}/impersonate #
export const ImpersonateSchema = z.object({
reason: z.string().trim().min(10).max(500),
ticketRef: z.string().trim().max(60).optional(),
});Resposta 200:
{
"data": {
"impersonationSessionId": "ses_01K3F8VR3S...",
"subscriber": { "id": "sub_01K3F8QZ7M...", "displayName": "Ana", "tier": "PAID" },
"expiresAt": "2026-08-25T09:30:00.000Z",
"readOnly": true
},
"meta": { "requestId": "req_01K3F8VR3S...", "timestamp": "2026-08-25T09:00:00.000Z" }
}A sessão criada tem subject_type = 'SUBSCRIBER', subscriber_id do alvo,
impersonated_by_admin_id do administrador e expires_at = now() + 30 minutos. O JWT
carrega imp com o identificador do administrador.
O cookie emitido é __Host-session, o mesmo do assinante — a sessão administrativa
continua viva em __Host-admin-session, então sair da impersonação é apenas revogar a
sessão de impersonação e recarregar.
Erros: IMPERSONATION_NOT_ALLOWED (403), IMPERSONATION_REASON_REQUIRED (422),
SUBSCRIBER_NOT_FOUND (404), SUBSCRIBER_DELETED (410), CONFLICT (409, já existe
impersonação ativa deste administrador).
8.13.3 Bloqueio de escrita #
export function assertNotImpersonating(ctx: SessionContext): void {
if (ctx.impersonatedByAdminId) {
throw apiError('FORBIDDEN', [{ field: 'session', issue: 'not_allowed' }]);
}
}Chamada obrigatória no início de todo handler de escrita executável sob sessão de
assinante, independentemente do prefixo da rota. O teste de arquitetura enumera todo POST,
PATCH, PUT e DELETE cuja guarda seja requireSubscriber — o critério é a guarda,
não o caminho — e falha o build se algum não contiver assertNotImpersonating.
O critério anterior, baseado no prefixo /api/me/*, é insuficiente e está proibido: as
escritas de maior consequência do assinante — cancelamento, reativação, troca de plano,
troca de cartão e reenvio — vivem sob /api/subscriptions/* e /api/devotionals/*. Com o
critério de caminho, um operador de suporte que impersone alguém para "entender a
reclamação" e clique sem querer em "Cancelar assinatura" executa o cancelamento de verdade:
cancela no provedor de pagamento, grava o evento, dispara e-mail e mensagem no WhatsApp — e
o teste de arquitetura fica verde, porque a rota não casa com o prefixo. A pessoa recebe
uma confirmação de cancelamento que nunca pediu, e a auditoria registra a ação como se fosse
dela.
Adicionalmente, a sessão de impersonação carrega scope = 'IMPERSONATION_READONLY', e o
invólucro withApi de 8.11.3 recusa qualquer método não seguro nessa sessão antes de
chegar ao handler. A verificação por handler passa a ser a segunda camada, nunca a única:
uma trava que depende de o desenvolvedor lembrar de chamar uma função é uma trava que
eventualmente falha.
8.13.4 Auditoria #
| Momento | action |
Conteúdo |
|---|---|---|
| Início | subscriber.impersonate.start |
entity_id do assinante, reason, ticketRef, ip, session_id |
| Cada requisição | subscriber.impersonate.request |
Método, caminho e request_id. Sem corpo |
| Fim explícito | subscriber.impersonate.end |
Duração efetiva |
| Fim por expiração | subscriber.impersonate.expire |
Duração de 30 minutos |
O registro por requisição é verboso de propósito. A pergunta que a auditoria precisa responder é "o que exatamente o suporte viu da conta desta pessoa", e só o par método+caminho responde isso.
8.14 Diagramas de sequência #
8.14.1 Login do assinante por OTP #
Assinante Navegador API Redis Postgres WhatsApp
│ │ │ │ │ │
│ digita telefone│ │ │ │ │
├───────────────►│ │ │ │ │
│ │ POST /api/auth/otp/request │ │ │
│ ├──────────────►│ │ │ │
│ │ │ rate limit ip+phone │ │
│ │ ├──────────────────►│ │ │
│ │ │◄──────────────────┤ ok │ │
│ │ │ busca subscriber por phone_hmac │ │
│ │ ├────────────────────────────────►│ │
│ │ │◄────────────────────────────────┤ encontrado │
│ │ │ invalida OTPs anteriores │ │
│ │ │ gera código, grava sha256(pepper‖id‖code) │
│ │ ├────────────────────────────────►│ │
│ │ │ envia template codigo_acesso_v1 │ │
│ │ ├───────────────────────────────────────────────►│
│ │ │◄───────────────────────────────────────────────┤ wamid
│ │◄──────────────┤ 200 {sent:true, expiresInSeconds:600} │
│◄───────────────┤ "Código enviado" │ │ │
│ │ │ │ │ │
│ recebe 482913 no WhatsApp ◄────────────────────────────────────────────────────┤
│ │ │ │ │ │
│ digita 482913 │ │ │ │ │
├───────────────►│ │ │ │ │
│ │ POST /api/auth/otp/verify │ │ │
│ ├──────────────►│ │ │ │
│ │ │ SELECT ... FOR UPDATE │ │
│ │ ├────────────────────────────────►│ │
│ │ │ verifica hash em tempo constante│ │
│ │ │ consumed_at = now() │ │
│ │ ├────────────────────────────────►│ │
│ │ │ INSERT em sessions │ │
│ │ ├────────────────────────────────►│ │
│ │ │ assina JWT EdDSA (exp 15 min) │ │
│ │◄──────────────┤ 200 + Set-Cookie __Host-session │ │
│ │ + Set-Cookie __Host-refresh │
│◄───────────────┤ painel │ │ │8.14.2 Login do administrador #
Admin Navegador API Redis Postgres
│ │ │ │ │
│ e-mail + senha │ │ │ │
├───────────────►│ │ │ │
│ │ POST /api/auth/admin/login │ │
│ ├────────────────►│ │ │
│ │ │ rate limit ip + email │
│ │ ├────────────────►│ │
│ │ │ SELECT admin_users WHERE email │
│ │ ├────────────────────────────────►│
│ │ │◄────────────────────────────────┤
│ │ │ locked_until > now()? → 403 ACCOUNT_LOCKED
│ │ │ argon2.verify (~120 ms) │
│ │ │ (hash fictício se e-mail não existe)
│ │ │ falha → failed_login_count++ → 401
│ │ │ sucesso: cria desafio 5 min │
│ │ ├────────────────►│ SET authchl:{id}
│ │◄────────────────┤ 200 {challengeId, requires:"TOTP"}
│ │ + Set-Cookie __Host-auth-challenge
│◄───────────────┤ tela de código │ │ │
│ │ │ │ │
│ código 6 díg. │ │ │ │
├───────────────►│ │ │ │
│ │ POST /api/auth/admin/totp │ │
│ ├────────────────►│ │ │
│ │ │ GET + DEL authchl:{id} (consome)│
│ │ ├────────────────►│ │
│ │ │ decifra totp_secret_encrypted │
│ │ ├────────────────────────────────►│
│ │ │ verifica TOTP, janela ±1 período│
│ │ │ código já usado? → 401 TOTP_INVALID
│ │ ├────────────────►│ SET totp:used:{id}:{counter}
│ │ │ INSERT sessions (12 h) │
│ │ ├────────────────────────────────►│
│ │ │ failed_login_count = 0 │
│ │◄────────────────┤ 200 + Set-Cookie __Host-admin-session
│◄───────────────┤ painel admin │ │ │8.14.3 Refresh de sessão com detecção de reuso #
Navegador API Postgres
│ │ │
│ GET /api/me (JWT expirado) │
├────────────────────►│ │
│◄────────────────────┤ 401 SESSION_EXPIRED │
│ │ │
│ POST /api/auth/refresh (cookie __Host-refresh) │
├────────────────────►│ │
│ │ sha256(refresh) → busca sessão
│ ├────────────────────────────►│
│ │◄────────────────────────────┤ sessão A
│ │ │
│ │ ┌── A.revoked_at IS NULL ────────────────────┐
│ │ │ cria sessão B (parent = A) │
│ │ │ revoga A com reason='rotation' │
│ │ │ relê tier e role do banco │
│ │ │ assina novo JWT (15 min) │
│ │ └────────────────────────────────────────────┘
│◄────────────────────┤ 200 + novos cookies │
│ │ │
│ ... mais tarde, atacante apresenta o refresh de A ...
│ │ │
│ POST /api/auth/refresh (refresh antigo de A) │
├────────────────────►│ │
│ │ A.revoked_at = 'rotation' │
│ │ rotacionada há > 30 s, ou device diferente? → REUSO
│ │ revoga A, B, C (cadeia inteira)
│ ├────────────────────────────►│
│ │ alerta de segurança + e-mail ao titular
│◄────────────────────┤ 401 SESSION_REUSE_DETECTED │8.15 Casos de borda #
8.15.1 Número de telefone trocou de dono #
Cenário real: a operadora recicla um número desativado e ele vai para outra pessoa, que começa a receber os devocionais de quem tinha o número antes.
Sinais que o sistema reconhece:
| Sinal | Ação |
|---|---|
Palavra-chave de saída (SAIR, PARAR, CANCELAR, STOP, DESCADASTRAR, REMOVER) |
Opt-out imediato, confirmação enviada uma única vez |
Erro 131026 (não é possível entregar) por 7 dias seguidos |
blocked_at preenchido, envios cessam |
| Mensagem de entrada com texto que não corresponde a nenhuma intenção conhecida, vinda de número com opt-out | Ignorada; não reabre nada |
O sistema nunca cancela a assinatura paga de imediato por opt-out, para que um SAIR
acidental não destrua a contratação. Mas cobrança sem entrega não se sustenta. A regra
completa é: o opt-out interrompe os envios na hora e suspende a cobrança no ciclo
seguinte. O assinante recebe, no ato, uma mensagem pelo canal que ainda funciona — e-mail
verificado, ou o próprio WhatsApp, uma única vez, por ser mensagem transacional de
encerramento — com o texto: "Paramos de enviar. Sua próxima cobrança está suspensa. Se
quiser voltar, é só responder VOLTAR; se quiser encerrar de vez, cancele no painel." Se em
30 dias não houver reativação, a assinatura é cancelada ao fim do período já pago, com
aviso, e o acesso ao acervo permanece até essa data. O painel exibe o estado "envios
pausados, cobrança suspensa" com as duas ações possíveis.
O motivo é jurídico e direto: a base legal da entrega é o consentimento específico do Art. 11, I da LGPD; revogado o consentimento, não é lícito prestar o serviço, e cobrar por serviço que não se pode prestar é infração ao Código de Defesa do Consumidor além de dano reputacional garantido.
Se o novo dono do número tentar cadastrar, recebe SUBSCRIBER_ALREADY_EXISTS. A saída é o
fluxo de suporte: com prova de titularidade, um ADMIN marca a conta antiga como
bloqueada e libera o número. A operação é registrada em admin_audit_log com
subscriber.phone.release e justificativa obrigatória.
Troca do wa_id associado a um telefone. O identificador da plataforma de mensagens é
reemitido quando a pessoa reinstala o aplicativo em outro aparelho — e também quando o chip
troca de dono. Por isso, qualquer mudança do wa_id associado a um phone_hmac existente
produz, obrigatoriamente e na mesma transação: revogação de todas as sessões daquele
assinante (revoked_reason = 'admin_revoke'), invalidação de todos os OTPs ativos, e
phone_verified_at = NULL. A partir daí, nenhum dado pessoal é liberado antes de uma nova
verificação por código bem-sucedida. Nunca se entrega conta, CPF ou histórico a um número
apenas porque ele respondeu no WhatsApp.
8.15.1.1 Reverificação por dormência #
A posse do número é o único fator do assinante (8.1.1), e números brasileiros são reemitidos pelas operadoras. Por isso, o código de acesso sozinho não concede sessão plena quando a conta está dormente.
Uma conta é dormente quando ocorre qualquer uma destas três condições:
| Condição | Verificação |
|---|---|
| Sem sinal de vida há 90 dias | Nenhuma mensagem de entrada do assinante nem login bem-sucedido nos últimos 90 dias |
| Bloqueio por erro de entrega | blocked_at preenchido por erro 131026 reincidente |
| Saída sem retorno | Opt-out por palavra-chave sem reativação posterior |
Nesses casos, POST /api/auth/otp/verify cria uma sessão com scope = 'RESTRICTED', que
permite exclusivamente: ler o devocional do dia, executar opt-out e cancelar a
assinatura. A sessão restrita recusa com 403 REVERIFICATION_REQUIRED o acesso a
GET /api/me, ao acervo, à tela de assinatura, à tela de pagamentos, à troca de telefone
e, sobretudo, à exportação de portabilidade e à exclusão de conta.
A elevação para sessão plena exige o link mágico por e-mail verificado (8.3) ou a conferência de titularidade pelo suporte descrita em 8.15.2, item 2. O painel explica em uma frase: "Faz tempo que você não aparece. Para ver seus dados e sua assinatura, confirme por e-mail ou fale com a gente."
Motivo registrado: entregar sessão plena por posse de número a uma conta parada há meses transforma a reciclagem de chip — evento comum, frequente e fora do nosso controle — em vazamento de CPF, histórico financeiro e 18 meses de mensagens de outra pessoa. Ana cancela a linha em março; em maio a operadora reemite o número para Bruno; Bruno vê chegar um devocional, pede o código, recebe no próprio aparelho e, sem esta regra, exporta em três cliques o dossiê completo de Ana.
8.15.2 Assinante perdeu acesso ao WhatsApp #
Ordem de recuperação, do mais simples ao mais custoso:
- E-mail verificado. Magic link (8.3). Resolve sem intervenção humana. É a razão de o cadastro incentivar (sem exigir) o e-mail.
- Sem e-mail, com assinatura ativa. O suporte confirma titularidade pelos 4 últimos
dígitos do CPF, pelo valor e data da última cobrança e pela bandeira e 4 últimos dígitos
do cartão. Com dois dos três conferindo, um
ADMINcadastra e verifica um e-mail para a conta. A ação é auditada comsubscriber.email.set_by_support. - Sem e-mail, sem assinatura ativa. Não há prova suficiente de titularidade. A orientação é criar conta nova com o número atual. Assinante FREE não tem histórico que justifique o risco de entregar acesso à conta errada.
- Número novo, conta antiga com assinatura ativa. Fluxo de troca de número:
confirmação de titularidade pelos dados de cobrança, OTP no número novo e migração feita
por
ADMIN.phone_e164é atualizado,wa_idé zerado (será repreenchido no primeiro contato) esubscriber.phone.changeé auditado.
Troca de número nunca é self-service. PHONE_CHANGE_NOT_ALLOWED é a resposta para
qualquer tentativa por rota de assinante. O motivo é direto: quem controla o WhatsApp
controla o login, então permitir trocar o número pela sessão transformaria um acesso
temporário em sequestro permanente da conta.
8.15.3 Conta excluída #
deleted_at preenchido produz, em ordem:
| Momento | Efeito |
|---|---|
| Imediato | Todas as sessões revogadas com admin_revoke; todos os OTPs invalidados |
| Imediato | deleted_at preenchido; sai de todas as consultas de envio, que filtram por deleted_at IS NULL |
| Imediato | Login devolve SUBSCRIBER_DELETED (410), nunca 404 |
| Imediato | Assinatura ativa é cancelada no provedor de pagamento |
| 30 dias | Anonimização (6.33.5): telefone, e-mail e nome zerados |
| 5 anos | Expurgo de consent_events e payment_events |
410 Gone em vez de 404 é escolha deliberada: o titular precisa saber que a conta foi
excluída, não que "não existe". A informação vazada é sobre a conta dele mesmo, que ele já
tem.
Durante os 30 dias, um OWNER pode restaurar a conta (subscriber.restore, auditado). O
telefone permanece bloqueado para novo cadastro nesse período, porque
uq_subscribers_phone_hmac é único sobre as linhas não excluídas e a linha ainda existe.
Depois da anonimização, phone_hmac é substituído por um valor derivado e o número fica
livre para uma conta nova.
DELETED não é um status: é deleted_at preenchido. O enum SubscriberStatus tem sete
valores (PENDING_VERIFICATION, VERIFIED_PENDING_OPTIN, ACTIVE_FREE, ACTIVE_PAID,
PAUSED, OPTED_OUT, BLOCKED), e a exclusão é sinalizada pela coluna de data, não por um
oitavo valor — o que evita que uma consulta esqueça de filtrar um dos dois e trate conta
excluída como ativa.
8.15.4 Assinante rebaixado de PAID para FREE no meio da sessão #
Cenário: o webhook de PAYMENT_OVERDUE chega às 14:32. O assinante está com o painel
aberto e um JWT emitido às 14:25 com tier: "PAID", válido até 14:40.
Decisão canônica: o claim tier do JWT é uma dica de interface, nunca autoridade de
autorização. Toda decisão de entitlement lê o tier do banco, por resolveEntitlements.
Linha do tempo concreta:
14:25 JWT emitido com tier=PAID, exp=14:40
14:32 webhook PAYMENT_OVERDUE
├─ subscriptions.status = EXPIRED
├─ subscribers.tier = FREE
└─ tudo na mesma transação, sem carência
14:33 GET /api/me/devotionals/{id}/audio-url
├─ JWT ainda diz tier=PAID
├─ guarda de entitlement lê subscribers.tier do banco → FREE
└─ 403 ENTITLEMENT_DENIED ← revogação efetiva na hora
14:33 o front recebe 403 e abre o fluxo de upgrade
14:40 JWT expira
14:40 POST /api/auth/refresh → novo JWT com tier=FREETrês consequências obrigatórias:
- A sessão não é revogada. Rebaixamento não é comprometimento de segurança. Derrubar o login de alguém que acabou de ter o cartão recusado é hostil e não resolve nada.
- Nenhuma consulta usa
claims.tierpara decidir acesso. Um teste de arquitetura proíbe qualquer referência aclaims.tierfora da camada de apresentação. - O envio já reflete o novo tier, inclusive dentro do lote em execução. O planejador
lê
subscribers.tieràs 05:40 e gravadelivery_attempts.tier_at_send, mas esse valor é registro histórico, nunca autoridade. Imediatamente antes de chamar o provedor, e dentro do mesmo job, o motor de envio relêsubscribers.tier,opt_out_at,deleted_ateblocked_atpor chave primária e reavalia o tier no momento do disparo (Seção 18.5). Assinante que perdeu o acesso pago entre o planejamento das 05:40 e o disparo das 06:00 recebe apenas o texto do plano gratuito e não recebe áudio. Sem essa releitura, o congelamento das 05:40 concederia na prática um dia de carência a todo inadimplente — exatamente a carência que a regra do produto proíbe. O CHECKchk_delivery_audio_step_paid(6.20.3) continua garantindo no banco que nenhuma etapa de áudio é planejada para tier FREE; ele valida o planejamento, e por isso não substitui a revalidação no disparo.
O caminho inverso — FREE para PAID após confirmação de pagamento — segue a mesma lógica e é
até mais visível: o assinante recebe 403 até o pagamento confirmar e passa a receber
200 no instante seguinte, sem precisar sair e entrar de novo.
8.15.5 Outros casos tratados #
| Caso | Comportamento |
|---|---|
| Dois OTPs pedidos em abas diferentes | O segundo invalida o primeiro. Só o mais recente funciona. A interface avisa: "Enviamos um novo código; use o mais recente." |
| Relógio do servidor fora de sincronia | clockTolerance: 5 no JWT absorve até 5 s. O alerta clock_drift (Seção 23.8) dispara com desvio acima de 2 segundos em relação à fonte de tempo, isto é, bem antes de o limite de tolerância ser atingido — um alerta que só dispara quando a falha já ocorreu não é alerta. Verificação de TOTP com deriva de ±1 período absorve até 30 s no dispositivo do usuário. |
| Redis indisponível durante o login | Rotas de autenticação devolvem SERVICE_UNAVAILABLE (7.12.4). Sem Redis não há proteção contra força bruta, e sessões existentes continuam funcionando pela verificação no banco. |
| Postgres indisponível | Toda autenticação falha com DATABASE_UNAVAILABLE. Não há caminho degradado: sem banco não é possível verificar revogação. |
| Cookie de sessão sem cookie de refresh | Comportamento normal por 15 minutos, depois SESSION_EXPIRED sem recuperação. O usuário refaz o login. |
| Cookie de refresh sem cookie de sessão | O refresh funciona e emite os dois cookies. É o estado normal após 15 minutos de inatividade. |
Administrador rebaixado de ADMIN para EDITOR |
Todas as sessões dele são revogadas com admin_revoke. Papel é autorização estrutural, então a revogação é imediata e total — ao contrário do tier. |
Último OWNER tenta se rebaixar |
LAST_OWNER_PROTECTED (409). Verificado dentro da transação com SELECT count(*) ... FOR UPDATE sobre as contas OWNER ativas, para que duas requisições simultâneas não removam os dois últimos. |
| Impersonação ativa e o administrador faz logout | A sessão de impersonação é revogada junto, com revoked_reason = 'admin_revoke'. Sessão de suporte nunca sobrevive à sessão que a criou. |
| Opt-out, cancelamento de assinatura, troca de cartão ou pedido de eliminação tentados durante a impersonação | Impossível, sem exceção: withApi recusa qualquer método não seguro sob scope = 'IMPERSONATION_READONLY', e assertNotImpersonating é a segunda camada em todo handler com guarda requireSubscriber (8.13.3). O critério é a guarda, não o prefixo da rota. |
| Conta dormente pede código e acerta | Sessão nasce com scope = 'RESTRICTED' (8.15.1.1). Ler o devocional do dia, sair e cancelar funcionam; ver dados pessoais, acervo, pagamentos e exportação devolvem 403 REVERIFICATION_REQUIRED. |
wa_id do assinante muda |
Todas as sessões são revogadas, os OTPs ativos invalidados e phone_verified_at zerado. Nova verificação por código é obrigatória antes de qualquer dado pessoal (8.15.1). |
| Magic link aberto duas vezes | O primeiro consumo grava consumed_at. O segundo devolve MAGIC_LINK_INVALID, e o front oferece pedir um novo. |
| Login bem-sucedido com senha que precisa de rehash | O hash é reescrito com os parâmetros correntes na mesma requisição, de forma transparente (8.5.1). |
9. Landing Page e Página de Vendas — Especificação Funcional #
9.1 Escopo, princípios e decisões desta seção #
Esta seção especifica a superfície pública do produto: tudo que um visitante não autenticado vê antes de virar assinante. Ela define rotas, blocos, componentes, dados, comportamento, acessibilidade, SEO, performance, analytics e páginas de erro.
Ela não define o texto de marketing. Headlines, subheadlines, bullets, FAQ, depoimentos e rótulos de CTA são propriedade da Seção 10. Onde esta seção precisa de texto, ela nomeia a chave de conteúdo e diz que o copy aprovado da Seção 10 preenche o bloco. O componente recebe o texto por prop ou por leitura do catálogo de copy; nunca duplica string de marketing dentro do JSX.
Decisões tomadas aqui e válidas para todo o documento:
| # | Decisão | Justificativa |
|---|---|---|
| D9.1 | A landing e os painéis vivem no mesmo app Next.js (apps/web), separados por route groups (marketing), (app) e (admin). |
Um deploy, um domínio de sessão, zero duplicação de design system. |
| D9.2 | Páginas públicas são estáticas por padrão (SSG). O único segmento com revalidação é a home, por causa do bloco do devocional público do dia (ISR, revalidate = 300). |
LCP baixo sem custo de servidor por visita. |
| D9.3 | Não existe página pública por data de devocional no MVP (/devocional/2026-08-25 não é rota). O acervo é benefício de assinante e vive atrás de login (Seção 14.6). O público vê apenas o devocional do dia corrente, com áudio de amostra. |
Evita canibalizar o valor da assinatura e reduz superfície de SEO duplicado. |
| D9.4 | Analytics é first-party e sem cookie por padrão. Eventos vão para POST /api/public/events, que loga estruturado (Seção 23). A rota pública nunca escreve em daily_metrics: quem consolida é o job de rollup das 03:10 (Seção 21.5), lendo os eventos agregados do dia anterior já fechado. Ferramentas de terceiros são opcionais e só carregam com consentimento (Seção 9.14). |
Conformidade LGPD por desenho e nenhuma tabela nova fora da lista fechada da Seção 6. |
| D9.5 | O formulário de captura da landing não cria assinante. Ele só valida e faz handoff para /cadastro, que é território da Seção 11. |
Uma única implementação do cadastro, um único ponto de consentimento. |
| D9.6 | Nenhuma página pública lê a tabela subscribers. A superfície pública consome apenas devotionals publicados, plans e a matriz de entitlements. |
Reduz risco de vazamento de dado pessoal em cache de CDN. |
Restrição de estilo permanente: sem emoji, sem exclamação em série, sem promessa de resultado espiritual. A Seção 10 já garante isso no copy; a Seção 9 garante que nenhum componente injete decoração fora do copy aprovado.
9.2 Mapa de rotas públicas #
Todas as rotas abaixo vivem no route group (marketing) de apps/web, sem middleware de
autenticação, e respondem em https://palavradiaria.com.br. As versões www. redirecionam
com 308 para o apex.
| Rota | Arquivo | Renderização | Propósito | Indexável |
|---|---|---|---|---|
/ |
app/(marketing)/page.tsx |
ISR, revalidate = 300 |
Home e página de vendas principal. Contém a oferta completa, o devocional público do dia e o formulário de captura. | Sim |
/planos |
app/(marketing)/planos/page.tsx |
SSG | Comparativo detalhado FREE × PAID, preços mensal e anual, FAQ de cobrança, CTA para checkout. | Sim |
/termos |
app/(marketing)/termos/page.tsx |
SSG | Termos de Uso, com versão e data de vigência visíveis. | Sim |
/privacidade |
app/(marketing)/privacidade/page.tsx |
SSG | Política de Privacidade, bases legais, direitos do titular, contato do encarregado. Conteúdo jurídico definido na Seção 22. | Sim |
/faq |
app/(marketing)/faq/page.tsx |
SSG | Perguntas frequentes completas. Fonte do JSON-LD FAQPage. |
Sim |
/contato |
app/(marketing)/contato/page.tsx |
SSG + Server Action | Formulário de contato e canais de atendimento. | Sim |
/cancelamento |
app/(marketing)/cancelamento/page.tsx |
SSG | Explica, em linguagem simples, todas as formas de cancelar: palavra-chave no WhatsApp, painel, e-mail. Exigência de transparência do CDC. | Sim |
/404 |
app/not-found.tsx |
Estático | Página de não encontrado. | Não (noindex) |
/500 |
app/error.tsx + app/global-error.tsx |
Estático | Falha de renderização. | Não |
/manutencao |
app/manutencao/page.tsx |
Estático, servido com 503 |
Janela de manutenção programada. | Não |
Redirecionos permanentes (308) configurados em next.config.ts:
// apps/web/next.config.ts (trecho)
const redirects = async () => [
{ source: '/precos', destination: '/planos', permanent: true },
{ source: '/planos/', destination: '/planos', permanent: true },
{ source: '/politica-de-privacidade', destination: '/privacidade', permanent: true },
{ source: '/termos-de-uso', destination: '/termos', permanent: true },
{ source: '/cancelar', destination: '/cancelamento', permanent: true },
{ source: '/perguntas-frequentes', destination: '/faq', permanent: true },
];Rotas públicas que não são páginas e existem para servir a landing:
| Rota | Método | Auth | Propósito |
|---|---|---|---|
/api/public/devotional-preview |
GET |
Nenhuma | Devocional público do dia (teaser + metadados + URL do áudio de amostra). Detalhe em 9.16.1. |
/api/public/plans |
GET |
Nenhuma | Planos ativos com preço formatado e matriz de entitlements. Detalhe em 9.16.2. |
/api/public/events |
POST |
Nenhuma | Ingestão de eventos de analytics first-party. Detalhe em 9.16.3. |
/api/public/contact |
POST |
Nenhuma + Turnstile | Envio do formulário de contato. Detalhe em 9.16.4. |
/api/public/config |
GET |
Nenhuma | Configuração pública não secreta que a landing e o painel consomem em tempo de execução. Detalhe em 9.16.5. |
/sitemap.xml |
GET |
Nenhuma | Gerado por app/sitemap.ts. |
/robots.txt |
GET |
Nenhuma | Gerado por app/robots.ts. |
/opengraph-image |
GET |
Nenhuma | Imagem OG dinâmica por rota, via ImageResponse. |
9.3 Estrutura de blocos da home, em ordem #
A home é uma sequência determinística de blocos. A ordem abaixo é normativa: o executor
não pode reordenar sem alterar esta seção. Cada bloco é um Server Component, exceto onde
indicado como Client Component ('use client').
9.3.1 Tabela de blocos #
| # | Bloco | Componente | Tipo | Dados exibidos | Comportamento |
|---|---|---|---|---|---|
| B1 | Barra de anúncio | <AnnouncementBar> |
Server | Texto curto do copy aprovado da Seção 10; link para /planos. |
Dispensável: botão "fechar" grava pd_announcement_dismissed em localStorage por 30 dias. Só aparece se a chave de configuração landing.announcement_enabled (BOOL, padrão false) estiver ligada. A chave segue o formato grupo.chave e integra o catálogo de settings da Seção 26.8.1, semeado pela Seção 6. |
| B2 | Cabeçalho | <SiteHeader> |
Client | Logotipo, navegação (Planos, FAQ, Contato), botão Entrar (→ /login), CTA primário. |
Sticky a partir de 96 px de rolagem, com sombra. Em telas < md, vira menu em Sheet do shadcn/ui. |
| B3 | Herói | <HeroSection> |
Server | Headline, subheadline e CTA do copy aprovado da Seção 10; mockup de conversa do WhatsApp; formulário de captura inline (B3a). | Primeiro elemento do DOM depois do header. Contém o LCP: a imagem do mockup é priority. |
| B3a | Captura inline | <LeadCaptureForm variant="hero"> |
Client | Campo único de telefone + CTA. | Ver 9.7. |
| B4 | Prova social curta | <SocialProofStrip> |
Server | Contagem de assinantes ativos arredondada para baixo à centena, avaliação média e número de devocionais publicados. | Números vêm de /api/public/plans → meta.stats. Se assinantes_ativos < 500, o bloco esconde a contagem e mostra apenas os devocionais publicados. Regra anti-exagero. |
| B5 | Como funciona | <HowItWorksSection> |
Server | Três passos ilustrados: cadastro, confirmação no WhatsApp, devocional às 06:00. | Ícones inline em SVG, sem biblioteca de ícones externa no caminho crítico. |
| B6 | Amostra de áudio | <AudioSampleSection> |
Client | Devocional público do dia: título, referência bíblica, teaser e player de áudio de amostra. | Ver 9.6. |
| B7 | Benefícios | <BenefitsGrid> |
Server | Seis cartões de benefício do copy aprovado da Seção 10. | Grade 1 / 2 / 3 colunas por breakpoint. |
| B8 | Comparativo de planos | <PlanComparison> |
Server | Matriz FREE × PAID renderizada a partir dos entitlements. Preços mensal e anual com alternador. | Ver 9.5. |
| B9 | Depoimentos | <TestimonialCarousel> |
Client | Depoimentos reais cadastrados, com nome, cidade e foto opcional. Os blocos de exemplo da Seção 10.9 são gabaritos de redação e nunca são renderizados. | Carrossel com rolagem por scroll-snap; sem autoplay (ver 9.10.6). Renderiza null quando não há depoimento real: a seção não ocupa espaço nem desloca o layout, e nada a substitui — nem texto de espera, nem esqueleto (Seção 10.9). |
| B10 | FAQ resumido | <FaqAccordion limit={6}> |
Server | Seis perguntas mais frequentes do copy aprovado da Seção 10 + link para /faq. |
Accordion do shadcn/ui, um item aberto por vez, primeiro item aberto por padrão. |
| B11 | Garantia e transparência | <TrustSection> |
Server | Como cancelar, o que acontece com os dados, contato do encarregado, selo de pagamento pela Asaas. | Link direto para /cancelamento e /privacidade. |
| B12 | CTA final | <FinalCtaSection> |
Server | Repetição da oferta e do formulário de captura (variant="final"). |
Segunda instância do <LeadCaptureForm> com source="final_cta". |
| B13 | Rodapé | <SiteFooter> |
Server | Navegação secundária, CNPJ, endereço, e-mail do encarregado, versão dos termos, link para preferências de cookies. | O link "Preferências de cookies" reabre o modal de consentimento (9.14). |
9.3.2 Regras transversais aos blocos #
- Cada bloco de topo renderiza um
<section>comaria-labelledbyapontando para oiddo seu próprio título. Blocos sem título visível usamaria-label. - Cada bloco recebe
data-block="B3"(o identificador da tabela acima) para permitir correlação com os eventos de analytics da Seção 9.13 sem depender de seletor CSS. - Nenhum bloco pode empurrar layout depois da hidratação. Todo elemento de altura
variável reserva espaço com
min-heightouaspect-ratio(orçamento de CLS em 9.12). - Blocos B4, B6 e B8 dependem de dados de servidor. Se a origem falhar, cada um tem
degradação definida:
- B4: esconde o bloco inteiro.
- B6: mostra o estado de erro do player (9.6.4) e mantém o restante da página.
- B8: renderiza a matriz a partir do fallback estático embutido em build time
(
getEntitlementMatrix()depackages/core), sem preço; o preço vira "consulte em /planos" e o CTA aponta para/planos.
9.3.3 Ordem visual em mobile #
Em viewports < 768 px, a ordem lógica do DOM é mantida (importante para leitores de
tela), com dois ajustes puramente visuais implementados por order do Flexbox dentro
do mesmo bloco, nunca entre blocos:
- Em B3, o formulário de captura aparece acima do mockup da conversa.
- Em B8, o cartão do plano PAID aparece antes do FREE, porque é a ação desejada.
Nunca usar order para reordenar seções inteiras: isso quebra a ordem de navegação por
teclado (Seção 9.10.4).
9.4 Componentes React e contratos de props #
Os componentes de marketing vivem em apps/web/src/components/marketing/. Todos são
tipados; nenhum aceita any. Os tipos de domínio (EntitlementMatrix, PublicPlan,
PublicDevotional) são exportados por packages/core e reaproveitados pelo worker.
// packages/core/src/types/public.ts
// 'plan_free' é sintético; não existe em plans (Seção 13.1). Ele é montado pelo handler
// da rota pública de planos e nunca é aceito em nenhuma escrita.
export type PlanCode = 'plan_free' | 'plan_monthly' | 'plan_annual';
export type BillingCycle = 'MONTHLY' | 'YEARLY';
export type Tier = 'FREE' | 'PAID';
export interface PublicPlan {
code: PlanCode;
tier: Tier;
name: string; // "Gratuito" | "Mensal" | "Anual"
cycle: BillingCycle | null; // null para plan_free
priceCents: number; // 0 | 1990 | 19900
currency: 'BRL';
priceLabel: string; // "R$ 19,90/mês" — formatado no servidor
monthlyEquivalentLabel: string | null; // "R$ 16,58/mês" para o anual
savingsLabel: string | null; // "economize 16%" — arredondado para baixo (Seção 9.5.3)
isRecommended: boolean;
}
export interface EntitlementRow {
key: string; // "daily_frequency", "audio", "archive", ...
label: string; // rótulo em português, vindo da Seção 13
free: EntitlementCell;
paid: EntitlementCell;
}
export type EntitlementCell =
| { kind: 'boolean'; value: boolean }
| { kind: 'text'; value: string }
| { kind: 'quota'; value: number; unit: string };
export interface EntitlementMatrix {
version: string; // "2026-08-25" — muda quando a matriz muda
rows: EntitlementRow[];
}
export interface PublicDevotional {
id: string; // "dev_01H..."
date: string; // "2026-08-25" (America/Sao_Paulo)
title: string;
bibleReference: string; // "Salmos 23:1-3"
bibleVersion: string; // "Almeida 1911" | "Bíblia Livre" — nunca a sigla "ARC"
teaser: string; // 40 a 300 caracteres, sem quebras (Seção 15.2.5)
sampleAudioUrl: string | null;// URL assinada, TTL curto; null se ainda não gerado
sampleAudioDurationSec: number | null;
sampleAudioMimeType: 'audio/mpeg' | null;
}Contratos dos componentes principais:
// apps/web/src/components/marketing/hero-section.tsx
export interface HeroSectionProps {
headline: string;
subheadline: string;
primaryCta: { label: string; href: string };
secondaryCta?: { label: string; href: string };
/** Bullets curtos exibidos sob o CTA. Máximo de 3. */
highlights: readonly string[];
/** Mockup da conversa. Sempre local, nunca hotlink. */
media: { src: string; alt: string; width: number; height: number };
}
// apps/web/src/components/marketing/plan-comparison.tsx
export interface PlanComparisonProps {
plans: readonly PublicPlan[];
matrix: EntitlementMatrix;
/** Ciclo destacado por padrão no alternador. */
defaultCycle: BillingCycle;
/** Para onde o CTA leva. Sempre /cadastro?plan=..., nunca direto ao checkout. */
ctaHrefBuilder: (plan: PublicPlan) => string;
}
// apps/web/src/components/marketing/audio-sample-player.tsx
export interface AudioSamplePlayerProps {
devotional: PublicDevotional;
/** Rótulos vindos do copy aprovado da Seção 10. */
labels: {
play: string; pause: string; loading: string;
error: string; retry: string; unavailable: string;
};
onEvent?: (e: AudioPlayerEvent) => void;
}
export type AudioPlayerEvent =
| { type: 'play'; positionSec: number }
| { type: 'pause'; positionSec: number }
| { type: 'progress'; milestone: 25 | 50 | 75 | 100 }
| { type: 'error'; reason: 'network' | 'decode' | 'not_found' | 'expired' };
// apps/web/src/components/marketing/lead-capture-form.tsx
export interface LeadCaptureFormProps {
variant: 'hero' | 'final' | 'inline';
/** Rastreia de qual bloco veio o lead. Vai para o evento e para a query string. */
source: 'hero' | 'final_cta' | 'plans_page' | 'faq_page';
/** Plano pré-selecionado quando o formulário está ao lado de um cartão de plano. */
planCode?: PlanCode;
submitLabel: string;
helperText: string;
legalText: string; // aviso curto sobre consentimento; texto completo em /cadastro
}Os componentes de UI genéricos (Button, Card, Accordion, Sheet, Dialog,
Skeleton, Slider) vêm de shadcn/ui sobre primitivos Radix, conforme a Seção 4. Nenhum
componente de marketing define variantes de botão próprias: todas as variantes vivem no
buttonVariants central, descrito na Seção 5.
9.5 Comparativo de planos a partir da matriz de entitlements #
Regra dura: a tabela de comparação nunca é escrita à mão no JSX. Ela é derivada da
matriz canônica de entitlements, cuja fonte única é a Seção 13 e cuja implementação é a
função resolveEntitlements() em packages/core. Se a matriz mudar, a landing muda
sozinha, sem edição de componente.
9.5.1 Derivação #
// packages/core/src/entitlements/matrix.ts
import { resolveEntitlements } from './resolve';
/**
* Constrói a matriz de comparação pública a partir da MESMA função que o motor de
* envio e o painel usam para autorizar. Sem literais duplicados.
*/
export function getEntitlementMatrix(): EntitlementMatrix {
const free = resolveEntitlements({ tier: 'FREE' });
const paid = resolveEntitlements({ tier: 'PAID' });
return {
version: ENTITLEMENT_MATRIX_VERSION,
rows: [
{
key: 'send_frequency',
label: 'Frequência do devocional',
free: { kind: 'text', value: describeFrequency(free) },
paid: { kind: 'text', value: describeFrequency(paid) },
},
{
key: 'full_text',
label: 'Texto completo do devocional',
free: { kind: 'boolean', value: free.fullText },
paid: { kind: 'boolean', value: paid.fullText },
},
{
key: 'audio',
label: 'Áudio narrado',
free: { kind: 'boolean', value: free.audio },
paid: { kind: 'boolean', value: paid.audio },
},
{
key: 'archive',
label: 'Acervo no painel',
free: { kind: 'text', value: describeArchive(free) },
paid: { kind: 'text', value: describeArchive(paid) },
},
{
key: 'manual_resend',
label: 'Reenvio manual pelo painel',
free: { kind: 'quota', value: free.manualResendPerDay, unit: 'por dia' },
paid: { kind: 'quota', value: paid.manualResendPerDay, unit: 'por dia' },
},
{
key: 'support',
label: 'Suporte',
free: { kind: 'text', value: describeSupport(free) },
paid: { kind: 'text', value: describeSupport(paid) },
},
],
};
}describeFrequency, describeArchive e describeSupport são funções puras que traduzem
os valores resolvidos para português. Elas são as únicas produtoras de texto derivado
de entitlement no sistema e são reutilizadas pelo painel do assinante (Seção 14.2.1) e pela
ficha do assinante no painel administrativo (Seção 15.7.2). Um teste de contrato (Seção 24) falha o build se a
quantidade de linhas da matriz mudar sem atualização do teste — isso força revisão
consciente do comparativo sempre que um entitlement nasce ou morre.
9.5.2 Renderização #
- Em
>= md: tabela HTML real (<table>com<caption>visualmente oculto,<thead>comscope="col"e primeira coluna comscope="row"). Leitores de tela anunciam linha e coluna corretamente. - Em
< md: dois cartões empilhados (<PlanCard>), PAID primeiro, cada um listando as mesmas linhas em<dl>. Não usar tabela com rolagem horizontal: falha em alvo de toque e em zoom de 200%. - Célula
boolean: truerenderiza um ícone de marcação com<span class="sr-only">contendo "Incluído". Célulaboolean: falserenderiza traço com texto oculto "Não incluído". Nunca depender só de cor ou de glifo. - Célula
quotarenderiza"{value}x {unit}"(ex.:3x por dia).
9.5.3 Alternador mensal/anual #
// Estado local do <PlanComparison>, sem persistência entre páginas.
// Regra: alternar NÃO recarrega a página nem chama a API. Os dois preços já vieram.
type CycleToggleState = { cycle: BillingCycle };- Controle é um
role="radiogroup"com doisrole="radio", navegável por setas. - Ao trocar para
YEARLY, exibesavingsLabeldo plano anual (economize 16%). O rótulo vem calculado do servidor; o cliente nunca faz aritmética de preço. - Exemplo concreto com os preços padrão da Seção 1: mensal R$ 19,90 → 12 meses =
R$ 238,80; anual R$ 199,00; economia = R$ 39,80 = 16,66% → arredondado para baixo,
savingsLabel = "economize 16%". A regra de arredondamento é sempre para baixo, para nunca exagerar o benefício. - O evento
plan_cycle_toggledé emitido a cada troca (Seção 9.13).
9.5.4 Destino do CTA #
O CTA de qualquer plano leva a /cadastro?plan=<code>&source=<source>. Ele nunca
leva direto ao checkout da Asaas. Motivo: o checkout exige assinante identificado e
verificado por OTP; a Seção 11 é dona desse fluxo e a Seção 12 é dona do checkout.
Exemplo: o cartão anual na home gera
/cadastro?plan=plan_annual&source=home_plan_comparison.
9.6 Player de amostra de áudio #
9.6.1 O que é a amostra #
O bloco B6 exibe o devocional público do dia: o mesmo conteúdo que os assinantes receberam às 06:00, com teaser (não o texto completo) e um áudio de amostra de até 60 segundos, cortado do início da narração completa.
Decisões:
- O corte de 60 s é gerado pelo pipeline de mídia (Seção 16) como um derivado adicional
do MP3 web, com sufixo de chave
sample. Não há transcodificação em tempo de request. - Formato servido ao navegador: MP3 128 kbps mono (
audio/mpeg). O OGG/Opus existe para o WhatsApp e não é usado no player web, porque a compatibilidade de MP3 em Safari é irrestrita. - A URL é assinada com TTL de 15 minutos (Seção 16). Como a home é ISR com
revalidate = 300, a URL em cache nunca é mais velha que 5 minutos e sempre tem pelo menos 10 minutos de validade restante quando o visitante a recebe. - Se o devocional do dia ainda não tem áudio pronto (
AUDIO_PENDING), a API devolvesampleAudioUrl: nulle o player entra no estado "indisponível" (9.6.4), sem erro.
9.6.2 Marcação e controles #
O player usa um elemento <audio> nativo com controls removido e controles
customizados, para atingir contraste e alvo de toque adequados. Requisitos:
<div role="group" aria-label="Amostra do devocional de hoje">
<audio
ref={audioRef}
preload="none" // nunca baixa áudio sem intenção do usuário
src={devotional.sampleAudioUrl ?? undefined}
onLoadedMetadata={...} onTimeUpdate={...} onEnded={...} onError={...}
/>
<button type="button" aria-label={isPlaying ? labels.pause : labels.play}
aria-pressed={isPlaying} className="h-12 w-12 ...">…</button>
<div role="slider" tabIndex={0}
aria-label="Posição da reprodução"
aria-valuemin={0} aria-valuemax={durationSec}
aria-valuenow={positionSec}
aria-valuetext={formatTimeAria(positionSec, durationSec)} />
<output aria-live="off">{formatTime(positionSec)} / {formatTime(durationSec)}</output>
</div>Comportamento de teclado no role="slider":
| Tecla | Efeito |
|---|---|
Espaço / Enter no botão |
Alterna reproduzir/pausar |
Seta esquerda / direita |
±5 s |
Seta abaixo / acima |
±5 s |
Page Up / Page Down |
±15 s |
Home / End |
Início / fim |
M |
Alterna mudo |
aria-live="off" no contador é intencional: o tempo muda a cada 250 ms e anunciá-lo
inundaria o leitor de tela. Mudanças de estado relevantes (iniciou, pausou, terminou,
falhou) são anunciadas por uma região aria-live="polite" separada e de baixa frequência.
9.6.3 Estados de carregamento #
| Estado | Gatilho | UI | Duração máxima |
|---|---|---|---|
idle |
Render inicial | Botão de reproduzir habilitado, duração já conhecida via sampleAudioDurationSec (evita CLS). |
— |
buffering |
play() chamado, readyState < 3 |
Botão vira spinner com aria-busy="true" e rótulo labels.loading. |
10 s, depois vira error:network. |
playing |
onPlay |
Botão vira pausa, barra progride via requestAnimationFrame limitado a 4 Hz. |
— |
paused |
onPause |
Botão volta a reproduzir, posição preservada. | — |
ended |
onEnded |
Botão volta a reproduzir, posição volta a 0, CTA secundário "Assine para ouvir completo" ganha foco visual (não foco de teclado). | — |
unavailable |
sampleAudioUrl === null |
Botão desabilitado com aria-disabled="true" e texto labels.unavailable. Sem spinner infinito. |
— |
error |
onError ou timeout |
Ver 9.6.4. | — |
Só um player pode tocar por vez na página. O <AudioSampleSection> registra o elemento
em um contexto simples; um segundo play() pausa o anterior.
9.6.4 Erros do player #
| Causa | Detecção | AudioPlayerEvent.reason |
Mensagem exibida | Ação oferecida |
|---|---|---|---|---|
| Rede indisponível | MediaError.MEDIA_ERR_NETWORK ou timeout de 10 s |
network |
"Não foi possível carregar o áudio. Verifique sua conexão." | Botão "Tentar de novo" (recarrega src com cache-buster). |
| Codec/decodificação | MEDIA_ERR_DECODE ou MEDIA_ERR_SRC_NOT_SUPPORTED |
decode |
"Seu navegador não conseguiu tocar este áudio." | Link "Baixar amostra" apontando para a mesma URL com download. |
| URL assinada expirada | 403 do storage, detectado como MEDIA_ERR_NETWORK após fetch(url, {method:'HEAD'}) de diagnóstico |
expired |
"O link do áudio expirou." | Botão "Atualizar" que refaz GET /api/public/devotional-preview e substitui o src. |
| Áudio não existe | sampleAudioUrl === null na resposta |
not_found |
labels.unavailable: "O áudio de hoje está sendo preparado." |
Nenhuma; o restante do bloco continua utilizável. |
Regras adicionais:
- No máximo 2 tentativas automáticas de recarregar após
network, com espera de 1 s e 3 s. A terceira exige clique. - Toda ocorrência de
erroremite o eventoaudio_sample_error(Seção 9.13) comreasonedevotionalId, e um logwarnno servidor apenas quando o diagnóstico de expiração confirma403(Seção 23). - O player nunca reproduz automaticamente. Autoplay é proibido em toda a superfície pública, por acessibilidade e por política de navegadores.
9.6.5 Acessibilidade do player #
- Contraste de todos os controles ≥ 4,5:1 para texto e ≥ 3:1 para os ícones e a barra de progresso, conforme 9.10.2.
- Alvo de toque mínimo 44 × 44 CSS px no botão principal e 44 px de altura na área de arrasto da barra.
prefers-reduced-motion: reducedesliga a animação de forma de onda e mantém apenas a barra linear.- O player é totalmente operável sem mouse e sem áudio: o teaser textual do mesmo devocional está ao lado, então nenhuma informação existe apenas em áudio (WCAG 1.2.1).
9.7 Formulário de captura e handoff para o cadastro #
9.7.1 O que ele faz e o que ele não faz #
O <LeadCaptureForm> coleta apenas o telefone. Ele não coleta nome, não coleta
e-mail, não pede consentimento e não cria nenhum registro no banco. Sua única função é
reduzir o atrito da primeira ação e levar o visitante ao cadastro com o campo já
preenchido.
Justificativa da decisão: o consentimento precisa ser dado uma vez, com texto versionado
e registro imutável em consent_events (Seção 22). Coletar consentimento em dois lugares
distintos duplica risco jurídico e código. Portanto, o consentimento acontece só em
/cadastro (Seção 11.3).
9.7.2 Validação no cliente #
Validação com react-hook-form + Zod, usando o mesmo schema compartilhado que a Seção
11 usa no servidor, importado de packages/core:
// packages/core/src/schemas/phone.ts
import { z } from 'zod';
import { normalizeBrazilPhone } from '../phone';
export const phoneInputSchema = z
.string()
.trim()
.min(1, 'Informe seu número de WhatsApp.')
.transform((v) => v.replace(/[^\d+]/g, ''))
.superRefine((v, ctx) => {
const result = normalizeBrazilPhone(v);
if (!result.ok) {
ctx.addIssue({ code: 'custom', message: PHONE_ERROR_MESSAGES[result.reason] });
}
})
.transform((v) => normalizeBrazilPhone(v).e164!);As mensagens de erro exatas, a normalização e o tratamento de DDI estrangeiro são canônicos na Seção 11.4. O formulário de captura apenas reaproveita.
Máscara de digitação: aplicada progressivamente em (00) 00000-0000, sem impedir colagem
de números em qualquer formato. A máscara é apresentação; o valor submetido é sempre o
E.164 produzido pelo schema.
9.7.3 Handoff #
Ao submeter com sucesso, o formulário não faz requisição. Ele navega:
const params = new URLSearchParams({
phone: e164, // "+5511987654321"
source, // "hero" | "final_cta" | ...
...(planCode ? { plan: planCode } : {}),
});
router.push(`/cadastro?${params.toString()}`);Regras do handoff:
- O telefone trafega na query string em E.164. Isso é dado pessoal em URL, então:
- a rota
/cadastroénoindex(9.11.2) e não é logada com query string no acesso (a Seção 23 define o redator de logs que removephonede URLs); - imediatamente após a hidratação,
/cadastrofazhistory.replaceStatepara removerphoneda barra de endereços, mantendo o valor apenas no estado do formulário. Isso evita vazamento porReferere por histórico compartilhado.
- a rota
sourceeplanpermanecem na URL: não são dados pessoais e são úteis para atribuição.- Se o visitante chegar em
/cadastrosemphone, o campo aparece vazio. Nenhum erro. - Se
planfor um código inexistente ou inativo,/cadastroignora o parâmetro e segue com o fluxo padrão (escolha depois do opt-in, Seção 11.7).
9.7.4 Proteção contra abuso #
O formulário de captura não chama a API, então não há superfície de abuso nele. A
proteção real (rate limit por IP e por número, e verificação Turnstile) vive nos
endpoints da Seção 11.12. O único controle aqui é o honeypot: um campo
<input name="company" tabIndex={-1} autoComplete="off" aria-hidden="true"> escondido
com position:absolute; left:-9999px. Se preenchido, o submit vira no-op silencioso e
emite lead_form_honeypot_triggered.
9.8 Páginas secundárias #
9.8.1 /planos #
Estrutura em ordem: cabeçalho compartilhado → título e subtítulo (copy aprovado da Seção
10) → <PlanComparison> em versão expandida (todas as linhas da matriz, sem corte) →
bloco de formas de pagamento (cartão de crédito e PIX, conforme Seção 12; boleto é
declarado como não aceito) → FAQ de cobrança (subconjunto do copy aprovado da Seção 10,
filtrado por category: 'billing') → bloco de cancelamento com link para /cancelamento
→ CTA final → rodapé.
Dados: getEntitlementMatrix() em build + plans ativos lidos em build. Como o preço é
um dado de negócio raramente alterado, /planos é SSG puro e é revalidado por
revalidação sob demanda: quando um administrador altera um plano (Seção 15.12.16), o
handler chama revalidatePath('/planos') e revalidatePath('/').
Obrigatório por transparência (CDC): a página mostra, em texto legível e não em rodapé minúsculo, (a) o valor total anual quando o ciclo anual está selecionado, (b) que a renovação é automática, (c) que o cancelamento pode ser feito a qualquer momento pelo painel, (d) que não há multa nem fidelidade.
9.8.2 /termos e /privacidade #
- Conteúdo em MDX estático versionado no repositório
(
apps/web/src/content/legal/termos-v{n}.mdx). - Cada documento tem, no topo: número da versão, data de vigência e um resumo de uma frase do que mudou em relação à versão anterior.
- O rodapé de cada página lista as versões anteriores como links permanentes
(
/termos?v=1). Versões antigas são renderizadas com aviso "Esta versão não está mais em vigor". - A versão vigente dos termos é a mesma string gravada em
consent_events.policy_versionno cadastro (Seção 11.3.4). A constanteCURRENT_TERMS_VERSIONvive empackages/coree é lida pelos dois lados. Um teste garante que existe arquivo MDX para a versão corrente. - Sumário lateral (
<TableOfContents>) gerado a partir dosh2/h3do MDX, sticky em>= lg, colapsado em< lg.
9.8.3 /faq #
- Todas as perguntas do copy aprovado da Seção 10, agrupadas por categoria:
product,whatsapp,billing,privacy,technical. - Cada pergunta tem
idestável derivado de slug (#como-cancelo-a-assinatura) para deep-link. - Fonte do JSON-LD
FAQPage(9.11.4). Regra: apenas as perguntas da página/faqentram no JSON-LD; o FAQ resumido da home não emite JSON-LD próprio, para não duplicar. - Campo de busca client-side com filtragem por substring sem acento
(
normalize('NFD').replace(/\p{Diacritic}/gu,'')), sem chamada de rede. - Estado vazio da busca: "Nenhuma pergunta encontrada para termo." + link para
/contato.
9.8.4 /contato #
Formulário com quatro campos e um Server Action:
| Campo | Tipo | Obrigatório | Regra |
|---|---|---|---|
name |
texto | Sim | 2 a 80 caracteres. |
email |
Sim | Formato válido; usado só para responder. | |
subject |
select | Sim | duvida, cobranca, cancelamento, privacidade, outro. |
message |
textarea | Sim | 10 a 2000 caracteres, contador visível a partir de 1800. |
Comportamento: POST /api/public/contact (9.16.4) envia e-mail transacional pelo Resend
para a caixa de suporte, com Reply-To do remetente. Nenhuma tabela nova: a mensagem
não é persistida no banco; o e-mail é o registro. Se subject = 'privacidade', o e-mail é
copiado para o endereço do encarregado (Seção 22).
Estados: idle → submitting (botão desabilitado com spinner) → success (formulário
some, mensagem de confirmação com prazo de resposta de 1 dia útil) ou error (mensagem
inline, dados preservados, botão reabilitado).
Anti-abuso: Cloudflare Turnstile em modo invisível + rate limit de 3 envios por IP por
hora (chave Redis rl:contact:{ip}). Estourou: 429 com code: RATE_LIMITED e mensagem
"Muitas mensagens enviadas. Tente novamente em uma hora."
9.8.5 /cancelamento #
Página de transparência exigida por boa prática de consumo e por política de plataformas de pagamento. Estrutura fixa:
- Frase de abertura direta: cancelar é possível a qualquer momento, sem multa.
- Três caminhos, cada um em um cartão com passos numerados:
- Pelo WhatsApp: enviar
SAIR. Efeito imediato nos envios. Aviso explícito, em destaque: isso interrompe as mensagens na hora e suspende a próxima cobrança, mas não encerra a assinatura por si só — se não houver reativação em 30 dias, ela é cancelada ao fim do período já pago. Quem quiser encerrar de imediato cancela no painel. A distinção canônica está na Seção 20 e a regra de cobrança, na Seção 13. - Pelo painel: entrar em
/app/assinaturae usar "Cancelar assinatura". Cancela a cobrança e mantém o acesso pago até o fim do período já pago. - Por e-mail: escrever para a caixa de suporte com o número cadastrado.
- Pelo WhatsApp: enviar
- Tabela "O que acontece depois", derivada das regras da Seção 13:
| Ação | Mensagens no WhatsApp | Cobrança | Acesso pago |
|---|---|---|---|
Enviar SAIR |
Param imediatamente | Próximo ciclo suspenso; sem reativação em 30 dias, a assinatura é cancelada ao fim do período pago | Continua ativo até o fim do ciclo pago |
| Cancelar no painel | Continuam até o fim do ciclo pago | Não renova | Até current_period_end, depois FREE |
| Ambos | Param imediatamente | Não renova | Até current_period_end, depois FREE |
- Bloco sobre dados: como pedir exportação e exclusão (
/app/dados, Seção 14.8). - CTA discreto de retenção: link para
/app/preferenciasexplicando a pausa de 1 a 30 dias como alternativa ao cancelamento (Seção 14.7.2). Sem barreira, sem pop-up de interceptação.
9.9 Responsividade #
9.9.1 Breakpoints #
Os breakpoints são os padrões do Tailwind (Seção 4), sem customização, para reduzir carga cognitiva:
| Nome | Largura mínima | Uso principal |
|---|---|---|
| (base) | 0 | Telefone em pé. Layout de coluna única. |
sm |
640 px | Telefone grande / telefone deitado. |
md |
768 px | Tablet. Comparativo vira tabela. Navegação sai do menu lateral. |
lg |
1024 px | Notebook. Herói vira duas colunas. Sumário lateral aparece. |
xl |
1280 px | Desktop. Largura máxima de conteúdo em 1200 px. |
2xl |
1536 px | Monitor grande. Conteúdo permanece em 1200 px, margens crescem. |
Mobile-first é obrigatório: as classes base descrevem o telefone e os modificadores
adicionam complexidade para cima. É proibido escrever max-* como estratégia primária.
9.9.2 Regras por bloco #
| Bloco | < md |
md |
>= lg |
|---|---|---|---|
| B2 Cabeçalho | Logotipo + botão de menu (Sheet lateral direita, largura 320 px) |
Navegação horizontal | Navegação + dois CTAs |
| B3 Herói | Coluna única, formulário acima do mockup, mockup com aspect-ratio: 9/16 limitado a 420 px de altura |
Coluna única com mockup menor | Duas colunas 6/6, mockup à direita |
| B5 Como funciona | 1 coluna, passos empilhados com conector vertical | 3 colunas | 3 colunas com conector horizontal |
| B6 Player | Largura total, controles em duas linhas | Uma linha | Uma linha, com forma de onda |
| B7 Benefícios | 1 coluna | 2 colunas | 3 colunas |
| B8 Planos | Cartões empilhados, PAID primeiro | Tabela comparativa | Tabela comparativa com coluna de destaque |
| B9 Depoimentos | Carrossel horizontal com scroll-snap, 1 por vez | 2 por vez | 3 por vez, sem carrossel |
| B13 Rodapé | Acordeões por grupo de links | 2 colunas | 4 colunas |
9.9.3 Alvos de toque e ergonomia #
- Alvo mínimo 44 × 44 CSS px para qualquer elemento interativo (WCAG 2.2, 2.5.8 exige 24 px; adotamos 44 px como padrão do produto, mais rígido).
- Espaçamento mínimo de 8 px entre alvos adjacentes.
- CTAs primários em mobile ficam na metade inferior da tela quando aparecem em bloco de altura completa, por alcance do polegar.
- Nenhum
hoveré requisito para descobrir função. Todo estado de hover tem equivalente emfocus-visiblee em toque. - Fonte base 16 px em mobile (impede zoom automático do iOS em campos de formulário).
<input type="tel" inputMode="numeric" autoComplete="tel-national">no campo de telefone, para abrir o teclado numérico.- Suporte a zoom de até 200% sem rolagem horizontal (WCAG 1.4.4) e a
text-spacingaumentado (WCAG 1.4.12): nenhum contêiner usa altura fixa empxpara texto.
9.10 Acessibilidade #
9.10.1 Nível exigido #
WCAG 2.2 nível AA é obrigatório em todas as rotas públicas e em todo o painel do assinante (Seção 14). O painel administrativo (Seção 15) também mira AA, com exceção documentada e única: o calendário com arrastar-e-soltar oferece alternativa por teclado e por menu, mas o gesto de arrasto em si não é replicável por leitor de tela — a alternativa cobre o requisito 2.5.7 (Movimentos de arrasto).
Critérios novos da 2.2 explicitamente cobertos:
| Critério | Como é atendido |
|---|---|
| 2.4.11 Foco não obscurecido (mínimo) | O cabeçalho sticky reserva scroll-margin-top: 96px em todos os alvos de âncora e em elementos focáveis. |
| 2.4.13 Aparência do foco | Anel de foco de 2 px sólido com contraste ≥ 3:1 contra o fundo adjacente e contra o próprio componente, com 2 px de deslocamento. |
| 2.5.7 Movimentos de arrasto | Toda funcionalidade de arrasto tem alternativa por clique/teclado (calendário administrativo, barra do player). |
| 2.5.8 Tamanho do alvo (mínimo) | 44 px, acima dos 24 px exigidos. |
| 3.2.6 Ajuda consistente | Link para /contato e o e-mail de suporte aparecem na mesma posição do rodapé em todas as páginas. |
| 3.3.7 Entrada redundante | Telefone informado no herói é pré-preenchido em /cadastro. Telefone verificado não é pedido de novo no checkout (Seção 12). |
| 3.3.8 Autenticação acessível (mínimo) | Login por OTP no WhatsApp; o campo de código aceita colagem e autoComplete="one-time-code". Sem CAPTCHA visual obrigatório no fluxo de login; o Turnstile roda em modo invisível e, se exigir interação, oferece alternativa não visual. |
9.10.2 Contraste #
- Texto normal: ≥ 4,5:1. Texto grande (≥ 24 px, ou ≥ 18,66 px em negrito): ≥ 3:1.
- Componentes de interface e gráficos essenciais: ≥ 3:1 (bordas de campo, ícones informativos, barra de progresso do player, marcação de "incluído" na tabela de planos).
- Estados desabilitados estão isentos por norma, mas o produto adota ≥ 3:1 também neles, para não produzir botões ilegíveis.
- A paleta é validada em CI: um teste (Seção 24) percorre os tokens de cor definidos na Seção 5 e falha se qualquer par documentado como "texto sobre fundo" cair abaixo do mínimo.
- Nenhuma informação é transmitida só por cor (WCAG 1.4.1): status de plano usa
ícone + texto; erro de formulário usa ícone + texto +
aria-invalid.
9.10.3 Foco visível e ordem #
:focus-visibleestilizado globalmente;outline: nonesem substituto é proibido por regra de lint (Seção 5).- Ordem de tabulação segue a ordem do DOM. Nenhum
tabindexpositivo em lugar nenhum. - Link "Pular para o conteúdo" é o primeiro elemento focável de toda página, visível ao
receber foco, apontando para
#conteudo-principalno<main>. - Modais (
Dialogdo Radix) prendem o foco, fecham comEsce devolvem o foco ao elemento que os abriu. - O
Sheetde navegação mobile faz o mesmo e marca o conteúdo de trás cominert.
9.10.4 Navegação por teclado #
Toda funcionalidade pública é operável só com teclado. Casos que exigem atenção:
| Componente | Teclas | Observação |
|---|---|---|
| Menu mobile | Esc fecha, Tab circula dentro |
aria-expanded no botão. |
| Acordeão FAQ | Enter/Espaço alterna, Seta cima/baixo move entre cabeçalhos, Home/End |
Padrão Radix. |
| Alternador de ciclo | Seta move e seleciona |
radiogroup. |
| Carrossel de depoimentos | Tab entra em cada cartão; botões "anterior"/"próximo" focáveis |
Sem autoplay, então não há requisito 2.2.2. |
| Player | Ver 9.6.2 | — |
| Banner de cookies | Recebe foco ao aparecer, Esc equivale a "Rejeitar não essenciais" |
Ver 9.14. |
9.10.5 Leitores de tela e semântica #
- Um
<h1>por página. Hierarquia sem saltos. - Landmarks:
<header>,<nav aria-label="Principal">,<main id="conteudo-principal">,<footer>,<nav aria-label="Rodapé">. - Todas as imagens decorativas com
alt=""earia-hidden="true". O mockup da conversa temaltdescritivo real (ex.: "Conversa de WhatsApp mostrando o devocional do dia com título, versículo e um áudio de três minutos"). - Ícones que carregam significado têm texto acessível; ícones ao lado de texto são
aria-hidden. - Erros de formulário:
aria-invalid="true",aria-describedbyapontando para o parágrafo do erro, e a lista de erros do submit anunciada em regiãorole="alert". lang="pt-BR"no<html>. Trechos em outro idioma (nomes de tecnologia não traduzidos) não recebemlangpróprio por não afetarem pronúncia relevante.- Teste automatizado com
axe-coreroda em CI sobre todas as rotas públicas (Seção 24) e falha o build com qualquer violação de severidadeseriousoucritical.
9.10.6 Movimento e prefers-reduced-motion #
/* apps/web/src/styles/globals.css (trecho normativo) */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}Regras de produto:
- Nenhum carrossel avança sozinho. Nenhum conteúdo pisca. Nada se move por mais de 5 segundos sem controle do usuário (WCAG 2.2.2 atendido por ausência de gatilho).
- Animação de forma de onda do player e efeito de paralaxe do herói são desligados sob
reduce. - Transições de entrada por rolagem (
fade-in-up) usamIntersectionObservere são substituídas por aparição imediata sobreduce. - Nenhuma animação com mais de 3 flashes por segundo existe no produto (WCAG 2.3.1).
9.11 SEO técnico #
9.11.1 Metadados por rota #
Metadados são declarados com a API Metadata do App Router. Um helper central evita
divergência:
// apps/web/src/lib/seo/build-metadata.ts
import type { Metadata } from 'next';
const SITE_NAME = 'Palavra Diária';
const BASE_URL = process.env.PUBLIC_SITE_URL!; // https://palavradiaria.com.br
export function buildMetadata(input: {
title: string; // sem sufixo; o helper adiciona
description: string; // 140 a 160 caracteres
path: string; // "/planos"
ogImagePath?: string; // default: `${path}/opengraph-image`
noindex?: boolean;
}): Metadata {
const url = new URL(input.path, BASE_URL).toString();
return {
metadataBase: new URL(BASE_URL),
title: `${input.title} | ${SITE_NAME}`,
description: input.description,
alternates: { canonical: url, languages: { 'pt-BR': url } },
robots: input.noindex
? { index: false, follow: false }
: { index: true, follow: true,
googleBot: { index: true, follow: true, 'max-image-preview': 'large',
'max-snippet': -1, 'max-video-preview': -1 } },
openGraph: {
type: 'website', siteName: SITE_NAME, locale: 'pt_BR',
url, title: input.title, description: input.description,
images: [{ url: input.ogImagePath ?? `${input.path}/opengraph-image`,
width: 1200, height: 630, alt: input.title }],
},
twitter: {
card: 'summary_large_image',
title: input.title, description: input.description,
images: [input.ogImagePath ?? `${input.path}/opengraph-image`],
},
};
}Tabela normativa de metadados. Os textos exatos de título e descrição são propriedade do copy aprovado da Seção 10; a tabela abaixo fixa a estrutura e os limites.
| Rota | title (≤ 60 chars antes do sufixo) |
description |
Indexa | Canonical |
|---|---|---|---|---|
/ |
Proposta de valor principal | 140–160 chars, com "WhatsApp", "devocional" e "todos os dias" | Sim | https://palavradiaria.com.br/ |
/planos |
Foco em preço e comparação | 140–160 chars, com valores | Sim | /planos |
/faq |
Foco em dúvidas | 140–160 chars | Sim | /faq |
/termos |
"Termos de Uso" | Resumo neutro | Sim | /termos (versões antigas: canonical aponta para a vigente) |
/privacidade |
"Política de Privacidade" | Resumo neutro | Sim | /privacidade |
/contato |
"Fale com a gente" | Resumo neutro | Sim | /contato |
/cancelamento |
"Como cancelar" | Resumo neutro | Sim | /cancelamento |
/cadastro, /login, /app/*, /admin/* |
— | — | Não (noindex, nofollow) |
— |
/404, /500, /manutencao |
— | — | Não | — |
9.11.2 robots.txt e sitemap.xml #
// apps/web/src/app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const isProduction = process.env.APP_ENV === 'production';
if (!isProduction) {
return { rules: [{ userAgent: '*', disallow: '/' }] };
}
return {
rules: [
{ userAgent: '*', allow: '/',
disallow: ['/api/', '/app/', '/admin/', '/cadastro', '/login', '/manutencao'] },
],
sitemap: `${process.env.PUBLIC_SITE_URL}/sitemap.xml`,
host: process.env.PUBLIC_SITE_URL,
};
}Regra dura: em staging, robots.txt bloqueia tudo. Um teste E2E (Seção 24) verifica
isso antes de qualquer deploy de staging.
// apps/web/src/app/sitemap.ts
import type { MetadataRoute } from 'next';
export default function sitemap(): MetadataRoute.Sitemap {
const base = process.env.PUBLIC_SITE_URL!;
const now = new Date();
return [
{ url: `${base}/`, lastModified: now, changeFrequency: 'daily', priority: 1.0 },
{ url: `${base}/planos`, lastModified: now, changeFrequency: 'monthly', priority: 0.9 },
{ url: `${base}/faq`, lastModified: now, changeFrequency: 'monthly', priority: 0.7 },
{ url: `${base}/cancelamento`, lastModified: now, changeFrequency: 'yearly', priority: 0.4 },
{ url: `${base}/contato`, lastModified: now, changeFrequency: 'yearly', priority: 0.4 },
{ url: `${base}/termos`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },
{ url: `${base}/privacidade`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },
];
}O sitemap é estático por decisão D9.3: não há URLs públicas por devocional, logo o sitemap tem sete entradas e nunca cresce.
9.11.3 Canonical e hreflang #
- Toda página emite
<link rel="canonical">absoluto para si mesma, sem query string. hreflangdeclarado comopt-BRapontando para a própria URL, maisx-defaultpara a mesma URL. Não há outra localidade e não haverá no MVP (multi-idioma está fora de escopo, Seção 2). Declarar mesmo assim evita ambiguidade para o Google em um domínio.com.br.- Parâmetros de atribuição (
?source=,?utm_*) nunca criam URL canônica nova. www→ apex por308no Caddy (Seção 25), antes do Next.js.
9.11.4 Dados estruturados (JSON-LD) #
Três blocos, injetados como <script type="application/ld+json"> renderizados no
servidor. Nunca gerados no cliente.
// apps/web/src/lib/seo/json-ld.ts
export function organizationJsonLd() {
return {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Palavra Diária',
url: 'https://palavradiaria.com.br',
logo: 'https://palavradiaria.com.br/logo-512.png',
sameAs: [] as string[],
contactPoint: [{
'@type': 'ContactPoint',
contactType: 'customer support',
email: 'suporte@palavradiaria.com.br',
availableLanguage: ['Portuguese'],
areaServed: 'BR',
}],
};
}
export function productJsonLd(plans: readonly PublicPlan[]) {
return {
'@context': 'https://schema.org',
'@type': 'Product',
name: 'Palavra Diária — Plano Completo',
description: 'Devocional cristão em texto e áudio, todos os dias, no WhatsApp.',
brand: { '@type': 'Brand', name: 'Palavra Diária' },
offers: plans
.filter((p) => p.tier === 'PAID')
.map((p) => ({
'@type': 'Offer',
price: (p.priceCents / 100).toFixed(2),
priceCurrency: 'BRL',
availability: 'https://schema.org/InStock',
url: `https://palavradiaria.com.br/cadastro?plan=${p.code}`,
priceValidUntil: '2027-12-31',
})),
};
}
export function faqPageJsonLd(items: readonly { question: string; answer: string }[]) {
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items.map((i) => ({
'@type': 'Question',
name: i.question,
acceptedAnswer: { '@type': 'Answer', text: i.answer },
})),
};
}Onde cada um é emitido:
| JSON-LD | Rotas |
|---|---|
Organization |
/ apenas |
Product + Offer |
/ e /planos |
FAQPage |
/faq apenas |
BreadcrumbList |
Não emitido: a hierarquia é rasa demais para agregar valor |
Regra dura contra penalidade: não emitir AggregateRating nem Review enquanto não
houver avaliações reais coletadas e verificáveis. Depoimentos de marketing na página não
autorizam marcação de review.
9.11.5 Outros sinais técnicos #
<html lang="pt-BR">;<meta name="theme-color">com valor claro e escuro.- URLs em português, minúsculas, com hífen, sem acento e sem barra final.
- Imagem OG por rota gerada em build com
ImageResponse(fonte local, sem rede). next-sitemapnão é usado; a API nativa basta.- Nenhum conteúdo importante depende de JavaScript: todos os blocos textuais da home são Server Components e aparecem no HTML inicial.
9.12 Performance #
9.12.1 Orçamento #
Medido no P75 de campo (CrUX/RUM próprio) e verificado em CI com Lighthouse em perfil móvel emulado (Moto G Power, 4G lento, 4× CPU throttling).
| Métrica | Alvo | Falha o CI acima de |
|---|---|---|
| LCP | ≤ 2,0 s | 2,5 s |
| CLS | ≤ 0,05 | 0,10 |
| INP | ≤ 150 ms | 200 ms |
| TTFB (edge/HTML cacheado) | ≤ 300 ms | 600 ms |
| FCP | ≤ 1,4 s | 1,8 s |
| Peso total da home (comprimido, primeira visita) | ≤ 350 KB | 500 KB |
| JavaScript da home (comprimido) | ≤ 120 KB | 170 KB |
| Requisições na primeira visita | ≤ 25 | 35 |
| Lighthouse Performance (mobile) | ≥ 92 | < 85 |
| Lighthouse Accessibility | 100 | < 100 |
| Lighthouse SEO | ≥ 95 | < 90 |
| Lighthouse Best Practices | ≥ 95 | < 90 |
O alvo de LCP < 2,0 s em 4G é a meta canônica de desempenho da superfície pública, e esta subseção é a dona dela. Nenhuma outra seção redefine esse número; a capacidade do motor de envio é assunto separado, na Seção 18.14. O alvo não pode ser relaxado.
9.12.2 Estratégia de renderização #
| Rota | Estratégia | Revalidação |
|---|---|---|
/ |
ISR | export const revalidate = 300 + revalidação sob demanda quando um devocional é publicado (Seção 15.4) |
/planos |
SSG | Sob demanda ao salvar plano (Seção 15.12.16) |
/faq, /termos, /privacidade, /cancelamento |
SSG | Somente por deploy |
/contato |
SSG + Server Action | Somente por deploy |
/api/public/* |
Dinâmico | Cache-Control específico por rota (9.16) |
O bloco B6 é o único da home que depende de dado que muda diariamente. Ele é renderizado no servidor durante a revalidação, então o visitante nunca espera por I/O. Se a revalidação falhar, o Next.js serve a versão anterior (stale-while-revalidate) e um alerta é emitido (Seção 23).
9.12.3 Imagens #
next/imagepara tudo, comformats: ['image/avif', 'image/webp'].- Todas as imagens são locais, versionadas no repositório ou servidas pelo bucket próprio. Nenhum hotlink de terceiro no caminho crítico.
widtheheightsempre declarados.sizesobrigatório em imagens responsivas.- Apenas o mockup do herói tem
priorityefetchPriority="high". Todo o resto éloading="lazy"comdecoding="async". - Fotos de depoimento: 96 × 96, servidas em AVIF, com
placeholder="blur"eblurDataURLgerado em build. - Ilustrações e ícones: SVG inline, otimizado com SVGO em build, sem sprite externo.
- Limite: nenhuma imagem individual acima de 120 KB após otimização. Um script de CI falha o build se o diretório de imagens públicas ultrapassar 1,5 MB no total.
9.12.4 Fontes #
- Uma única família variável, auto-hospedada via
next/font/local, subconjuntolatin+latin-ext(necessário para acentuação portuguesa). display: 'swap',preload: truepara o peso usado no herói,preload: falsepara os demais.adjustFontFallbackligado, para eliminar deslocamento no swap.- Zero requisição a
fonts.googleapis.comoufonts.gstatic.com. Isso também evita transferência internacional de IP sem base legal (Seção 22). - Nenhuma fonte de ícone. Ícones são SVG.
9.12.5 JavaScript #
- Server Components por padrão.
'use client'apenas nos blocos B2, B3a, B6, B9 e no banner de consentimento. next/dynamiccomssr: falsepara o carrossel de depoimentos e para o modal de preferências de cookies (só carregam sob interação/visibilidade).- Nenhuma biblioteca de animação pesada. Transições em CSS puro.
- TanStack Query não é usado na landing: não há dado de usuário para sincronizar. Ele entra só nos painéis (Seções 14 e 15).
- Recharts nunca entra no bundle público.
- Script de terceiro (se houver consentimento) carrega com
next/scriptestrategy="lazyOnload", nuncabeforeInteractive. - Orçamento verificado por
@next/bundle-analyzerem CI, com limite por rota configurado.
9.12.6 Cabeçalhos e cache #
Definidos no Caddy (Seção 25) e complementados em next.config.ts:
HTML de rota SSG/ISR: Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
Assets com hash: Cache-Control: public, max-age=31536000, immutable
/api/public/plans: Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600
/api/public/devotional-preview: Cache-Control: public, max-age=0, s-maxage=120, stale-while-revalidate=600
/api/public/events: Cache-Control: no-storeSegurança de cabeçalhos (CSP, HSTS, X-Content-Type-Options, Referrer-Policy,
Permissions-Policy) é canônica na Seção 22 e aplicada globalmente; a landing não
sobrescreve nada. Três exigências que a superfície pública impõe à política da Seção 22.3.1
e que precisam estar satisfeitas para as telas desta seção funcionarem:
media-srcprecisa incluir o domínio de mídia, senão o elemento<audio>do player de amostra (9.6) não toca.connect-srcprecisa incluir o mesmo domínio de mídia, além do próprio domínio.media-srcgoverna o elemento<audio>; a requisição que o player faz antes de criar o blob é governada porconnect-src. Sem as duas, o áudio falha silenciosamente.- As rotas com formulário —
/cadastro,/contatoe a tela de pedido de código — usam a variante de política para páginas com formulário, que liberahttps://challenges.cloudflare.comemscript-src, emframe-srce emconnect-src. Semframe-srcexplícito, odefault-src 'none'bloqueia o widget anti-bot e a taxa de cadastro cai a zero. O mesmo host precisa constar da lista de destinos permitidos do cliente HTTP com proteção contra SSRF (Seção 22.2.6), porque a verificação do token é feita pelo servidor.
9.13 Analytics e eventos de conversão #
9.13.1 Arquitetura #
Decisão D9.4 detalhada: o cliente chama um único helper, que faz navigator.sendBeacon
para POST /api/public/events. O endpoint valida com Zod, descarta o que não estiver no
catálogo e registra um log estruturado (event: 'analytics') consumido pela stack de
observabilidade da Seção 23. Esses eventos agregados são consolidados em daily_metrics
pelo job de rollup das 03:10, sobre o dia D−1 já fechado (Seção 21.5); a rota pública
nunca escreve nessa tabela. Nenhuma tabela nova é criada.
// apps/web/src/lib/analytics/track.ts
import { ANALYTICS_EVENTS, type AnalyticsEventName, type AnalyticsPayload } from './catalog';
export function track<E extends AnalyticsEventName>(name: E, props: AnalyticsPayload<E>): void {
if (typeof window === 'undefined') return;
const body = JSON.stringify({
name,
props,
path: window.location.pathname,
ts: new Date().toISOString(),
// anonymousId: ULID em sessionStorage, expira ao fechar a aba. NUNCA em cookie.
anonymousId: getOrCreateAnonymousId(),
});
const url = '/api/public/events';
if (navigator.sendBeacon) navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
else void fetch(url, { method: 'POST', body, keepalive: true, headers: { 'content-type': 'application/json' } });
}Propriedades comuns injetadas pelo servidor, nunca pelo cliente: country (do cabeçalho
do proxy), deviceType (derivado de User-Agent, apenas mobile|tablet|desktop) e
referrerHost (apenas o host, nunca a URL completa). O IP não é armazenado; ele é
usado só para rate limit em memória do Redis, com chave hasheada e TTL de 1 hora.
9.13.2 Catálogo completo de eventos #
Nomes em snake_case, em inglês (identificador técnico). Nenhum evento fora desta lista é
aceito pelo endpoint; envio desconhecido retorna 422 com code: UNKNOWN_EVENT.
| Evento | Quando dispara | Propriedades |
|---|---|---|
page_view |
Ao montar cada rota pública e a cada navegação client-side | path, referrerHost, deviceType |
scroll_depth |
Uma vez por marco de 25/50/75/100% de rolagem da página | path, depth |
hero_cta_clicked |
Clique no CTA primário do herói | label, destination |
nav_cta_clicked |
Clique no CTA do cabeçalho | label, destination, sticky (bool) |
lead_form_focused |
Primeiro foco no campo de telefone de qualquer instância | source, variant |
lead_form_validation_failed |
Submit bloqueado por validação | source, reason (empty, invalid_format, foreign_ddi, too_short) |
lead_form_submitted |
Submit válido, antes do router.push |
source, planCode | null |
lead_form_honeypot_triggered |
Honeypot preenchido | source |
plan_cycle_toggled |
Troca no alternador mensal/anual | from, to, page |
plan_cta_clicked |
Clique no CTA de um cartão/coluna de plano | planCode, cycle, page, position |
plan_comparison_viewed |
Bloco B8 atinge 50% de visibilidade por ≥ 1 s | page |
audio_sample_play |
Primeiro play() do player |
devotionalId |
audio_sample_progress |
Marcos de 25/50/75/100% da amostra | devotionalId, milestone |
audio_sample_error |
Qualquer erro do player | devotionalId, reason |
faq_item_opened |
Abertura de item do acordeão | questionId, page |
faq_search_used |
Busca no /faq com ≥ 3 caracteres, com debounce de 500 ms |
resultCount |
testimonial_advanced |
Avanço manual no carrossel | index, method (button | swipe | keyboard) |
pricing_page_viewed |
Montagem de /planos |
referrerHost |
cancellation_page_viewed |
Montagem de /cancelamento |
referrerHost |
contact_form_submitted |
Envio bem-sucedido em /contato |
subject |
cookie_consent_shown |
Banner exibido | — |
cookie_consent_decided |
Decisão registrada | decision (all, essential_only, custom), categories |
outbound_link_clicked |
Clique em link externo (rodapé, selo de pagamento) | host |
error_page_viewed |
Montagem de 404 ou 500 | status, path |
Eventos de conversão profunda (signup_started, otp_verified, optin_confirmed,
checkout_started, subscription_activated) não pertencem a esta seção: são
disparados pelo servidor nos fluxos das Seções 11 e 12, com o assinante já identificado.
A landing só produz eventos anônimos.
9.13.3 Funil de conversão declarado #
O funil oficial, usado no dashboard da Seção 21, é:
page_view (/) 100%
→ lead_form_focused
→ lead_form_submitted
→ signup_started (Seção 11)
→ otp_verified (Seção 11)
→ optin_confirmed (Seção 11) ← conversão FREE
→ checkout_started (Seção 12)
→ subscription_activated (Seção 12) ← conversão PAIDA junção entre o mundo anônimo e o identificado acontece uma única vez: no
signup_started, o servidor recebe o anonymousId do sessionStorage e o registra no
log estruturado junto ao subscriberId. O anonymousId não é persistido em nenhuma
tabela — a correlação vive apenas nos logs, com a retenção da Seção 23.
9.13.4 Amostragem e volume #
scroll_deptheaudio_sample_progresssão amostrados a 25% em produção (ANALYTICS_SAMPLE_RATE=0.25), decidido no cliente comMath.random(). Todos os demais eventos são enviados integralmente.- Rate limit de 60 eventos por
anonymousIdpor minuto. Excedente é descartado com429e não é reenviado. - Falha de rede em analytics nunca bloqueia navegação:
sendBeaconé fire-and-forget e ofetchde fallback usakeepalivesemawait.
9.14 Consentimento de cookies e LGPD na superfície pública #
9.14.1 Inventário de cookies e armazenamento #
| Nome | Tipo | Categoria | Finalidade | Duração | Precisa de consentimento |
|---|---|---|---|---|---|
__Host-session |
Cookie | Essencial | Sessão autenticada (Seção 8). Path=/, Secure, sem Domain |
30 dias (assinante) / 12 h (admin) | Não |
__Host-refresh |
Cookie | Essencial | Renovação da sessão (Seção 8). Path=/, Secure, sem Domain — o prefixo __Host- exige Path=/, e qualquer outro caminho faz o navegador descartar o cookie |
30 dias (assinante) / 12 h (admin) | Não |
__Host-csrf |
Cookie | Essencial | Token anti-CSRF, nome único em todo o documento; enviado de volta no header X-CSRF-Token |
Sessão | Não |
pd_consent |
Cookie | Essencial | Guarda a própria decisão de consentimento | 180 dias | Não |
pd_anonymous_id |
sessionStorage |
Essencial/analítico anônimo | Deduplicação de eventos na sessão | Até fechar a aba | Não (não é cookie, não persiste, não identifica) |
pd_announcement_dismissed |
localStorage |
Essencial | Não repetir barra de anúncio | 30 dias | Não |
_ga, _ga_* |
Cookie | Analítico de terceiro | Google Analytics 4, se habilitado | 13 meses | Sim |
_fbp, _fbc |
Cookie | Publicidade | Meta Pixel, se habilitado | 90 dias | Sim |
Decisão explícita: GA4 e Meta Pixel são opcionais e vêm desligados por padrão
(NEXT_PUBLIC_GA_ID e NEXT_PUBLIC_META_PIXEL_ID vazios, Seção 26). O produto funciona
inteiro com analytics first-party. Se o operador ligar um deles, o banner passa a exibir a
categoria correspondente.
9.14.2 Banner e modal #
- O banner aparece na primeira visita, ancorado no rodapé, sem escurecer a página e sem bloquear a leitura. Não é um muro.
- Três botões, com peso visual igual entre aceitar e rejeitar (exigência de
consentimento livre):
Aceitar todos,Rejeitar não essenciais,Personalizar. Personalizarabre umDialogcom uma chave por categoria:Essenciais(fixa em ligado e desabilitada),Análise,Publicidade. Cada categoria explica em uma frase o que faz e quais cookies usa.Escno banner equivale aRejeitar não essenciais. Fechar sem escolher não é tratado como aceite.- A decisão é gravada em
pd_consentcom o formato:
{
"version": "2026-08-25",
"decidedAt": "2026-08-25T12:31:02.000Z",
"categories": { "essential": true, "analytics": false, "advertising": false }
}- Mudança na versão do aviso de cookies invalida o consentimento anterior e reexibe o banner.
- O link "Preferências de cookies" no rodapé reabre o modal em qualquer momento, com os valores atuais.
- Revogar é tão fácil quanto consentir: um clique em
Rejeitar não essenciaisdentro do modal apaga os cookies de terceiro viadocument.cookiecomexpiresno passado e recarrega a página.
9.14.3 Comportamento sem consentimento #
Este é o estado padrão e precisa funcionar perfeitamente:
- Nenhum script de terceiro é injetado.
next/scriptsó monta depois de lerpd_consent.categories.analytics === true. - O analytics first-party continua funcionando, porque:
- não usa cookie;
- o identificador vive em
sessionStoragee morre ao fechar a aba; - o IP não é armazenado;
- os dados agregados não permitem reidentificar ninguém. Base legal declarada: legítimo interesse (LGPD, Art. 7º, IX) para medição agregada de audiência própria, com minimização de dados. A Seção 22 registra a avaliação.
- Todas as funcionalidades da landing permanecem disponíveis. Nenhum bloco é escondido, nenhum CTA é desabilitado. Recusar consentimento não degrada o serviço.
- O banner não reaparece na mesma sessão depois de uma decisão; reaparece após 180 dias ou se a versão mudar.
9.14.4 Outras exigências LGPD na superfície pública #
- Link para
/privacidadeno rodapé de todas as páginas e ao lado de todo formulário. - Nome e e-mail do encarregado de dados visíveis em
/privacidadee no rodapé. - O formulário de contato informa, acima do botão, a finalidade do tratamento e o prazo de retenção do e-mail.
- Nenhuma transferência internacional de dados no caminho público quando GA4/Pixel estão desligados: fontes locais, imagens locais, API própria.
9.15 Páginas de erro e manutenção #
9.15.1 404 #
app/not-found.tsx, estático, noindex. Conteúdo:
<h1>"Página não encontrada".- Frase curta explicando que o endereço não existe ou mudou.
- Quatro links úteis: início,
/planos,/faq,/contato. - Campo de busca do FAQ embutido (mesmo componente de 9.8.3), porque a origem mais comum de 404 é link antigo de conteúdo.
- Emite
error_page_viewedcomstatus: 404e opathtentado. - Responde com HTTP 404 de verdade, não 200 com corpo de erro.
9.15.2 500 #
Dois arquivos:
app/error.tsx: erro de renderização dentro do layout. Mantém cabeçalho e rodapé, mostra<h1>"Algo deu errado", botãoTentar de novo(chamareset()), link para/contatoe orequestIdem texto pequeno e selecionável.app/global-error.tsx: falha no próprio layout raiz. HTML mínimo, CSS inline, sem dependência de fonte ou de componente. Exibe a mesma mensagem e orequestId.
Regras:
- O
requestIdmostrado é o mesmo ULID do envelope de erro da Seção 7 e do cabeçalhoX-Request-Id, permitindo que o suporte localize o log exato (Seção 23). - Nunca exibir stack trace, nome de arquivo, versão de dependência ou mensagem crua de exceção.
- Emite
error_page_viewedcomstatus: 500. - Responde com HTTP 500.
9.15.3 Página de manutenção #
- Ativada pela variável
MAINTENANCE_MODE=true(Seção 26), lida pelo middleware. - O middleware responde 503 com
Retry-After: 900para todas as rotas, exceto:/manutencao,/api/internal/health,/api/webhooks/*(webhooks de Asaas e Meta nunca entram em manutenção; eles continuam sendo aceitos e enfileirados, conforme Seções 12 e- e os assets estáticos.
- Conteúdo: título, previsão de retorno lida de
MAINTENANCE_UNTIL(ISO 8601, exibida em horário de Brasília) e o e-mail de suporte. - A página é 100% estática e não depende de banco nem de Redis, para funcionar mesmo com a infraestrutura parcialmente indisponível.
- Um administrador com sessão válida e papel
OWNERpode passar pela manutenção enviando o cabeçalhoX-Maintenance-Bypasscom o valor deMAINTENANCE_BYPASS_TOKEN, para validar o deploy antes de reabrir.
9.16 Endpoints públicos da landing #
Todos seguem o envelope de sucesso e de erro definido na Seção 7. Todos ecoam
X-Request-Id. Nenhum exige autenticação.
9.16.1 GET /api/public/devotional-preview #
- Método/caminho:
GET /api/public/devotional-preview - Autenticação: nenhuma. Papel exigido: nenhum.
- Request: sem corpo. Sem parâmetros de query (não é permitido pedir outra data — decisão D9.3).
- Efeitos colaterais: nenhum. Operação de leitura pura, portanto naturalmente idempotente e segura para repetição.
- Cache:
public, max-age=0, s-maxage=120, stale-while-revalidate=600.
Regra de seleção: o devocional cujo scheduled_for é o dia corrente em
America/Sao_Paulo e cujo status é PUBLISHED ou SENT. Se não houver, cai para o
mais recente SENT dos últimos 7 dias. Se ainda assim não houver, responde 404.
Resposta 200:
{
"data": {
"id": "dev_01HZX9C7Q0S6M2R4T8V1Y3B5N7",
"date": "2026-08-25",
"title": "O Senhor é o meu pastor",
"bibleReference": "Salmos 23:1-3",
"bibleVersion": "Almeida 1911",
"teaser": "Quando o pastor guia, a ovelha não precisa saber o caminho inteiro. Ela precisa saber de quem seguir.",
"sampleAudioUrl": "https://media.palavradiaria.com.br/devotionals/2026/08/dev_01HZX.../sample.mp3?X-Amz-Expires=900&X-Amz-Signature=...",
"sampleAudioDurationSec": 58,
"sampleAudioMimeType": "audio/mpeg"
},
"meta": { "requestId": "req_01HZXA2M4P8K6D0F2G4H6J8L0N", "timestamp": "2026-08-25T09:00:00.000Z" }
}Erros:
| HTTP | code |
Quando | Mensagem |
|---|---|---|---|
| 404 | DEVOTIONAL_NOT_FOUND |
Nenhum devocional publicado hoje nem nos 7 dias anteriores | "Nenhum devocional publicado no momento." |
| 429 | RATE_LIMITED |
Mais de 120 requisições por IP por minuto | "Muitas requisições. Tente novamente em instantes." |
| 500 | INTERNAL_ERROR |
Falha ao assinar a URL ou ao consultar o banco | "Não foi possível carregar o devocional." |
Observações de segurança: o campo reflection_md, o bible_text completo e o prayer
não são retornados. O público recebe apenas o teaser. Isso é intencional e é o que
preserva o valor da assinatura.
9.16.2 GET /api/public/plans #
- Método/caminho:
GET /api/public/plans - Autenticação: nenhuma. Papel exigido: nenhum.
- Request: sem corpo, sem parâmetros.
- Efeitos colaterais: nenhum. Idempotente.
- Cache:
public, max-age=60, s-maxage=300, stale-while-revalidate=600.
O item plan_free é sintetizado no handler e não corresponde a nenhuma linha da tabela
plans. Ser gratuito é a ausência de assinatura vigente (Seção 13.1). O handler lê plans
para os planos pagos ativos, ordenados por sort_order, e prepende o item gratuito com
valores fixos { code: 'plan_free', tier: 'FREE', name: 'Gratuito', cycle: null, priceCents: 0 }. Nenhuma escrita do sistema aceita esse código.
Resposta 200 (valores conforme os padrões de preço da Seção 1):
{
"data": {
"plans": [
{ "code": "plan_free", "tier": "FREE", "name": "Gratuito", "cycle": null,
"priceCents": 0, "currency": "BRL", "priceLabel": "R$ 0",
"monthlyEquivalentLabel": null, "savingsLabel": null, "isRecommended": false },
{ "code": "plan_monthly", "tier": "PAID", "name": "Mensal", "cycle": "MONTHLY",
"priceCents": 1990, "currency": "BRL", "priceLabel": "R$ 19,90/mês",
"monthlyEquivalentLabel": null, "savingsLabel": null, "isRecommended": false },
{ "code": "plan_annual", "tier": "PAID", "name": "Anual", "cycle": "YEARLY",
"priceCents": 19900, "currency": "BRL", "priceLabel": "R$ 199,00/ano",
"monthlyEquivalentLabel": "R$ 16,58/mês", "savingsLabel": "economize 16%",
"isRecommended": true }
],
"matrix": {
"version": "2026-08-25",
"rows": [
{ "key": "send_frequency", "label": "Frequência do devocional",
"free": { "kind": "text", "value": "1x por semana, aos domingos" },
"paid": { "kind": "text", "value": "Todos os dias" } },
{ "key": "audio", "label": "Áudio narrado",
"free": { "kind": "boolean", "value": false },
"paid": { "kind": "boolean", "value": true } }
]
}
},
"meta": {
"requestId": "req_01HZXA3N5Q9L7E1G3H5J7K9M1P",
"timestamp": "2026-08-25T09:00:00.000Z",
"stats": { "publishedDevotionals": 412, "activeSubscribers": 2300 }
}
}meta.stats.activeSubscribers é arredondado para baixo à centena e só aparece quando o
valor real é ≥ 500 (regra do bloco B4). Abaixo disso, a chave é omitida.
Erros:
| HTTP | code |
Quando | Mensagem |
|---|---|---|---|
| 429 | RATE_LIMITED |
> 120 req/IP/min | "Muitas requisições. Tente novamente em instantes." |
| 500 | INTERNAL_ERROR |
Falha de banco | "Não foi possível carregar os planos." |
Não existe 404: mesmo que a tabela plans esteja vazia, a resposta sempre traz o item
plan_free, que é montado pelo handler e não depende do banco.
9.16.3 POST /api/public/events #
- Método/caminho:
POST /api/public/events - Autenticação: nenhuma. Papel exigido: nenhum.
- Cache:
no-store. - Efeitos colaterais: escreve apenas log estruturado. Não escreve dado pessoal e
não escreve em
daily_metrics— a consolidação é do job de rollup das 03:10 sobre o dia D−1 fechado (Seção 21.5). - Idempotência: não idempotente por natureza (é um contador). Reenvio duplicado é aceitável e absorvido pela amostragem. O cliente nunca faz retry automático.
Schema de request:
import { z } from 'zod';
export const analyticsEventSchema = z.object({
name: z.enum(ANALYTICS_EVENT_NAMES), // catálogo de 9.13.2
props: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.refine((p) => Object.keys(p).length <= 12, 'Muitas propriedades.')
.default({}),
path: z.string().max(200).startsWith('/'),
ts: z.iso.datetime(),
anonymousId: z.string().length(26).regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
});
export const analyticsBatchSchema = z.object({
events: z.array(analyticsEventSchema).min(1).max(20),
}).or(analyticsEventSchema.transform((e) => ({ events: [e] })));Regras de saneamento no servidor, obrigatórias:
- Qualquer chave de
propsque case com/phone|email|cpf|token|password|name/ié descartada antes de logar. pathtem sua query string removida.tsfora da janela de ±10 minutos em relação ao relógio do servidor é substituído pelo horário do servidor.- O IP nunca é gravado; é usado apenas para a chave de rate limit
rl:events:{sha256(ip)}com TTL de 60 s.
Resposta 200 (mínima, para não desperdiçar banda):
{ "data": { "accepted": 3, "rejected": 0 },
"meta": { "requestId": "req_01HZXA4P6R0M8F2H4J6K8L0N2Q", "timestamp": "2026-08-25T09:00:00.000Z" } }Erros:
| HTTP | code |
Quando | Mensagem |
|---|---|---|---|
| 400 | INVALID_JSON |
Corpo não é JSON válido | "Requisição inválida." |
| 422 | VALIDATION_ERROR |
Falha de schema; details lista campo e problema |
"Dados inválidos." |
| 422 | UNKNOWN_EVENT |
name fora do catálogo |
"Evento desconhecido." |
| 429 | RATE_LIMITED |
> 60 eventos por anonymousId por minuto |
"Limite de eventos atingido." |
| 413 | PAYLOAD_TOO_LARGE |
Corpo acima de 8 KB | "Requisição grande demais." |
9.16.4 POST /api/public/contact #
- Método/caminho:
POST /api/public/contact - Autenticação: nenhuma. Papel exigido: nenhum. Exige token Turnstile válido.
- Efeitos colaterais: envia e-mail transacional pelo Resend. Não persiste no banco.
- Idempotência: protegida por chave
Idempotency-Keyopcional enviada pelo cliente (ULID gerado no primeiro submit). Se a mesma chave chegar em até 10 minutos, o servidor responde200com o mesmo corpo e não reenvia o e-mail. Chave em Redis (idem:contact:{key}), TTL 600 s.
Schema:
export const contactSchema = z.object({
name: z.string().trim().min(2, 'Informe seu nome.').max(80, 'Nome muito longo.'),
email: z.email('Informe um e-mail válido.').max(160),
subject: z.enum(['duvida', 'cobranca', 'cancelamento', 'privacidade', 'outro']),
message: z.string().trim().min(10, 'Escreva pelo menos 10 caracteres.')
.max(2000, 'Máximo de 2000 caracteres.'),
turnstileToken: z.string().min(1),
company: z.string().max(0).optional(), // honeypot: precisa estar vazio
});Resposta 200:
{ "data": { "sent": true, "expectedReplyBusinessDays": 1 },
"meta": { "requestId": "req_01HZXA5Q7S1N9G3J5K7L9M1P3R", "timestamp": "2026-08-25T09:00:00.000Z" } }Erros:
| HTTP | code |
Quando | Mensagem |
|---|---|---|---|
| 422 | VALIDATION_ERROR |
Falha de schema | "Dados inválidos." |
| 422 | CAPTCHA_FAILED |
Turnstile inválido ou expirado | "Não conseguimos confirmar que você não é um robô. Recarregue a página." |
| 422 | HONEYPOT_TRIGGERED |
Campo company preenchido |
"Dados inválidos." (resposta genérica, deliberadamente) |
| 429 | RATE_LIMITED |
> 3 envios por IP por hora | "Muitas mensagens enviadas. Tente novamente em uma hora." |
| 502 | EMAIL_PROVIDER_ERROR |
Resend indisponível após 2 tentativas | "Não foi possível enviar sua mensagem agora. Escreva para suporte@palavradiaria.com.br." |
O 502 inclui o endereço de suporte na própria mensagem, para que o usuário nunca fique
sem caminho.
9.16.5 GET /api/public/config #
- Método/caminho:
GET /api/public/config - Autenticação: nenhuma. Papel exigido: nenhum.
- Request: sem corpo, sem parâmetros.
- Efeitos colaterais: nenhum. Leitura pura, idempotente.
- Cache:
public, max-age=300, s-maxage=900.
É a rota que a landing e o painel consultam para não embutir no build o número de
WhatsApp do negócio, o e-mail de suporte, o e-mail do encarregado e a versão vigente das
políticas. Todos os campos vêm de settings e de feature_flags (Seções 6 e 26).
Nenhum segredo é devolvido: chaves com is_secret = true nunca entram nesta resposta,
e um teste de contrato falha o build se alguma entrar.
Resposta 200:
{
"data": {
"whatsappNumberDisplay": "(11) 99999-9999",
"supportEmail": "suporte@palavradiaria.com.br",
"dpoEmail": "privacidade@palavradiaria.com.br",
"policyVersion": "2026-08-01",
"termsVersion": "2026-08-01",
"pixEnabled": true,
"cardEnabled": true,
"maintenanceMode": false
},
"meta": { "requestId": "req_01HZXA6R8T2P0H4K6L8M0N2P4S", "timestamp": "2026-08-25T09:00:00.000Z" }
}Erros:
| HTTP | code |
Quando | Mensagem |
|---|---|---|---|
| 500 | INTERNAL_ERROR |
Falha ao ler settings ou feature_flags |
"Não foi possível carregar a configuração." |
10. Copy Aprovado — Página de Vendas e Mensagens ao Assinante #
Este capítulo entrega o copy final, aprovado para publicação, de todos os textos voltados ao assinante e ao visitante: página de vendas, microcopy de interface, e-mails transacionais e divulgação. Nada aqui é rascunho. Onde há mais de uma opção (headlines, por exemplo), a opção recomendada está marcada e justificada. Toda regra de produto citada no texto é consistente com a Seção 13 (entitlements e ciclo de vida da assinatura) e a Seção 18 (motor de envio diário): horário fixo às 6h, sem carência em inadimplência, sem período de teste do plano pago, plano gratuito permanente, sem aplicativo, sem comunidade, sem pedido de oração.
10.1 Princípios de voz e tom #
O que dizer:
- Falar como alguém que também luta para manter o hábito devocional — não como uma autoridade espiritual.
- Ser concreto sobre o que o produto faz. Frases curtas, verbos no presente.
- Nomear o benefício prático (constância, simplicidade) antes de qualquer benefício emocional.
- Ser honesto sobre limitações do produto sempre que perguntado ou sempre que a omissão criar expectativa falsa (voz sintética, horário fixo, ausência de app).
O que nunca dizer:
- Nunca prometer transformação espiritual, milagre, resposta de oração ou resultado na vida do assinante. O produto entrega um devocional; o que a pessoa faz com ele é dela.
- Nunca usar culpa ("você está falhando com Deus", "por que você ainda não fez isso?").
- Nunca fingir que a voz do áudio é humana quando é gerada por computador.
- Nunca prometer horário flexível, aplicativo, comunidade ou qualquer recurso fora do escopo declarado na Seção 2.
- Nunca usar urgência artificial ("só hoje", "últimas vagas") — não há campanha promocional no MVP (Seção 2).
Três frases boas:
- "Todos os dias, às 6h, uma palavra chega no seu WhatsApp antes do resto do mundo acordar." — concreto, sem promessa vaga, ancorado no mecanismo real (horário fixo).
- "Você não precisa lembrar. A gente lembra por você." — nomeia o problema real (esquecimento) sem culpar o leitor.
- "O áudio é narrado por uma voz gerada por inteligência artificial, treinada para soar natural em português." — honesto sobre a tecnologia, sem esconder nem se desculpar.
Três frases ruins e o motivo:
- "Sua vida vai mudar em 7 dias." — promessa de resultado que o produto não controla. Viola a regra de não prometer transformação.
- "Se você realmente ama a Deus, não vai deixar de ler a Palavra hoje." — usa culpa como gatilho. Proibido pelo tom definido acima.
- "Uma voz amiga vai te acompanhar todos os dias." — insinua companhia humana quando a voz é sintética. Omissão desonesta sobre a natureza do áudio.
10.2 Hero da home #
Headlines (5 opções):
1. Um devocional por dia, direto no seu WhatsApp, às 6 da manhã.
2. A Palavra de Deus chega antes do seu primeiro café.
3. Comece o dia com Deus sem precisar lembrar disso.
4. Seu devocional diário, em texto e áudio, no WhatsApp que você já usa.
5. Todo dia, às 6h, uma pausa com Deus antes da correria começar.Subheadlines (5 opções):
1. Texto e áudio narrado, entregues automaticamente todos os dias. Sem aplicativo para
instalar, sem senha para lembrar.
2. Receba reflexão bíblica, oração e áudio narrado no WhatsApp, no mesmo horário, todos
os dias. Cancele quando quiser.
3. Um devocional curto, com base bíblica e oração, em texto e em áudio, direto no seu
WhatsApp. Todos os dias às 6h.
4. Experimente grátis com um devocional por semana, ou assine e receba todos os dias, com
áudio narrado incluso.
5. Sem app. Sem grupo. Sem enrolação. Só o devocional, todos os dias, no WhatsApp.Botão principal: Quero receber grátis
Botão secundário: Ouvir um devocional de exemplo
Recomendação: usar a headline 1 com a subheadline 1 — a combinação nomeia o mecanismo concreto (WhatsApp, 6h, texto e áudio) sem depender de metáfora, o que reduz ambiguidade para um público que decide rápido em mobile.
10.3 Bloco "o problema" #
Você já decidiu começar a ler a Bíblia todo dia. Mais de uma vez.
Funciona bem na primeira semana. Depois vem um dia corrido, ou um dia ruim, e a leitura
fica pra depois. Depois vira dois dias. Depois você nem lembra em que capítulo parou.
O problema não é falta de vontade. É que manter um hábito sozinho, sem ninguém pra
lembrar, é difícil pra qualquer pessoa — com qualquer hábito.
O Palavra Diária resolve essa parte: a lembrança. Todos os dias, no mesmo horário, o
devocional chega até você. Você só precisa abrir o WhatsApp que já abre de qualquer jeito.10.4 Bloco "como funciona" #
Passo 1 — Cadastre seu WhatsApp
Você informa seu número, confirma com um código e pronto. Não precisa criar senha nem
baixar nada.
Passo 2 — Escolha seu plano
Continue no plano gratuito, com um devocional por semana, ou assine o plano completo, com
devocional em texto e áudio todos os dias.
Passo 3 — Receba todos os dias às 6h
Todo dia, no mesmo horário, o devocional chega no seu WhatsApp. Você lê, ouve, e segue com
o seu dia.10.5 Bloco "o que você recebe" #
Texto completo todos os dias
Reflexão bíblica com referência, texto do versículo e uma oração curta para fechar.
Áudio narrado (plano assinante)
O mesmo devocional narrado em áudio, para ouvir enquanto se arruma ou dirige.
Sempre no mesmo horário
Todos os dias às 6h da manhã, horário de Brasília. Sem variação, sem imprevisto.
Direto no WhatsApp
Sem aplicativo novo para instalar. Chega no WhatsApp que você já tem aberto.
Acervo dos devocionais anteriores
No seu painel web você reencontra os devocionais já enviados, para reler quando quiser.
Cancele quando quiser
Sem fidelidade, sem multa. Você cancela pelo painel em menos de um minuto.10.6 Bloco de amostra #
Quer ouvir antes de assinar?
Preparamos um devocional de demonstração, igual ao que você receberia num dia comum: um
texto bíblico, uma reflexão curta e uma oração — narrado com a mesma voz que você vai
ouvir todos os dias, caso assine o plano completo.
[Ouvir devocional de exemplo]
Sem cadastro. Sem compromisso. Só para você saber exatamente o que vai receber.10.7 Comparativo de planos #
Título do bloco: Escolha como quer receber
| Plano | Nome comercial | Posicionamento em uma linha |
|---|---|---|
| FREE | Plano Gratuito | Para experimentar o devocional aos domingos, sem custo, para sempre. |
| PAID | Plano Assinante | Para quem quer o hábito diário completo, em texto e áudio, todos os dias. |
Nota obrigatória de implementação (não é copy): o "Plano Gratuito" desta tabela é um
item sintético, montado pelo handler. Ele não corresponde a nenhuma linha da tabela
plans: ser gratuito é a ausência de assinatura vigente (Seção 13.1). O handler lê os
planos pagos ativos e prepende o item gratuito com valores fixos
(code: 'plan_free', tier: 'FREE', name: 'Gratuito', cycle: null, priceCents: 0).
Nenhuma escrita usa esse código. Contrato da resposta em 9.16.2.
Texto de cada linha do comparativo:
Frequência do envio
Gratuito: um devocional por semana, aos domingos.
Assinante: um devocional todos os dias, inclusive domingo.
Formato
Gratuito: texto completo.
Assinante: texto completo e áudio narrado.
Acervo no painel
Gratuito: últimos 7 dias.
Assinante: acervo completo desde a primeira vez que você assinou.
Reenvio do devocional do dia
Gratuito: 1 vez por dia, pelo painel.
Assinante: até 3 vezes por dia, pelo painel.
Suporte
Gratuito: central de ajuda e e-mail.
Assinante: e-mail com resposta em até 1 dia útil.
Custo
Gratuito: R$ 0, sem prazo de expiração.
Assinante: R$ 19,90 por mês ou R$ 199,00 por ano (economize 16%).O rótulo de economia exibido é sempre "economize 16%", arredondado para baixo a partir de 16,66% pela regra da Seção 9.5.3. O número exato de 16,7% só aparece em documentação interna; a página nunca promete mais do que entrega.
10.8 Bloco de preço #
Plano Assinante
R$ 19,90 por mês
ou R$ 199,00 por ano — equivalente a R$ 16,58 por mês. Economize 16%.
Pagamento por cartão de crédito (renovação automática) ou PIX.
Cancele quando quiser, direto pelo painel. Sem fidelidade, sem multa de cancelamento.
Sem aplicativo para instalar — tudo funciona pelo WhatsApp e por este painel, no navegador.Nota de honestidade obrigatória a incluir sempre que preço aparecer junto de condições: não existe período de teste gratuito do plano pago. Quem quer experimentar antes de pagar usa o plano gratuito (Seção 10.6 e 10.7) ou ouve a amostra (Seção 10.6).
10.9 Prova social #
Estrutura de depoimento, repetida 4 vezes: foto ou iniciais, nome e cidade (ou "assinante verificado" quando o nome não puder ser publicado), tempo de assinatura, texto do depoimento.
[PLACEHOLDER — substituir por depoimento real antes de publicar]
"Depoimento de exemplo 1: descreva aqui, com as palavras do próprio assinante, o que
mudou na rotina dele — não na vida espiritual dele — desde que começou a receber o
devocional. Ex.: forma como incorporou o hábito no dia a dia."
— Nome do assinante, cidade — assinante há X meses
[PLACEHOLDER — substituir por depoimento real antes de publicar]
"Depoimento de exemplo 2: foque em um detalhe concreto do produto (horário, áudio, não
precisar lembrar) em vez de elogio genérico."
— Nome do assinante, cidade — assinante há X meses
[PLACEHOLDER — substituir por depoimento real antes de publicar]
"Depoimento de exemplo 3: se possível, cite a situação em que o áudio foi útil (dirigindo,
se arrumando, etc.)."
— Nome do assinante, cidade — assinante há X meses
[PLACEHOLDER — substituir por depoimento real antes de publicar]
"Depoimento de exemplo 4: se possível, cite a transição do plano gratuito para o
assinante e o motivo da troca."
— Nome do assinante, cidade — assinante há X mesesRegra de queda, obrigatória. Os quatro blocos acima são gabaritos de redação e
nunca são renderizados. O bloco de prova social só aparece quando existir ao menos
um depoimento real cadastrado. Com zero depoimentos, o componente
<TestimonialCarousel> não é montado, a seção inteira some da página, e nada a substitui —
nem texto de espera, nem esqueleto, nem depoimento genérico. A nota legal abaixo só aparece
junto com depoimentos reais.
Nota legal obrigatória, exibida junto ao bloco de depoimentos:
Todos os depoimentos publicados nesta página são de assinantes reais e foram publicados
com autorização expressa de cada pessoa. Nenhum depoimento é pago, encenado ou gerado
artificialmente.10.10 FAQ #
1. Como eu cancelo minha assinatura?
Pelo seu painel, em Configurações > Assinatura > Cancelar assinatura. O cancelamento é
imediato no sistema, mas o acesso ao plano completo continua até o fim do período que
você já pagou. Depois disso, você volta automaticamente para o plano gratuito — não perde
o devocional, só passa a recebê-lo aos domingos.
2. E se eu não gostar?
Como não existe período de teste do plano pago, recomendamos usar o plano gratuito ou
ouvir o devocional de exemplo antes de assinar (veja a seção "Quer ouvir antes de
assinar?" nesta página). Se depois de assinar você decidir que não é para você, cancela a
qualquer momento pelo painel e não é cobrado novamente.
3. Preciso instalar algum aplicativo?
Não. Tudo funciona pelo WhatsApp que você já usa e por este painel, que abre direto no
navegador do celular ou computador. Não existe aplicativo do Palavra Diária.
4. Posso mudar o horário de envio?
Não. O envio acontece sempre às 6h da manhã, horário de Brasília, para todos os
assinantes, e esse horário não é personalizável. Se você não abrir a mensagem na hora, ela
continua disponível no WhatsApp e também no seu painel.
5. Qual versão da Bíblia vocês usam?
Usamos a Almeida Revista e Corrigida na edição de 1911, identificada nas mensagens como
"Almeida 1911", com apoio da Bíblia Livre em alguns trechos. Escolhemos exatamente essas
edições porque estão em domínio público e podem ser reproduzidas livremente, com
fidelidade ao texto. A atribuição impressa em toda entrega é sempre o nome completo da
edição — nunca a sigla "ARC", que no mercado brasileiro identifica uma edição revisada e
protegida por direitos autorais, e que por isso é proibida como código e como atribuição
neste produto.
6. Quem escreve os devocionais?
Uma equipe editorial humana escreve, revisa e programa cada devocional. Não usamos
inteligência artificial para gerar o conteúdo do texto — apenas para narrar o áudio (veja
a próxima pergunta).
7. A voz do áudio é humana ou gerada por computador?
É gerada por computador, com uma tecnologia de síntese de voz de alta qualidade treinada
para soar natural em português. Não é a voz de uma pessoa real gravando todos os dias. Você
pode ouvir uma amostra antes de assinar.
8. Meus dados estão seguros?
Sim. Seus dados são armazenados de forma criptografada e usados só para entregar o
serviço e processar o pagamento. Você pode pedir a exportação ou a exclusão dos seus dados
a qualquer momento pelo painel. Tratamos essas informações de acordo com a Lei Geral de
Proteção de Dados (LGPD).
9. Posso presentear a assinatura para outra pessoa?
No momento, cada assinatura é vinculada ao número de WhatsApp de quem vai recebê-la. Para
presentear alguém, cadastre a assinatura com o número de WhatsApp da pessoa presenteada e
finalize o pagamento com os seus próprios dados de cobrança.
10. Funciona em qualquer celular?
Sim. Se o seu celular tem WhatsApp instalado e funcionando, você recebe o devocional
normalmente, seja Android ou iPhone. O painel do assinante funciona em qualquer navegador
de celular ou computador.
11. Como eu pago?
Por cartão de crédito, com renovação automática mensal ou anual, ou por PIX, com uma nova
cobrança gerada a cada ciclo. Você escolhe a forma de pagamento no momento da assinatura e
pode alterá-la depois pelo painel.
12. O que acontece se meu pagamento falhar?
O acesso ao plano completo é encerrado imediatamente quando um pagamento falha — não há
tolerância nem prazo extra de acesso. Você continua recebendo o devocional gratuito aos
domingos normalmente. Assim que o pagamento for regularizado, o acesso ao plano completo
volta a valer a partir do novo pagamento confirmado.10.11 CTA final da home #
Título: Comece hoje. É grátis para experimentar.
Frase: Cadastre seu WhatsApp em menos de um minuto e receba seu primeiro devocional no
próximo domingo, às 6h.
Botão: Quero receber grátis10.12 Rodapé #
Links: Como funciona · Planos · Perguntas frequentes · Termos de uso · Política de
privacidade · Fale com a gente
Aviso de LGPD: Tratamos seus dados pessoais conforme a Lei Geral de Proteção de Dados —
veja nossa Política de Privacidade para saber como acessar, corrigir ou excluir suas
informações.
Contato: Dúvidas, sugestões ou problemas com sua assinatura? Escreva para
contato@palavradiaria.com.br — respondemos todos os dias úteis.10.13 Microcopy da interface #
Convenção de chave: screen.componente.estado. Textos abaixo são literais, prontos para
uso direto em componentes de UI.
| Chave | Contexto | Texto |
|---|---|---|
signup.phone.label |
Rótulo do campo de telefone no cadastro | Seu WhatsApp |
signup.phone.placeholder |
Placeholder do campo de telefone | (11) 91234-5678 |
signup.phone.help |
Texto de ajuda abaixo do campo | Vamos enviar um código de confirmação por WhatsApp para este número. |
signup.phone.error.invalid |
Erro de validação de telefone | Digite um número de celular brasileiro válido, com DDD. |
signup.consent.label |
Checkbox de consentimento | Aceito receber devocionais no WhatsApp e concordo com os Termos de uso e a Política de privacidade. |
signup.consent.error.required |
Erro quando o checkbox não é marcado | Para continuar, você precisa aceitar o envio de mensagens no WhatsApp. |
signup.submit.button |
Botão de envio do formulário de cadastro | Enviar código de confirmação |
signup.submit.loading |
Estado de carregamento do botão de cadastro | Enviando... |
otp.title |
Título da tela de código OTP | Confirme seu número |
otp.subtitle |
Subtítulo da tela de código OTP | Enviamos um código de 6 dígitos para o seu WhatsApp. |
otp.input.label |
Rótulo do campo de código | Código de 6 dígitos |
otp.resend.button |
Botão de reenvio de código | Reenviar código |
otp.resend.cooldown |
Texto de espera antes de poder reenviar | Você poderá pedir um novo código em {seconds}s. |
otp.error.invalid |
Erro de código incorreto | Código incorreto. Você ainda tem {attemptsLeft} tentativa(s). |
otp.error.expired |
Erro de código expirado | Esse código expirou. Peça um novo código para continuar. |
otp.error.too_many_requests |
Erro de limite de reenvios excedido | Você atingiu o limite de códigos por hora. Tente novamente mais tarde. |
whatsapp_optin.pending.title |
Tela aguardando confirmação no WhatsApp | Falta um passo |
whatsapp_optin.pending.body |
Instrução para confirmar opt-in no WhatsApp | Abra o WhatsApp e toque em "Confirmar" na mensagem que acabamos de te enviar. |
plans.free.badge |
Selo do plano gratuito | Grátis para sempre |
plans.paid.badge |
Selo do plano pago | Mais escolhido |
plans.cta.free |
Botão de seleção do plano gratuito | Continuar no plano gratuito |
plans.cta.paid |
Botão de seleção do plano pago | Assinar plano completo |
checkout.name.label |
Rótulo do campo de nome no checkout | Nome completo |
checkout.cpf.label |
Rótulo do campo de CPF no checkout | CPF |
checkout.cpf.help |
Texto de ajuda sobre o CPF no checkout | Pedimos o CPF porque é uma exigência da instituição que processa o pagamento. |
checkout.cpf.error.invalid |
Erro de CPF inválido | Digite um CPF válido. |
checkout.payment_method.label |
Rótulo do seletor de forma de pagamento | Como você quer pagar? |
checkout.payment_method.card |
Opção de pagamento por cartão | Cartão de crédito |
checkout.payment_method.pix |
Opção de pagamento por PIX | PIX |
checkout.cycle.monthly |
Opção de ciclo mensal | Mensal — R$ 19,90/mês |
checkout.cycle.yearly |
Opção de ciclo anual | Anual — R$ 199,00/ano (economize 16%) |
checkout.submit.button |
Botão de finalizar pagamento | Confirmar assinatura |
checkout.submit.loading |
Estado de carregamento do botão de checkout | Processando pagamento... |
checkout.pix.waiting |
Tela de espera de pagamento PIX | Escaneie o QR Code ou copie o código para pagar. Assim que o pagamento for confirmado, você recebe uma mensagem no WhatsApp. |
checkout.error.payment_declined |
Erro de pagamento recusado | Não conseguimos confirmar o pagamento. Verifique os dados do cartão ou tente outra forma de pagamento. |
checkout.error.generic |
Erro genérico de checkout | Algo deu errado ao processar seu pagamento. Tente novamente em alguns instantes. |
checkout.success.title |
Título de sucesso pós-checkout | Assinatura confirmada |
dashboard.today.empty |
Estado vazio do devocional do dia no painel | O devocional de hoje ainda não foi enviado. Ele chega às 6h. |
dashboard.today.resend.button |
Botão de reenvio manual do devocional | Reenviar para meu WhatsApp |
dashboard.today.resend.limit_reached |
Erro de limite de reenvio atingido | Você já usou os reenvios disponíveis para hoje no seu plano. |
dashboard.archive.title |
Título da seção de acervo | Devocionais anteriores |
dashboard.archive.locked |
Aviso de acervo limitado no plano gratuito | No plano gratuito, você vê os últimos 7 dias. Assine o plano completo para ver o acervo inteiro. |
dashboard.subscription.status.active |
Rótulo de status de assinatura ativa | Assinatura ativa |
dashboard.subscription.status.canceled_scheduled |
Rótulo de cancelamento agendado | Cancelamento confirmado — acesso completo até {date} |
dashboard.subscription.status.expired |
Rótulo de assinatura expirada por falha de pagamento | Pagamento não identificado — você está no plano gratuito |
dashboard.subscription.cancel.button |
Botão de cancelamento de assinatura | Cancelar assinatura |
dashboard.subscription.cancel.confirm_title |
Título do modal de confirmação de cancelamento | Tem certeza que quer cancelar? |
dashboard.subscription.cancel.confirm_body |
Corpo do modal de confirmação de cancelamento | Você continua com o plano completo até {date}. Depois disso, passa a receber só o devocional gratuito, aos domingos. |
dashboard.subscription.cancel.confirm_button |
Botão de confirmação final de cancelamento | Sim, cancelar assinatura |
dashboard.subscription.cancel.success |
Mensagem de sucesso após cancelar | Cancelamento confirmado. Seu plano completo continua ativo até {date}. |
dashboard.subscription.reactivate.button |
Botão para reativar assinatura cancelada dentro do período pago | Reativar assinatura |
dashboard.export.button |
Botão de exportação de dados (LGPD) | Baixar meus dados |
dashboard.export.success |
Mensagem de sucesso de exportação solicitada | Preparamos seu arquivo. Ele fica disponível para download aqui no painel, e avisamos por e-mail quando estiver pronto. |
dashboard.export.download |
Botão de download do arquivo pronto, dentro do painel | Baixar arquivo agora |
dashboard.export.download_limit |
Erro de limite de downloads do mesmo arquivo | Você já baixou este arquivo 3 vezes. Peça uma nova exportação se precisar dele de novo. |
dashboard.pause.active |
Aviso de pausa ativa | Pausado até {lastPausedDay}; você volta a receber em {resumeDay}. |
dashboard.delete_account.button |
Botão de exclusão de conta | Excluir minha conta |
dashboard.delete_account.confirm_body |
Aviso do modal de exclusão de conta | Isso encerra sua assinatura e remove seus dados pessoais em até 30 dias, conforme nossa Política de privacidade. |
whatsapp.optout.confirmation |
Confirmação enviada no WhatsApp quando o opt-out foi pedido pelo WhatsApp e não há assinatura paga vigente | Você não vai mais receber devocionais por aqui. Se mudar de ideia, responda VOLTAR a qualquer momento. |
whatsapp.optout.subscription_notice |
Confirmação enviada no WhatsApp quando o opt-out foi pedido pelo WhatsApp por quem tem assinatura paga vigente | Paramos de enviar. Sua próxima cobrança está suspensa. Se quiser voltar, é só responder VOLTAR; se quiser encerrar de vez, cancele no painel. |
dashboard.subscription.status.optout_suspended |
Rótulo no painel enquanto o opt-out mantém a assinatura suspensa | Envios pausados, cobrança suspensa |
errors.generic.retry |
Mensagem genérica de erro com opção de nova tentativa | Não foi possível concluir agora. Tente novamente em instantes. |
Regra de canal das duas chaves de opt-out acima. Elas são mutuamente exclusivas e valem
apenas para o opt-out pedido pelo WhatsApp: nesse caso o sistema envia exatamente uma
mensagem no WhatsApp — whatsapp.optout.subscription_notice quando há assinatura paga
vigente, whatsapp.optout.confirmation nos demais casos — e nada mais. Opt-out pedido pelo
painel ou pelo link de descadastro não produz mensagem no WhatsApp: a confirmação aparece
na tela e, quando houver e-mail verificado, também por e-mail (Seções 14.7.3 e 20.4.1). O
motivo é direto: quem pediu para parar de receber mensagens no WhatsApp por um canal que não
é o WhatsApp não deve receber mais uma mensagem no WhatsApp.
10.14 Página de obrigado (pós-assinatura) #
Título: Pronto! Sua assinatura está ativa.
Texto: A partir de amanhã às 6h, você recebe seu devocional em texto e áudio, todos os
dias, no WhatsApp cadastrado. Enquanto isso, dá uma olhada no seu painel — lá você
encontra os devocionais já publicados e pode ajustar seus dados quando quiser.
Próximo passo: [Ir para o meu painel]10.15 Copy dos e-mails transacionais #
E-mail 1 — Confirmação de assinatura
Assunto: Sua assinatura do Palavra Diária está confirmada
Olá, {{firstName}},
Sua assinatura do Palavra Diária foi confirmada com sucesso.
Plano: {{planName}}
Valor: {{formattedPrice}}
Próxima cobrança: {{nextDueDate}}
A partir de agora, você recebe devocional em texto e áudio todos os dias às 6h, no
WhatsApp cadastrado no número {{phoneMasked}}.
Para acompanhar sua assinatura, acessar o acervo completo de devocionais ou fazer
alterações, acesse seu painel:
{{dashboardUrl}}
Qualquer dúvida, é só responder este e-mail.
Equipe Palavra DiáriaE-mail 2 — Lembrete de cobrança
Assunto: Sua próxima cobrança do Palavra Diária é em {{daysUntilDue}} dias
Olá, {{firstName}},
Sua assinatura do plano {{planName}} será renovada automaticamente em {{nextDueDate}}, no
valor de {{formattedPrice}}.
Forma de pagamento: {{paymentMethodLabel}}
Se estiver tudo certo, você não precisa fazer nada. Se quiser alterar a forma de
pagamento ou cancelar antes da renovação, acesse seu painel:
{{dashboardUrl}}
Equipe Palavra DiáriaE-mail 3 — Falha de pagamento
Assunto: Não conseguimos confirmar seu pagamento
Olá, {{firstName}},
Não conseguimos confirmar o pagamento da sua assinatura do Palavra Diária.
Como consequência, seu acesso ao plano completo foi encerrado agora. Você continua
recebendo o devocional gratuito aos domingos, normalmente.
Para voltar a receber o devocional completo todos os dias, atualize sua forma de
pagamento e finalize uma nova assinatura pelo painel:
{{dashboardUrl}}
Se você acredita que isso é um engano, responda este e-mail que a gente verifica com
você.
Equipe Palavra DiáriaE-mail 4 — Cancelamento confirmado
Assunto: Seu cancelamento foi confirmado
Olá, {{firstName}},
Confirmamos o cancelamento da sua assinatura do Palavra Diária.
Seu plano completo continua ativo até {{currentPeriodEnd}}, já que esse período já está
pago. Depois dessa data, você passa a receber automaticamente o devocional gratuito, aos
domingos.
Mudou de ideia? Você pode reativar a assinatura a qualquer momento antes de
{{currentPeriodEnd}} pelo painel, sem perder o período já pago:
{{dashboardUrl}}
Sentiremos sua falta nos outros dias da semana. Esperamos te ver de volta.
Equipe Palavra DiáriaE-mail 5 — Exportação de dados pronta
Assunto: Seus dados estão prontos para download
Olá, {{firstName}},
O arquivo com os seus dados pessoais, conforme solicitado no seu painel, está pronto.
Entre no seu painel, em Meus dados, e clique em "Baixar arquivo agora":
{{dashboardUrl}}
Por segurança, o download acontece apenas dentro do painel, com você autenticado. Este
e-mail é só um aviso: ele não contém link de download. O arquivo fica disponível por 7
dias e pode ser baixado até 3 vezes.
O arquivo contém seus dados de cadastro, histórico de assinatura e registros de
consentimento, no formato JSON.
Qualquer dúvida sobre o conteúdo do arquivo ou sobre seus direitos de titular de dados,
responda este e-mail.
Equipe Palavra Diária10.16 Anúncios e divulgação orgânica #
Textos curtos para redes sociais (5):
1. Todo dia às 6h, um devocional chega no seu WhatsApp — texto e áudio, sem precisar
lembrar. Comece grátis: palavradiaria.com.br
2. Você não esquece de ver o WhatsApp. Também não vai esquecer do seu devocional. Todo
dia, às 6h. Experimente grátis: palavradiaria.com.br
3. Um devocional por semana, de graça, para sempre. Ou todo dia, em texto e áudio, no
plano completo. Você escolhe: palavradiaria.com.br
4. Sem aplicativo novo. Sem senha para lembrar. Só o devocional, no WhatsApp que você já
usa, às 6h da manhã. palavradiaria.com.br
5. Quer ouvir antes de assinar? Temos um devocional de exemplo, narrado, esperando por
você. Sem cadastro: palavradiaria.com.brMensagens de indicação entre amigos pelo WhatsApp (3):
1. Oi! Comecei a receber um devocional todo dia às 6h no WhatsApp, com texto e áudio.
Achei bem prático porque não preciso lembrar de nada. Se quiser conhecer:
palavradiaria.com.br
2. Tô usando um serviço que manda um devocional bíblico todo dia de manhã, direto no
WhatsApp. Tem uma versão gratuita também, um por semana. Passa lá: palavradiaria.com.br
3. Achei que você ia gostar disso: um devocional em texto e áudio, todo dia às 6h, sem
precisar instalar nada. Dá uma olhada: palavradiaria.com.br11. Cadastro, Opt-in e Onboarding do Assinante #
11.1 Visão geral do fluxo #
O cadastro tem quatro etapas e produz um assinante apto a receber devocionais. As etapas são sequenciais e cada uma tem um estado persistido. Nenhuma mensagem de devocional sai antes da Etapa 3 concluída.
| Etapa | Nome | Onde acontece | Estado resultante |
|---|---|---|---|
| 1 | Identificação e consentimento | Web, /cadastro |
PENDING_VERIFICATION |
| 2 | Verificação de posse do número (OTP) | WhatsApp + web | VERIFIED_PENDING_OPTIN |
| 3 | Confirmação ativa de opt-in | ACTIVE_FREE |
|
| 4 | Escolha entre continuar gratuito ou assinar | Web, /cadastro/plano |
ACTIVE_FREE ou ACTIVE_PAID |
Decisões desta seção:
| # | Decisão | Justificativa |
|---|---|---|
| D11.1 | A Etapa 4 é opcional e não bloqueante. Quem fecha o navegador depois da Etapa 3 já é assinante gratuito ativo e começa a receber. | Maximiza a conversão para FREE, que é o topo do funil pago. |
| D11.2 | O consentimento web (Etapa 1) e a confirmação no WhatsApp (Etapa 3) são dois registros distintos em consent_events, com channel diferente. |
Opt-in em duas etapas é exigência de conformidade; provar as duas exige dois registros. |
| D11.3 | A sessão do assinante é criada no fim da Etapa 2, não da Etapa 3. | Ele precisa navegar em /cadastro/plano e no painel mesmo antes de confirmar o opt-in no WhatsApp. |
| D11.4 | Um número em PENDING_VERIFICATION há mais de 24 horas é apagado por hard delete pelo job de manutenção. Ele nunca ocupou o índice único de forma legítima. |
Evita que abandono no meio bloqueie o número para sempre. |
| D11.5 | welcome_backfill entrega o devocional do dia uma única vez a quem confirma depois das 06:00, inclusive no tier FREE em dia que não é domingo. Isso é uma exceção de onboarding, declarada e limitada a um envio por assinante na vida. |
Sem isso, quem se cadastra numa segunda-feira espera seis dias para ver o produto. |
| D11.6 | O CPF não é pedido no cadastro. Ele é pedido apenas no checkout (Seção 12), porque só a criação de cliente na Asaas o exige. | Minimização de dados (LGPD). Assinante gratuito nunca informa CPF. |
11.2 Diagrama de estados do assinante #
┌──────────────────────────────────────────────┐
│ (não existe ainda) │
└───────────────────┬──────────────────────────┘
│ POST /api/signups
│ (nome + telefone + consentimento)
▼
┌────────────────────────────┐
┌───────▶│ PENDING_VERIFICATION │
│ │ OTP enviado, aguardando │
│ └──────┬──────────────┬──────┘
│ │ │
reenvio de OTP │ │ OTP ok │ 24 h sem verificar
(máx. 3/hora) │ │ │ → job de limpeza
└───────────────┤ ▼
│ (hard delete, número liberado)
▼
┌─────────────────────────────┐
│ VERIFIED_PENDING_OPTIN │◀────────┐
│ sessão criada; mensagem de │ │ reenvio da
│ boas-vindas no WhatsApp │ │ mensagem de
└──────┬───────────────┬──────┘ │ boas-vindas
│ │ │ (máx. 3, 1/hora)
toca "Quero │ │ 7 dias sem │
receber" ou │ │ confirmar │
responde SIM │ ▼ │
│ ┌─────────────┐ │
│ │ OPTED_OUT │ │
│ │ (expirado) │ │
│ └──────┬──────┘ │
│ │ VOLTAR / │
│ │ painel │
│ └────────────────┘
▼
┌───────────────────┐ webhook PAYMENT_CONFIRMED ┌──────────────────┐
│ ACTIVE_FREE │───────────────────────────────▶│ ACTIVE_PAID │
│ tier = FREE │◀───────────────────────────────│ tier = PAID │
│ domingo 06:00 │ eventos de cobrança da 11.8.1 │ todo dia 06:00 │
└───┬───────────┬───┘ ou fim do período pago └───┬──────────┬───┘
│ │ fim do período pago │ │
SAIR/PARAR │ │ │ SAIR │
/painel │ │ pausa (1 a 30 dias) │ /painel │ pausa
▼ ▼ ▼ ▼
┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────────┐
│ OPTED_OUT │ │ PAUSED │ │ OPTED_OUT │ │ PAUSED │
│ envios off │ │ envios off │ │ envios off │ │ envios off │
│ cobrança │ │ até │ │ cobrança │ │ até │
│ suspensa │ │ paused_until │ │ suspensa │ │ paused_until │
└──────┬──────┘ └──────┬───────┘ └──────┬──────┘ └──────┬───────┘
│ VOLTAR ou reativação no painel │ │
└────────────────▶ (volta ao estado de tier atual) ◀────────┴────────┘
Fora do fluxo acima, dois estados de exceção:
BLOCKED entra por bloqueio administrativo ou por erro permanente da Meta;
sai por desbloqueio administrativo. Envios cessam; cobrança inalterada.
deleted_at NÃO é um status: é a coluna preenchida pelo pedido de eliminação (LGPD).
Ela vence qualquer status e é terminal: nenhuma transição sai dela.Leitura obrigatória do diagrama: OPTED_OUT interrompe os envios na hora e suspende a
cobrança do ciclo seguinte — sem reativação em 30 dias, a assinatura é cancelada ao fim
do período já pago (regra canônica na Seção 13.4.6). A eliminação por LGPD não é
um status: é deleted_at preenchido, e é terminal e irreversível fora da janela de 72 h
da Seção 14.8.2. As duas regras reaparecem em 11.8.
11.3 Etapa 1 — Formulário web de cadastro #
11.3.1 Rota e layout #
- Rota:
/cadastro(route group(marketing), mas comnoindex, nofollow). - Sem cabeçalho de navegação completo: apenas logotipo, link "Voltar ao início" e o
indicador de progresso
1 de 3. - O indicador de progresso mostra três passos:
Seus dados→Código→Confirmação no WhatsApp. A Etapa 4 não aparece no indicador porque é opcional (D11.1). - Se o visitante chegou pelo formulário de captura (Seção 9.7.3), o campo de telefone já
vem preenchido e o foco inicial vai para o campo
name.
11.3.2 Campos #
| Campo | Rótulo visível | Tipo | Obrigatório | autoComplete |
|---|---|---|---|---|
name |
"Seu nome" | text |
Sim | given-name |
phone |
"Seu WhatsApp" | tel |
Sim | tel-national |
email |
"E-mail (opcional)" | email |
Não | email |
consent |
Texto de consentimento versionado | checkbox |
Sim | — |
Não há campo de senha. Não há confirmação de e-mail. Não há campo de CPF (D11.6).
11.3.3 Validação campo a campo #
Schema compartilhado entre cliente e servidor, em packages/core:
// packages/core/src/schemas/signup.ts
import { z } from 'zod';
import { phoneInputSchema } from './phone';
const NAME_ALLOWED = /^[\p{L}\p{M}][\p{L}\p{M}'’\- ]*[\p{L}\p{M}.]$/u;
export const signupSchema = z.object({
name: z.string()
.trim()
.min(2, 'Informe seu nome com pelo menos 2 letras.')
.max(80, 'Use no máximo 80 caracteres.')
.regex(NAME_ALLOWED, 'Use apenas letras, espaços, hífen e apóstrofo.')
.refine((v) => !/(https?:|www\.|\d{4,})/i.test(v), 'Esse nome não parece válido.')
.transform(collapseSpaces),
phone: phoneInputSchema, // ver 11.4
email: z.union([z.literal(''), z.email('Informe um e-mail válido.').max(160)])
.optional()
.transform((v) => (v ? v.trim().toLowerCase() : undefined)),
consent: z.literal(true, {
error: 'É necessário aceitar para receber as mensagens.',
}),
policyVersion: z.string().min(1), // preenchido pelo servidor, não pelo usuário
source: z.enum(['hero', 'final_cta', 'plans_page', 'faq_page', 'direct']).default('direct'),
planCode: z.enum(['plan_monthly', 'plan_annual']).optional(),
turnstileToken: z.string().min(1),
company: z.string().max(0).optional(), // honeypot
});Regras exatas por campo, com as mensagens de erro em português que a interface exibe:
name
| Situação | Mensagem |
|---|---|
| Vazio | "Informe seu nome." |
Menos de 2 caracteres após trim |
"Informe seu nome com pelo menos 2 letras." |
| Mais de 80 caracteres | "Use no máximo 80 caracteres." |
| Contém dígito, URL, emoji ou caractere de controle | "Use apenas letras, espaços, hífen e apóstrofo." |
| Contém 4 ou mais dígitos consecutivos | "Esse nome não parece válido." |
Normalização aplicada: trim, colapso de espaços múltiplos em um só, remoção de
caracteres de controle e de zero-width. Acentos são preservados. O nome é usado como
parâmetro {{1}} de templates do WhatsApp em algumas mensagens (Seção 19), e parâmetro de
template não aceita quebra de linha nem 4 espaços consecutivos — a normalização já
garante isso na entrada, e o motor de envio revalida (Seção 18).
Exemplo concreto: entrada " maria das graças " → armazenado
"maria das graças" em name, exibido com capitalização original (o sistema não
recapitaliza: "d'Ávila" e "MARIA" são escolhas do usuário).
phone
| Situação | Mensagem |
|---|---|
| Vazio | "Informe seu número de WhatsApp." |
| Menos de 10 dígitos após limpeza | "Número incompleto. Inclua o DDD." |
| Não normalizável para E.164 | "Não reconhecemos esse número. Use o formato (11) 91234-5678." |
| DDI diferente de 55 | "Por enquanto atendemos apenas números do Brasil (+55)." |
| DDD inexistente na lista oficial | "DDD inválido. Confira os dois primeiros dígitos." |
| Número fixo (não celular) | "Informe um número de celular com WhatsApp." |
email
| Situação | Mensagem |
|---|---|
| Preenchido e inválido | "Informe um e-mail válido." |
| Mais de 160 caracteres | "E-mail muito longo." |
| Já usado por outro assinante ativo | Ver 11.11, caso E8 — não bloqueia o cadastro. |
consent
| Situação | Mensagem |
|---|---|
| Desmarcado ao submeter | "É necessário aceitar para receber as mensagens." |
O checkbox nunca vem pré-marcado. Consentimento pré-marcado é inválido sob a LGPD.
Comportamento de validação na interface:
- Validação
onBlurno primeiro contato com o campo eonChangedepois que o campo já errou uma vez (modoonToucheddo react-hook-form). Nunca validaronChangedesde o primeiro caractere: isso mostra erro enquanto a pessoa ainda digita. - Ao submeter com erros, o foco vai para o primeiro campo inválido e um
role="alert"anuncia "Há N campos para corrigir". - Erros de servidor voltam no envelope de erro da Seção 7 com
details[].fielde são mapeados de volta para o campo correspondente viasetError.
11.3.4 Texto de consentimento e seu versionamento #
O rótulo do checkbox é o texto de consentimento versionado. O texto exato aprovado é propriedade do copy da Seção 10; esta seção fixa a mecânica:
- A constante
CURRENT_CONSENT_VERSIONvive empackages/core(ex.:"2026-08-25"). - O servidor ignora qualquer
policyVersionenviado pelo cliente e grava sempre a versão vigente no servidor, na colunaconsent_events.policy_version. Isso impede que um cliente forjado registre consentimento para uma versão antiga. - No submit bem-sucedido, o servidor cria um registro em
consent_events(append-only, Seção 6) com:type = 'OPT_IN_WEB',policy_version, o texto integral exibido,ip,user_agent,channel = 'WEB',created_at. Os nomes de tipo e de coluna são os do enum e do schema da Seção 6; nenhuma grafia alternativa é aceita. - Guardar o texto integral, e não só a versão, é deliberado: em disputa, o que vale é o que a pessoa leu.
11.3.5 Proteções #
| Proteção | Regra | Resposta ao estourar |
|---|---|---|
| Turnstile invisível | Token obrigatório e verificado no servidor | 422 CAPTCHA_FAILED |
Honeypot company |
Precisa estar vazio | 422 HONEYPOT_TRIGGERED (mensagem genérica) |
| Rate limit por IP | 5 cadastros por IP por hora | 429 RATE_LIMITED |
| Rate limit por número | 3 pedidos de OTP por número por hora (Seção 8.2.1) | 429 OTP_RATE_LIMITED |
| Rate limit global de OTP | 500 pedidos por hora no sistema, com reserva de 20% para números já cadastrados; acima disso, alerta e fila | 503 SERVICE_BUSY |
Esta tabela é réplica informativa: os limites de OTP são canônicos na Seção 8.2.1 e não
podem ser redefinidos aqui. Dois pontos que decorrem dela e valem para o cadastro: os
contadores contam pedidos, não envios, e são incrementados antes de qualquer consulta
ao banco, para número existente e inexistente igualmente — contar apenas envios reais
transformaria a diferença de comportamento no quarto pedido em um oráculo de enumeração de
assinantes. E a resposta é idêntica em código HTTP, corpo e error.code para número
cadastrado e não cadastrado.
O widget do Turnstile roda em modo invisível nesta tela (decisão de acessibilidade da
Seção 9.10.1), e a rota /cadastro é servida com a variante de CSP para páginas com
formulário, que libera https://challenges.cloudflare.com em script-src, frame-src e
connect-src (Seção 22.3.1). O mesmo host precisa constar da lista de destinos permitidos
do cliente HTTP com proteção contra SSRF (Seção 22.2.6), porque a verificação do token é
feita pelo servidor. Sem qualquer uma das duas liberações, nenhum cadastro é possível.
11.4 Normalização de telefone brasileiro #
Esta subseção é a única fonte da normalização de telefone. A função
normalizePhoneBR() — exportada como normalizeBrazilPhone() em
packages/core/src/phone.ts — é a única autorizada a produzir um phone_e164, e nenhuma
outra seção reimplementa a regra. A identidade do assinante é esse telefone normalizado; a
Seção 8.1 define apenas como essa identidade vira sessão.
A validação de formato E.164 acontece exclusivamente aqui, na borda, com Zod, antes de
qualquer escrita. O banco não valida formato de telefone: a coluna phone_e164 guarda um
envelope cifrado (Seção 22.5.3) e um CHECK sobre ela não teria o valor em claro para
comparar. Qualquer CHECK de formato E.164 sobre coluna cifrada é proibido.
11.4.1 O problema do nono dígito #
Desde 2016 todos os celulares brasileiros têm nove dígitos após o DDD, começando com 9.
O problema prático é que o ecossistema não é consistente:
- Pessoas digitam o número dos dois jeitos:
(11) 91234-5678e(11) 1234-5678. - O WhatsApp, para números brasileiros antigos (anteriores à migração), historicamente
propaga um
wa_idsem o nono dígito, mesmo quando o número real o tem. O identificador que a Meta devolve em um webhook de entrada pode, portanto, ser551112345678enquanto o número que a pessoa cadastrou é5511912345678. - Números do DDD 11 ao 28 (região Sudeste, onde a migração começou) são os mais afetados.
- Consequência: buscar o assinante apenas por igualdade exata falha silenciosamente. A mensagem de entrada não encontra dono, a janela de 24 h não é registrada e o assinante parece "não responder" para o sistema.
11.4.2 A regra #
Implementada em packages/core/src/phone.ts, usada por web, worker e CLI. Ninguém mais
normaliza telefone.
// packages/core/src/phone.ts
import parsePhoneNumberFromString from 'libphonenumber-js/max';
export type PhoneNormalizationFailure =
| 'empty' | 'unparseable' | 'foreign_ddi' | 'invalid_ddd'
| 'not_mobile' | 'too_short' | 'too_long';
export type PhoneNormalizationResult =
| { ok: true; e164: string; ddd: string; national: string }
| { ok: false; reason: PhoneNormalizationFailure };
const VALID_DDDS = new Set([
'11','12','13','14','15','16','17','18','19',
'21','22','24','27','28',
'31','32','33','34','35','37','38',
'41','42','43','44','45','46','47','48','49',
'51','53','54','55',
'61','62','63','64','65','66','67','68','69',
'71','73','74','75','77','79',
'81','82','83','84','85','86','87','88','89',
'91','92','93','94','95','96','97','98','99',
]);
export function normalizeBrazilPhone(input: string): PhoneNormalizationResult {
const raw = (input ?? '').trim();
if (!raw) return { ok: false, reason: 'empty' };
const parsed = parsePhoneNumberFromString(raw, 'BR');
if (!parsed) return { ok: false, reason: 'unparseable' };
if (parsed.countryCallingCode !== '55') return { ok: false, reason: 'foreign_ddi' };
const national = parsed.nationalNumber; // "11912345678"
const ddd = national.slice(0, 2);
if (!VALID_DDDS.has(ddd)) return { ok: false, reason: 'invalid_ddd' };
let subscriber = national.slice(2); // "912345678" ou "12345678"
if (subscriber.length === 8) {
// Aceita a forma sem o nono dígito e CANONIZA adicionando o 9,
// desde que o primeiro dígito indique celular (6, 7, 8 ou 9).
if (!/^[6-9]/.test(subscriber)) return { ok: false, reason: 'not_mobile' };
subscriber = `9${subscriber}`;
} else if (subscriber.length === 9) {
if (!subscriber.startsWith('9')) return { ok: false, reason: 'not_mobile' };
} else if (subscriber.length < 8) {
return { ok: false, reason: 'too_short' };
} else {
return { ok: false, reason: 'too_long' };
}
return { ok: true, e164: `+55${ddd}${subscriber}`, ddd, national: `${ddd}${subscriber}` };
}
/** Variante sem o nono dígito. `null` quando não se aplica. */
export function withoutNinthDigit(e164: string): string | null {
const m = /^\+55(\d{2})9(\d{8})$/.exec(e164);
return m ? `+55${m[1]}${m[2]}` : null;
}
/** Variante com o nono dígito. `null` quando não se aplica. */
export function withNinthDigit(e164: string): string | null {
const m = /^\+55(\d{2})([6-9]\d{7})$/.exec(e164);
return m ? `+55${m[1]}9${m[2]}` : null;
}Mensagens exibidas por reason (usadas pelo formulário de captura da Seção 9.7 e pelo
cadastro):
export const PHONE_ERROR_MESSAGES: Record<PhoneNormalizationFailure, string> = {
empty: 'Informe seu número de WhatsApp.',
unparseable: 'Não reconhecemos esse número. Use o formato (11) 91234-5678.',
foreign_ddi: 'Por enquanto atendemos apenas números do Brasil (+55).',
invalid_ddd: 'DDD inválido. Confira os dois primeiros dígitos.',
not_mobile: 'Informe um número de celular com WhatsApp.',
too_short: 'Número incompleto. Inclua o DDD.',
too_long: 'Número com dígitos demais. Confira e tente de novo.',
};Exemplos concretos de normalização:
| Entrada | e164 resultante |
Observação |
|---|---|---|
11912345678 |
+5511912345678 |
Caso comum. |
(11) 91234-5678 |
+5511912345678 |
Máscara removida. |
11 91234 5678 |
+5511912345678 |
Espaços removidos. |
+55 11 91234-5678 |
+5511912345678 |
DDI explícito. |
1112345678 |
erro not_mobile |
8 dígitos começando com 1: é telefone fixo, não celular. |
1181234567 |
+5511981234567 |
8 dígitos começando com 8 → nono dígito adicionado na canonização. |
11981234567 |
+5511981234567 |
Mesmo número, já com o nono dígito. Colide com a linha anterior, como deve. |
1131234567 |
erro not_mobile |
Fixo de São Paulo; não recebe WhatsApp de negócio no MVP. |
+351912345678 |
erro foreign_ddi |
Portugal. |
00912345678 |
erro invalid_ddd |
DDD 00 não existe. |
11.4.3 Persistência dos quatro campos #
Quatro colunas em subscribers (Seção 6 é a dona do schema e do tipo físico; aqui só
declaramos o que a normalização produz e como a busca acontece):
phone_e164— valor canônico, sempre com o nono dígito, guardado como envelope cifrado (Seção 22.5.3). Nunca indexado, nunca comparado por igualdade.phone_hmac— HMAC-SHA-256 determinístico do E.164 canônico. É a coluna de identidade e de unicidade, com índice único parcial (WHERE deleted_at IS NULL). Toda busca por telefone é feita por ela.wa_id— identificador devolvido pela Meta no primeiro contato bem-sucedido, também cifrado e sem índice.wa_id_hmac— HMAC dowa_id, índice único parcial (WHERE wa_id_hmac IS NOT NULL). Nulo até o primeiro webhook.
O wa_id é preenchido de duas origens: (a) o campo contacts[0].wa_id da resposta de
envio da Cloud API e (b) o campo contacts[0].wa_id do webhook de entrada. Quando os dois
divergirem, vale o do webhook de entrada, porque é o identificador com o qual a Meta vai
falar conosco dali em diante.
Regra de chip reciclado, obrigatória. Preencher um wa_id que estava nulo é o caminho
normal. Trocar um wa_id já preenchido por outro valor não é: números brasileiros são
reemitidos pelas operadoras, e quem recebe o número reemitido não é o titular. Quando o
wa_id_hmac calculado divergir do já gravado para aquele telefone, o sistema, na mesma
transação: grava o novo valor, revoga todas as sessões do assinante e marca a conta
como pendente de reverificação. A partir daí, qualquer rota que devolva dado pessoal —
conta, CPF, histórico de pagamentos, acervo, exportação — responde
403 REVERIFICATION_REQUIRED até uma nova verificação por código ser concluída. Responder
no WhatsApp nunca é, por si só, prova de titularidade.
11.4.4 Busca em cascata #
Toda vez que o sistema recebe uma mensagem ou um status da Meta, precisa achar o assinante. A busca é uma cascata de quatro tentativas, na ordem, parando na primeira que encontrar exatamente um resultado:
// packages/core/src/phone-lookup.ts
import { blindIndex } from './crypto/blind-index'; // HMAC determinístico (Seção 22.5.4)
export async function findSubscriberByWhatsApp(db: Db, incomingWaId: string) {
const e164 = incomingWaId.startsWith('+') ? incomingWaId : `+${incomingWaId}`;
// Nenhuma comparação abaixo toca a coluna cifrada. Toda busca é por índice cego.
// 1. wa_id exato — o caminho mais rápido e o mais confiável.
const byWaId = await db.subscriber.findFirst({
where: { waIdHmac: blindIndex(incomingWaId), deletedAt: null },
});
if (byWaId) return { subscriber: byWaId, matchedBy: 'wa_id' as const };
// 2. phone_e164 exato.
const byPhone = await db.subscriber.findFirst({
where: { phoneHmac: blindIndex(e164), deletedAt: null },
});
if (byPhone) return { subscriber: byPhone, matchedBy: 'phone_exact' as const };
// 3. variante SEM o nono dígito (o número cadastrado tem 9, a Meta mandou sem).
const stripped = withoutNinthDigit(e164);
if (stripped) {
const bySt = await db.subscriber.findFirst({
where: { phoneHmac: blindIndex(stripped), deletedAt: null },
});
if (bySt) return { subscriber: bySt, matchedBy: 'without_ninth' as const };
}
// 4. variante COM o nono dígito (a Meta mandou sem, nós guardamos com).
const added = withNinthDigit(e164);
if (added) {
const byAd = await db.subscriber.findFirst({
where: { phoneHmac: blindIndex(added), deletedAt: null },
});
if (byAd) return { subscriber: byAd, matchedBy: 'with_ninth' as const };
}
return { subscriber: null, matchedBy: 'none' as const };
}Regras que acompanham a cascata:
- Auto-cura: quando o resultado vem de
without_ninthouwith_ninth, o sistema grava imediatamentewa_id(cifrado) ewa_id_hmacnaquele assinante. A próxima busca acerta no passo 1. Um loginfocommatchedByregistra a correção. A auto-cura só é silenciosa quandowa_id_hmacestava nulo; se havia outro valor, aplica-se a regra de chip reciclado de 11.4.3 — sessões revogadas e reverificação obrigatória. matchedByé registrado eminbound_messages(Seção 6) para permitir medir quantos contatos precisaram de cascata. Se a taxa passar de 5% ao mês, é sinal de problema na normalização de entrada e gera alerta (Seção 23).- Se a cascata devolver
none, a mensagem de entrada é persistida eminbound_messagescomsubscriber_id = NULLe ignorada pelo motor conversacional, exceto se o texto for uma palavra-chave de saída — nesse caso ela é respondida com a confirmação padrão para evitar reclamação de spam. Regra detalhada na Seção 19. - A cascata nunca casa dois assinantes. Como
phone_hmacé único e as variantes são derivações determinísticas, o único cenário de ambiguidade é existirem simultaneamente+5511912345678e+551112345678no banco. Isso é impedido na escrita: o cadastro sempre canoniza para a forma com nono dígito (11.4.2), então a forma sem nove nunca geraphone_hmac.
11.5 Etapa 2 — Verificação por OTP no WhatsApp #
O mecanismo do código (geração, hash, TTL de 10 minutos, 5 tentativas, 3 pedidos por
hora por número, template codigo_acesso_v1 de categoria AUTHENTICATION, fallback por
e-mail) é canônico na Seção 8.2. Esta seção especifica a experiência.
11.5.1 A tela /cadastro/codigo #
Elementos, em ordem:
- Título: "Digite o código que enviamos".
- Subtítulo com o número mascarado:
(11) 9****-5678. A máscara mostra DDD e os quatro últimos dígitos. - Link "Número errado? Corrigir" → volta para a Etapa 1 com os campos preservados e invalida o OTP em aberto (endpoint em 11.12.5).
- Campo de código: seis caixas de um dígito (
<input inputMode="numeric" autoComplete="one-time-code" maxLength={1}>× 6) agrupadas em umrole="group" aria-label="Código de 6 dígitos".- Colar o código completo em qualquer caixa distribui os dígitos automaticamente.
Backspaceem caixa vazia move o foco para a anterior.- Setas esquerda/direita navegam.
- Ao preencher a sexta caixa, o formulário submete sozinho.
- Em leitores de tela, o rótulo do grupo e o
aria-describedbydo erro cobrem o conjunto; cada caixa temaria-label="Dígito 1 de 6".
- Contador regressivo de validade: "O código expira em 09:47". Atualiza a cada segundo,
dentro de
aria-live="off"; aos 60 segundos restantes, umaria-live="polite"anuncia uma única vez "O código expira em um minuto". - Botão "Reenviar código", desabilitado por 60 segundos após cada envio, com contador "Reenviar em 47s". Depois do terceiro envio na hora, o botão fica desabilitado com o texto "Limite de envios atingido. Tente novamente em X minutos."
- Link "Não recebi no WhatsApp" → abre um bloco explicativo com três verificações (número correto, WhatsApp instalado, checar arquivadas) e, se houver e-mail verificado no cadastro, o botão "Receber por e-mail" (fallback da Seção 8).
11.5.2 Estados da tela #
| Estado | Gatilho | UI |
|---|---|---|
awaiting |
Entrada na tela | Caixas vazias, foco na primeira, contador correndo. |
submitting |
Sexta caixa preenchida | Caixas desabilitadas, spinner sobre o botão. |
invalid |
422 OTP_INVALID |
Caixas em vermelho, tremor de 200 ms (desligado sob prefers-reduced-motion), mensagem "Código incorreto. Você tem N tentativas." Caixas são limpas e o foco volta à primeira. |
expired |
410 OTP_EXPIRED |
Mensagem "Esse código expirou." + botão "Enviar novo código" habilitado imediatamente. |
locked |
429 OTP_ATTEMPTS_EXCEEDED |
Caixas desabilitadas, mensagem "Muitas tentativas. Pedimos um novo código para você." e um novo OTP é enviado automaticamente após 60 s, se ainda houver cota horária. Se não houver: "Tente novamente às 15:20." |
rate_limited |
429 OTP_RATE_LIMITED no reenvio |
Botão desabilitado com horário exato de liberação. |
success |
200 |
Transição imediata para /cadastro/confirmacao. |
Contagem de tentativas exibida ao usuário: mostra o restante, nunca o total. Após a segunda falha, a mensagem acrescenta "Confira se você está lendo o código mais recente."
11.5.3 O que acontece no sucesso #
Em uma única transação:
subscribers.statuspassa dePENDING_VERIFICATIONparaVERIFIED_PENDING_OPTIN.subscribers.phone_verified_atrecebenow().otp_codesdo número são invalidados (consumed_atpreenchido no usado, os demais marcados comosuperseded).- Uma
sessionsé criada e os cookies__Host-sessione__Host-refreshsão emitidos, ambos comPath=/(D11.3, mecânica na Seção 8). - O envio da mensagem de boas-vindas é enfileirado na fila
send.dispatch(Etapa 3). A lista de filas é canônica na Seção 18. - Um evento
otp_verifiedé logado comsubscriberIde oanonymousIdrecebido (Seção 9.13.3).
11.6 Etapa 3 — Boas-vindas e confirmação ativa de opt-in #
11.6.1 A mensagem no WhatsApp #
Enviada pelo template boas_vindas_v1 (categoria UTILITY), um dos oito templates
canônicos da Seção 17.5. O texto aprovado é propriedade da Seção 10 e o catálogo de
mensagens é a Seção 19. A estrutura fixa é:
- Header TEXT estático.
- Body com um parâmetro:
{{1}}= primeiro nome do assinante (extraído denameaté o primeiro espaço, truncado em 20 caracteres, sanitizado para não conter quebra de linha nem 4 espaços consecutivos). - Footer estático com a instrução de saída.
- Dois botões QUICK_REPLY:
Quero receber(payloadOPTIN_CONFIRM) eAgora não(payloadOPTIN_DECLINE).
Alternativas aceitas para confirmar, todas equivalentes:
| Entrada do assinante | Efeito |
|---|---|
Toque no botão Quero receber |
Opt-in confirmado. |
Texto SIM, S, QUERO, CONFIRMO, OK (sem acento, sem caixa) |
Opt-in confirmado. |
Toque no botão Agora não |
Nada muda; estado segue VERIFIED_PENDING_OPTIN. Resposta curta explicando que ele pode confirmar quando quiser. |
Palavra-chave de saída (SAIR, PARAR, ...) |
Vai direto para OPTED_OUT. |
| Qualquer outro texto | Resposta padrão pedindo a confirmação, no máximo uma vez a cada 6 horas para não virar loop. |
11.6.2 O que acontece na confirmação #
Em uma única transação:
subscribers.opt_in_confirmed_at = now().subscribers.status = 'ACTIVE_FREE'(ouACTIVE_PAID, se o pagamento já tiver sido confirmado antes do opt-in — cenário raro, mas possível quando alguém paga rápido e confirma depois; a regra é: o estado reflete otiercorrente).subscribers.service_window_expires_at = now() + 24h— porque o toque no botão é uma mensagem de entrada e abre a janela de atendimento (Seção 17).- Registro em
consent_eventscomtype = 'OPT_IN_WHATSAPP',channel = 'WHATSAPP',policy_versionvigente, e owa_idde origem. Este é o segundo dos dois registros exigidos por D11.2. wa_idewa_id_hmacsão gravados se ainda estiverem nulos (11.4.3).- Avaliação da regra
welcome_backfill(11.10). - Evento
optin_confirmedlogado.
11.6.3 A tela de espera na web #
A tela /cadastro/confirmacao fica visível enquanto o assinante ainda não confirmou:
- Título: "Confirme no WhatsApp para começar".
- Instrução com o número mascarado e o texto exato do botão que ele deve tocar.
- Botão primário "Abrir o WhatsApp" (
https://wa.me/<PHONE_NUMBER>— abre a conversa com o número do negócio). - Botão secundário "Reenviar mensagem", desabilitado por 60 s, limitado a 3 reenvios com intervalo mínimo de 1 hora entre eles.
- Polling de
GET /api/signups/currenta cada 5 segundos nos primeiros 2 minutos e a cada 15 segundos depois disso, com parada total em 10 minutos e um botão "Verificar agora". Sem WebSocket, coerente com a decisão da Seção 14.11. - Quando o polling detecta
optInConfirmed: true, a tela transiciona para a Etapa 4. - Link discreto "Pular por enquanto" → leva direto ao painel (
/app), que mostra um aviso persistente de que o opt-in ainda não foi confirmado.
11.6.4 Expiração do opt-in #
Se o assinante não confirmar em 7 dias, o job diário de manutenção — fila
maintenance.cleanup, Seção 18 — move o registro para OPTED_OUT com
opt_out_reason = 'OPTIN_EXPIRED'. Ele não é
apagado: o número fica bloqueado para novo cadastro e o caminho de volta é VOLTAR no
WhatsApp ou o login no painel, que reoferece a confirmação. A mensagem de boas-vindas não
é reenviada automaticamente depois da expiração.
11.7 Etapa 4 — Continuar gratuito ou assinar #
Rota: /cadastro/plano. Exige sessão válida. Exibida logo após a confirmação de opt-in e
acessível depois pelo painel.
Conteúdo:
- Confirmação positiva: "Pronto. Você já está recebendo." com o resumo do que o plano gratuito entrega, derivado da matriz de entitlements (Seção 13), nunca escrito à mão.
<PlanComparison>(o mesmo componente da Seção 9.4), comdefaultCyclevindo do parâmetroplanda URL quando ele existir. O cartão "Plano Gratuito" exibido aqui é o item sintético montado pelo handler (Seção 9.16.2): ele não existe na tabelaplans, porque ser gratuito é a ausência de assinatura vigente (Seção 13.1).- Dois caminhos:
- "Continuar no plano gratuito" → navega para
/appcom um toast de boas-vindas. Nenhuma escrita no banco: o assinante já estáACTIVE_FREE. - "Assinar o plano completo" → handoff para o checkout, que é território da
Seção 12. O destino é
/app/assinatura/checkout?plan=<code>, e é lá que o CPF é pedido (D11.6).
- "Continuar no plano gratuito" → navega para
- Aviso de transparência abaixo dos botões: renovação automática, cancelamento a qualquer
momento, sem multa. Link para
/cancelamento.
Se o assinante chegou com plan=plan_annual na URL desde o formulário de captura da
landing (Seção 9.7.3), o cartão anual vem pré-selecionado e o botão principal já diz
"Assinar o plano anual". A escolha nunca é aplicada automaticamente: sempre exige clique.
11.8 Estados e transições do assinante #
Enum SubscriberStatus, coluna subscribers.status (Seção 6 é a dona da definição
física). O enum tem sete valores, e apenas estes: PENDING_VERIFICATION,
VERIFIED_PENDING_OPTIN, ACTIVE_FREE, ACTIVE_PAID, PAUSED, OPTED_OUT, BLOCKED.
A eliminação não é um status. Conta excluída é deleted_at IS NOT NULL. Essa coluna
vence qualquer valor de status em toda leitura de fluxo, e não existe o valor DELETED
no enum. Escrever status = 'DELETED' é erro de implementação.
Invariantes obrigatórias, garantidas por CHECK no banco e por teste de propriedade:
status = 'ACTIVE_FREE' ⇔ tier = 'FREE' AND opt_in_confirmed_at IS NOT NULL AND opt_out_at IS NULL AND blocked_at IS NULL AND (paused_until IS NULL OR paused_until <= now()) AND deleted_at IS NULL
status = 'ACTIVE_PAID' ⇔ tier = 'PAID' AND opt_in_confirmed_at IS NOT NULL AND opt_out_at IS NULL AND blocked_at IS NULL AND (paused_until IS NULL OR paused_until <= now()) AND deleted_at IS NULL
status = 'PAUSED' ⇔ paused_until > now() AND opt_out_at IS NULL AND blocked_at IS NULL AND deleted_at IS NULL
status = 'OPTED_OUT' ⇔ opt_out_at IS NOT NULL AND blocked_at IS NULL AND deleted_at IS NULL
status = 'BLOCKED' ⇔ blocked_at IS NOT NULL AND opt_out_at IS NULL AND deleted_at IS NULLPAUSED é derivado de paused_until e volta sozinho: quando paused_until <= now(), o
assinante é novamente ACTIVE_FREE ou ACTIVE_PAID conforme o tier corrente, sem que
nenhum job precise ter rodado. O tier não muda durante a pausa e a cobrança não é
alterada (Seção 20.6).
11.8.1 Tabela de transições permitidas #
| De | Para | Gatilho | Efeitos colaterais |
|---|---|---|---|
| — | PENDING_VERIFICATION |
POST /api/signups |
Cria subscribers, cria consent_events (web), enfileira OTP. |
PENDING_VERIFICATION |
PENDING_VERIFICATION |
Reenvio de OTP ou correção de número | Novo otp_codes; se o número mudou, atualiza phone_e164 e reinicia a cota horária. |
PENDING_VERIFICATION |
VERIFIED_PENDING_OPTIN |
OTP correto | Cria sessão, marca phone_verified_at, enfileira boas-vindas. |
PENDING_VERIFICATION |
(removido) | Job de limpeza após 24 h | Hard delete. Libera o número. |
VERIFIED_PENDING_OPTIN |
ACTIVE_FREE |
Botão Quero receber ou SIM |
opt_in_confirmed_at, janela de 24 h, consent_events (WhatsApp), avaliação de welcome_backfill. |
VERIFIED_PENDING_OPTIN |
ACTIVE_PAID |
Idem, com pagamento já confirmado | Mesmos efeitos; tier já era PAID. |
VERIFIED_PENDING_OPTIN |
OPTED_OUT |
Palavra-chave de saída, ou 7 dias sem confirmar | opt_out_at, opt_out_reason. |
ACTIVE_FREE |
ACTIVE_PAID |
Webhook PAYMENT_CONFIRMED/PAYMENT_RECEIVED (Seção 12) |
tier = PAID na mesma transação da assinatura. |
ACTIVE_PAID |
ACTIVE_FREE |
PAYMENT_OVERDUE, PAYMENT_DELETED, PAYMENT_REFUNDED, PAYMENT_CHARGEBACK_REQUESTED, ou fim do período pago após cancelamento |
tier = FREE imediatamente, sem carência (Seção 13). O tier é relido no momento do disparo de cada mensagem, então a revogação alcança inclusive um lote já planejado (Seção 18.5). |
ACTIVE_FREE | ACTIVE_PAID |
PAUSED |
Pausa de 1 a 30 dias pelo painel (Seção 14.7.2) ou PAUSAR no WhatsApp (Seção 20.6) |
paused_until; envios param; tier e cobrança inalterados; consent_events com type = 'PAUSE_STARTED'. |
PAUSED |
ACTIVE_FREE | ACTIVE_PAID |
paused_until <= now(), ou "Retomar agora" no painel |
paused_until = NULL; volta ao estado correspondente ao tier; consent_events com type = 'PAUSE_ENDED'. |
ACTIVE_FREE | ACTIVE_PAID | PAUSED |
OPTED_OUT |
Palavra-chave de saída, ou opt-out no painel | opt_out_at; envios param na hora; a cobrança do ciclo seguinte é suspensa e, sem reativação em 30 dias, a assinatura é cancelada ao fim do período pago (Seção 13.4.6). |
OPTED_OUT |
ACTIVE_FREE |
VOLTAR ou reativação no painel, com tier = FREE |
opt_out_at = NULL, novo consent_events com type = 'RE_OPT_IN'. |
OPTED_OUT |
ACTIVE_PAID |
Idem, com tier = PAID |
Mesmos efeitos. |
ACTIVE_FREE | ACTIVE_PAID | PAUSED | OPTED_OUT |
BLOCKED |
Erro 131026 da Meta em 3 dias consecutivos, bloqueio pelo assinante detectado por webhook, ou bloqueio administrativo |
Grava blocked_at e blocked_reason; envios cessam; cobrança não é alterada. |
BLOCKED |
ACTIVE_FREE | ACTIVE_PAID |
Qualquer mensagem recebida do assinante, ou desbloqueio administrativo | blocked_at = NULL, blocked_reason = NULL; volta ao tier resolvido por resolveEntitlements. |
VERIFIED_PENDING_OPTIN | ACTIVE_FREE | ACTIVE_PAID | PAUSED | OPTED_OUT | BLOCKED |
deleted_at preenchido |
Exclusão por solicitação LGPD (Seção 14.8.2) ou por administrador (Seção 15.7.3) | deleted_at; envios param; assinatura ativa é cancelada na Asaas; pseudonimização em até 30 dias, alcançando todas as tabelas com identificador pessoal, inclusive os registros de mensagem (Seção 22.9). O status não vira DELETED: ele permanece com o último valor válido e passa a ser irrelevante. |
deleted_at preenchido |
qualquer | — | Proibido. Terminal fora da janela de reversão de 72 h da Seção 14.8.2. Um novo cadastro com o mesmo número cria um registro novo; ver caso E3 em 11.11. |
11.8.2 Transições proibidas e como o código as impede #
PENDING_VERIFICATION → ACTIVE_*sem passar porVERIFIED_PENDING_OPTIN: impossível, porque a única escrita deopt_in_confirmed_atexigephone_verified_at IS NOT NULL.- Qualquer transição a partir de uma conta com
deleted_at IS NOT NULL: bloqueada por guarda na funçãoapplySubscriberTransition()e porWHERE deleted_at IS NULLem toda leitura de fluxo. A única exceção é a reversão administrativa dentro de 72 h (Seção 15.7.3), que zeradeleted_ate restaura o status anterior. - Qualquer transição não listada em 11.8.1 lança
IllegalStateTransitionError, que vira409 ILLEGAL_STATE_TRANSITIONna API e um logerrorcom o par de estados. - A função de transição vive em
packages/core/src/subscriber-state.tse é a única autorizada a escreverstatus,tier,opt_in_confirmed_at,opt_out_at,paused_until,blocked_atedeleted_at. Regra de revisão de código: nenhumprisma.subscriber.updatefora dela pode tocar essas sete colunas.
11.9 Onboarding pós-confirmação #
Sequência exata do que o assinante recebe no WhatsApp logo após confirmar. Os textos aprovados são propriedade da Seção 10; a ordem, os intervalos e as condições são desta seção.
| # | Quando | Conteúdo | Condição |
|---|---|---|---|
| 1 | Imediatamente (< 5 s) | Confirmação de opt-in: "Tudo certo, {primeiro nome}." + o horário de envio (06:00, horário de Brasília) + a frequência do tier + como sair (SAIR). |
Sempre. |
| 2 | Imediatamente após a #1 | welcome_backfill: o devocional do dia completo. Áudio junto, só para PAID. |
Só se a regra de 11.10 disser que sim. |
| 3 | +24 h, às 09:00 | Dica de uso: como pedir o devocional a qualquer momento (HOJE), como pausar (PAUSAR), como ver o acervo (link para /app/acervo). |
Só se o assinante ainda estiver ativo e não tiver dado opt-out. |
| 4 | +3 dias, às 09:00 | Só para ACTIVE_FREE: convite de upgrade, com um único CTA para /app/assinatura. |
Só para FREE; uma única vez na vida do assinante. Não é enviado se ele já tiver visitado o checkout. |
Regras que protegem o assinante de excesso:
- As mensagens 3 e 4 são enviadas como template se a janela de 24 h estiver fechada, e como texto livre se estiver aberta. O motor decide (Seção 18).
- Nenhuma mensagem de onboarding é enviada entre 21:00 e 08:00, horário de Brasília. Se o gatilho cair nessa faixa, ela é adiada para as 09:00 seguintes.
- Se o assinante der opt-out a qualquer momento, todas as mensagens de onboarding pendentes são canceladas na fila.
- A expectativa de horário é declarada em texto: "Todo dia às 6h da manhã, horário de Brasília." Nunca "todo dia cedo".
11.10 Regra do welcome_backfill #
Enunciado. Quem confirma o opt-in depois das 06:00 do dia corrente recebe o devocional daquele dia imediatamente, uma única vez, e entra no ciclo normal no dia seguinte.
Algoritmo, executado dentro da transação de confirmação (11.6.2):
// packages/core/src/onboarding/welcome-backfill.ts
export function shouldSendWelcomeBackfill(input: {
confirmedAtLocal: Date; // já convertido para America/Sao_Paulo
dailySendHour: number; // 6
todaysDevotional: { id: string; status: DevotionalStatus } | null;
alreadyBackfilled: boolean; // subscribers.welcome_backfill_sent_at IS NOT NULL
}): { send: boolean; reason: string } {
if (input.alreadyBackfilled) return { send: false, reason: 'already_backfilled' };
if (!input.todaysDevotional) return { send: false, reason: 'no_devotional_today' };
if (!['PUBLISHED', 'SENT'].includes(input.todaysDevotional.status)) {
return { send: false, reason: 'devotional_not_published' };
}
if (input.confirmedAtLocal.getHours() < input.dailySendHour) {
// Confirmou antes das 06:00: o envio normal do dia ainda vai acontecer.
return { send: false, reason: 'before_daily_send' };
}
return { send: true, reason: 'ok' };
}Regras complementares, todas obrigatórias:
- Uma vez na vida. A coluna
subscribers.welcome_backfill_sent_at(Seção 6) é preenchida no envio. Reativação após opt-out não dá direito a novo backfill. - Idempotência com o motor de envio. O backfill grava em
delivery_attemptsa mesma chave única do envio diário,send:{subscriberId}:{devotionalDate}, comreason = 'WELCOME_BACKFILL'. Se o motor diário tentar enviar o mesmo devocional para o mesmo assinante mais tarde, o índice único bloqueia e o envio é pulado. Isso resolve sozinho o caso de alguém confirmar às 05:58 e o lote das 06:00 pegá-lo. - FREE também recebe (D11.5), mesmo em dia que não é domingo, e mesmo que o próximo envio regular dele seja só no domingo seguinte. O backfill não conta como o envio semanal, exceto se cair em um domingo — nesse caso ele é o envio da semana, pela idempotência da chave.
- Conteúdo por tier. FREE recebe texto completo. PAID recebe texto completo + áudio.
A decisão vem de
resolveEntitlements()(Seção 13), nunca de condicional local. - Janela aberta garantida. O toque no botão de confirmação é uma mensagem de entrada, então a janela de 24 h está aberta e o backfill vai como texto livre, sem custo de template e sem limite de 1024 caracteres.
- Faixa noturna. O backfill ignora a restrição de 21:00–08:00 da Seção 11.9, porque foi o próprio assinante que acabou de agir. Ele pediu; ele recebe.
- Sem devocional publicado. Se
todaysDevotionalfor nulo ou não publicado, o assinante recebe apenas a mensagem #1 do onboarding e o primeiro devocional chega no ciclo normal. Nada de erro visível.
Exemplos concretos:
| Cenário | Confirmação | Tier | Resultado |
|---|---|---|---|
| Cadastro na terça 14:30 | 14:32 | FREE | Recebe o devocional de terça na hora. Próximo envio: domingo 06:00. |
| Cadastro na terça 05:20 | 05:22 | PAID | Não recebe backfill. Recebe o envio normal às 06:00, com áudio. |
| Cadastro no domingo 10:00 | 10:03 | FREE | Recebe o devocional de domingo na hora. Próximo envio: domingo seguinte. |
| Cadastro na quinta 23:50 | 23:52 | PAID | Recebe texto + áudio na hora, apesar do horário. |
| Reativação depois de opt-out, sexta 15:00 | 15:00 | PAID | Não recebe backfill (already_backfilled). Volta ao ciclo no sábado 06:00. |
| Cadastro na quarta 08:00, sem devocional publicado para quarta | 08:01 | PAID | Só a mensagem de confirmação. Primeiro devocional: quinta 06:00. |
11.11 Casos de borda #
Cada caso tem gatilho, comportamento definido, resposta de API e o que o usuário vê.
E1 — Número já cadastrado e ativo (ACTIVE_FREE ou ACTIVE_PAID)
O cadastro não cria duplicata e não revela publicamente que o número existe de
forma explorável. Resposta 200 com { "data": { "next": "LOGIN" } } e a interface
navega para /login?phone=<mascarado> com a mensagem "Esse número já tem cadastro. Vamos
te enviar um código para entrar." Um OTP de login é enviado (mesma cota horária). O
consentimento não é regravado. Justificativa: o número não é segredo — quem digita já
sabe que é dele — mas transformar o cadastro em oráculo de "esse número é cliente?" é
evitado ao usar exatamente a mesma latência e o mesmo formato de resposta do caminho
normal.
E2 — Número já cadastrado e com opt-out (OPTED_OUT)
Resposta 200 com { "data": { "next": "REACTIVATE" } }. Envia OTP. Depois de verificar,
a tela mostra "Você tinha cancelado o recebimento em 12/03/2026. Quer voltar a receber?"
com um botão de reativação que exige novo consentimento e grava consent_events com
type = 'RE_OPT_IN'. Se havia assinatura paga ativa e não cancelada, a tela informa isso e
o assinante volta direto para ACTIVE_PAID.
E3 — Número já cadastrado e excluído (deleted_at IS NOT NULL)
O registro antigo é intocável. O novo cadastro cria um assinante novo, com novo ULID.
Isso exige que o índice único de phone_hmac seja parcial: WHERE deleted_at IS NULL
(a Seção 6 define assim). Nada do histórico anterior é restaurado, nem acervo, nem
consentimento, nem assinatura. Um log info registra a recriação, correlacionando os dois
ULIDs para auditoria, sem expor isso ao usuário.
E4 — OTP expirado
410 OTP_EXPIRED. A tela mostra "Esse código expirou." e habilita "Enviar novo código"
imediatamente, ignorando o cooldown de 60 s (mas não a cota de 3 por hora). Se a cota
estiver esgotada, mostra o horário exato de liberação.
E5 — Usuário abandona no meio Três subcasos:
- Abandona antes de verificar: registro
PENDING_VERIFICATIONé apagado por hard delete em 24 h (D11.4). O número volta a estar livre. - Abandona depois de verificar e antes de confirmar: fica
VERIFIED_PENDING_OPTIN. Nenhum devocional é enviado. Após 7 dias viraOPTED_OUT(11.6.4). - Abandona depois de confirmar, na Etapa 4: já é
ACTIVE_FREEe recebe normalmente. Nenhuma ação necessária.
E6 — Usuário bloqueia o número do negócio no WhatsApp
Detectado pelo código de erro 131026 ("Message undeliverable") ou por falhas
consecutivas de entrega. Comportamento: após 3 falhas consecutivas com 131026 em
dias diferentes, o assinante é movido para BLOCKED, com blocked_at = now() e
blocked_reason = 'UNDELIVERABLE_131026' (transição de 11.8.1), e um e-mail é enviado (se
houver e-mail verificado) explicando o que aconteceu e como voltar. BLOCKED tem saída:
qualquer mensagem recebida do assinante, ou um desbloqueio administrativo, zera
blocked_at e devolve o assinante ao tier corrente. Não se usa OPTED_OUT aqui, porque
não houve manifestação de vontade do titular. Se ele tiver assinatura paga ativa, um alerta
operacional é gerado (Seção 23) para atendimento humano, porque cobrar sem entregar é
inaceitável. O tratamento completo dos códigos da Meta é canônico na Seção 27.
E7 — Número inválido ou DDI estrangeiro
422 VALIDATION_ERROR com details: [{ "field": "phone", "issue": "foreign_ddi" }].
A interface exibe a mensagem correspondente de PHONE_ERROR_MESSAGES (11.4.2). Para DDI
estrangeiro, o texto acrescenta um link para /contato com o assunto pré-selecionado
outro, para registrar interesse. Não há lista de espera no MVP.
E8 — E-mail já usado por outro assinante
O e-mail não é identidade (a identidade é o telefone), então isso não bloqueia o
cadastro. Comportamento: o cadastro prossegue, o e-mail é salvo como não verificado e
nenhum e-mail é disparado para ele. Um alerta interno de baixa severidade é logado. Se o
assinante quiser usar aquele e-mail como fallback de login (Seção 8), precisa verificá-lo
em /app/perfil — e a verificação falha com 409 EMAIL_ALREADY_IN_USE enquanto o outro
assinante o mantiver verificado. Justificativa: famílias compartilham e-mail, e bloquear
o cadastro por isso perde assinante sem ganho de segurança.
E9 — Corrida entre duas abas
Cenário: a pessoa abre /cadastro em duas abas e submete nas duas com o mesmo número.
- A criação usa
INSERT ... ON CONFLICT (phone_hmac) WHERE deleted_at IS NULL DO NOTHINGseguido de leitura. A segunda aba encontra o registro existente emPENDING_VERIFICATIONe reaproveita, sem erro. - O OTP é único por número: a segunda submissão não gera código novo se houver um
ativo com menos de 60 s de vida; ela devolve
200comotpAlreadySent: truee o mesmoexpiresAt. Assim, o código que chegou no WhatsApp continua válido, evitando o clássico "recebi dois códigos e nenhum funciona". - Se a primeira aba verificar e a segunda tentar verificar com o mesmo código já
consumido, a segunda recebe
409 OTP_ALREADY_USEDe a interface simplesmente navega para o estado atual do cadastro, sem mensagem de erro. - Bloqueio pessimista não é usado. A garantia vem do índice único e do
ON CONFLICT.
E10 — Número com WhatsApp Business de outra empresa Não é distinguível pela API e não é tratado de forma especial. Se a entrega falhar, cai em E6.
E11 — Nome com caracteres inválidos
Emoji, sequências de zero-width e caracteres de controle são removidos silenciosamente
na normalização. Se, depois da remoção, sobrar menos de 2 caracteres, retorna
422 VALIDATION_ERROR com a mensagem de nome curto. Dígitos e URLs não são removidos:
eles disparam erro de validação, porque quase sempre indicam preenchimento automático de
robô.
E12 — Consentimento marcado e depois desmarcado antes do submit
O submit é bloqueado no cliente e, se burlado, no servidor com
422 CONSENT_REQUIRED. Nenhum registro é criado. Nenhum OTP é enviado.
E13 — Telefone correto, mas a pessoa não tem WhatsApp
O envio do OTP falha com erro da Meta indicando destinatário inválido. Após a primeira
falha, a tela de código mostra um aviso adicional: "Não conseguimos enviar pelo WhatsApp.
Confira se esse número tem WhatsApp ativo." e oferece "Corrigir número". Se o cadastro
tinha e-mail, oferece o fallback por e-mail (Seção 8). O registro fica
PENDING_VERIFICATION e é limpo em 24 h se nada acontecer.
E14 — Relógio do cliente adiantado ou atrasado
O contador de expiração do OTP é calculado a partir do expiresAt retornado pelo servidor
menos o horário do servidor recebido no mesmo payload, aplicado ao relógio local como
um delta. Nunca comparar expiresAt com Date.now() diretamente. Isso evita contador
negativo em dispositivos com relógio errado.
E15 — Assinante confirma opt-in duas vezes (toque duplo no botão)
A segunda confirmação é idempotente: opt_in_confirmed_at só é escrito se estiver nulo.
Nenhum consent_events duplicado é criado (índice único em
(subscriber_id, type, policy_version) para os tipos de opt-in). Nenhum backfill
duplicado é enviado (welcome_backfill_sent_at).
E16 — Cadastro durante janela de manutenção
/cadastro responde 503 com a página de manutenção (Seção 9.15.3). Nenhum estado
parcial é criado. Webhooks continuam sendo aceitos, então quem já estava no meio do fluxo
consegue confirmar o opt-in pelo WhatsApp normalmente e o processamento acontece quando a
manutenção termina.
11.12 Endpoints do cadastro #
Todos seguem o envelope de sucesso e de erro da Seção 7. Todos ecoam X-Request-Id.
11.12.1 POST /api/signups #
- Autenticação: nenhuma. Papel exigido: nenhum.
- Idempotência: sim, por número. Reenvio com o mesmo
phoneemPENDING_VERIFICATIONreaproveita o registro e, se houver OTP ativo com menos de 60 s, não gera outro (caso E9). - Efeitos colaterais: pode criar
subscribers, criaconsent_events, criaotp_codes, enfileira o envio do código na filasend.dispatch(Seção 18).
Request (schema signupSchema de 11.3.3):
{
"name": "Maria das Graças",
"phone": "(11) 91234-5678",
"email": "maria@exemplo.com.br",
"consent": true,
"source": "hero",
"planCode": "plan_annual",
"turnstileToken": "0.abc..."
}Resposta 200:
{
"data": {
"next": "VERIFY_OTP",
"signupId": "sgn_01HZXB1C3D5E7F9G1H3J5K7M9N",
"phoneMasked": "(11) 9****-5678",
"otpAlreadySent": false,
"otpExpiresAt": "2026-08-25T12:41:00.000Z",
"resendAvailableAt": "2026-08-25T12:32:00.000Z",
"serverTime": "2026-08-25T12:31:00.000Z"
},
"meta": { "requestId": "req_01HZXB2D4E6F8G0H2J4K6L8M0N", "timestamp": "2026-08-25T12:31:00.000Z" }
}next pode ser VERIFY_OTP (fluxo normal), LOGIN (caso E1) ou REACTIVATE (caso E2).
serverTime existe para o cálculo de delta do caso E14.
Erros:
| HTTP | code |
Quando |
|---|---|---|
| 422 | VALIDATION_ERROR |
Qualquer falha de signupSchema; details traz field e issue. |
| 422 | CONSENT_REQUIRED |
consent diferente de true. |
| 422 | HONEYPOT_TRIGGERED |
Campo company preenchido. |
| 422 | CAPTCHA_FAILED |
Turnstile inválido. |
| 429 | RATE_LIMITED |
Mais de 5 cadastros por IP por hora. |
| 429 | OTP_RATE_LIMITED |
Mais de 3 pedidos de OTP para o número na hora, contados antes de qualquer consulta ao banco; details traz retryAfterSeconds. |
| 503 | SERVICE_BUSY |
Cota global de OTP estourada. |
| 502 | WHATSAPP_SEND_FAILED |
A Meta rejeitou o envio do OTP de forma definitiva (caso E13). |
11.12.2 POST /api/signups/verifications #
- Autenticação: nenhuma (é o que cria a sessão). Papel exigido: nenhum.
- Idempotência: o código é de uso único. Reenvio do mesmo código já consumido devolve
409 OTP_ALREADY_USED. - Efeitos colaterais: transição de estado, criação de
sessions, emissão dos cookies de sessão, enfileiramento da mensagem de boas-vindas na filasend.dispatch.
Request:
export const verifySignupSchema = z.object({
signupId: z.string().length(30).startsWith('sgn_'),
code: z.string().regex(/^\d{6}$/, 'O código tem 6 dígitos.'),
});Resposta 200:
{
"data": {
"next": "AWAIT_OPTIN",
"subscriberId": "sub_01HZXB3E5F7G9H1J3K5L7M9N1P",
"status": "VERIFIED_PENDING_OPTIN",
"whatsappDeepLink": "https://wa.me/5511999999999",
"welcomeSentAt": "2026-08-25T12:33:10.000Z"
},
"meta": { "requestId": "req_01HZXB4F6G8H0J2K4L6M8N0P2Q", "timestamp": "2026-08-25T12:33:10.000Z" }
}Os cookies __Host-session e __Host-refresh acompanham a resposta, ambos com Path=/
(Seção 8).
Erros:
| HTTP | code |
Quando | Efeito adicional |
|---|---|---|---|
| 422 | VALIDATION_ERROR |
Código fora do formato | — |
| 422 | OTP_INVALID |
Código errado; details.remainingAttempts |
Incrementa tentativas. |
| 410 | OTP_EXPIRED |
Passou de 10 minutos | Invalida o código. |
| 409 | OTP_ALREADY_USED |
Código já consumido | Nenhum. |
| 429 | OTP_ATTEMPTS_EXCEEDED |
5 tentativas erradas | Invalida o código e agenda novo envio em 60 s. |
| 404 | SIGNUP_NOT_FOUND |
signupId inexistente ou já limpo pelo job de 24 h |
— |
| 409 | ILLEGAL_STATE_TRANSITION |
Registro não está em PENDING_VERIFICATION |
— |
11.12.3 POST /api/signups/otp-resends #
- Autenticação: nenhuma. Papel exigido: nenhum.
- Idempotência: não. Cada chamada bem-sucedida gera um código novo e invalida o anterior.
- Efeitos colaterais: cria
otp_codes, enfileira envio, consome cota horária.
Request: { "signupId": "sgn_01H..." }.
Resposta 200:
{
"data": {
"otpExpiresAt": "2026-08-25T12:45:00.000Z",
"resendAvailableAt": "2026-08-25T12:36:00.000Z",
"remainingResendsThisHour": 1,
"serverTime": "2026-08-25T12:35:00.000Z"
},
"meta": { "requestId": "req_01HZXB5G7H9J1K3L5M7N9P1Q3R", "timestamp": "2026-08-25T12:35:00.000Z" }
}Erros: 404 SIGNUP_NOT_FOUND, 429 OTP_COOLDOWN (antes dos 60 s, com
details.retryAfterSeconds), 429 OTP_RATE_LIMITED (cota horária), 409 ILLEGAL_STATE_TRANSITION, 502 WHATSAPP_SEND_FAILED.
11.12.4 GET /api/signups/current #
- Autenticação: sessão do assinante ou
signupIdem query, aceito apenas enquanto o cadastro estiver emPENDING_VERIFICATIONouVERIFIED_PENDING_OPTIN. - Papel exigido:
SUBSCRIBERquando autenticado por sessão. - Idempotência: leitura pura.
- Cache:
no-store. - Uso: polling da tela de espera (11.6.3).
Resposta 200:
{
"data": {
"status": "VERIFIED_PENDING_OPTIN",
"phoneMasked": "(11) 9****-5678",
"optInConfirmed": false,
"welcomeSentAt": "2026-08-25T12:33:10.000Z",
"welcomeResendAvailableAt": "2026-08-25T13:33:10.000Z",
"remainingWelcomeResends": 2,
"tier": "FREE",
"nextStep": "AWAIT_OPTIN",
"serverTime": "2026-08-25T12:34:20.000Z"
},
"meta": { "requestId": "req_01HZXB6H8J0K2L4M6N8P0Q2R4S", "timestamp": "2026-08-25T12:34:20.000Z" }
}Quando o opt-in é confirmado, optInConfirmed vira true, status vira ACTIVE_FREE ou
ACTIVE_PAID e nextStep vira CHOOSE_PLAN.
Erros: 401 UNAUTHENTICATED, 404 SIGNUP_NOT_FOUND, 429 RATE_LIMITED (acima de 30
chamadas por minuto por signupId, que já é o dobro do necessário para o polling
especificado).
11.12.5 POST /api/signups/phone-corrections #
- Autenticação: nenhuma; exige
signupIdválido emPENDING_VERIFICATION. - Idempotência: enviar o mesmo número novamente é no-op e devolve
200. - Efeitos colaterais: atualiza
phone_e164, invalida todos osotp_codesdo número antigo, gera novo OTP para o novo número, consome cota horária do novo número.
Request:
export const phoneCorrectionSchema = z.object({
signupId: z.string().length(30).startsWith('sgn_'),
phone: phoneInputSchema,
});Resposta 200: mesmo formato de POST /api/signups, com next: "VERIFY_OTP".
Erros: 422 VALIDATION_ERROR, 404 SIGNUP_NOT_FOUND, 409 ILLEGAL_STATE_TRANSITION
(já verificado), 409 PHONE_ALREADY_REGISTERED (o novo número já pertence a um assinante
ativo — a interface então oferece o caminho de login do caso E1), 429 OTP_RATE_LIMITED,
429 PHONE_CORRECTION_LIMIT (máximo de 3 correções por signupId).
11.12.6 POST /api/signups/welcome-resends #
- Autenticação: sessão do assinante. Papel exigido:
SUBSCRIBER. - Idempotência: não; cada chamada envia de novo, respeitando o limite.
- Efeitos colaterais: enfileira a mensagem de boas-vindas na fila
send.dispatch. - Limites: mínimo de 60 minutos entre reenvios, máximo de 3 reenvios por assinante.
Resposta 200:
{
"data": { "sentAt": "2026-08-25T13:35:00.000Z", "remainingResends": 1,
"nextResendAvailableAt": "2026-08-25T14:35:00.000Z" },
"meta": { "requestId": "req_01HZXB7J9K1L3M5N7P9Q1R3S5T", "timestamp": "2026-08-25T13:35:00.000Z" }
}Erros: 401 UNAUTHENTICATED, 409 ILLEGAL_STATE_TRANSITION (opt-in já confirmado),
429 WELCOME_RESEND_COOLDOWN, 429 WELCOME_RESEND_LIMIT, 502 WHATSAPP_SEND_FAILED.
11.12.7 Endpoints referenciados, não definidos aqui #
| Endpoint | Dono |
|---|---|
POST /api/auth/otp/verify (login por OTP) |
Seção 8 |
POST /api/auth/logout (sair) |
Seção 8 |
POST /api/webhooks/whatsapp (recebe a confirmação de opt-in) |
Seção 17 |
POST /api/me/subscription (checkout) |
Seção 12 |
12. Integração de Pagamentos — Asaas #
Esta seção é a dona de tudo que toca a Asaas: autenticação, cliente HTTP, chamadas de saída,
checkout, webhooks, reconciliação, reembolso e chargeback. Nenhuma outra seção emite chamadas
à Asaas diretamente; todas passam pelo cliente tipado descrito em 12.3. O ciclo de vida da
assinatura e a matriz de entitlements que consomem estes eventos são da Seção 13. O envelope
de sucesso/erro e o catálogo de códigos das rotas /api/* são da Seção 7. O schema das
tabelas citadas aqui é da Seção 6.
12.1 Papel da Asaas no produto e escopo da integração #
A Asaas é o único provedor de pagamentos do produto. Ela é responsável por:
- guardar o cadastro fiscal do pagador (
customer); - manter a assinatura recorrente (
subscription) e gerar uma cobrança (payment) por ciclo; - capturar o cartão de crédito e renovar a cobrança automaticamente;
- emitir e liquidar cobranças PIX;
- notificar mudanças de estado por webhook;
- ser a fonte da verdade financeira.
O sistema não implementa: cálculo de imposto, antifraude próprio, carteira, saldo, transferências, split de recebíveis ou emissão fiscal. Ver 12.16.
Três invariantes governam a integração inteira:
- A Asaas vence. Sempre que o nosso banco e a Asaas discordarem sobre o estado de uma cobrança ou assinatura, o estado da Asaas é copiado para o nosso banco, nunca o contrário. A reconciliação de 12.13 existe exatamente para isso.
- Nenhum efeito de negócio acontece no handler do webhook. O handler persiste e enfileira. O efeito acontece no worker, de forma idempotente. Ver 12.10 e 12.12.
- Não há período de carência. A revogação de acesso é imediata e ocorre na mesma transação do processamento do evento. A regra completa, com a distinção entre inadimplência e cancelamento voluntário, está na Seção 13.3 e 13.4.
12.2 Ambientes, base URLs, autenticação e variáveis #
| Ambiente | Base URL da API | Painel | Chave usada |
|---|---|---|---|
local |
https://api-sandbox.asaas.com/v3 |
sandbox | ASAAS_API_KEY (sandbox) |
staging |
https://api-sandbox.asaas.com/v3 |
sandbox | ASAAS_API_KEY (sandbox) |
production |
https://api.asaas.com/v3 |
produção | ASAAS_API_KEY (produção) |
A base URL nunca é escrita inline no código. Ela vem de ASAAS_API_BASE_URL, e o registro
completo das variáveis de ambiente é da Seção 26. As variáveis próprias desta seção:
| Variável | Exemplo | Obrigatória | Significado |
|---|---|---|---|
ASAAS_API_BASE_URL |
https://api-sandbox.asaas.com/v3 |
sim | Base da API v3 |
ASAAS_API_KEY |
$aact_... |
sim | Chave privada, header access_token |
ASAAS_WEBHOOK_TOKEN |
string aleatória de 48 bytes | sim | Segredo do header asaas-access-token |
ASAAS_TIMEOUT_MS |
10000 |
não (default 10000) | Timeout por tentativa |
ASAAS_MAX_RETRIES |
4 |
não (default 4) | Tentativas extras após a primeira |
ASAAS_RATE_LIMIT_RPS |
8 |
não (default 8) | Teto local de requisições por segundo |
PIX_CHARGE_LEAD_DAYS |
3 |
não (default 3) | Antecedência de geração da cobrança PIX |
O corpo máximo aceito no webhook não é variável desta seção e não tem nome próprio de
provedor: é WEBHOOK_MAX_BODY_BYTES, único para todas as rotas de webhook, registrado na
Seção 26.3 e com o valor fixado na Seção 7.14. Não existe, e não pode ser criada, uma
variável ASAAS_WEBHOOK_MAX_BODY_BYTES.
12.2.1 Autenticação #
Toda requisição de saída leva:
access_token: <ASAAS_API_KEY>
Content-Type: application/json
User-Agent: palavra-diaria/1.0 (+https://palavradiaria.com.br)Regras duras:
ASAAS_API_KEYnunca aparece em log, em mensagem de erro, em resposta de API, em stack trace ou em artefato de build. O redator de logs da Seção 23 mascara qualquer valor que case com/\$?aact_[A-Za-z0-9+/=_-]+/e qualquer header cujo nome case, sem diferenciar maiúsculas, comaccess_token,authorizationouasaas-access-token.- A chave de produção só existe no ambiente
production. Um teste de fumaça no boot chamaGET /v3/customers?limit=1; se o ambiente forproductioneASAAS_API_BASE_URLapontar para sandbox (ou vice-versa), o processo aborta comFATAL asaas_environment_mismatch. - A chave é rotacionável sem deploy: ela é lida do ambiente a cada boot e o container
webe o containerworkersão reiniciados em sequência.
12.2.2 Verificação de ambiente no boot #
// packages/integrations/src/asaas/assert-environment.ts
const SANDBOX_HOST = 'api-sandbox.asaas.com';
const PRODUCTION_HOST = 'api.asaas.com';
export function assertAsaasEnvironment(appEnv: 'local' | 'staging' | 'production', baseUrl: string): void {
const host = new URL(baseUrl).host;
const expected = appEnv === 'production' ? PRODUCTION_HOST : SANDBOX_HOST;
if (host !== expected) {
throw new Error(`asaas_environment_mismatch: appEnv=${appEnv} host=${host} expected=${expected}`);
}
}12.3 Cliente HTTP, limites de taxa e política de retry #
O cliente vive em packages/integrations/src/asaas/ e expõe métodos tipados
(createCustomer, createSubscription, getPixQrCode, refundPayment, …). Nenhum
fetch direto para a Asaas é permitido fora deste pacote; a regra é verificada por um lint
customizado descrito na Seção 5.
12.3.1 Limites de taxa #
A Asaas aplica limitação por conta e responde 429 Too Many Requests quando o teto é
excedido. O sistema não depende de descobrir o teto exato em tempo de execução: ele impõe um
teto local mais conservador e trata 429 como sinal de recuo.
- Teto local:
ASAAS_RATE_LIMIT_RPS=8requisições por segundo, aplicado por um token bucket compartilhado no Redis (chaverl:asaas), de modo quewebeworkersomados nunca ultrapassem o teto. - Concorrência máxima simultânea: 4 conexões (
maxSockets: 4no agente HTTP). - Jobs em lote (reconciliação de 12.13) usam um limitador do BullMQ com
limiter: { max: 8, duration: 1000 }, o que garante o mesmo teto sem competir com o tráfego de checkout. O checkout não passa por fila: ele é síncrono, executado no processowebdentro da própria requisição do assinante, e por isso consome o token bucket com prioridade natural. As três filas de cobrança sãobilling.webhook,billing.reconcileebilling.lifecycle, exatamente com esses nomes, conforme o catálogo de filas da Seção 18.8; não existem filasbilling.interactive,billing.batchnembilling.events.
12.3.2 Classificação de falhas #
| Classe | Condição | Retry? |
|---|---|---|
RETRIABLE_NETWORK |
ECONNRESET, ETIMEDOUT, EAI_AGAIN, ECONNREFUSED, abort por timeout |
sim |
RETRIABLE_SERVER |
HTTP 500, 502, 503, 504 | sim |
RETRIABLE_THROTTLE |
HTTP 429 | sim, respeitando Retry-After se presente |
CLIENT_ERROR |
HTTP 400, 404, 422 | não |
AUTH_ERROR |
HTTP 401, 403 | não, alerta imediato asaas_auth_failed |
UNKNOWN |
qualquer outro status | não |
Requisições GET são idempotentes por natureza e podem ser repetidas sem cuidado extra.
Requisições POST que criam objetos (customers, subscriptions, payments) só são
repetidas quando a falha é RETRIABLE_NETWORK antes de resposta ou RETRIABLE_SERVER;
nesses casos a repetição é precedida por uma checagem de existência via externalReference
(ver 12.3.4), para não criar duplicata.
12.3.3 Backoff exponencial com jitter #
// packages/integrations/src/asaas/retry.ts
const BASE_DELAY_MS = 400;
const MAX_DELAY_MS = 15_000;
/** Full jitter: delay = random(0, min(MAX, BASE * 2^attempt)). */
export function backoffDelayMs(attempt: number, random: () => number = Math.random): number {
const ceiling = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
return Math.floor(random() * ceiling);
}
export async function withRetry<T>(
fn: (attempt: number) => Promise<T>,
opts: { maxRetries: number; isRetriable: (e: unknown) => boolean; retryAfterMs?: (e: unknown) => number | null },
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
try {
return await fn(attempt);
} catch (error) {
lastError = error;
if (attempt === opts.maxRetries || !opts.isRetriable(error)) throw error;
const explicit = opts.retryAfterMs?.(error) ?? null;
const delay = explicit ?? backoffDelayMs(attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}Com ASAAS_MAX_RETRIES=4 e BASE_DELAY_MS=400, os tetos de espera por tentativa são
0–400 ms, 0–800 ms, 0–1600 ms e 0–3200 ms. O pior caso soma 6 s de espera sobre 5 tentativas
de 10 s de timeout: 56 s no limite absoluto. Por isso o checkout interativo usa
maxRetries: 2 (teto de 1,2 s de espera) e o processamento em fila usa o valor completo.
Quando 429 traz Retry-After em segundos, o valor é respeitado e limitado a 30 s. Se
Retry-After ausente, aplica-se o jitter normal.
12.3.4 Idempotência das criações #
A Asaas não expõe um header de chave de idempotência. O sistema resolve isso com
externalReference, que é sempre o ULID do nosso registro correspondente:
| Objeto Asaas | externalReference |
|---|---|
customer |
subscribers.id |
subscription |
subscriptions.id |
payment avulso |
payments.id |
Antes de repetir um POST de criação após falha ambígua, o cliente consulta
GET /v3/{recurso}?externalReference={id}&limit=1. Se vier totalCount >= 1, adota o objeto
existente e não cria outro. Esta checagem é obrigatória e está encapsulada em
createIdempotent():
async function createIdempotent<T extends { id: string }>(
resource: 'customers' | 'subscriptions' | 'payments',
externalReference: string,
body: unknown,
): Promise<T> {
const existing = await client.get<{ data: T[]; totalCount: number }>(
`/${resource}`, { externalReference, limit: 1 },
);
if (existing.totalCount >= 1) return existing.data[0];
return client.post<T>(`/${resource}`, body);
}12.3.5 Observabilidade do cliente #
Cada chamada emite um log estruturado asaas_request com requestId, method, path,
attempt, status, durationMs, asaasErrorCode. O corpo da requisição é logado apenas em
rotas sem dado sensível; as rotas de tokenização e de criação de assinatura com cartão têm o
corpo suprimido por completo (ver 12.9). As métricas expostas ao Prometheus da Seção 23 são
asaas_request_duration_seconds (histograma por path e status) e
asaas_request_failures_total (contador por class).
12.4 Modelo de objetos da Asaas e mapeamento para o nosso schema #
Três objetos da Asaas são usados. Nenhum outro.
Asaas Palavra Diária
┌───────────────────┐ ┌───────────────────────┐
│ customer │ 1 1 │ subscribers │
│ id: cus_000... │◄──────────────────►│ asaas_customer_id │
│ cpfCnpj │ │ (CPF em profile) │
└────────┬──────────┘ └───────────┬───────────┘
│ 1 │ 1
│ │
│ N │ 0..1 (ACTIVE)
┌────────▼──────────┐ ┌───────────▼───────────┐
│ subscription │ 1 1 │ subscriptions │
│ id: sub_000... │◄──────────────────►│ asaas_subscription_id │
│ cycle, value │ │ plan_id, status │
└────────┬──────────┘ └───────────┬───────────┘
│ 1 │ 1
│ N │ N
┌────────▼──────────┐ ┌───────────▼───────────┐
│ payment │ 1 1 │ payments │
│ id: pay_000... │◄──────────────────►│ asaas_payment_id │
│ status, dueDate │ │ status, due_date │
└───────────────────┘ └───────────────────────┘
┌───────────────────────┐
webhook event ────────────────►│ payment_events (raw) │
└───────────────────────┘Colunas, tipos, índices e comportamento ON DELETE de subscribers, subscriptions,
payments e payment_events são definidos na Seção 6. Aqui está apenas o mapeamento de
campos, que é responsabilidade desta seção.
12.4.1 customer → subscribers e subscriber_profiles #
| Campo Asaas | Origem no nosso lado | Observação |
|---|---|---|
name |
subscriber_profiles.full_name |
obrigatório na Asaas |
cpfCnpj |
subscriber_profiles.cpf (envelope cifrado) |
obrigatório na Asaas; só CPF no MVP. A busca por CPF usa o índice cego subscriber_profiles.cpf_hmac; a exibição usa cpf_last4. Colunas declaradas na Seção 6.4 |
email |
subscribers.email |
enviado quando verificado; opcional |
mobilePhone |
subscribers.phone_e164 sem +55 |
formato DDNNNNNNNNN |
externalReference |
subscribers.id |
ULID |
notificationDisabled |
constante true |
quem fala com o assinante somos nós |
id (retorno) |
subscribers.asaas_customer_id |
char(20), único |
notificationDisabled: true é decisão canônica: a Asaas não envia e-mail nem SMS ao
assinante. Toda comunicação sobre cobrança sai pelos nossos canais, com o texto aprovado na
Seção 10 e o catálogo de mensagens da Seção 19. Isso evita mensagens em duplicidade e mantém
a marca consistente.
12.4.2 subscription → subscriptions #
| Campo Asaas | Nossa coluna | Observação |
|---|---|---|
id |
asaas_subscription_id |
único, parcial WHERE ... IS NOT NULL |
customer |
derivado de subscriber_id |
|
billingType |
billing_type |
CREDIT_CARD | PIX |
cycle |
derivado de plans.interval |
MONTHLY | YEARLY |
value (decimal na Asaas) |
subscriptions.amount_cents (inteiro) |
Conversão exclusiva de packages/integrations/src/asaas/mapper.ts; nenhum outro arquivo converte. Entrada 19.9, 19.90 e 199 produzem 1990, 1990 e 19900 |
nextDueDate |
next_due_date |
date |
status (ACTIVE/INACTIVE/EXPIRED) |
não copiado | ver nota |
externalReference |
subscriptions.id |
ULID |
description |
constante | Palavra Diária — plano mensal | — plano anual |
Nota deliberada: o status da assinatura na Asaas não é copiado para
subscriptions.status. Os dois vocabulários são diferentes: o nosso estado é derivado do
histórico de cobranças e das ações do assinante, conforme a máquina de estados da Seção 13.2.
A Asaas mantém uma assinatura ACTIVE mesmo com cobrança vencida, o que colidiria com a
regra de revogação imediata. O status da Asaas é guardado no evento bruto e usado apenas
pela reconciliação (12.13) para detectar assinaturas removidas do lado da Asaas.
12.4.3 payment → payments #
| Campo Asaas | Nossa coluna | Observação |
|---|---|---|
id |
asaas_payment_id |
único |
subscription |
resolvido para subscription_id |
pode ser nulo em cobrança avulsa |
customer |
resolvido para subscriber_id |
|
value (decimal na Asaas) |
payments.amount_cents (inteiro) |
Mesmo conversor de 12.4.2 |
netValue (decimal) |
payments.net_amount_cents (inteiro) |
pode ser nulo antes da liquidação |
value − netValue |
payments.fee_cents (inteiro) |
Taxa de adquirência retida, gravada no momento da liquidação. Nulo enquanto net_amount_cents for nulo |
billingType |
billing_type |
|
status |
status (enum próprio, ver 12.4.4) |
traduzido |
dueDate |
due_date |
date |
confirmedDate |
confirmed_at |
timestamptz, 12:00 local se só data |
paymentDate / clientPaymentDate |
confirmed_at, quando confirmedDate vier vazio |
O instante canônico do pagamento é coalesce(confirmed_at, received_at, due_date) (Seção 6.13); não existe coluna paid_at |
invoiceUrl |
invoice_url |
link da fatura hospedada |
transactionReceiptUrl |
receipt_url |
comprovante |
externalReference |
payments.id quando criado por nós |
nulo quando gerado pelo ciclo |
deleted |
reflete em status = DELETED |
Quando a Asaas gera a cobrança do ciclo automaticamente, ela não recebe o nosso ULID em
externalReference. Nesse caso a linha em payments é criada pelo processador do evento
PAYMENT_CREATED, com id gerado por nós e asaas_payment_id vindo do evento.
Fronteira de unidade monetária, declarada aqui porque é aqui que ela existe. A Asaas
representa dinheiro como decimal (19.90); o nosso banco representa dinheiro como inteiro em
centavos (amount_cents = 1990), conforme a Seção 6 e a convenção de unidades da Seção 21.2.
A conversão acontece em um único arquivo, packages/integrations/src/asaas/mapper.ts, nas duas
direções:
// packages/integrations/src/asaas/mapper.ts
/** Decimal da Asaas -> centavos inteiros. Arredonda meio para cima e nunca usa float como saída. */
export function toCents(value: number | string): number {
const normalized = typeof value === 'string' ? value.trim().replace(',', '.') : String(value);
const [whole, frac = ''] = normalized.split('.');
const cents = Number(whole) * 100 + Number((frac + '00').slice(0, 2));
if (!Number.isSafeInteger(cents)) throw new AppError('BILLING_AMOUNT_UNPARSEABLE', 502);
return cents;
}
/** Centavos inteiros -> decimal com duas casas, para o corpo enviado à Asaas. */
export function toAsaasValue(amountCents: number): number {
return Number((amountCents / 100).toFixed(2));
}Nenhum outro arquivo do repositório multiplica ou divide valor monetário por 100. Uma regra de
lint (Seção 5) reprova a expressão / 100 e * 100 aplicada a identificador terminado em
_cents ou Cents fora desse módulo. Motivo: cada lugar que converte é um lugar que pode
converter errado, e erro de unidade em dinheiro só aparece na conciliação do fim do mês.
Custos unitários — mensagem de WhatsApp e caractere narrado — não passam por aqui: eles são
gravados em micros de real (*_cost_micros, divisor 1.000.000) pelas Seções 17 e 16, e a
diferença de unidade está declarada na Seção 21.2. Nenhuma fórmula soma centavos com micros.
12.4.4 Enum PaymentStatus e ranque de progressão #
O enum próprio, declarado na Seção 6, tem os valores abaixo. O ranque é o mecanismo que torna o processamento imune a eventos fora de ordem (ver 12.12.3).
payments.status |
Ranque | Origem típica |
|---|---|---|
PENDING |
0 | PAYMENT_CREATED |
OVERDUE |
10 | PAYMENT_OVERDUE |
CONFIRMED |
20 | PAYMENT_CONFIRMED |
RECEIVED |
30 | PAYMENT_RECEIVED |
DELETED |
80 | PAYMENT_DELETED |
REFUNDED |
90 | PAYMENT_REFUNDED |
CHARGEBACK |
95 | PAYMENT_CHARGEBACK_REQUESTED |
Regra: uma transição só é aplicada se novoRanque > ranqueAtual. OVERDUE (10) chegando
depois de CONFIRMED (20) é descartado como evento tardio. REFUNDED (90) sobre RECEIVED
(30) é aplicado normalmente. A única exceção deliberada é PENDING → PENDING com
dueDate alterada, tratada por PAYMENT_UPDATED, que altera campos sem mexer no ranque.
12.5 Cliente Asaas: criação, CPF e LGPD #
12.5.1 Quando o customer é criado #
O customer é criado no início do checkout, antes da assinatura, e apenas uma vez por
assinante. Se subscribers.asaas_customer_id já estiver preenchido, ele é reutilizado; se o
CPF ou o nome mudarem no painel, o customer é atualizado (POST /v3/customers/{id}), nunca
recriado. Um assinante nunca tem dois customer na Asaas.
12.5.2 Campos obrigatórios e validação #
name e cpfCnpj são obrigatórios pela Asaas. O checkout, cuja especificação de formulário
é da Seção 11, coleta ambos. Regras de validação aplicadas antes de qualquer chamada:
name: 3 a 100 caracteres, ao menos duas palavras separadas por espaço, apenas letras (incluindo acentuadas), espaço, apóstrofo e hífen. Normalizado comtrim()e colapso de espaços múltiplos.cpfCnpj: apenas dígitos após remoção de.e-; exatamente 11 dígitos; dígitos verificadores válidos; rejeita as 10 sequências repetidas (00000000000…99999999999). CNPJ é aceito pelo schema mas rejeitado no MVP comcode: TAX_ID_MUST_BE_CPF, porque o produto é vendido a pessoa física.
// packages/core/src/tax-id.ts
const REPEATED = new Set(Array.from({ length: 10 }, (_, d) => String(d).repeat(11)));
export function normalizeCpf(input: string): string {
return input.replace(/\D+/g, '');
}
export function isValidCpf(input: string): boolean {
const cpf = normalizeCpf(input);
if (cpf.length !== 11) return false;
if (REPEATED.has(cpf)) return false;
const digits = cpf.split('').map(Number);
// Primeiro dígito verificador: pesos 10..2 sobre os 9 primeiros dígitos.
let sum = 0;
for (let i = 0; i < 9; i++) sum += digits[i] * (10 - i);
let check = (sum * 10) % 11;
if (check === 10) check = 0;
if (check !== digits[9]) return false;
// Segundo dígito verificador: pesos 11..2 sobre os 10 primeiros dígitos.
sum = 0;
for (let i = 0; i < 10; i++) sum += digits[i] * (11 - i);
check = (sum * 10) % 11;
if (check === 10) check = 0;
return check === digits[10];
}
/** Máscara para exibição e log: 123.***.***-01 */
export function maskCpf(input: string): string {
const cpf = normalizeCpf(input);
if (cpf.length !== 11) return '***';
return `${cpf.slice(0, 3)}.***.***-${cpf.slice(9)}`;
}Verificação manual do algoritmo com o CPF 529.982.247-25:
primeiro dígito — 5·10 + 2·9 + 9·8 + 9·7 + 8·6 + 2·5 + 2·4 + 4·3 + 7·2 = 295;
295 · 10 mod 11 = 2, igual ao 10º dígito. Segundo dígito —
5·11 + 2·10 + 9·9 + 9·8 + 8·7 + 2·6 + 2·5 + 4·4 + 7·3 + 2·2 = 348;
348 · 10 mod 11 = 5, igual ao 11º dígito. CPF válido.
O schema Zod usado no checkout:
import { z } from 'zod';
export const taxIdSchema = z
.string()
.trim()
.transform(normalizeCpf)
.refine((v) => v.length === 11, { message: 'O CPF deve ter 11 dígitos.' })
.refine(isValidCpf, { message: 'CPF inválido. Confira os números digitados.' });12.5.3 Tratamento de CPF inválido #
| Cenário | HTTP | code |
message ao assinante |
|---|---|---|---|
| Menos ou mais de 11 dígitos | 422 | TAX_ID_INVALID_LENGTH |
"O CPF deve ter 11 dígitos." |
| Dígitos verificadores errados | 422 | TAX_ID_INVALID |
"CPF inválido. Confira os números digitados." |
| Sequência repetida | 422 | TAX_ID_INVALID |
"CPF inválido. Confira os números digitados." |
| 14 dígitos (CNPJ) | 422 | TAX_ID_MUST_BE_CPF |
"No momento aceitamos apenas CPF de pessoa física." |
A Asaas recusa o CPF (invalid_cpfCnpj) |
422 | TAX_ID_REJECTED_BY_PROVIDER |
"Não conseguimos validar este CPF. Confira os dados e tente novamente." |
A validação local roda antes da chamada à Asaas, para não gastar requisição nem expor a recusa do provedor. A recusa do provedor ainda é tratada porque a Asaas faz verificações adicionais que o algoritmo local não cobre.
12.5.4 CPF e LGPD #
O CPF é dado pessoal e identificador fiscal. A coleta existe por uma razão única e
declarada: a Asaas exige cpfCnpj para criar o customer, sem o qual não há cobrança. Isso
caracteriza execução de contrato (base legal do Art. 7º, V). Consequências operacionais
obrigatórias, cuja política completa é da Seção 22:
- O CPF é armazenado uma única vez, na coluna
subscriber_profiles.cpf, como envelope cifrado em AES-256-GCM com a chaveENCRYPTION_KEY, nunca emsubscribers. A busca por igualdade usa a coluna de índice cegosubscriber_profiles.cpf_hmac, um HMAC-SHA-256 calculado comPHONE_INDEX_KEY. As colunas, os índices e o algoritmo do envelope são declarados na Seção 6.4 e na Seção 22.5; esta seção apenas as consome. O CPF é cifrado comENCRYPTION_KEYe indexado comPHONE_INDEX_KEY, como toda outra coluna de dado pessoal (Seção 22.5.3): não há chave de cifra específica para identificador fiscal, e não existe variávelTAX_ID_ENCRYPTION_KEYno registro da Seção 26.3. cpf_hmacnão é único. Dois cadastros podem legitimamente carregar o mesmo CPF — um titular anonimizado que volta a assinar dentro da retenção fiscal, e o caso corriqueiro de alguém contratar para um familiar com o próprio CPF. O CPF repetido em mais de duas contas ativas alimenta a pontuação de risco antifraude e aparece como sinal ao operador, em vez de produzir um erro de banco que ninguém consegue interpretar.- Nunca é exibido inteiro no painel do assinante nem no painel administrativo: sempre
maskCpf(), alimentado porcpf_last4. - Nunca aparece em log, em evento de analytics, em mensagem de WhatsApp ou em e-mail.
- É retido por 5 anos após o último pagamento, por obrigação fiscal, e por isso não é
zerado no primeiro estágio da eliminação por pedido do titular. Esse estágio é
pseudonimização, não anonimização: enquanto o CPF cifrado e o
asaas_customer_idexistirem, a linha continua sendo dado pessoal e permanece integralmente no escopo da LGPD. A anonimização de fato — zerarcpf,cpf_hmac,asaas_customer_id,asaas_subscription_ideasaas_payment_id, e solicitar à Asaas a exclusão do cadastro correspondente — acontece quando a retenção fiscal vence. Os dois estágios estão descritos na Seção 22.9 e o item retido é explicado ao titular na Seção 20.9. - O texto de consentimento do checkout diz, literalmente: "Usamos seu CPF apenas para emitir
a cobrança junto ao nosso processador de pagamentos." O texto versionado é registrado em
consent_eventsconforme a Seção 11 e a Seção 22. - Assinantes do plano gratuito nunca informam CPF. A coleta acontece só no checkout pago — e, sendo o plano gratuito a simples ausência de assinatura vigente (Seção 13.1), não existe nenhum fluxo gratuito que passe por esta seção.
12.6 Checkout com cartão de crédito #
12.6.1 Visão geral #
O plano de cartão usa recorrência automática da Asaas: uma assinatura é criada com
creditCardToken e, a cada ciclo, a Asaas cobra o cartão sozinha e emite os webhooks. O
sistema não guarda cartão, não guarda token de cartão por conta própria além do identificador
retornado, e não inicia cobranças manuais de cartão.
12.6.2 Diagrama de sequência — cartão de crédito #
Assinante web (Next.js) Asaas API v3 Postgres worker WhatsApp
│ │ │ │ │ │
│ 1. abre /assinar │ │ │ │ │
├─────────────────►│ │ │ │ │
│ │ 2. sessão válida? │ │ │ │
│ ├────────────────────────────────────────►│ │ │
│ │◄────────────────────────────────────────┤ │ │
│ 3. nome, CPF, │ │ │ │ │
│ plano, cartão │ │ │ │ │
├─────────────────►│ │ │ │ │
│ │ 4. valida CPF local │ │ │ │
│ │ 5. POST /customers │ │ │ │
│ ├────────────────────►│ │ │ │
│ │◄─ cus_000006241...──┤ │ │ │
│ │ 6. grava asaas_customer_id │ │ │
│ ├────────────────────────────────────────►│ │ │
│ │ 7. POST /creditCard/tokenize │ │ │
│ ├────────────────────►│ │ │ │
│ │◄─ creditCardToken ──┤ │ │ │
│ │ 8. cria subscriptions (PENDING_PAYMENT)│ │ │
│ ├────────────────────────────────────────►│ │ │
│ │ 9. POST /subscriptions (token) │ │ │
│ ├────────────────────►│ │ │ │
│ │◄─ sub_000000123... ─┤ │ │ │
│ │ 10. grava asaas_subscription_id │ │ │
│ ├────────────────────────────────────────►│ │ │
│ 11. 200 + tela │ │ │ │ │
│ "processando"│ │ │ │ │
│◄─────────────────┤ │ │ │ │
│ │ │ │ │ │
│ │ 12. webhook PAYMENT_CREATED │ │ │
│ │◄────────────────────┤ │ │ │
│ │ 13. grava payment_events + enfileira ──────────────────►│ │
│ │ 14. webhook PAYMENT_CONFIRMED │ │ │
│ │◄────────────────────┤ │ │ │
│ │ 15. grava + enfileira ─────────────────────────────────►│ │
│ │ │ │ 16. tx: ACTIVE, tier=PAID │
│ │ │ │◄──────────────┤ │
│ │ │ │ 17. msg de boas-vindas paga │
│ │ │ │ ├─────────────►│
│ 18. painel mostra "Plano ativo" (polling/TanStack Query) │ │ │
│◄─────────────────┤ │ │ │ │O passo 11 devolve 200 com a assinatura ainda em PENDING_PAYMENT. A confirmação vem por
webhook (passo 15). O painel faz polling de GET /api/me/subscription a cada 2 s por
até 60 s; passado esse tempo, mostra "Estamos confirmando seu pagamento. Avisamos no WhatsApp
assim que estiver tudo certo." O texto exato é da Seção 10.
12.6.3 Passo 5 — criar o customer #
POST /v3/customers
access_token: <ASAAS_API_KEY>
Content-Type: application/json{
"name": "Maria Aparecida de Souza",
"cpfCnpj": "52998224725",
"email": "maria@exemplo.com.br",
"mobilePhone": "11987654321",
"externalReference": "01K3Q8R7V2N4B6D8F0H2J4L6M8",
"notificationDisabled": true
}Resposta 200:
{
"object": "customer",
"id": "cus_000006241382",
"dateCreated": "2026-09-06",
"name": "Maria Aparecida de Souza",
"email": "maria@exemplo.com.br",
"cpfCnpj": "52998224725",
"personType": "FISICA",
"deleted": false,
"notificationDisabled": true,
"externalReference": "01K3Q8R7V2N4B6D8F0H2J4L6M8"
}Erros possíveis:
| Situação | Resposta Asaas | Nosso code |
HTTP |
|---|---|---|---|
| CPF recusado | 400 com errors[].code = invalid_cpfCnpj |
TAX_ID_REJECTED_BY_PROVIDER |
422 |
| Nome ausente | 400 invalid_name |
CUSTOMER_NAME_INVALID |
422 |
| Chave inválida | 401 |
PAYMENT_PROVIDER_AUTH_ERROR |
502 |
| Excesso de requisições | 429 |
retry interno; se persistir PAYMENT_PROVIDER_UNAVAILABLE |
503 |
| Indisponibilidade | 500/502/503/504 |
retry interno; se persistir PAYMENT_PROVIDER_UNAVAILABLE |
503 |
Efeitos colaterais: grava subscribers.asaas_customer_id, grava
subscriber_profiles.full_name, cpf (envelope cifrado), cpf_hmac e cpf_last4, e registra
admin_audit_log apenas se a alteração partir do painel administrativo. Idempotência:
reexecutar o passo com o mesmo assinante devolve o customer existente sem criar outro
(12.3.4).
12.6.4 Passo 7 — tokenizar o cartão #
POST /v3/creditCard/tokenize{
"customer": "cus_000006241382",
"creditCard": {
"holderName": "MARIA A DE SOUZA",
"number": "5162306219378829",
"expiryMonth": "05",
"expiryYear": "2031",
"ccv": "318"
},
"creditCardHolderInfo": {
"name": "Maria Aparecida de Souza",
"email": "maria@exemplo.com.br",
"cpfCnpj": "52998224725",
"postalCode": "01310930",
"addressNumber": "1578",
"phone": "11987654321"
},
"remoteIp": "189.45.12.90"
}Resposta 200:
{
"creditCardNumber": "8829",
"creditCardBrand": "MASTERCARD",
"creditCardToken": "a75a1d98-c52d-4a6b-a413-71e00b193c99"
}Persistimos apenas creditCardNumber (últimos 4), creditCardBrand e creditCardToken em
subscriptions.card_last4, card_brand e asaas_card_token. O PAN, o CVV e a validade não
são persistidos em lugar nenhum. Ver 12.9.
remoteIp é o IP real do assinante, extraído do header X-Forwarded-For deixado pelo Caddy,
tomando o primeiro endereço da lista e validando que é um IP público. Ele é exigido pela
análise antifraude da Asaas; enviar o IP do servidor degrada a aprovação.
Erros possíveis:
| Situação | Resposta Asaas | Nosso code |
HTTP |
|---|---|---|---|
| Número inválido | 400 invalid_creditCard |
CARD_INVALID |
422 |
| Validade expirada | 400 expired_creditCard |
CARD_EXPIRED |
422 |
| Recusa do emissor na validação | 400 credit_card_not_authorized |
CARD_DECLINED |
422 |
| Endereço/CEP inconsistente | 400 invalid_postalCode |
CARD_HOLDER_INFO_INVALID |
422 |
| Falha de rede | — | PAYMENT_PROVIDER_UNAVAILABLE |
503 |
Mensagens ao assinante (texto de UI, Seção 10 é a dona do copy definitivo):
CARD_DECLINED → "Seu cartão foi recusado. Tente outro cartão ou pague com PIX."
12.6.5 Passo 9 — criar a assinatura #
POST /v3/subscriptions{
"customer": "cus_000006241382",
"billingType": "CREDIT_CARD",
"value": 19.90,
"nextDueDate": "2026-09-06",
"cycle": "MONTHLY",
"description": "Palavra Diária — plano mensal",
"externalReference": "01K3Q9A1C3E5G7J9L1N3P5R7T9",
"creditCardToken": "a75a1d98-c52d-4a6b-a413-71e00b193c99",
"remoteIp": "189.45.12.90"
}Resposta 200:
{
"object": "subscription",
"id": "sub_000000123456",
"dateCreated": "2026-09-06",
"customer": "cus_000006241382",
"value": 19.90,
"nextDueDate": "2026-10-06",
"cycle": "MONTHLY",
"billingType": "CREDIT_CARD",
"status": "ACTIVE",
"externalReference": "01K3Q9A1C3E5G7J9L1N3P5R7T9",
"deleted": false
}nextDueDate enviado é sempre hoje em America/Sao_Paulo, para que a primeira cobrança
seja imediata. A Asaas devolve nextDueDate já apontando para o ciclo seguinte.
Erros possíveis:
| Situação | Nosso code |
HTTP | Efeito |
|---|---|---|---|
| Token de cartão inválido/expirado | CARD_TOKEN_INVALID |
422 | assinatura local vira CANCELED, checkout reinicia |
| Cartão recusado na primeira cobrança | CARD_DECLINED |
422 | assinatura local vira CANCELED, sem efeito no tier |
| Valor divergente do plano | PLAN_PRICE_MISMATCH |
409 | bloqueio, alerta operacional |
| Assinante já tem assinatura ativa | SUBSCRIPTION_ALREADY_ACTIVE |
409 | nada é criado (ver Seção 13.12.5) |
| Indisponibilidade | PAYMENT_PROVIDER_UNAVAILABLE |
503 | assinatura local fica PENDING_PAYMENT, reconciliação resolve |
Efeitos colaterais: cria subscriptions em PENDING_PAYMENT antes da chamada, dentro de
uma transação que também toma um advisory lock por assinante
(pg_advisory_xact_lock(hashtext('subscription:' || subscriber_id))), o que impede dois
checkouts simultâneos. Idempotência: garantida por externalReference (12.3.4) e pelo índice
único parcial que permite no máximo uma assinatura ACTIVE ou PENDING_PAYMENT por
assinante (Seção 6, detalhado em 13.12.5).
12.6.6 Renovação automática do cartão #
A cada ciclo a Asaas gera a cobrança e tenta o cartão. O sistema recebe PAYMENT_CREATED e,
em seguida, PAYMENT_CONFIRMED (autorização) e PAYMENT_RECEIVED (liquidação). Se a
tentativa falhar, a Asaas pode repetir internamente por alguns dias; o sistema não espera
essas tentativas. O acesso só cai quando PAYMENT_OVERDUE chega. Se uma tentativa posterior
for aprovada, PAYMENT_CONFIRMED restaura o acesso e recalcula o período. Ver 13.12.6.
Cartão expirado é um caso previsto: subscriptions.card_exp_month e
subscriptions.card_exp_year alimentam o job billing.card_expiry_notice, executado na fila
billing.lifecycle às 09:00 do dia 1º de cada mês, que avisa por WhatsApp quem tem cartão
vencendo no mês corrente, com link para trocar o cartão no painel (endpoint em 13.13.5).
12.7 Checkout com PIX #
12.7.1 Modelo adotado #
PIX não tem débito recorrente. A assinatura PIX é criada normalmente com
billingType: "PIX", e a Asaas emite uma cobrança nova por ciclo. O assinante paga cada
cobrança manualmente. O sistema gera QR Code e código copia-e-cola, envia lembretes antes do
vencimento (13.5) e revoga o acesso quando PAYMENT_OVERDUE chega.
A cobrança do ciclo seguinte é gerada pela Asaas com antecedência. O sistema busca a próxima
cobrança pendente PIX_CHARGE_LEAD_DAYS=3 dias antes do vencimento, para conseguir enviar o
lembrete D-3 já com o código copia-e-cola dentro da mensagem.
12.7.2 Diagrama de sequência — PIX #
Assinante web (Next.js) Asaas API v3 Postgres worker WhatsApp
│ │ │ │ │ │
│ 1. escolhe PIX │ │ │ │ │
├─────────────────►│ │ │ │ │
│ │ 2. POST /customers (se necessário)│ │ │
│ ├──────────────────►│ │ │ │
│ │ 3. cria subscriptions PENDING_PAYMENT │ │
│ ├──────────────────────────────────►│ │ │
│ │ 4. POST /subscriptions (PIX) │ │ │
│ ├──────────────────►│ │ │ │
│ │◄─ sub_00000012... ┤ │ │ │
│ │ 5. GET /subscriptions/{id}/payments│ │ │
│ ├──────────────────►│ │ │ │
│ │◄─ pay_00000098... ┤ │ │ │
│ │ 6. GET /payments/{id}/pixQrCode │ │ │
│ ├──────────────────►│ │ │ │
│ │◄─ encodedImage, payload, expiration┤ │ │
│ 7. QR + copia-e-cola + validade │ │ │ │
│◄─────────────────┤ │ │ │ │
│ │ │ │ │ │
│ 8. paga no banco │ │ │ │ │
├──────────────────────────────────────► │ │ │
│ │ 9. webhook PAYMENT_RECEIVED │ │ │
│ │◄──────────────────┤ │ │ │
│ │ 10. persiste + enfileira ──────────────────────►│ │
│ │ │ 11. tx: ACTIVE, tier=PAID │ │
│ │ │◄──────────────┤ │ │
│ │ │ 12. confirmação de pagamento │
│ │ │ │ ├───────────►│
│ 13. painel: "Plano ativo até 06/10/2026" │ │ │
│◄─────────────────┤ │ │ │ │12.7.3 Passo 4 — assinatura PIX #
{
"customer": "cus_000006241382",
"billingType": "PIX",
"value": 199.00,
"nextDueDate": "2026-09-06",
"cycle": "YEARLY",
"description": "Palavra Diária — plano anual",
"externalReference": "01K3QB2D4F6H8K0M2P4R6T8V0X"
}12.7.4 Passo 6 — obter o QR Code #
GET /v3/payments/pay_000000098765/pixQrCodeResposta 200:
{
"encodedImage": "iVBORw0KGgoAAAANSUhEUgAAAS...",
"payload": "00020126580014BR.GOV.BCB.PIX0136a1b2c3d4-...5204000053039865802BR5913PALAVRA DIARIA6009SAO PAULO62070503***6304AB12",
"expirationDate": "2026-09-06 23:59:59",
"success": true
}Tratamento:
encodedImageé PNG em base64. É renderizado comodata:image/png;base64,.... Não é salvo em storage nem em banco: é volátil e obtido sob demanda.payloadé o copia-e-cola. O painel oferece botão "Copiar código". A mensagem de WhatsApp envia opayloadem uma mensagem separada, sem texto ao redor, para o assinante conseguir copiar com um toque.expirationDatevem em horário de Brasília, sem fuso explícito. É convertido para UTC comdate-fns-tzassumindoAmerica/Sao_Pauloe guardado empayments.pix_expires_at.- Se
expirationDatejá passou, o sistema não exibe o QR: chamaPOST /v3/payments/{id}atualizandodueDatepara hoje e busca o QR novamente. Se ainda assim falhar, respondePIX_CODE_UNAVAILABLEe instrui o assinante a usar oinvoiceUrlda cobrança.
Erros possíveis:
| Situação | code |
HTTP | Comportamento |
|---|---|---|---|
| Cobrança inexistente | PAYMENT_NOT_FOUND |
404 | painel oferece recriar checkout |
| Cobrança já paga | PAYMENT_ALREADY_PAID |
409 | painel mostra estado atual |
| Cobrança removida | PAYMENT_DELETED |
409 | painel oferece recriar checkout |
| PIX indisponível na conta | PIX_NOT_ENABLED |
503 | alerta operacional imediato |
| Falha transitória | PIX_CODE_UNAVAILABLE |
503 | botão "Tentar de novo" |
12.7.5 Endpoint interno de QR Code #
GET /api/me/subscription/pix-code
- Autenticação: sessão de assinante (Seção 8). Papel:
SUBSCRIBER. - Request: sem corpo. Query opcional
?paymentId=pay_...restrita a cobranças do próprio assinante.
export const pixCodeQuerySchema = z.object({
paymentId: z.string().regex(/^pay_[0-9]{12,}$/).optional(),
});- Resposta
200:
{
"data": {
"paymentId": "pay_000000098765",
"amountCents": 19900,
"dueDate": "2026-09-06",
"expiresAt": "2026-09-07T02:59:59.000Z",
"qrCodeImage": "data:image/png;base64,iVBORw0KGgo...",
"copyPasteCode": "00020126580014BR.GOV.BCB.PIX...6304AB12",
"invoiceUrl": "https://www.asaas.com/i/098765"
},
"meta": { "requestId": "req_01K3QC5F7H9K1M3P5R7T9V1X3Z", "timestamp": "2026-09-06T12:04:11.220Z" }
}- Erros:
UNAUTHENTICATED(401),SUBSCRIPTION_NOT_FOUND(404),PAYMENT_NOT_FOUND(404),PAYMENT_ALREADY_PAID(409),BILLING_TYPE_NOT_PIX(409),PIX_CODE_UNAVAILABLE(503),RATE_LIMITED(429). - Efeitos colaterais: atualiza
payments.pix_expires_atepayments.invoice_url. - Idempotência: leitura pura do ponto de vista do negócio; repetível à vontade. Limite de taxa de 10 requisições por minuto por assinante para não abusar da API da Asaas.
12.7.6 Expiração da cobrança PIX #
Uma cobrança PIX vence no fim do dia de dueDate. Depois disso, a Asaas emite
PAYMENT_OVERDUE e o acesso é revogado (Seção 13.3). O QR antigo deixa de ser exibido; o
painel mostra o botão "Reativar assinatura", que gera uma nova cobrança conforme 13.10.
Exemplo numérico: plano mensal PIX, dueDate = 2026-09-10 (quinta-feira). Lembrete D-3 em
07/09 às 06:30, D-1 em 09/09 às 06:30, D0 em 10/09 às 09:00. Sem pagamento,
PAYMENT_OVERDUE chega na madrugada de 11/09 e o assinante já não recebe o devocional de
11/09 às 06:00 — ele volta a ser FREE e só receberá o texto no domingo 13/09.
12.8 Catálogo de chamadas HTTP à Asaas #
Todas as chamadas de saída usadas pelo sistema, sem exceção. Qualquer necessidade fora desta lista exige alteração desta seção.
| # | Método | Caminho | Quando | Retry |
|---|---|---|---|---|
| 1 | POST |
/v3/customers |
início do checkout | rede/5xx, com checagem de externalReference |
| 2 | POST |
/v3/customers/{id} |
assinante altera nome ou CPF | rede/5xx |
| 3 | GET |
/v3/customers?externalReference={ulid} |
idempotência e reconciliação | sim |
| 4 | POST |
/v3/creditCard/tokenize |
checkout com cartão e troca de cartão | não |
| 5 | POST |
/v3/subscriptions |
criação da assinatura | rede/5xx com checagem prévia |
| 6 | GET |
/v3/subscriptions/{id} |
reconciliação e painel admin | sim |
| 7 | POST |
/v3/subscriptions/{id} |
troca de ciclo, troca de cartão, mudança de valor | rede/5xx |
| 8 | DELETE |
/v3/subscriptions/{id} |
cancelamento | rede/5xx (404 é sucesso) |
| 9 | GET |
/v3/subscriptions/{id}/payments |
obter cobrança do ciclo | sim |
| 10 | GET |
/v3/payments?subscription={id}&offset=&limit= |
reconciliação | sim |
| 11 | GET |
/v3/payments/{id} |
conferência pontual | sim |
| 12 | POST |
/v3/payments/{id} |
reagendar vencimento PIX | rede/5xx |
| 13 | GET |
/v3/payments/{id}/pixQrCode |
exibir QR e montar lembrete | sim |
| 14 | POST |
/v3/payments/{id}/refund |
reembolso pelo admin | não |
| 15 | DELETE |
/v3/payments/{id} |
remover cobrança pendente órfã | rede/5xx |
A chamada 4 não é repetida porque uma repetição implicaria reter o PAN além do necessário; em falha, o assinante é convidado a tentar de novo. A chamada 14 não é repetida automaticamente para evitar reembolso duplo; falha vira tarefa manual no painel administrativo com alerta.
12.8.1 Cancelar assinatura #
DELETE /v3/subscriptions/sub_000000123456{ "deleted": true, "id": "sub_000000123456" }404 é tratado como sucesso idempotente: a assinatura já não existe do lado da Asaas, que é
exatamente o estado desejado. 500 entra em retry. A semântica de negócio do cancelamento
(quando o acesso termina) é da Seção 13.4 e 20.7.
12.8.2 Reembolsar #
POST /v3/payments/pay_000000098765/refund{ "value": 19.90, "description": "Reembolso solicitado pelo assinante" }Resposta 200 traz status: "REFUNDED". O reembolso parcial é aceito pela API mas
não é usado no MVP: reembolso é sempre integral. Decisão registrada aqui; motivo: cálculo
de valor parcial exigiria política de prorrateio, que o MVP não tem (13.7.2).
12.9 Segurança de dados de cartão e posicionamento PCI-DSS #
12.9.1 O que trafega, o que fica e o que nunca é registrado #
| Dado | Trafega pelo nosso servidor | Persistido | Aparece em log |
|---|---|---|---|
| PAN (número do cartão) | sim, uma vez, só em memória | nunca | nunca |
| CVV | sim, uma vez, só em memória | nunca | nunca |
| Validade (mês/ano) | sim, uma vez | apenas mês/ano em subscriptions.card_exp_month/year |
nunca |
| Nome impresso | sim | não | nunca |
| Últimos 4 dígitos | sim | subscriptions.card_last4 |
permitido |
| Bandeira | sim | subscriptions.card_brand |
permitido |
creditCardToken |
sim | subscriptions.asaas_card_token |
nunca |
| CPF do titular | sim | envelope cifrado em subscriber_profiles.cpf, com cpf_hmac e cpf_last4 |
apenas mascarado |
| IP do assinante | sim | consent_events.ip |
permitido |
12.9.2 Controles obrigatórios #
- A rota
POST /api/checkout/card-tokensé a única que aceita dados de cartão. Ela é marcada comexport const dynamic = 'force-dynamic'e com um flag internoSENSITIVE_ROUTE = trueque instrui o middleware de log a não gravar corpo, nem query, nem headers além dex-request-id. - O corpo da requisição nunca é serializado para log, nem em caso de erro. O handler de erro
dessa rota registra apenas
{ requestId, code, asaasErrorCode }. - Nenhum campo de cartão entra em cache de servidor, em
sessionStorage, emlocalStorage, em cookie ou em URL. O formulário usaautocomplete="cc-number"einputmode="numeric", comnameausente para não ser capturado por extensões de preenchimento genérico. - Os campos de cartão são limpos do DOM (
form.reset()e sobrescrita das refs) assim que a tokenização retorna. - O relatório de erro do cliente (Seção 23) tem
beforeSendque descarta qualquer evento originado na rota de checkout de cartão e remove qualquer string com 13 a 19 dígitos consecutivos. - TLS 1.2 no mínimo, TLS 1.3 preferido, imposto pelo Caddy; HSTS com
max-age=31536000eincludeSubDomains. - Content-Security-Policy estrita na página de checkout, sem
unsafe-inlinee sem scripts de terceiros — nenhuma tag de analytics, pixel ou chat é carregada nessa rota. Isso é requisito, não preferência. - Backups e dumps não podem conter PAN porque ele nunca é gravado; ainda assim, o script de
verificação
ops scan:panroda semanalmente contra um dump de teste procurando padrões de PAN válidos por Luhn e falha o pipeline se encontrar algo. - A rota de tokenização, como toda rota que altera estado sob sessão de assinante, é
recusada em sessão de impersonação administrativa. A impersonação é integralmente
somente leitura, sem exceção, e a recusa acontece no invólucro de API antes de chegar ao
handler (Seção 8.13). O critério é a guarda de autenticação da rota, não o prefixo do
caminho:
POST /api/checkout/card-tokens,POST /api/me/subscriptionePOST /api/me/subscription/cardestão todos cobertos.
12.9.3 Decisão sobre PCI-DSS: SAQ A-EP não se aplica; adotamos SAQ D-Merchant #
O produto usa checkout transparente: o formulário é nosso e os dados do cartão são transmitidos ao nosso servidor, que os repassa imediatamente à Asaas para tokenização e os descarta. Essa arquitetura é a exigida pelo modelo de tokenização da Asaas, que autentica por chave privada e portanto não pode ser chamada diretamente pelo navegador.
Consequência de conformidade, decidida e registrada:
- SAQ A não se aplica: ele exige que todas as funções de pagamento sejam terceirizadas e que a página do comerciante não receba dado de cartão. Não é o nosso caso.
- SAQ A-EP também não se aplica: ele cobre comerciantes cuja página influencia a transação mas não recebe dado de titular de cartão. Nós recebemos.
- Decisão: o questionário aplicável é o SAQ D — Merchant, com escopo restrito ao
contêiner
web, ao proxycaddye ao segmento de rede entre eles.worker,postgreseredisficam fora do escopo de dados de cartão porque nenhum dado de titular chega até eles — o que é assegurado pelos controles de 12.9.2.
Obrigações que essa decisão cria. Esta subseção é a dona da lista; a Seção 22.5.6 é a responsável por executá-las, agendá-las e registrar a evidência de cada uma. A lista é normativa e nenhuma das cinco pode ser omitida do plano de conformidade:
| # | Obrigação | Periodicidade | Evidência exigida |
|---|---|---|---|
| 1 | Varredura por fornecedor autorizado (ASV) do domínio de checkout | Trimestral | Relatório ASV aprovado, arquivado com data |
| 2 | Revisão e reassinatura do SAQ D — Merchant | Anual | Questionário assinado pelo responsável |
| 3 | Política de segurança da informação escrita, revisada e divulgada | Anual | Documento versionado, com registro de leitura |
| 4 | Correção de vulnerabilidades classificadas critical e high |
Em até 30 dias da descoberta | Registro de cada item, com data de descoberta e de correção |
| 5 | Revisão de acessos a sistemas no escopo de cartão | Trimestral | Lista de contas revisada e assinada |
Rota de redução de escopo, já decidida como evolução: migrar a captura do cartão para uma página hospedada da Asaas, acessada por redirecionamento completo. Isso tira a nossa página inteiramente do fluxo do dado de cartão e reclassifica a operação para SAQ A.
A distinção entre os dois questionários de redução importa e fica registrada para não ser decidida duas vezes: redirecionamento para página hospedada pelo processador → SAQ A; componente embutido que a nossa página carrega (iframe ou script do processador dentro do nosso HTML) → SAQ A-EP, porque a nossa página continua influenciando a transação. A rota decidida para este produto é o redirecionamento, com alvo SAQ A; qualquer proposta de componente embutido é uma decisão nova e muda o alvo para SAQ A-EP.
A troca é isolada atrás da interface PaymentProvider em packages/integrations, e nenhum
código de domínio muda. O gatilho para executar essa migração é qualquer um destes: exigência
da adquirente, volume mensal acima de 20.000 transações, ou a primeira release após o MVP — o
que vier primeiro. Enquanto a migração não acontecer, as cinco obrigações da tabela acima
continuam valendo integralmente.
12.10 Webhook da Asaas — recepção #
12.10.1 Contrato do endpoint #
POST /api/webhooks/asaas
- Autenticação: header
asaas-access-token, comparado em tempo constante comASAAS_WEBHOOK_TOKEN. Não há sessão, não há cookie, não há CSRF. Papel: nenhum (rota pública autenticada por segredo compartilhado). - Limites: corpo máximo
WEBHOOK_MAX_BODY_BYTES, o mesmo teto de 7.14 para toda rota de webhook; acima disso,413comcode: PAYLOAD_TOO_LARGE. O proxy reverso da Seção 25.7.2 aplica o teto externo com o mesmo número, e o teto externo nunca é menor que o interno. - Resposta de sucesso:
200com corpo mínimo. Esta rota não usa o envelope padrão da Seção 7, por decisão explícita: a Asaas espera apenas um2xx, e o corpo é irrelevante para ela. Corpo devolvido:{"received":true}.
// Schema de validação de superfície. O corpo bruto é preservado independentemente disso.
export const asaasWebhookEnvelopeSchema = z.object({
id: z.string().min(1),
event: z.string().min(1),
dateCreated: z.string().optional(),
payment: z.record(z.unknown()).optional(),
subscription: z.record(z.unknown()).optional(),
});Exemplo de corpo recebido:
{
"id": "evt_05b708f961d739ea7eba7e4db318f621&368604",
"event": "PAYMENT_CONFIRMED",
"dateCreated": "2026-09-06 09:14:02",
"payment": {
"object": "payment",
"id": "pay_000000098765",
"customer": "cus_000006241382",
"subscription": "sub_000000123456",
"value": 19.90,
"netValue": 18.91,
"billingType": "CREDIT_CARD",
"status": "CONFIRMED",
"dueDate": "2026-09-06",
"confirmedDate": "2026-09-06",
"invoiceUrl": "https://www.asaas.com/i/098765",
"externalReference": null
}
}12.10.2 Comparação em tempo constante #
import { timingSafeEqual } from 'node:crypto';
export function isValidWebhookToken(received: string | null, expected: string): boolean {
if (!received) return false;
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual exige comprimentos iguais; comparar tamanhos antes vazaria informação,
// então normalizamos por hash de comprimento fixo.
const ha = createHash('sha256').update(a).digest();
const hb = createHash('sha256').update(b).digest();
return timingSafeEqual(ha, hb);
}Falha de token: responde 401 com {"received":false}, registra
WARN asaas_webhook_unauthorized com requestId e IP, incrementa a métrica
asaas_webhook_unauthorized_total. Cinco ocorrências em 5 minutos disparam alerta, porque
isso indica varredura ou token desatualizado após rotação.
12.10.3 Por que a resposta precisa ser rápida #
A Asaas mantém uma fila de sincronização por integração. Quando o endpoint responde erro ou demora demais, o evento é reenfileirado; após falhas consecutivas, a Asaas interrompe a fila e passa a acumular eventos, exigindo reativação manual no painel. Uma fila interrompida significa assinantes pagos que não são ativados e inadimplentes que não são revogados — falha silenciosa e cara.
Por isso o handler faz exatamente três coisas, sempre nessa ordem, e nada mais:
- mede o tamanho do corpo antes de lê-lo e valida o token;
- grava o evento bruto em
payment_eventscomON CONFLICT DO NOTHINGsobre o índice único deasaas_event_id; - enfileira o job
billing.webhook, na filabilling.webhook, comjobIdigual aoasaas_event_id.
Orçamento de tempo: p99 abaixo de 2 s, alvo prático de 150 ms. O handler tem um timeout
interno de 1,5 s; se a gravação não concluir nesse prazo, ele ainda responde 200 e registra
ERROR asaas_webhook_persist_timeout, porque perder um evento é recuperável pela
reconciliação de 12.13, enquanto travar a fila da Asaas não é. Essa é uma decisão consciente
de trocar completude imediata por disponibilidade da fila.
Corpo inválido nunca produz resposta não-2xx. A validação do token é a única condição
que autoriza 401, e ela vem primeiro. Autenticado o remetente, qualquer falha posterior —
corpo que não é JSON, schema reprovado, evento desconhecido, assinante inexistente,
indisponibilidade do banco ou da fila — responde 200 com corpo {"received":true} e persiste
o ocorrido:
| Falha após a autenticação | payment_events.processing_status |
O que é guardado | Resposta |
|---|---|---|---|
| Corpo não é JSON | PARSE_ERROR |
Corpo cru, íntegro, em payment_events.payload; o corpo cru como texto vai para webhook_deliveries.body |
200 |
| JSON válido, schema reprovado | SCHEMA_REJECTED |
Corpo cru e o erro do Zod | 200 |
| Banco ou fila indisponível | PERSIST_FAILED |
Corpo cru em arquivo de recuperação, relido pela reconciliação | 200 |
| Evento fora da lista de 12.11 | IGNORED_UNKNOWN_EVENT |
Corpo cru | 200 |
Acima de 3 ocorrências de PARSE_ERROR ou SCHEMA_REJECTED em 15 minutos dispara o alerta
webhook_payload_rejected, severidade alta, para o plantonista (Seção 23.8).
Motivo, registrado para que ninguém "conserte" isso depois: 4xx e 5xx levam o provedor a
retentar e, em seguida, a desativar a assinatura do webhook. Perder o canal de eventos é
catastroficamente pior do que engolir um corpo malformado, porque é por esse canal que a
revogação imediata de acesso pago acontece — sem ele, todo inadimplente continua recebendo
conteúdo pago por dias, e o único detector seria a reconciliação das 04:00.
O único caminho de 4xx além do 401 é 413, para corpo acima de
WEBHOOK_MAX_BODY_BYTES, medido antes da leitura do corpo. Um corpo grande demais
não é evento legítimo da Asaas, e recusá-lo protege a memória do processo.
Detector de canal morto. Um webhook desativado pelo provedor é silencioso por natureza: o
painel continua verde, o envio continua funcionando, e ninguém percebe até a primeira
reclamação. Por isso existe o alerta webhook_silence (Seção 23.8), severidade crítica,
para o plantonista: nenhum evento de cobrança processado com sucesso nos últimos 90 minutos
entre 08:00 e 22:00. É o único detector de assinatura de webhook desativada pelo provedor.
12.10.4 Persistência bruta e deduplicação #
O corpo cru é gravado como jsonb em payment_events.payload, sem transformação, junto com
asaas_event_id, event_type, received_at e processed_at NULL. O índice único em
asaas_event_id (Seção 6) é o mecanismo de deduplicação: um reenvio do mesmo evento não cria
segunda linha e não enfileira segundo job, porque o jobId do BullMQ também é o
asaas_event_id e o BullMQ descarta duplicatas de jobId enquanto o job existir.
Quando o corpo não pode ser interpretado, ainda assim ele é guardado. A linha nasce com
asaas_event_id = 'unparseable:' || sha256(corpo cru), o que preserva a deduplicação sem
depender de um campo que o corpo talvez não tenha, e com processing_status conforme a tabela
de 12.10.3. Um corpo guardado é reprocessável; um corpo descartado é perda definitiva.
Retenção: payment_events é retido por 5 anos por obrigação fiscal, conforme a política da
Seção 22.
export async function handleAsaasWebhook(rawBody: string, headers: Headers) {
if (!isValidWebhookToken(headers.get('asaas-access-token'), env.ASAAS_WEBHOOK_TOKEN)) {
return json({ received: false }, { status: 401 });
}
let parsed: z.infer<typeof asaasWebhookEnvelopeSchema> | null = null;
let failure: 'PARSE_ERROR' | 'SCHEMA_REJECTED' | null = null;
try {
parsed = asaasWebhookEnvelopeSchema.parse(JSON.parse(rawBody));
} catch (error) {
failure = error instanceof SyntaxError ? 'PARSE_ERROR' : 'SCHEMA_REJECTED';
logger.error({ event: 'asaas_webhook_rejected', result: failure });
metrics.webhookPayloadRejectedTotal.inc({ provider: 'asaas', result: failure });
}
try {
await withTimeout(1_500, async () => {
const eventId = parsed?.id ?? `unparseable:${sha256Hex(rawBody)}`;
await db.paymentEvent.createMany({
data: [{
id: ulid(),
asaasEventId: eventId,
eventType: parsed?.event ?? 'UNPARSEABLE',
payload: (parsed ?? {}) as unknown as Prisma.JsonObject,
payloadRaw: rawBody,
receivedAt: new Date(),
processedAt: failure ? new Date() : null,
processingResult: failure,
}],
skipDuplicates: true,
});
// Corpo irrecuperável não vira job: não há o que processar, e a linha já guarda a prova.
if (failure) return;
await billingWebhookQueue.add('billing.webhook', { asaasEventId: eventId }, {
jobId: eventId,
attempts: 8,
backoff: { type: 'exponential', delay: 2_000 },
removeOnComplete: 1_000,
removeOnFail: false,
});
});
} catch {
// Banco ou fila indisponível: o corpo cru vai para o arquivo de recuperação e a
// reconciliação de 12.13 o relê. Responder erro aqui desativaria a fila da Asaas.
logger.error({ event: 'asaas_webhook_persist_failed' });
await spillToRecoveryFile('asaas', rawBody);
}
// 200 em todo caminho autenticado, sem exceção. Ver 12.10.3.
return json({ received: true }, { status: 200 });
}12.10.5 Configuração do webhook na Asaas #
Configurado uma vez por ambiente, no painel da Asaas ou via API de webhooks:
| Campo | Valor |
|---|---|
| URL | https://api.palavradiaria.com.br/api/webhooks/asaas |
| E-mail de notificação de falha | ops@palavradiaria.com.br |
| Token de autenticação | valor de ASAAS_WEBHOOK_TOKEN |
| Versão da API | v3 |
| Fila | ativada, com envio sequencial |
| Eventos | os 12 de 12.11, apenas |
Rotação do token: gerar novo valor, atualizar a variável, reiniciar web, atualizar o painel
da Asaas. Durante a janela de rotação o sistema aceita dois tokens, lendo
ASAAS_WEBHOOK_TOKEN e ASAAS_WEBHOOK_TOKEN_PREVIOUS; a segunda variável é removida em até
24 horas. O runbook está na Seção 27.
12.11 Eventos tratados — tabela completa #
Coluna "fora de ordem" descreve o que fazer se o evento chegar depois de um evento mais
avançado do mesmo payment ou subscription. O mecanismo é o ranque de 12.4.4 combinado com
a recomputação de estado de 12.12.4. As mensagens citadas têm o texto definitivo na Seção 19.
Esta tabela é dona de duas coisas apenas: quais eventos são tratados e qual estado de pagamento cada um grava. O efeito sobre o acesso do assinante nunca é descrito aqui. Ele é sempre e apenas o de duas funções, definidas na Seção 13.3, chamadas dentro da mesma transação do processamento do evento:
revokePaidAccess(tx, subscriberId, reason)— retira o acesso pago, sem carência.grantPaidAccess(tx, subscriberId, periodEnd)— concede ou prorroga o acesso pago.
Descrever por extenso o efeito de revogação em oito linhas de tabela criaria oito lugares que podem divergir da Seção 13.3 na primeira mudança de regra. Por isso a coluna "Efeito no acesso" abaixo diz qual função é chamada, e nada mais.
Recência de evento: não existem colunas payments.last_event_at nem
subscriptions.last_event_at. Onde a tabela abaixo fala em comparar a idade do evento, a
comparação é feita contra o received_at do evento já processado mais recente daquele
payment ou subscription, lido de payment_events — que é append-only e já é a fonte da
verdade dessa ordenação.
| # | Evento | Significado | Efeito no nosso estado | Efeito no acesso | Mensagem ao assinante | Fora de ordem |
|---|---|---|---|---|---|---|
| 1 | PAYMENT_CREATED |
Cobrança gerada para um ciclo | payments criado/atualizado com status=PENDING, due_date, amount_cents; vincula subscription_id |
Nenhuma função de acesso é chamada | PIX: nenhuma no ato; o lembrete D-3 (13.5) usará esta cobrança. Cartão: nenhuma | Ranque 0. Se a linha já existe com ranque maior, apenas completa campos ausentes e não rebaixa o status |
| 2 | PAYMENT_UPDATED |
Valor, vencimento ou descrição mudaram | Atualiza amount_cents, due_date, invoice_url, billing_type; não altera status |
Nenhuma função de acesso é chamada | Só se due_date mudou e é PIX: "A data da sua cobrança mudou para DD/MM." |
Aplica sempre os campos, comparando dateCreated do evento com o received_at do último evento processado do mesmo payment; eventos mais antigos são ignorados |
| 3 | PAYMENT_CONFIRMED |
Pagamento autorizado (cartão) ou compensado; ainda pode não estar liquidado | payments.status=CONFIRMED, confirmed_at; assinatura vai a ACTIVE; current_period_start/end recalculados |
Chama grantPaidAccess() na mesma transação (Seção 13.3) |
Confirmação de pagamento com data de término do ciclo | Ranque 20. Se já RECEIVED (30) ou terminal, ignora o status mas ainda recomputa a assinatura (idempotente) |
| 4 | PAYMENT_RECEIVED |
Valor efetivamente creditado | payments.status=RECEIVED, received_at, net_amount_cents, fee_cents; assinatura ACTIVE |
Chama grantPaidAccess() na mesma transação (Seção 13.3) |
Nenhuma se PAYMENT_CONFIRMED já foi processado; caso contrário, envia a confirmação |
Ranque 30. Chegando sem CONFIRMED prévio, faz o trabalho dos dois |
| 5 | PAYMENT_OVERDUE |
Vencimento passou sem pagamento | payments.status=OVERDUE; subscriptions.status=EXPIRED, ended_at=now(), end_reason=NON_PAYMENT |
Chama revokePaidAccess() na mesma transação (Seção 13.3) |
Aviso de perda de acesso com link de reativação | Ranque 10. Se o pagamento já está CONFIRMED/RECEIVED, o evento é descartado e registra WARN asaas_overdue_after_paid |
| 6 | PAYMENT_DELETED |
Cobrança removida na Asaas | payments.status=DELETED; se era a cobrança do ciclo vigente, subscriptions.status=CANCELED, end_reason=PAYMENT_DELETED |
Chama revokePaidAccess() na mesma transação (Seção 13.3) |
"Sua cobrança foi cancelada e seu acesso ao plano pago foi encerrado." | Ranque 80, sempre aplicado. Se o pagamento estava RECEIVED, aplica a revogação assim mesmo e emite alerta billing_deleted_after_paid para revisão manual |
| 7 | PAYMENT_REFUNDED |
Estorno concluído | payments.status=REFUNDED, refunded_at; subscriptions.status=REFUNDED, end_reason=REFUND |
Chama revokePaidAccess() na mesma transação (Seção 13.3) |
Confirmação de estorno com prazo bancário | Ranque 90, sempre aplicado, mesmo chegando antes de RECEIVED |
| 8 | PAYMENT_CHARGEBACK_REQUESTED |
Titular contestou a compra | payments.status=CHARGEBACK; subscriptions.status=REFUNDED, end_reason=CHARGEBACK; marca subscribers.billing_blocked_at |
Chama revokePaidAccess() na mesma transação (Seção 13.3) |
Mensagem neutra de encerramento com canal de contato | Ranque 95, sempre aplicado |
| 9 | PAYMENT_CHARGEBACK_DISPUTE |
Contestação em disputa com documentação | Grava payments.chargeback_stage=DISPUTE e cria tarefa no painel administrativo |
Nenhuma função de acesso é chamada; o acesso já foi retirado pelo evento 8 | Nenhuma | Sem ranque; só anota. Ignorado se o pagamento não existir |
| 10 | SUBSCRIPTION_CREATED |
Assinatura criada na Asaas | Confirma asaas_subscription_id, billing_type, next_due_date na nossa linha localizada por externalReference |
Nenhuma função de acesso é chamada | Nenhuma | Se a nossa linha não existir, cria uma órfã em PENDING_PAYMENT e sinaliza para a reconciliação (13.12.4) |
| 11 | SUBSCRIPTION_UPDATED |
Ciclo, valor ou vencimento mudaram | Atualiza next_due_date, amount_cents, billing_type; se o cycle mudou, atualiza plan_id para o plano correspondente |
Nenhuma função de acesso é chamada | Só quando o ciclo mudou: confirmação de troca de plano | Compara dateCreated com o received_at do último evento processado da mesma subscription; mais antigo é ignorado |
| 12 | SUBSCRIPTION_DELETED |
Assinatura removida na Asaas | Se a nossa já está CANCELED/EXPIRED/REFUNDED, apenas confirma. Se está ACTIVE, encerra: status=CANCELED, end_reason=PROVIDER_DELETED, acesso mantido até current_period_end |
Nenhuma chamada imediata; revokePaidAccess() é chamada pelo job billing.lifecycle quando current_period_end vence (Seção 13.4) |
Confirmação de cancelamento com a data exata de término | Idempotente: reprocessar não muda nada |
Qualquer evento fora desta lista é gravado em payment_events, marcado
processed_at = now() com processing_status = 'IGNORED_UNKNOWN_EVENT', e registrado como
INFO asaas_event_ignored. Nunca causa erro nem retry.
12.12 Processamento assíncrono e idempotente #
12.12.1 O job #
Job billing.webhook, na fila billing.webhook, concorrência 4, attempts: 8, backoff
exponencial iniciando em 2 s (2 s, 4 s, 8 s, … até ~4 min).
Não existe fila morta separada. Após 8 falhas, o job fica no estado failed da própria
fila billing.webhook e grava uma linha em job_runs com status = 'DEAD', job_name,
queue, bull_job_id, attempt e error. job_runs é a dead-letter do sistema. O painel
administrativo lista os jobs mortos a partir dessa tabela, com botão de reprocessar, e o alerta
billing_event_dead_letter dispara na primeira linha DEAD da fila billing.webhook. A
retenção do estado failed desta fila é de 30 dias.
12.12.2 Fluxo do processador #
export async function processAsaasEvent(asaasEventId: string): Promise<void> {
await db.$transaction(async (tx) => {
// 1. Trava o evento e garante processamento único.
const event = await tx.$queryRaw`
SELECT * FROM payment_events WHERE asaas_event_id = ${asaasEventId} FOR UPDATE`;
if (!event) throw new RetriableError('event_not_persisted_yet');
if (event.processed_at !== null) return; // já processado: sai em silêncio
// 2. Trava o agregado do assinante para serializar eventos concorrentes.
const link = await resolveLinks(tx, event); // subscriber, subscription, payment
if (link.subscriberId) {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'sub:' + link.subscriberId}))`;
}
// 3. Aplica o efeito bruto do evento (upsert em payments/subscriptions).
const outcome = await applyEvent(tx, event, link);
// 4. Recomputa o estado derivado a partir dos fatos, nunca por delta.
if (link.subscriptionId) await recomputeSubscriptionState(tx, link.subscriptionId);
// 5. Marca como processado.
await tx.paymentEvent.update({
where: { asaasEventId },
data: { processedAt: new Date(), processingResult: outcome.result },
});
// 6. Enfileira efeitos externos APÓS o commit.
outcome.sideEffects.forEach((e) => tx.afterCommit(() => enqueue(e)));
}, { isolationLevel: 'ReadCommitted', timeout: 15_000 });
}Pontos de projeto que valem explicação:
- O
FOR UPDATEsobrepayment_eventsmais oprocessed_atfuncionam como exactly-once lógico: duas execuções concorrentes do mesmo evento serializam, e a segunda sai no passo 1. - O advisory lock por assinante impede que dois eventos diferentes (por exemplo,
PAYMENT_CONFIRMEDde uma cobrança ePAYMENT_OVERDUEde outra) se intercalem e produzam estado incoerente. - Mensagens de WhatsApp e e-mails nunca são disparados dentro da transação. Eles entram
em
afterCommit. Se o processo morrer entre o commit e o enfileiramento, o job é repetido, cai no passo 1 e sai — e a mensagem se perde. Para cobrir isso, a mensagem de confirmação de pagamento também é reconstruída pelo job de reconciliação (12.13.4), que comparasubscription_eventscommessage_logs. RetriableError('event_not_persisted_yet')cobre a corrida em que o job é consumido antes do commit da gravação do webhook. O backoff resolve em milissegundos.
12.12.3 Idempotência em três camadas #
- Recepção: índice único em
payment_events.asaas_event_idejobIddo BullMQ igual ao id do evento. - Aplicação: ranque de
payments.status(12.4.4) e comparação dedateCreateddo evento com oreceived_atdo último evento já processado do mesmo objeto, lido depayment_events, para os campos que não têm ranque. - Derivação:
recomputeSubscriptionStateé uma função pura do estado persistido; rodar duas vezes produz o mesmo resultado.
12.12.4 recomputeSubscriptionState #
Esta função é o coração da resistência a eventos fora de ordem. Ela não olha o evento; olha as cobranças gravadas e deriva o estado.
export async function recomputeSubscriptionState(tx: Tx, subscriptionId: string): Promise<void> {
const sub = await tx.subscription.findUniqueOrThrow({ where: { id: subscriptionId } });
// O intervalo do ciclo vem do plano; subscriptions não tem coluna de ciclo própria.
const plan = await tx.plan.findUniqueOrThrow({ where: { id: sub.planId } });
const payments = await tx.payment.findMany({
where: { subscriptionId }, orderBy: { dueDate: 'desc' },
});
const terminal = payments.find((p) => p.status === 'REFUNDED' || p.status === 'CHARGEBACK');
const latestPaid = payments.find((p) => p.status === 'RECEIVED' || p.status === 'CONFIRMED');
const latestOverdue = payments.find((p) => p.status === 'OVERDUE');
let next: SubscriptionStatus;
let periodEnd: Date | null = sub.currentPeriodEnd;
if (terminal) {
next = 'REFUNDED';
periodEnd = terminal.refundedAt ?? new Date();
} else if (latestPaid && (!latestOverdue || latestOverdue.dueDate < latestPaid.dueDate)) {
next = sub.cancelRequestedAt ? 'CANCELED' : 'ACTIVE';
periodEnd = addCycle(latestPaid.confirmedAt ?? latestPaid.paidAt!, plan.interval);
} else if (latestOverdue) {
next = 'EXPIRED';
periodEnd = latestOverdue.dueDate;
} else {
next = sub.status === 'PENDING_PAYMENT' ? 'PENDING_PAYMENT' : sub.status;
}
if (next !== sub.status || +periodEnd! !== +(sub.currentPeriodEnd ?? 0)) {
await tx.subscription.update({
where: { id: subscriptionId },
data: { status: next, currentPeriodEnd: periodEnd },
});
await tx.subscriptionEvent.create({
data: { id: ulid(), subscriptionId, fromStatus: sub.status, toStatus: next, source: 'WEBHOOK' },
});
}
await syncSubscriberTier(tx, sub.subscriberId);
}syncSubscriberTier materializa subscribers.tier a partir de resolveEntitlements
(Seção 13.6). O cache existe por desempenho no motor de envio; a fonte da verdade continua
sendo a função. A função dona da recomputação, e o nome pelo qual as outras seções devem
referenciá-la, é recomputeSubscriptionState; syncSubscriberTier é uma etapa interna dela e
nunca é chamada de fora.
Consequência para o motor de envio, e é a razão pela qual esta materialização é segura.
subscribers.tier é relido pelo motor imediatamente antes de cada chamada ao provedor de
mensagens, e não apenas no planejamento do lote das 05:40 (Seção 18.5). Um assinante cujo
PAYMENT_OVERDUE for processado às 05:52 — depois do planejamento, antes do disparo das
06:00 — já está com tier = 'FREE' gravado quando o motor relê, e por isso recebe apenas o
texto do plano gratuito, sem áudio, naquele mesmo dia. Sem essa releitura, o congelamento das
05:40 concederia na prática um dia de carência a todo inadimplente, o que a regra de revogação
imediata proíbe.
12.12.5 Falhas e casos de borda do processamento #
| Caso | Comportamento |
|---|---|
Evento cita subscription que não existe no nosso banco |
Cria assinatura órfã em PENDING_PAYMENT sem subscriber_id só se houver externalReference reconhecível; caso contrário grava processing_status = 'ORPHAN_SUBSCRIPTION' e cria tarefa administrativa. Nunca falha o job |
Evento cita customer desconhecido |
processing_status = 'UNKNOWN_CUSTOMER', alerta billing_unknown_customer, sem retry |
Assinante com deleted_at preenchido |
Aplica o efeito financeiro (cobrança/estorno) mas não envia mensagem |
| Falha ao enviar mensagem de confirmação | Não afeta o estado financeiro; o job de mensagem tem retry próprio (Seção 18) |
| Postgres indisponível | Job falha e é repetido; a Asaas não é afetada porque o webhook já respondeu 200 |
| Valor do evento diverge do plano | Aplica o valor da Asaas, grava payments.amount_cents real, e emite alerta billing_amount_mismatch com expected e received |
Evento chega duas vezes com ids diferentes e mesmo payment |
O ranque impede regressão; o segundo evento vira NO_OP |
12.13 Reconciliação diária #
12.13.1 Quando e como roda #
Job repetível billing.reconcile, todos os dias às 04:00 America/Sao_Paulo, registrado no
BullMQ com tz: 'America/Sao_Paulo' e jobId: 'billing-reconcile-daily'. Roda antes do
planejamento de envio das 05:40, de propósito: qualquer divergência de tier é corrigida antes
de o motor de envio decidir quem recebe áudio.
Duração alvo: menos de 10 minutos para 3.000 assinaturas pagas. Com o teto de 8 requisições
por segundo e 2 chamadas por assinatura, o pior caso é ~12,5 minutos; por isso a varredura usa
consultas em lote (GET /v3/payments?subscription=... só para os divergentes) e um filtro
prévio que reduz o conjunto.
12.13.2 O que compara #
Universo examinado a cada execução:
- Todas as
subscriptionscomstatus IN ('ACTIVE','PENDING_PAYMENT','CANCELED'). - Todas as
subscriptionsque mudaram de estado nas últimas 48 horas, em qualquer status. - Todos os
paymentscomdue_date >= now() - interval '35 days'estatus IN ('PENDING','OVERDUE','CONFIRMED'). payment_eventscomprocessed_at IS NULLhá mais de 15 minutos — indício de job perdido.
Para cada assinatura do conjunto, busca GET /v3/subscriptions/{id} e
GET /v3/payments?subscription={id}&limit=100, e compara campo a campo:
| Campo nosso | Campo Asaas | Divergência típica |
|---|---|---|
payments.status |
payment.status |
webhook perdido |
payments.due_date |
payment.dueDate |
vencimento reagendado |
payments.amount_cents |
payment.value |
valor alterado no painel da Asaas |
subscriptions.next_due_date |
subscription.nextDueDate |
ciclo avançou sem evento |
subscriptions.amount_cents |
subscription.value |
mudança de preço aplicada manualmente |
| existência da assinatura | 404 ou deleted: true |
removida na Asaas |
subscribers.tier |
derivado | tier materializado desatualizado |
12.13.3 Como corrige #
A regra é única e absoluta: a Asaas vence. O procedimento por divergência:
- Atualiza a linha local com o valor da Asaas.
- Sintetiza um
payment_eventslocal comasaas_event_id = 'recon:' + <paymentId> + ':' + <status>eevent_typecorrespondente, marcadosource = 'RECONCILIATION'. O prefixorecon:garante que não colida com um id real e mantém a idempotência: se o evento verdadeiro chegar atrasado, o ranque impede reaplicação. - Chama
recomputeSubscriptionState(12.12.4). - Registra em
subscription_eventscomsource = 'RECONCILIATION'. - Envia a mensagem ao assinante apenas quando o efeito é ganho ou perda de acesso e
nenhuma mensagem equivalente foi registrada em
message_logsnas últimas 24 horas. Isso evita mensagem duplicada quando o webhook chegou mas a mensagem falhou, e garante a mensagem quando o webhook nunca chegou.
Casos especiais:
| Situação | Ação |
|---|---|
Assinatura existe aqui e retorna 404 na Asaas |
Trata como SUBSCRIPTION_DELETED: encerra no fim do ciclo pago |
| Assinatura existe na Asaas e não aqui | Cria linha órfã, tenta vincular por externalReference e, se não achar, por customer → subscribers.asaas_customer_id; se ainda assim não achar, gera tarefa administrativa ORPHAN_SUBSCRIPTION |
Pagamento RECEIVED na Asaas e OVERDUE aqui |
Restaura acesso: ACTIVE, tier PAID, mensagem "Recebemos seu pagamento" |
Pagamento OVERDUE na Asaas e RECEIVED aqui |
Revoga: EXPIRED, tier FREE, mensagem de perda de acesso, alerta billing_recon_downgrade (é o caso mais grave e precisa de olho humano) |
Nosso tier é PAID sem assinatura ativa nem cortesia vigente |
Rebaixa para FREE e registra TIER_DRIFT_FIXED |
Nosso tier é FREE com assinatura ACTIVE |
Promove a PAID e registra TIER_DRIFT_FIXED |
payment_events pendente há mais de 15 min |
Reenfileira o job billing.webhook, na fila billing.webhook, com o mesmo jobId |
| A API da Asaas está fora do ar | O job falha, é repetido às 04:30, 05:00 e 05:30; se as três falharem, alerta billing_recon_failed e o motor de envio usa o tier materializado como está |
12.13.4 Reparo de mensagens perdidas #
Além do estado, a reconciliação repara comunicação. Para cada subscription_events das
últimas 72 horas cujo tipo exige mensagem (ativação, perda de acesso, estorno), verifica se
existe message_logs correspondente com template_name esperado e status diferente de
failed. Não existindo, enfileira o envio com a data original citada no texto. Limite: no
máximo uma reparação por assinante por dia, para não transformar uma falha sistêmica em
enxurrada de mensagens.
12.13.5 Relatório gerado #
Cada execução grava uma linha em job_runs com job_name = 'billing.reconcile',
started_at, finished_at, status e um result jsonb:
{
"scannedSubscriptions": 3128,
"scannedPayments": 3402,
"asaasRequests": 6289,
"divergences": {
"paymentStatus": 7,
"dueDate": 2,
"amount": 0,
"subscriptionMissingLocally": 1,
"subscriptionMissingRemotely": 0,
"tierDrift": 3
},
"actions": {
"upgradedToPaid": 5,
"downgradedToFree": 2,
"messagesRepaired": 4,
"eventsRequeued": 1,
"adminTasksCreated": 1
},
"durationMs": 418_220,
"errors": []
}O relatório aparece no painel administrativo em "Operação → Reconciliação", com histórico de
30 dias, e é enviado por e-mail a ops@palavradiaria.com.br apenas quando
downgradedToFree > 0, subscriptionMissingLocally > 0 ou errors.length > 0. Execuções
limpas não geram e-mail, para que o e-mail continue significando alguma coisa.
12.14 Reembolso e chargeback #
12.14.1 Reembolso solicitado pelo assinante #
O assinante não reembolsa sozinho pelo painel. Ele abre o pedido pelo canal de suporte, e um administrador executa. Decisão registrada: reembolso automático abriria espaço para abuso do padrão "assina, recebe o conteúdo do mês, pede reembolso" sem nenhuma barreira.
Política, decidida aqui e publicada na página de vendas (Seção 9): reembolso integral em até
7 dias corridos da primeira cobrança, conforme o direito de arrependimento do Art. 49 do
Código de Defesa do Consumidor. Fora dessa janela, o reembolso é discricionário e depende de
aprovação de um ADMIN.
Fluxo:
- Admin abre a assinatura no painel e aciona "Reembolsar cobrança".
- O sistema exige justificativa de 10 a 500 caracteres e confirmação por senha do admin.
- Chama
POST /v3/payments/{id}/refundcom o valor integral. - Grava
admin_audit_logcomaction = 'PAYMENT_REFUND',entity_type = 'payment',entity_id, justificativa. - A Asaas emite
PAYMENT_REFUNDED; o processamento normal (12.11, evento 7) revoga o acesso imediatamente e cancela a assinatura na Asaas comDELETE /v3/subscriptions/{id}. - O assinante recebe a mensagem de confirmação de estorno com o prazo bancário.
Não esperamos o webhook para saber que deu certo: a resposta da chamada 14 já traz
status: REFUNDED, e o processador é idempotente, então o efeito é aplicado na hora e o
webhook posterior vira NO_OP.
Prazo bancário informado ao assinante: cartão de crédito, até duas faturas; PIX, até 1 dia útil. Esses prazos são do arranjo de pagamento, não nossos, e o texto deixa isso claro.
12.14.2 Chargeback #
Chargeback é iniciado pelo titular junto ao emissor. O sistema descobre pelo evento
PAYMENT_CHARGEBACK_REQUESTED.
Efeito imediato, sem exceção: payments.status = CHARGEBACK, subscriptions.status = REFUNDED
com end_reason = CHARGEBACK, tier FREE, e subscribers.billing_blocked_at = now().
billing_blocked_at bloqueia novo checkout com cartão para aquele assinante. Ele ainda pode
assinar por PIX. O bloqueio é removido manualmente por um ADMIN, com justificativa
registrada. Motivo da decisão: um assinante com chargeback aberto que reassina por cartão
gera um segundo chargeback com alta probabilidade, e taxa de chargeback elevada ameaça a
conta de recebimento inteira.
A mensagem enviada é neutra e sem acusação: informa que o acesso foi encerrado e oferece o canal de contato. Não afirmamos fraude, não cobramos explicação.
Disputa: ao receber PAYMENT_CHARGEBACK_DISPUTE, o sistema cria uma tarefa administrativa com
prazo, listando as evidências que devem ser reunidas: registro de consent_events do opt-in,
message_logs das entregas do período, e o receipt_url da cobrança. O sistema não envia
documentação automaticamente; o envio é feito por uma pessoa pelo painel da Asaas.
Métrica de vigilância: chargeback_rate_30d = chargebacks nos últimos 30 dias dividido por
cobranças confirmadas no mesmo período. Alerta em 0,5% e alerta crítico em 0,9%, porque as
bandeiras costumam agir em torno de 1%. A definição vive junto das demais na Seção 21.
12.14.3 Comparação dos dois fluxos #
| Aspecto | Reembolso | Chargeback |
|---|---|---|
| Quem inicia | Assinante via suporte, executado por admin | Titular junto ao emissor |
| Como o sistema sabe | Resposta da chamada + webhook | Somente webhook |
| Custo | Valor devolvido | Valor devolvido + taxa + risco de conta |
| Acesso | Revogado imediatamente | Revogado imediatamente |
| Assinatura na Asaas | Removida por nós | Removida por nós |
| Novo checkout com cartão | Permitido | Bloqueado até liberação manual |
| Mensagem | Confirmação de estorno com prazo | Aviso neutro de encerramento |
12.15 Modo sandbox e roteiro de testes #
12.15.1 Preparação #
O ambiente local e o staging apontam para https://api-sandbox.asaas.com/v3 com uma
chave de sandbox. O webhook do sandbox precisa de URL pública; em desenvolvimento, usa-se um
túnel HTTP e a URL é registrada no painel sandbox. Alternativamente, o CLI de operação
reproduz eventos sem túnel:
pnpm ops asaas:replay --event PAYMENT_CONFIRMED --payment pay_000000098765
pnpm ops asaas:replay --file ./fixtures/asaas/payment-overdue.jsonasaas:replay monta o corpo, assina com ASAAS_WEBHOOK_TOKEN e faz POST no endpoint local.
É o mesmo caminho de código do webhook real, inclusive a validação de token.
12.15.2 Cartões de teste #
Valores publicados pela Asaas para o ambiente de sandbox. O executor confere a lista vigente no painel sandbox antes de escrever os testes, porque ela pode ganhar novos casos.
| Cenário | Número | Validade | CVV | Resultado esperado |
|---|---|---|---|---|
| Aprovado | 5162306219378829 |
05/2031 |
318 |
PAYMENT_CONFIRMED |
| Recusado pelo emissor | 5184019740373151 |
05/2031 |
318 |
CARD_DECLINED na criação |
| Número inválido (falha Luhn) | 4000000000000001 |
05/2031 |
123 |
CARD_INVALID |
| Validade expirada | 5162306219378829 |
01/2020 |
318 |
CARD_EXPIRED |
Em sandbox, o titular deve ter CPF válido; o CPF 52998224725 usado nos exemplos serve.
12.15.3 Como simular cada cenário #
| Cenário | Como provocar |
|---|---|
| Pagamento confirmado (cartão) | Checkout com o cartão aprovado |
| Pagamento recebido (PIX) | POST /v3/payments/{id}/receiveInCash com paymentDate de hoje |
| Vencimento / inadimplência | POST /v3/payments/{id} alterando dueDate para ontem e aguardar o processamento noturno do sandbox; ou pnpm ops asaas:replay --event PAYMENT_OVERDUE |
| Cobrança removida | DELETE /v3/payments/{id} |
| Estorno | POST /v3/payments/{id}/refund |
| Chargeback | Sem gatilho na API do sandbox: usar asaas:replay --event PAYMENT_CHARGEBACK_REQUESTED |
| Assinatura removida | DELETE /v3/subscriptions/{id} |
| Troca de ciclo | POST /v3/subscriptions/{id} com cycle: "YEARLY" e value: 199.00 |
| Webhook fora de ordem | asaas:replay de PAYMENT_OVERDUE depois de PAYMENT_CONFIRMED |
| Webhook duplicado | Repetir o mesmo asaas:replay duas vezes; a segunda deve virar NO_OP |
| Token de webhook errado | curl manual com header inválido, esperando 401 |
| Indisponibilidade da Asaas | Variável ASAAS_API_BASE_URL apontando para uma porta fechada; valida o backoff e o PAYMENT_PROVIDER_UNAVAILABLE |
Cada cenário desta tabela tem um teste automatizado correspondente. A estratégia de testes, os utilitários de fixture e a política de cobertura são da Seção 24.
12.15.4 Dados sintéticos e limpeza #
O ambiente staging roda pnpm ops seed:billing --subscribers 50 que cria assinantes
sintéticos com telefones da faixa de teste e CPFs válidos gerados por algoritmo. A limpeza
(pnpm ops purge:sandbox) remove assinaturas e clientes criados pelo seed usando o prefixo
externalReference 01SEED, e nunca roda quando APP_ENV=production — o comando aborta com
erro se detectar a base URL de produção.
12.16 Fora de escopo declarado #
Os itens abaixo não existem no produto e não devem ser implementados, nem parcialmente, nem "preparados para o futuro":
- Split de pagamento entre recebedores. A conta Asaas é única.
- Antecipação de recebíveis. O fluxo de caixa segue o prazo padrão do arranjo.
- Emissão de nota fiscal, de serviço ou de consumidor, automática ou manual pelo sistema.
- Boleto bancário como forma de pagamento. O prazo de compensação é incompatível com a regra de revogação imediata e com a entrega diária.
- Cupons, descontos e campanhas promocionais. Não existe tabela de cupons, não existe
campo de desconto no checkout e o preço vem sempre de
plans. - Carteira, saldo, transferências, cobrança avulsa fora de assinatura e link de pagamento compartilhável.
- Assinatura com múltiplos itens, upgrade com prorrateio e cobrança por uso.
Se algum desses itens for pedido depois, ele entra como escopo novo, com desenho próprio.
13. Assinaturas: Ciclo de Vida, Inadimplência e Entitlements #
Esta seção é a dona da máquina de estados da assinatura e da matriz de entitlements. Toda decisão de "este assinante pode receber áudio?", "quantos reenvios ele tem hoje?", "ele recebe todo dia ou só domingo?" resolve-se aqui, por uma única função. Nenhuma outra seção recalcula entitlement por conta própria — o motor de envio (Seção 18), o painel do assinante (Seção 14), o painel administrativo (Seção 15) e o catálogo de mensagens (Seção 19) chamam a função definida em 13.6 e usam o resultado.
As chamadas à Asaas citadas aqui pertencem à Seção 12. O schema das tabelas é da Seção 6. O envelope de resposta e o catálogo global de códigos de erro são da Seção 7.
13.1 Planos: preço, ciclo e representação #
13.1.1 Catálogo #
Dois planos pagos. Não há plano intermediário, não há trial, não há cupom.
plans.code |
Nome exibido | Preço | Ciclo | plans.interval |
amount_cents |
Valor mensal equivalente |
|---|---|---|---|---|---|---|
plan_monthly |
Plano mensal | R$ 19,90 | mensal | MONTHLY |
1990 |
R$ 19,90 |
plan_annual |
Plano anual | R$ 199,00 | anual | YEARLY |
19900 |
R$ 16,58 |
A coluna que guarda o ciclo chama-se plans.interval, com esse nome exato e nenhum outro.
Não existem plans.cycle nem plans.billing_cycle em lugar algum do documento.
O plano gratuito não é uma linha em plans. Ser gratuito é a ausência de assinatura
paga vigente. Decisão registrada: modelar o gratuito como plano criaria uma assinatura
fantasma sem cobrança, sem ciclo e sem contraparte na Asaas, e obrigaria toda consulta a
distinguir "assinatura real" de "assinatura fictícia". A ausência é mais simples e não perde
nenhuma informação.
Consequência para a interface, declarada aqui para não ser reinventada: onde a tela precisar
exibir um "plano gratuito" — a tabela comparativa da página de vendas, o seletor de plano no
painel —, esse item é sintetizado pelo handler e não corresponde a nenhuma linha da tabela
plans. O handler lê plans para os planos pagos ativos, ordenados por sort_order, e
prepende um item fixo { code: 'plan_free', tier: 'FREE', name: 'Gratuito', interval: null, amountCents: 0 }. O código plan_free existe apenas na camada de apresentação: nenhuma
escrita o usa, nenhuma consulta o procura em plans, e criar essa linha no banco para "fazer a
rota funcionar" é erro, não atalho.
Economia do plano anual: R$ 238,80 − R$ 199,00 = R$ 39,80 por ano, ou 16,7% de desconto. O
rótulo exibido ao público é arredondado para baixo — "economize 16%" — para nunca prometer
mais do que se entrega; o número exato de 16,7% aparece só em documentação interna. O cálculo é
feito a partir de plans.amount_cents, nunca digitado no texto.
13.1.2 Representação no nosso banco #
plans guarda code, display_name, amount_cents, currency (sempre BRL),
interval, active e sort_order. Colunas, tipos e índices na Seção 6.
subscriptions guarda plan_id, amount_cents (cópia do preço no momento da contratação,
não referência viva), billing_type, status, current_period_start, current_period_end,
next_due_date, cancel_requested_at, cancel_at_period_end, ended_at, end_reason,
pending_plan_id, pending_plan_effective_at, asaas_subscription_id, asaas_card_token,
card_last4, card_brand, card_exp_month, card_exp_year.
subscriptions não tem coluna própria de ciclo. O ciclo de uma assinatura é sempre lido de
plans.interval através de plan_id. Onde uma resposta de API expõe billingCycle, o valor é
derivado dessa leitura, não de uma coluna. Motivo: um plano nunca muda de intervalo — trocar de
ciclo é trocar de plano (13.8) —, então copiar o intervalo criaria uma segunda verdade que só
poderia divergir.
Dinheiro é sempre inteiro em centavos nas colunas *_amount_cents, com divisor 100 para
reais, conforme a convenção de unidades da Seção 21.2. Nenhum valor monetário desta seção é
representado em ponto flutuante, e a conversão para o decimal que a Asaas espera acontece em um
único lugar (Seção 12.4.3).
A cópia do preço em subscriptions.amount_cents é deliberada e é a base da regra de 13.12.7:
mudar plans.amount_cents não altera o que um assinante existente paga.
13.1.3 Representação na Asaas #
| Nosso conceito | Objeto/campo na Asaas |
|---|---|
plan_monthly |
subscription.cycle = "MONTHLY", value = 19.90 |
plan_annual |
subscription.cycle = "YEARLY", value = 199.00 |
| assinatura | subscription com externalReference = subscriptions.id |
| cobrança do ciclo | payment gerado pela Asaas |
A Asaas não tem catálogo de produtos: cada assinatura carrega o próprio valor e ciclo. Por
isso o nosso plans é a única fonte de preço, e o valor é enviado a cada criação.
13.2 Máquina de estados #
13.2.1 Estados #
subscriptions.status |
Significado | Concede acesso PAID? |
|---|---|---|
PENDING_PAYMENT |
Criada, aguardando a primeira confirmação | Não |
ACTIVE |
Ciclo pago e vigente | Sim, até current_period_end |
CANCELED |
Cancelamento pedido pelo assinante ou pelo admin; ciclo pago segue valendo | Sim, até current_period_end |
EXPIRED |
Encerrada por falta de pagamento ou por fim de ciclo sem renovação | Não |
REFUNDED |
Encerrada por estorno ou chargeback | Não |
Não existe PAST_DUE, GRACE, SUSPENDED nem TRIALING. A ausência de PAST_DUE é a
tradução direta da regra de 13.3: não há estado intermediário entre "pagou" e "não tem
acesso".
CANCELED concede acesso porque representa um ciclo já pago cujo término foi antecipadamente
solicitado. EXPIRED não concede porque representa um ciclo não pago ou já terminado. Essa é
a distinção inteira do produto, e ela está detalhada em 13.4.
13.2.2 Diagrama #
POST /api/me/subscription
│
▼
┌──────────────────────┐
PAYMENT_DELETED│ PENDING_PAYMENT │ PAYMENT_OVERDUE / abandono 24h
┌──────────────┤ (sem acesso PAID) ├───────────────┐
│ └──────────┬───────────┘ │
│ │ PAYMENT_CONFIRMED │
│ │ ou PAYMENT_RECEIVED │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ ACTIVE │◄──────────┐ │
│ │ (acesso PAID) │ │ │
│ └───┬────┬────┬────────┘ │ │
│ │ │ │ │ │
│ cancelamento │ │ │ PAYMENT_REFUNDED │ │ PAYMENT_CONFIRMED
│ voluntário │ │ │ ou CHARGEBACK │ │ (retentativa aprovada)
│ (painel) │ │ │ │ │
│ ▼ │ ▼ │ │
│ ┌──────────────┐│ ┌──────────────┐ │ │
│ │ CANCELED ││ │ REFUNDED │ │ │
│ │ acesso PAID ││ │ sem acesso │ │ │
│ │ até o fim do ││ │ (terminal) │ │ │
│ │ ciclo pago ││ └──────────────┘ │ │
│ └──────┬───────┘│ │ │
│ │ │ PAYMENT_OVERDUE │ │
│ fim do ciclo│ │ PAYMENT_DELETED │ │
│ (job 00:05) │ │ (revogação imediata) │ │
│ ▼ ▼ │ │
│ ┌───────────────────────────┐ │ │
└──────►│ EXPIRED │─────────────┘ │
│ (sem acesso) │◄────────────────┘
└───────────┬───────────────┘
│ nova assinatura (13.10)
▼
volta para PENDING_PAYMENTEstados terminais: REFUNDED é definitivo para aquela linha. EXPIRED e CANCELED são
terminais para a linha, mas o assinante pode criar uma nova assinatura. Uma linha nunca
é reaproveitada: reativação sempre cria linha nova, o que preserva o histórico e simplifica a
auditoria.
13.2.3 Tabela de transições #
| # | De | Gatilho | Para | Efeitos colaterais | Mensagem | Quem causa |
|---|---|---|---|---|---|---|
| T1 | — | POST /api/me/subscription aceito |
PENDING_PAYMENT |
Cria linha; cria customer e subscription na Asaas; subscription_events CREATED |
Nenhuma | Assinante |
| T2 | PENDING_PAYMENT |
PAYMENT_CONFIRMED ou PAYMENT_RECEIVED |
ACTIVE |
current_period_start = confirmação, current_period_end = +1 ciclo; chama grantPaidAccess() na mesma transação (13.3.1) |
Confirmação de pagamento com data de término | Asaas (webhook) |
| T3 | PENDING_PAYMENT |
PAYMENT_OVERDUE |
EXPIRED |
end_reason = NON_PAYMENT; tier permanece FREE |
Aviso de cobrança não concluída com link para tentar de novo | Asaas |
| T4 | PENDING_PAYMENT |
PAYMENT_DELETED |
EXPIRED |
end_reason = PAYMENT_DELETED |
Nenhuma | Asaas ou admin |
| T5 | PENDING_PAYMENT |
Job billing.abandon-pending, fila billing.lifecycle, após 24 h sem evento |
EXPIRED |
end_reason = ABANDONED; DELETE /v3/subscriptions/{id} |
PIX: lembrete único com o código; cartão: nenhuma | Sistema |
| T6 | ACTIVE |
PAYMENT_CONFIRMED do ciclo seguinte |
ACTIVE |
current_period_end avança um ciclo; next_due_date atualizado; grantPaidAccess() |
Recibo de renovação | Asaas |
| T7 | ACTIVE |
PAYMENT_OVERDUE |
EXPIRED |
ended_at = now(), end_reason = NON_PAYMENT; chama revokePaidAccess() na mesma transação (13.3.1) |
Aviso de perda de acesso com botão de reativar | Asaas |
| T8 | ACTIVE |
PAYMENT_DELETED |
CANCELED |
end_reason = PAYMENT_DELETED; chama revokePaidAccess() na mesma transação (13.3.1) |
Aviso de encerramento | Asaas ou admin |
| T9 | ACTIVE |
PAYMENT_REFUNDED |
REFUNDED |
ended_at = now(); chama revokePaidAccess() (13.3.1); DELETE /v3/subscriptions/{id} |
Confirmação de estorno | Admin (12.14.1) |
| T10 | ACTIVE |
PAYMENT_CHARGEBACK_REQUESTED |
REFUNDED |
chama revokePaidAccess() (13.3.1); subscribers.billing_blocked_at = now(); DELETE na Asaas |
Aviso neutro de encerramento | Titular do cartão |
| T11 | ACTIVE |
POST /api/me/subscription/cancel |
CANCELED |
cancel_requested_at = now(), cancel_at_period_end = true; DELETE /v3/subscriptions/{id}; nenhuma função de acesso é chamada — o tier segue PAID até o fim do período pago |
Confirmação com a data exata de término | Assinante |
| T12 | ACTIVE |
Admin cancela pelo painel | CANCELED |
Igual a T11 + admin_audit_log |
Igual a T11 | Admin |
| T13 | ACTIVE |
SUBSCRIPTION_DELETED sem pedido nosso |
CANCELED |
end_reason = PROVIDER_DELETED; acesso até current_period_end; alerta operacional |
Confirmação de cancelamento | Asaas |
| T14 | CANCELED |
Job billing.lifecycle às 00:05 e current_period_end <= now() |
EXPIRED |
end_reason mantido; chama revokePaidAccess() (13.3.1) |
Mensagem de despedida com oferta de retorno | Sistema |
| T15 | CANCELED |
PAYMENT_REFUNDED |
REFUNDED |
chama revokePaidAccess() (13.3.1), antecipando T14 |
Confirmação de estorno | Admin |
| T16 | CANCELED |
PAYMENT_OVERDUE (cobrança do próximo ciclo que não deveria existir) |
EXPIRED |
chama revokePaidAccess() (13.3.1); alerta billing_canceled_but_charged |
Aviso de perda de acesso | Asaas |
| T17 | EXPIRED |
PAYMENT_CONFIRMED tardio da mesma cobrança |
ACTIVE |
Recalcula período a partir da confirmação; chama grantPaidAccess() (13.3.1) |
"Recebemos seu pagamento. Seu acesso foi restaurado." | Asaas |
| T18 | EXPIRED | CANCELED | REFUNDED |
POST /api/me/subscription (reativação) |
nova linha em PENDING_PAYMENT |
Linha antiga imutável; subscription_events REACTIVATION_STARTED |
Nenhuma até T2 | Assinante |
| T19 | qualquer | Reconciliação detecta divergência | conforme a Asaas | source = 'RECONCILIATION' em subscription_events |
Só se houver ganho/perda de acesso (12.13.3) | Sistema |
Transições proibidas, rejeitadas com code: INVALID_SUBSCRIPTION_TRANSITION (409) e
registro ERROR subscription_invalid_transition: REFUNDED → qualquer coisa;
EXPIRED → CANCELED; CANCELED → ACTIVE (a reativação cria linha nova, T18);
PENDING_PAYMENT → CANCELED (usa T4/T5).
A guarda é implementada como tabela de adjacência em packages/core, e toda escrita em
subscriptions.status passa por applyTransition():
const ALLOWED: Record<SubscriptionStatus, SubscriptionStatus[]> = {
PENDING_PAYMENT: ['ACTIVE', 'EXPIRED'],
ACTIVE: ['ACTIVE', 'CANCELED', 'EXPIRED', 'REFUNDED'],
CANCELED: ['EXPIRED', 'REFUNDED'],
EXPIRED: ['ACTIVE'],
REFUNDED: [],
};
export function assertTransition(from: SubscriptionStatus, to: SubscriptionStatus): void {
if (!ALLOWED[from].includes(to)) {
throw new DomainError('INVALID_SUBSCRIPTION_TRANSITION', `${from} -> ${to}`);
}
}13.3 A regra dura: não existe período de carência #
Não há período de carência. Em nenhuma circunstância. Para nenhum plano. Para nenhuma forma de pagamento. Para nenhum assinante.
O que isso significa, sem ambiguidade:
- Quando o pagamento não acontece, o acesso pago acaba no instante em que o sistema
descobre — ou seja, no processamento do webhook
PAYMENT_OVERDUE. Não há 1 dia, 3 dias, 7 dias, "até o fim do mês" ou "até o próximo domingo". - A revogação acontece na mesma transação de banco que processa o evento. Não há job noturno de rebaixamento, não há fila de "a rebaixar", não há janela em que o assinante esteja inadimplente e ainda pago.
- Não existe estado
PAST_DUE,GRACE_PERIODouSUSPENDEDno enum. Se alguém precisar de um, a resposta é não. - Não existe variável de ambiente, campo em
settingsou flag emfeature_flagsque configure carência. A ausência é estrutural, não configurável. - O mesmo vale para
PAYMENT_DELETED,PAYMENT_REFUNDEDePAYMENT_CHARGEBACK_REQUESTED: revogação imediata, na transação.
13.3.1 As duas funções que mudam o acesso — e são as únicas #
Todo ganho e toda perda de acesso pago passam por duas funções, e apenas duas, definidas aqui. Nenhuma outra seção descreve por extenso o que acontece quando o acesso muda: as Seções 12.11, 13.2.3, 13.9, 13.11 e 20 dizem qual função é chamada e param por aí. A razão é concreta: a lista de efeitos aparecia por extenso em oito linhas de tabela da Seção 12.11, em sete transições da 13.2.3 e em duas subseções da 20; eram dezessete lugares que divergiriam na primeira mudança de regra, e nenhum teste pegaria a divergência.
// packages/core/src/subscription/access.ts
/**
* Retira o acesso pago. Sem carência, sem agendamento, sem janela de tolerância.
* Chamada SEMPRE dentro da transação que processa o fato que causou a perda.
*/
export async function revokePaidAccess(
tx: Tx,
subscriberId: string,
reason: EndReason,
): Promise<void> {
await tx.subscriber.update({ where: { id: subscriberId }, data: { tier: 'FREE' } });
await tx.subscriptionEvent.create({
data: { id: ulid(), subscriberId, type: 'PAID_ACCESS_REVOKED', reason },
});
await invalidateEntitlementCache(tx, subscriberId);
tx.afterCommit(() => enqueueAccessRevokedMessage(subscriberId, reason));
}
/**
* Concede ou prorroga o acesso pago até `periodEnd`.
* Idempotente: chamar duas vezes com o mesmo `periodEnd` não produz efeito adicional.
*/
export async function grantPaidAccess(
tx: Tx,
subscriberId: string,
periodEnd: Date,
): Promise<void> {
await tx.subscriber.update({ where: { id: subscriberId }, data: { tier: 'PAID' } });
await tx.subscriptionEvent.create({
data: { id: ulid(), subscriberId, type: 'PAID_ACCESS_GRANTED', paidAccessUntil: periodEnd },
});
await invalidateEntitlementCache(tx, subscriberId);
}Efeitos de revokePaidAccess(), na ordem exata, e esta lista é a única do documento:
| # | Efeito | Momento |
|---|---|---|
| 1 | subscribers.tier = 'FREE' |
Mesma transação |
| 2 | Linha em subscription_events com type = 'PAID_ACCESS_REVOKED' e o motivo |
Mesma transação |
| 3 | Cache de entitlements invalidado | Mesma transação |
| 4 | Frequência cai para o dia único do plano gratuito, áudio desligado, acervo limitado a 7 dias, reenvios limitados a 1 por dia | Imediato, porque são derivados da função de 13.6.2 |
| 5 | Envios do lote de hoje ainda não disparados deixam de levar áudio | No disparo, pela revalidação obrigatória descrita abaixo |
| 6 | Mensagem de perda de acesso enfileirada | Após o commit |
O passo 4 não precisa de escrita nenhuma: resolveEntitlements deriva tudo do estado, então
mudar o estado muda o comportamento no mesmo instante. É por isso que a lista é curta.
13.3.2 A revogação alcança o lote já planejado #
O motor de envio planeja o lote do dia às 05:40 e dispara a partir das 06:00 (Seção 18). Entre
esses dois momentos há de vinte minutos a mais de meia hora, e o plano diário congela o tier de
cada assinante em delivery_attempts.tier_at_send.
tier_at_send é registro histórico, nunca autoridade. Imediatamente antes de chamar o
provedor de mensagens, e dentro do mesmo job, o motor relê subscribers.tier, opt_out_at,
deleted_at e blocked_at por chave primária e aplica o resultado na hora (Seção 18.5). Um
assinante planejado como PAID às 05:40 cujo PAYMENT_OVERDUE for processado às 05:52 recebe,
às 06:00, apenas o texto do plano gratuito, sem áudio, com
delivery_attempts.downgraded_at preenchido e tier_at_send_effective = 'FREE'.
Isso não é um detalhe de implementação do motor: é o que torna a regra desta seção verdadeira. Sem a releitura, o congelamento das 05:40 concederia na prática um dia de carência a todo inadimplente, todos os dias — exatamente o que os cinco itens acima proíbem. A mesma revalidação vale para as varreduras de reenvio que rodam até 23:55.
13.3.3 O caminho de código da inadimplência #
// packages/core/src/subscription/apply-overdue.ts
import { revokePaidAccess } from './access';
export async function applyOverdue(tx: Tx, payment: Payment): Promise<void> {
// Sem carência: a revogação é parte da MESMA transação do processamento do evento.
await tx.subscription.update({
where: { id: payment.subscriptionId! },
data: { status: 'EXPIRED', endedAt: new Date(), endReason: 'NON_PAYMENT', cancelAtPeriodEnd: false },
});
await revokePaidAccess(tx, payment.subscriberId, 'NON_PAYMENT');
// Não existe agendamento de rebaixamento futuro. Não existe janela de tolerância.
}E o teste que impede a regra de ser afrouxada por engano:
it('revoga o acesso na mesma transação, sem qualquer carência', async () => {
const { subscriber, subscription } = await seedActivePaidSubscriber();
await processAsaasEvent(await seedEvent('PAYMENT_OVERDUE', subscription));
const after = await db.subscriber.findUniqueOrThrow({ where: { id: subscriber.id } });
expect(after.tier).toBe('FREE');
expect(resolveEntitlements(await loadSubject(subscriber.id), new Date()).effectiveTier).toBe('FREE');
// Nenhum job de rebaixamento agendado: a lista precisa estar vazia nas três filas de cobrança.
for (const queue of [billingWebhookQueue, billingReconcileQueue, billingLifecycleQueue]) {
expect(await queue.getDelayed()).toHaveLength(0);
}
});E o teste que prova que a revogação alcança o lote já planejado, que é o caso que o teste acima não cobre:
it('rebaixa no disparo quem perdeu o acesso depois do planejamento', async () => {
const { subscriber, subscription } = await seedActivePaidSubscriber();
await planDailyBatch({ at: at('05:40') }); // congela tier_at_send = PAID
await processAsaasEvent(await seedEvent('PAYMENT_OVERDUE', subscription), { at: at('05:52') });
await dispatchDailyBatch({ at: at('06:00') });
const attempt = await db.deliveryAttempt.findFirstOrThrow({ where: { subscriberId: subscriber.id } });
expect(attempt.tierAtSend).toBe('PAID'); // histórico preservado
expect(attempt.tierAtSendEffective).toBe('FREE'); // o que de fato saiu
expect(attempt.downgradedAt).not.toBeNull();
expect(providerSpy.calls.filter((c) => c.type === 'audio')).toHaveLength(0);
});O que é permitido e não contradiz a regra: lembretes de cobrança antes do vencimento (13.5). Lembrar alguém de pagar não é dar prazo depois do vencimento.
13.4 Inadimplência versus cancelamento voluntário #
Estes dois casos são confundidos com frequência e produzem decisões erradas. Eles são diferentes na causa, no efeito e na data em que o acesso termina.
13.4.1 Definições #
(a) Inadimplência — o pagamento do ciclo não ocorreu. O assinante não tem crédito
adquirido para o período à frente. O acesso cai imediatamente, no momento do
PAYMENT_OVERDUE. Estado: EXPIRED.
(b) Cancelamento voluntário — o assinante já pagou o ciclo corrente e pede para não
renovar. Ele comprou aquele período e tem direito a ele. O acesso continua até
current_period_end e só então cai para FREE. Estado: CANCELED até o fim do período,
depois EXPIRED.
O ponto que precisa ficar claro: (b) não é carência. Carência seria dar acesso a um período não pago. Em (b) o período está pago; entregar o que foi vendido é obrigação contratual, não tolerância.
13.4.2 Exemplo numérico (a) — inadimplência PIX #
Assinante mensal PIX, R$ 19,90, ciclo vigente de 10/08/2026 a 10/09/2026.
Cobrança do ciclo seguinte: dueDate = 2026-09-10 (quinta-feira). Ele não paga.
2026-09-07 (seg) 2026-09-09 (qua) 2026-09-10 (qui) 2026-09-11 (sex) 2026-09-13 (dom)
│ │ │ │ │
lembrete D-3 lembrete D-1 lembrete D0 00:07 webhook 06:00 envio
06:30 WhatsApp 06:30 WhatsApp 09:00 WhatsApp PAYMENT_OVERDUE SEMANAL (texto,
│ │ │ → EXPIRED sem áudio)
│ │ │ → tier = FREE │
═══════╪══════════════════╪═════════════════╪══════════════════╪═════════════════════╪══════
ACESSO PAGO (diário + áudio) ───────────────────────────────► │ ACESSO GRATUITO ──►
06:00 de 11/09 (sex):
NADA é enviado.Números: o acesso pago termina em 11/09 às 00:07, não em 10/09 às 23:59 e não em 17/09. O devocional de 11/09 (sexta) não é enviado. O primeiro devocional que ele recebe depois disso é o de domingo, 13/09, apenas texto. Ele não recebe áudio a partir de 11/09.
13.4.3 Exemplo numérico (b) — cancelamento voluntário no cartão #
Assinante mensal no cartão, R$ 19,90, cobrança confirmada em 10/08/2026, ciclo de 10/08 a 10/09. Em 22/08/2026 às 21:14 ele cancela pelo painel.
2026-08-10 2026-08-22 21:14 2026-09-10 23:59:59 2026-09-11 00:05
│ │ │ │
pagamento cancelamento pedido fim do período pago job billing.lifecycle
confirmado status = CANCELED (current_period_end) → EXPIRED, tier FREE
│ cancel_at_period_end=true │ │
│ DELETE na Asaas │ │
═════╪═══════════════════╪══════════════════════════╪══════════════════════╪═══════════
ACESSO PAGO: diário + áudio, sem interrupção ──────────────────────────► │ GRATUITO ►
próximo envio:
domingo 13/09Números: ele continua recebendo devocional diário com áudio de 22/08 até 10/09, inclusive — 20 dias após pedir o cancelamento. Não há nova cobrança em 10/09 porque a assinatura foi removida na Asaas em 22/08. Em 11/09 às 00:05 ele passa a FREE.
13.4.4 Comparação lado a lado #
| (a) Inadimplência | (b) Cancelamento voluntário | |
|---|---|---|
| Causa | Pagamento não ocorreu | Assinante pediu para não renovar |
| Ciclo corrente | Não pago | Pago |
| Estado | EXPIRED |
CANCELED, depois EXPIRED |
| Tier no ato | FREE imediato |
PAID até current_period_end |
| Quem dispara | Webhook PAYMENT_OVERDUE |
Ação no painel |
| Data de término | Momento do webhook | current_period_end |
| Cobrança futura | Não existe | Removida na Asaas no ato |
| É carência? | Não — não há tolerância nenhuma | Não — é período já comprado |
| Mensagem | Perda de acesso + reativar | Confirmação com a data de término |
end_reason |
NON_PAYMENT |
USER_REQUEST |
13.4.5 O caso híbrido #
Um assinante CANCELED cujo cartão sofre estorno antes do fim do período pago perde o acesso
na hora (T15). O crédito deixou de existir, então o direito ao período também. Isso reforça a
regra: o acesso em CANCELED existe porque o dinheiro foi pago, não porque o assinante pediu
gentilmente.
13.4.6 Opt-out não cancela a assinatura, mas suspende a cobrança #
Terceiro caso, distinto dos dois anteriores e frequentemente confundido com eles: o assinante
pago escreve SAIR no WhatsApp, ou desliga o recebimento pelo painel. Os envios param na hora
(Seção 20.3). A pergunta que precisa de resposta escrita é: a cobrança continua?
A regra completa, e ela é obrigatória:
- O opt-out não cancela a assinatura de imediato. Um
SAIRacidental — e eles acontecem, porque a palavra chega por engano, por dedo errado, por criança com o telefone — não pode destruir uma contratação. - O opt-out suspende a cobrança do ciclo seguinte. Na mesma transação que grava
opt_out_at, a assinatura recebecancel_at_period_end = trueebilling_suspended_at = now(), e a cobrança futura é removida na Asaas comDELETE /v3/subscriptions/{id}. O período já pago segue valendo, como em qualquer cancelamento voluntário (13.4.1 caso b). As duas colunas não são redundantes e a relação entre elas é a declarada na Seção 6.11:cancel_at_period_endé o sinalizador booleano, verdadeiro em qualquer cancelamento voluntário e também no opt-out;billing_suspended_até o carimbo de quando o sinalizador foi ligado por opt-out, e permanece nulo quando a origem foi cancelamento voluntário. - O assinante recebe, no ato, uma confirmação com o conteúdo obrigatório: "Paramos de enviar. Sua próxima cobrança está suspensa. Se quiser voltar, é só responder VOLTAR; se quiser encerrar de vez, cancele no painel." O canal dessa confirmação é o da Seção 20.4.1 e não admite variação: opt-out pedido pelo WhatsApp é confirmado por uma única mensagem no WhatsApp e nada mais; opt-out pedido pelo painel ou pelo link de descadastro é confirmado na tela e por e-mail, quando houver e-mail verificado, e não gera mensagem no WhatsApp — quem pediu silêncio no WhatsApp por um canal que não é o WhatsApp não deve receber mais uma mensagem no WhatsApp.
- Se em 30 dias não houver reativação do recebimento, a assinatura é encerrada ao fim do
período já pago, com aviso, e o acesso ao acervo permanece até essa data. Quem executa é o
job
billing.lifecycle. - O painel exibe, nesse intervalo, o estado "envios pausados, cobrança suspensa", com as duas ações possíveis lado a lado: voltar a receber, ou cancelar de vez.
- Reativar o recebimento dentro dos 30 dias não ressuscita sozinho a cobrança: o assinante volta a receber com o acesso pago que já comprou, e o painel pergunta explicitamente se ele quer retomar a renovação automática. Retomar é uma ação dele, com um clique registrado.
Motivo registrado, porque a decisão parece burocrática e não é: a base legal da entrega é o consentimento específico do titular para tratamento de dado sensível. Revogado o consentimento, não é lícito prestar o serviço — e cobrar por um serviço que a lei nos proíbe de prestar é, ao mesmo tempo, infração ao Código de Defesa do Consumidor e o tipo de caso que vira reclamação pública. Manter a cobrança rodando enquanto os envios estão parados foi o buraco que esta regra fecha.
Isto não é carência e não contradiz a Seção 13.3: nada aqui concede acesso a período não pago. O que a regra faz é parar de cobrar por período futuro que não poderá ser entregue.
13.5 Cobrança e lembretes antes do vencimento #
Lembretes existem para reduzir inadimplência involuntária. Eles acontecem antes do vencimento, nunca depois. Nenhum lembrete concede acesso, adia revogação ou altera estado.
| Momento | Quando dispara | Público | Canal | Conteúdo |
|---|---|---|---|---|
| D-3 | 3 dias antes de due_date, 06:30 |
PIX | WhatsApp (janela aberta) ou template | Valor, data, código copia-e-cola em mensagem separada |
| D-1 | 1 dia antes, 06:30 | PIX | Valor, data ("amanhã"), código copia-e-cola | |
| D0 | dia do vencimento, 09:00 | PIX | Aviso de vencimento hoje + aviso de que o acesso termina amanhã | |
| D-5 | 5 dias antes, 10:00 | Cartão, só quando o cartão vence no mês | WhatsApp + e-mail se verificado | Pedido de atualização do cartão com link do painel |
| D-3 anual | 3 dias antes da renovação anual | Cartão e PIX, plano anual | WhatsApp + e-mail | Aviso de renovação com valor e data |
Textos (a redação final e as variações são da Seção 19; aqui está o conteúdo obrigatório):
- D-3 PIX: "Sua assinatura do Palavra Diária vence em 10/09. Valor: R$ 19,90. Pague pelo PIX com o código da próxima mensagem." Seguida de uma mensagem contendo apenas o copia-e-cola.
- D-1 PIX: "Sua assinatura vence amanhã, 10/09. Valor: R$ 19,90. Código PIX na próxima mensagem."
- D0 PIX: "Hoje é o último dia para pagar sua assinatura (R$ 19,90). Sem o pagamento, seu acesso ao plano pago termina amanhã." Seguida do copia-e-cola.
- D-5 cartão vencendo: "Seu cartão final 8829 vence este mês. Atualize em app.palavradiaria.com.br/assinatura para não perder o acesso."
- D-3 renovação anual: "Sua assinatura anual do Palavra Diária renova em 10/09 por R$ 199,00. Se quiser mudar ou cancelar, acesse app.palavradiaria.com.br/assinatura."
Regras de execução:
- O job
billing.payment-remindersroda na filabilling.lifecycle, todos os dias às 06:25 e às 08:55, consultandopaymentscomstatus = 'PENDING'edue_datenos deslocamentos previstos. - Existe um único template de lembrete de pagamento:
lembrete_pagamento_v1. Os cinco momentos da tabela acima são o mesmo template com parâmetros diferentes, distinguidos pela colunamessage_logs.message_key(dunning_d3,dunning_d1,dunning_d0,card_expiry_d5,annual_renewal_d3). Não se cria um template por estágio: cada template novo exige aprovação da Meta, amplia a superfície de recategorização e é mais uma coisa que pode ficarPAUSEDna véspera do vencimento. Os oito templates do produto estão na Seção 17.5 e nenhuma seção acrescenta um nono. - Idempotência: chave
reminder:{paymentId}:{offset}gravada emdelivery_attempts, com índice único. Um reprocessamento não reenvia. - Um assinante recebe no máximo 3 lembretes por cobrança. Cobranças reagendadas
(
PAYMENT_UPDATEDmudandodueDate) recalculam os deslocamentos, mas a chave de idempotência inclui o offset, então D-3 já enviado não repete. - Lembretes respeitam opt-out e pausa: quem fez opt-out (Seção 20.3) não recebe lembrete pelo WhatsApp. Recebe por e-mail se tiver e-mail verificado; se não tiver, não recebe nada e a cobrança segue seu curso. Isso é consequência aceita do opt-out.
- Lembretes não são enviados para assinaturas
CANCELED, porque não haverá nova cobrança.
Declaração explícita, para não restar dúvida: lembretes não são carência. Eles antecedem o vencimento. Depois do vencimento não há mensagem de cobrança nem prazo extra — há a mensagem de perda de acesso, e o acesso já foi revogado quando ela é enviada.
13.6 Matriz de entitlements e a função que a resolve #
13.6.1 A matriz #
Esta é a fonte única da verdade sobre o que cada tier recebe. Toda outra seção referencia
esta subseção e não redefine nenhuma linha. Onde outra seção precisar do limite de reenvios,
da frequência de envio ou do tamanho do acervo, ela chama resolveEntitlements() e usa o
campo correspondente.
| Capacidade | Campo | FREE | PAID |
|---|---|---|---|
| Frequência do devocional | sendFrequency |
WEEKLY_SUNDAY — domingo, 06:00 |
DAILY — todos os dias, 06:00 |
| Texto completo do devocional | fullTextEnabled |
true |
true |
| Áudio narrado | audioEnabled |
false |
true |
| Acervo no painel web | archiveWindowDays |
7 (últimos 7 dias) |
null (completo, desde a assinatura) |
| Reenvio manual do devocional do dia | manualResendsPerDay |
1 |
3 |
| Suporte | supportChannel |
FAQ_EMAIL |
EMAIL_SLA_1_BUSINESS_DAY |
Notas que evitam interpretação livre:
WEEKLY_SUNDAYusa o dia configurado na chave de configuraçãosend.free_tier_weekday, cujo valor padrão é0(domingo). A chave segue o formatogrupo.chavedo catálogo da Seção 26.8.1 e é lida desettings, nunca de variável de ambiente —FREE_TIER_SEND_WEEKDAYnão existe. A chave existe para permitir mudança operacional sem deploy; o padrão é domingo.archiveWindowDays = 7significa devocionais comscheduled_for >= hoje − 7 dias, contados em America/Sao_Paulo, independentemente de terem sido enviados a ele. O limite é imposto no servidor, na própria consulta do acervo, e não na montagem da tela: um assinante gratuito que chame a rota de listagem diretamente, com o maior limite aceito e percorrendo todos os cursores, recebe no máximo os 7 dias mais recentes, e a leitura de um devocional fora da janela devolve403. Filtrar só na interface deixaria o acervo pago inteiro acessível a qualquer pessoa com um cliente HTTP.archiveWindowDays = nullsignifica desde a data da primeira assinatura paga do assinante, não desde o cadastro. Quem foi pago, deixou de ser e voltou, vê tudo desde a primeira vez.manualResendsPerDayconta reenvios por dia de calendário local, zerando à meia-noite de America/Sao_Paulo.
13.6.2 A função #
// packages/core/src/entitlements.ts
export type Tier = 'FREE' | 'PAID';
export type SendFrequency = 'DAILY' | 'WEEKLY_SUNDAY';
export type SupportChannel = 'FAQ_EMAIL' | 'EMAIL_SLA_1_BUSINESS_DAY';
export type TierSource = 'SUBSCRIPTION' | 'COURTESY' | 'NONE';
/** Tudo de que a resolução precisa. Nenhuma consulta acontece dentro da função. */
export interface EntitlementSubject {
subscriberId: string;
optInConfirmedAt: Date | null;
optOutAt: Date | null;
pausedUntil: Date | null;
deletedAt: Date | null;
courtesyUntil: Date | null;
subscription: {
status: 'PENDING_PAYMENT' | 'ACTIVE' | 'CANCELED' | 'EXPIRED' | 'REFUNDED';
currentPeriodEnd: Date | null;
} | null;
}
export interface Entitlements {
effectiveTier: Tier;
tierSource: TierSource;
paidAccessUntil: Date | null;
sendFrequency: SendFrequency;
fullTextEnabled: boolean;
audioEnabled: boolean;
archiveWindowDays: number | null;
manualResendsPerDay: number;
supportChannel: SupportChannel;
canReceiveMessages: boolean;
blockedReason: 'NOT_OPTED_IN' | 'OPTED_OUT' | 'PAUSED' | 'DELETED' | null;
}
const FREE: Omit<Entitlements, 'effectiveTier' | 'tierSource' | 'paidAccessUntil' | 'canReceiveMessages' | 'blockedReason'> = {
sendFrequency: 'WEEKLY_SUNDAY',
fullTextEnabled: true,
audioEnabled: false,
archiveWindowDays: 7,
manualResendsPerDay: 1,
supportChannel: 'FAQ_EMAIL',
};
const PAID: typeof FREE = {
sendFrequency: 'DAILY',
fullTextEnabled: true,
audioEnabled: true,
archiveWindowDays: null,
manualResendsPerDay: 3,
supportChannel: 'EMAIL_SLA_1_BUSINESS_DAY',
};
/**
* Fonte única da verdade dos entitlements. Pura e determinística:
* mesma entrada + mesmo `now` produzem sempre a mesma saída.
*/
export function resolveEntitlements(subject: EntitlementSubject, now: Date): Entitlements {
const sub = subject.subscription;
// Acesso pago por assinatura: ACTIVE, ou CANCELED com período pago ainda vigente.
const subscriptionGrants =
!!sub &&
(sub.status === 'ACTIVE' || sub.status === 'CANCELED') &&
!!sub.currentPeriodEnd &&
sub.currentPeriodEnd.getTime() > now.getTime();
// Acesso pago por cortesia concedida pelo admin (13.11).
const courtesyGrants =
!!subject.courtesyUntil && subject.courtesyUntil.getTime() > now.getTime();
const isPaid = subscriptionGrants || courtesyGrants;
const base = isPaid ? PAID : FREE;
const tierSource: TierSource = subscriptionGrants ? 'SUBSCRIPTION' : courtesyGrants ? 'COURTESY' : 'NONE';
const paidAccessUntil = subscriptionGrants && courtesyGrants
? new Date(Math.max(sub!.currentPeriodEnd!.getTime(), subject.courtesyUntil!.getTime()))
: subscriptionGrants ? sub!.currentPeriodEnd
: courtesyGrants ? subject.courtesyUntil
: null;
// Elegibilidade de envio é ortogonal ao tier: um pagante em opt-out não recebe nada.
let blockedReason: Entitlements['blockedReason'] = null;
if (subject.deletedAt) blockedReason = 'DELETED';
else if (subject.optOutAt) blockedReason = 'OPTED_OUT';
else if (subject.pausedUntil && subject.pausedUntil.getTime() > now.getTime()) blockedReason = 'PAUSED';
else if (!subject.optInConfirmedAt) blockedReason = 'NOT_OPTED_IN';
return {
...base,
effectiveTier: isPaid ? 'PAID' : 'FREE',
tierSource,
paidAccessUntil,
canReceiveMessages: blockedReason === null,
blockedReason,
};
}13.6.3 Como as outras seções usam #
Regras de uso, obrigatórias:
- Nunca leia
subscribers.tierpara decidir comportamento. Essa coluna é um cache materializado, mantido porrecomputeSubscriptionState(Seção 12.12.4), e existe apenas para filtrar consultas em massa no motor de envio (WHERE tier = 'PAID'). A decisão final de cada envio chamaresolveEntitlements. - Nunca escreva
if (subscription.status === 'ACTIVE')fora depackages/core. Isso esquece cortesia e esqueceCANCELEDcom período vigente. - Nunca replique os números da tabela de 13.6.1 em outra seção, em outro arquivo, em outra query ou em um componente de UI. Leia do resultado da função.
- O
nowé sempre injetado. Testes usam relógio fixo. Nenhuma chamada anew Date()dentro da função.
Consumidores diretos, para referência: motor de envio diário (Seção 18) usa sendFrequency,
audioEnabled e canReceiveMessages; painel do assinante (Seção 14) usa archiveWindowDays,
manualResendsPerDay, effectiveTier e paidAccessUntil; catálogo de mensagens (Seção 19)
usa effectiveTier para escolher variantes de texto; dashboard (Seção 21) agrega
effectiveTier para métricas.
13.6.4 Exemplos resolvidos #
| Cenário | subscription |
courtesyUntil |
Saída |
|---|---|---|---|
| Cadastro sem assinatura | null |
null |
FREE, NONE, domingo, sem áudio, acervo 7 dias, 1 reenvio |
| Pago em dia, ciclo até 10/09 | ACTIVE, fim 10/09 |
null |
PAID, SUBSCRIPTION, paidAccessUntil = 10/09 |
| Cancelou em 22/08, ciclo até 10/09, hoje 05/09 | CANCELED, fim 10/09 |
null |
PAID, SUBSCRIPTION — ainda tem acesso |
| Mesmo caso, hoje 11/09 | CANCELED, fim 10/09 |
null |
FREE, NONE |
| Inadimplente hoje | EXPIRED, fim 10/09 |
null |
FREE, NONE — mesmo com currentPeriodEnd no futuro, EXPIRED nunca concede |
| Cortesia de 30 dias, sem assinatura | null |
25/09 | PAID, COURTESY, paidAccessUntil = 25/09 |
| Pago até 10/09 e cortesia até 25/09 | ACTIVE, fim 10/09 |
25/09 | PAID, SUBSCRIPTION, paidAccessUntil = 25/09 (o maior) |
| Pagante que fez opt-out | ACTIVE |
null |
PAID, mas canReceiveMessages = false, blockedReason = OPTED_OUT |
| Pagante em pausa até 30/09 | ACTIVE |
null |
PAID, canReceiveMessages = false, blockedReason = PAUSED |
A quinta linha merece destaque: EXPIRED nunca concede acesso, mesmo que
currentPeriodEnd esteja no futuro. Isso é o que torna a regra de 13.3 inviolável por
acidente — a função nem consulta a data quando o status é EXPIRED.
13.7 Upgrade de gratuito para pago #
13.7.1 Fluxo #
- Assinante FREE autenticado acessa
app.palavradiaria.com.br/assinar. - Escolhe plano (
plan_monthlyouplan_annual) e forma de pagamento (CREDIT_CARDouPIX). - Informa nome completo e CPF (Seção 12.5), aceita os termos, e o consentimento é gravado em
consent_events. POST /api/me/subscriptioncria a linha emPENDING_PAYMENTe a assinatura na Asaas.- Cartão: confirmação em segundos, T2 dispara, tier vira
PAID. PIX: o assinante paga, o webhook chega, T2 dispara. - Mensagem de boas-vindas do plano pago é enviada.
13.7.2 Prorrateio: não existe #
Decisão registrada: o MVP não faz prorrateio. O ciclo começa na confirmação do pagamento, não na data do pedido nem em um dia fixo do mês.
Justificativa: o assinante FREE não paga nada, então não há valor a compensar. E como não há mudança de plano com preços diferentes dentro do mesmo ciclo (ver 13.8), não há o segundo caso clássico de prorrateio. Implementar o cálculo custaria complexidade sem beneficiar ninguém.
Consequência concreta: quem confirma o pagamento em 22/08/2026 às 14:37 tem
current_period_start = 2026-08-22T17:37:00Z e
current_period_end = 2026-09-22T17:37:00Z no plano mensal. O ciclo é ancorado no instante da
confirmação, com precisão de segundo, e a soma usa date-fns addMonths / addYears, que
tratam corretamente meses curtos: confirmação em 31/01 gera término em 28/02 (ou 29/02 em ano
bissexto).
13.7.3 O devocional do dia #
Pergunta concreta: o assinante virou PAID às 14:37; o devocional das 06:00 de hoje já foi enviado como FREE, ou nem foi enviado porque hoje é terça. O que ele recebe?
Decisão: ao confirmar o primeiro pagamento, o sistema entrega o devocional do dia imediatamente, com áudio, fora do horário normal. Regras:
- Só acontece na primeira ativação de uma assinatura (
PENDING_PAYMENT→ACTIVEde uma linha nova), nunca em renovação. - Só acontece se existir devocional
PUBLISHEDcomscheduled_forigual à data local de hoje. - Se o assinante já recebeu o texto hoje (era domingo, tier FREE), o sistema envia apenas o áudio e a mensagem de fechamento, sem repetir o texto.
- Se ele não recebeu nada hoje, envia o pacote completo: texto, áudio e fechamento.
- Se a janela de atendimento de 24 h não estiver aberta, o envio segue a mesma mecânica de template e janela usada pelo motor de envio (Seção 18 e Seção 17); a confirmação de pagamento no painel já pede que o assinante responda no WhatsApp.
- Se o áudio do dia ainda não estiver pronto (
AUDIO_PENDING), o texto vai na hora e o áudio é enfileirado para assim que ficar pronto, com prazo máximo de 2 horas; passado isso, desiste e o assinante recebe normalmente no dia seguinte. - Idempotência: chave
welcome-send:{subscriptionId}emdelivery_attempts.
Exemplo: assinante FREE paga na terça-feira, 25/08/2026, às 14:37. Terça não é dia de envio FREE, então ele não recebeu nada. Às 14:39 ele recebe texto + áudio do devocional de 25/08. No dia 26/08 às 06:00 entra no fluxo diário normal.
13.7.4 Endpoint #
POST /api/me/subscription
- Autenticação: sessão de assinante. Papel:
SUBSCRIBER. - Request:
export const createSubscriptionSchema = z.object({
planCode: z.enum(['plan_monthly', 'plan_annual']),
billingType: z.enum(['CREDIT_CARD', 'PIX']),
holderName: z.string().trim().min(3).max(100)
.regex(/^[\p{L}][\p{L}'\- ]+ [\p{L}'\- ]+$/u, 'Informe nome e sobrenome.'),
taxId: taxIdSchema,
cardToken: z.string().uuid().optional(),
consentVersion: z.string().min(1),
}).refine((v) => v.billingType !== 'CREDIT_CARD' || !!v.cardToken, {
path: ['cardToken'], message: 'Token do cartão é obrigatório para pagamento com cartão.',
});- Resposta
200(PIX):
{
"data": {
"subscriptionId": "sub_01K3QB2D4F6H8K0M2P4R6T8V0X",
"status": "PENDING_PAYMENT",
"planCode": "plan_monthly",
"billingType": "PIX",
"amountCents": 1990,
"nextDueDate": "2026-08-25",
"pix": {
"paymentId": "pay_000000098765",
"copyPasteCode": "00020126580014BR.GOV.BCB.PIX...6304AB12",
"qrCodeImage": "data:image/png;base64,iVBORw0KGgo...",
"expiresAt": "2026-08-26T02:59:59.000Z"
}
},
"meta": { "requestId": "req_01K3QB4H6K8M0P2R4T6V8X0Z2B", "timestamp": "2026-08-25T17:37:04.881Z" }
}- Erros:
UNAUTHENTICATED(401),SUBSCRIPTION_ALREADY_ACTIVE(409),SUBSCRIBER_NOT_OPTED_IN(409),PLAN_NOT_FOUND(404),PLAN_INACTIVE(409),TAX_ID_INVALID(422),TAX_ID_MUST_BE_CPF(422),TAX_ID_REJECTED_BY_PROVIDER(422),CARD_TOKEN_INVALID(422),CARD_DECLINED(422),CARD_BILLING_BLOCKED(403, chargeback anterior — ver 12.14.2),CONSENT_VERSION_MISMATCH(409),RATE_LIMITED(429, 5 tentativas por hora por assinante),PAYMENT_PROVIDER_UNAVAILABLE(503). - Efeitos colaterais: cria/atualiza
customerna Asaas; criasubscriptions; criasubscription_eventsCREATED; gravaconsent_events; gravasubscriber_profiles. - Idempotência: o advisory lock por assinante mais o índice único parcial de assinatura
vigente garantem que duas chamadas simultâneas produzam uma criação e um
409. Uma chamada repetida depois de sucesso devolve409 SUBSCRIPTION_ALREADY_ACTIVEcom o id existente emerror.details, e o cliente redireciona para o painel.
13.8 Troca entre mensal e anual #
13.8.1 Regra exata #
A troca de ciclo é sempre agendada para o fim do ciclo pago. Nunca é imediata. Nunca gera cobrança extra. Nunca gera crédito.
O assinante pede a troca a qualquer momento. O sistema grava a intenção em
subscriptions.pending_plan_id e pending_plan_effective_at = current_period_end, e não
altera nada na Asaas até a virada. No fim do ciclo:
- Cartão: o job
billing.apply-plan-change, na filabilling.lifecycle, às 00:10, chamaPOST /v3/subscriptions/{id}com o novocycleevalue. A próxima cobrança automática já sai no valor novo. - PIX: mesma chamada; a próxima cobrança gerada pela Asaas já vem com o novo valor.
O assinante pode cancelar a troca agendada enquanto ela não for aplicada.
Justificativa da decisão: troca imediata exigiria prorrateio (crédito do período não usado do plano antigo contra o novo), que o MVP não tem (13.7.2). E a Asaas não faz esse cálculo por nós. Agendar para a virada resolve com zero aritmética e zero risco de cobrança errada.
13.8.2 Exemplos numéricos #
Mensal → anual. Ciclo mensal de 10/08 a 10/09, R$ 19,90 já pago. Em 22/08 ele pede o anual. Nada muda em agosto. Em 10/09 a Asaas cobra R$ 199,00 e o novo ciclo vai de 10/09/2026 a 10/09/2027. Ele não paga R$ 19,90 + R$ 199,00; paga só R$ 199,00.
Anual → mensal. Ciclo anual de 10/09/2026 a 10/09/2027, R$ 199,00 pago. Em 03/01/2027 ele pede o mensal. Ele continua com acesso pago até 10/09/2027 — os 8 meses restantes que já comprou. Em 10/09/2027 a cobrança sai R$ 19,90 e o ciclo passa a ser mensal. Não há devolução proporcional dos meses restantes, porque não há troca antecipada.
Assinante quer o anual agora, não em setembro. Resposta do produto: ele cancela a
assinatura mensal (T11) e contrata a anual imediatamente. O acesso pago continua sem
interrupção até current_period_end da mensal; a anual começa na confirmação do pagamento e
os dois períodos se sobrepõem. Nesse caso ele paga por uma sobreposição de até 30 dias. O
painel exibe esse aviso, literalmente, antes de confirmar: "Você já tem acesso pago até
10/09. Se contratar agora, o plano anual começa hoje e você paga pelo período que se
sobrepõe." Decisão consciente: transparência em vez de cálculo de crédito.
Observação técnica: essa sobreposição é a única situação em que uma segunda assinatura é
criada com uma anterior ainda em CANCELED. O índice único de 13.12.5 permite isso porque
cobre apenas ACTIVE e PENDING_PAYMENT; a CANCELED não bloqueia.
13.8.3 Endpoint #
POST /api/me/subscription/plan-change
- Autenticação: sessão de assinante. Papel:
SUBSCRIBER. - Request:
export const planChangeSchema = z.object({
targetPlanCode: z.enum(['plan_monthly', 'plan_annual']),
});- Resposta
200:
{
"data": {
"subscriptionId": "sub_01K3QB2D4F6H8K0M2P4R6T8V0X",
"currentPlanCode": "plan_monthly",
"pendingPlanCode": "plan_annual",
"effectiveAt": "2026-09-10T13:37:00.000Z",
"newAmountCents": 19900,
"message": "Sua troca para o plano anual acontece em 10/09/2026. Até lá, nada muda."
},
"meta": { "requestId": "req_01K3QC7K9M1P3R5T7V9X1Z3B5D", "timestamp": "2026-08-22T21:14:03.101Z" }
}- Erros:
UNAUTHENTICATED(401),SUBSCRIPTION_NOT_FOUND(404),SUBSCRIPTION_NOT_ACTIVE(409, quando o status não éACTIVE),PLAN_CHANGE_SAME_PLAN(409),PLAN_CHANGE_ALREADY_PENDING(409, com o pedido existente emdetails),PLAN_NOT_FOUND(404),PLAN_INACTIVE(409),RATE_LIMITED(429). - Efeitos colaterais: grava
pending_plan_idepending_plan_effective_at;subscription_eventsPLAN_CHANGE_SCHEDULED; envia mensagem de confirmação. - Idempotência: repetir com o mesmo
targetPlanCodedevolve200com o mesmo agendamento, sem criar novo evento. Com plano diferente, devolve409 PLAN_CHANGE_ALREADY_PENDING; para trocar, o assinante primeiro remove o agendamento.
DELETE /api/me/subscription/plan-change — remove o agendamento. Papel
SUBSCRIBER. Resposta 200 com { "data": { "canceled": true } }. Erros:
UNAUTHENTICATED (401), PLAN_CHANGE_NOT_PENDING (404). Idempotente: chamar duas vezes
devolve 404 na segunda, o que o cliente trata como sucesso.
13.9 Downgrade e rebaixamento #
Rebaixamento é a transição de PAID para FREE, por qualquer causa. O que muda, exatamente,
no instante em que ocorre:
| Item | Antes (PAID) | Depois (FREE) |
|---|---|---|
| Frequência | Diária | Domingo apenas |
| Áudio | Sim | Não |
| Acervo | Completo | Últimos 7 dias |
| Reenvios | 3/dia | 1/dia |
| Suporte | E-mail com SLA | FAQ e e-mail |
Regras de tratamento do que já existe:
- O acervo não é apagado. Os devocionais continuam no banco; a consulta do painel passa a
filtrar por
archiveWindowDays. Se o assinante voltar a ser PAID, o acervo inteiro reaparece, incluindo o período em que ele esteve FREE. Nada é perdido. - Áudios já entregues no WhatsApp continuam no aparelho do assinante. Não há como revogá-los e não tentamos.
- O player de áudio no painel deixa de funcionar para devocionais fora da janela. Tentar
acessar devolve
403 AUDIO_REQUIRES_PAID_PLAN, com mensagem "O áudio faz parte do plano pago." e botão para assinar. - Envios já enfileirados para hoje são cancelados se ainda não saíram. O motor de envio (Seção 18) revalida entitlements no momento do envio, imediatamente antes de chamar a API do WhatsApp. Um job enfileirado às 05:40 com o assinante PAID que é rebaixado às 05:52 não envia áudio às 06:00.
subscribers.tieré atualizado na mesma transação, para que a consulta em massa do motor de envio já veja o valor novo.- Contadores de reenvio do dia não são zerados. Quem já usou 2 reenvios como PAID e é rebaixado fica sem reenvio até a virada do dia, porque 2 ≥ 1.
Mensagem enviada no rebaixamento por inadimplência (texto definitivo na Seção 19): informa a perda, informa o que ele continua recebendo (domingo, texto) e oferece o link de reativação. Sem tom de cobrança e sem culpa.
13.10 Reativação depois de expirar #
13.10.1 Fluxo #
Reativar é contratar de novo. A linha antiga permanece imutável, com o histórico intacto, e
uma linha nova é criada (T18). O assinante usa a mesma rota POST /api/me/subscription de
13.7.4; o painel apenas apresenta o botão como "Reativar assinatura" quando existe assinatura
anterior encerrada.
O que é reaproveitado:
| Item | Reaproveitado? |
|---|---|
asaas_customer_id |
Sim, sempre. Nunca criamos segundo customer |
| Nome e CPF | Sim, pré-preenchidos; o assinante pode corrigir |
Cartão salvo (asaas_card_token) |
Não. O token fica preso à assinatura antiga; o assinante informa o cartão de novo |
| Opt-in do WhatsApp | Sim, se opt_in_confirmed_at estiver preenchido e não houver opt-out |
| Histórico de devocionais recebidos | Sim, integralmente |
| Acervo | Sim; volta a ser completo, incluindo o período FREE |
| Assinatura antiga | Não é alterada; fica como registro histórico |
13.10.2 O histórico é preservado #
Sim, e isso é explícito: nenhuma linha de subscriptions, payments, subscription_events,
payment_events, message_logs ou consent_events é apagada por causa de reativação. A
consulta de histórico no painel do assinante lista todas as assinaturas, com período e motivo
de encerramento. O painel administrativo mostra o mesmo, com mais detalhe.
13.10.3 Caso de borda: reativação com opt-out ativo #
Se o assinante fez opt-out de mensagens (Seção 20.3) e depois reativa a assinatura, o
pagamento é processado normalmente e o tier vira PAID, mas
canReceiveMessages continua false. O checkout exige que ele reative o recebimento
antes de concluir: a rota devolve 409 SUBSCRIBER_OPTED_OUT com a mensagem "Você pediu para
parar de receber mensagens. Reative o recebimento para assinar." e o painel mostra o botão de
reativação de mensagens. Decisão: cobrar por um serviço que não pode ser entregue seria erro
grave.
13.11 Concessão manual de acesso pago (cortesia) #
13.11.1 Como funciona #
Um ADMIN ou OWNER concede acesso pago por N dias sem cobrança. Casos de uso reais:
compensação por falha de entrega, parceria com liderança de igreja, teste de imprensa,
resolução de suporte.
Mecanismo: subscribers.courtesy_until timestamptz NULL. A função de 13.6.2 já considera esse
campo. Não há assinatura na Asaas, não há payments, não há cobrança, e nunca haverá
renovação automática.
Limites: 1 <= days <= 365. Concessões sucessivas estendem a data: conceder 30 dias a
quem tem courtesy_until daqui a 10 dias resulta em 40 dias a partir de hoje. Isso é
deliberado — o admin não precisa calcular.
13.11.2 Como expira #
Sem job. A expiração é uma consequência natural de courtesyUntil > now na função de
entitlements. O job billing.lifecycle das 00:05 apenas atualiza o cache
subscribers.tier e envia a mensagem de encerramento, mas mesmo que ele não rode, o acesso já
terá acabado na prática, porque toda decisão de envio chama a função.
Aviso ao assinante: 3 dias antes de courtesy_until, o job billing.courtesy-expiry-notice,
na fila billing.lifecycle, envia uma mensagem informando o fim e oferecendo assinatura. Só é enviada se o assinante não
tiver assinatura paga vigente, porque nesse caso o fim da cortesia não muda nada para ele.
13.11.3 Auditoria #
Toda concessão e toda revogação gravam:
admin_audit_log:admin_user_id,actor_type = 'ADMIN',actor_role(papel efetivo no momento da ação),action(COURTESY_GRANT|COURTESY_REVOKE),entity_type = 'subscriber',entity_id,metadatacomdays,previousCourtesyUntil,newCourtesyUntilereason. Os nomes das colunas são os da Seção 6.9, que é a dona do esquema deadmin_audit_log; esta seção apenas os consome.subscription_events:type(COURTESY_GRANTED|COURTESY_REVOKED),source = 'ADMIN'.
reason é obrigatório, de 10 a 500 caracteres. Sem justificativa, a rota devolve 422.
O painel administrativo tem um relatório "Cortesias vigentes" com total de assinantes,
valor equivalente não faturado (dias × R$ 19,90 ÷ 30, arredondado) e ranking de admins que
mais concederam nos últimos 90 dias. Isso existe porque cortesia é receita não realizada e
precisa ser visível.
13.11.4 Interação com assinatura real #
Cortesia e assinatura paga são independentes e podem coexistir. Regras:
- Conceder cortesia não cancela assinatura ativa e não interrompe cobrança. Se o
objetivo for parar de cobrar, cancele a assinatura — a rota de cortesia avisa isso na
resposta quando detecta assinatura ativa (campo
warning). paidAccessUntilé o maior entrecurrent_period_endecourtesy_until(13.6.2).- Se a assinatura for revogada por inadimplência (T7) enquanto a cortesia está vigente, o
assinante continua PAID por cortesia, com
tierSource = 'COURTESY'. A mensagem enviada nesse caso é diferente: informa a falha de pagamento mas esclarece que o acesso segue até a data da cortesia. Isso não é carência — é um acesso concedido por decisão comercial explícita, registrado e auditado. - Revogar a cortesia de quem tem assinatura ativa não tira o acesso: a assinatura continua concedendo.
13.11.5 Endpoints #
POST /api/admin/subscribers/{id}/courtesy
- Autenticação: sessão de admin com TOTP validado (Seção 8). Papel:
ADMINouOWNER.EDITORrecebe403. - Request:
export const grantCourtesySchema = z.object({
days: z.number().int().min(1).max(365),
reason: z.string().trim().min(10).max(500),
});- Resposta
200:
{
"data": {
"subscriberId": "sub_01K3Q8R7V2N4B6D8F0H2J4L6M8",
"previousCourtesyUntil": null,
"courtesyUntil": "2026-09-24T03:00:00.000Z",
"daysGranted": 30,
"effectiveTier": "PAID",
"tierSource": "COURTESY",
"warning": null
},
"meta": { "requestId": "req_01K3QD1M3P5R7T9V1X3Z5B7D9F", "timestamp": "2026-08-25T14:02:55.310Z" }
}Quando o assinante já tem assinatura ativa, warning vem preenchido:
"Este assinante tem assinatura ativa até 10/09/2026. A cortesia não interrompe a cobrança."
- Erros:
UNAUTHENTICATED(401),FORBIDDEN_ROLE(403),SUBSCRIBER_NOT_FOUND(404),SUBSCRIBER_DELETED(409),COURTESY_DAYS_OUT_OF_RANGE(422),COURTESY_REASON_REQUIRED(422),COURTESY_LIMIT_EXCEEDED(429, quando o resultado passaria de 365 dias a partir de hoje),IDEMPOTENCY_KEY_REQUIRED(400),IDEMPOTENCY_KEY_REUSED(422),RATE_LIMITED(429, 20 concessões por hora por admin). - Efeitos colaterais: atualiza
courtesy_until,subscribers.tier; gravaadmin_audit_logesubscription_events; agenda o aviso de D-3; envia mensagem de concessão ao assinante quando ele não era PAID antes. - Idempotência: não é idempotente por natureza — cada chamada estende o prazo. Por isso o
header
Idempotency-Keyé obrigatório aqui (Seção 7.7.1), como em toda escrita cara: chamada sem a chave devolve400 IDEMPOTENCY_KEY_REQUIRED; replay da mesma chave devolve a resposta gravada comIdempotent-Replay: true, sem estender o prazo de novo; a mesma chave com corpo diferente devolve422 IDEMPOTENCY_KEY_REUSED. As demais escritas caras desta seção — criação de assinatura, troca de plano e troca de cartão — seguem exatamente a mesma regra, pelo mesmo motivo: todas propagam uma chamada de escrita para a Asaas, e um duplo clique cobra duas vezes.
DELETE /api/admin/subscribers/{id}/courtesy
- Papel:
ADMINouOWNER. Request:{ "reason": string(10..500) }. - Resposta
200:{ "data": { "courtesyUntil": null, "effectiveTier": "FREE", "tierSource": "NONE" } }(ouPAID/SUBSCRIPTIONse houver assinatura ativa). - Erros:
UNAUTHENTICATED(401),FORBIDDEN_ROLE(403),SUBSCRIBER_NOT_FOUND(404),COURTESY_NOT_ACTIVE(404),COURTESY_REASON_REQUIRED(422). - Efeitos: zera
courtesy_until, recalcula tier, grava auditoria. Envia mensagem ao assinante apenas se ele perder o acesso pago com isso. - Idempotência: a segunda chamada devolve
404 COURTESY_NOT_ACTIVE, tratado como sucesso.
13.12 Casos de borda #
13.12.1 Webhook duplicado #
A Asaas reenvia o mesmo evento quando não recebe 2xx a tempo, e reenvios podem chegar depois
de o primeiro ter sido processado. Defesas, em três camadas (detalhe em 12.12.3): índice único
em payment_events.asaas_event_id, jobId do BullMQ igual ao id do evento, e processed_at
verificado sob FOR UPDATE.
Exemplo: PAYMENT_CONFIRMED do pay_000000098765 chega às 09:14:02 e de novo às 09:14:19. O
primeiro ativa a assinatura e envia a confirmação. O segundo grava nada (conflito no índice),
o job é descartado pelo jobId, e se ainda assim executar, sai no passo 1 do processador. O
assinante recebe uma mensagem, current_period_end é gravado uma vez, e o período não
avança duas vezes.
13.12.2 Webhook fora de ordem #
Exemplo real e desagradável: PAYMENT_OVERDUE (gerado 00:07) chega às 00:09, e
PAYMENT_CONFIRMED (gerado 00:05, de uma retentativa aprovada) chega às 00:11. Processados na
ordem de chegada, o resultado ingênuo seria EXPIRED e depois ACTIVE — correto por acaso.
Invertendo a chegada, o resultado ingênuo seria ACTIVE e depois EXPIRED — errado.
O sistema resolve com duas travas: o ranque de 12.4.4 impede que OVERDUE (10) sobrescreva
CONFIRMED (20); e recomputeSubscriptionState (12.12.4) deriva o estado dos fatos gravados,
não do último evento. O resultado é ACTIVE nas duas ordens de chegada.
Quando o OVERDUE é descartado por ranque, o sistema registra
WARN asaas_overdue_after_paid com os dois ids. Se esse aviso aparecer mais de 10 vezes por
dia, o alerta operacional dispara, porque pode indicar cobrança duplicada na Asaas.
13.12.3 Pagamento confirmado depois do cancelamento #
Cenário: assinante cancela em 22/08 (T11, CANCELED, DELETE na Asaas). Em 23/08 chega
PAYMENT_CONFIRMED de uma cobrança PIX que ele havia pago minutos antes do cancelamento.
Tratamento: o pagamento é legítimo e o dinheiro entrou. O sistema aplica PAYMENT_CONFIRMED
normalmente, avança current_period_end em um ciclo a partir da confirmação, e mantém
status = CANCELED com cancel_at_period_end = true. O assinante fica com acesso pago até o
novo current_period_end e depois cai para FREE. Ele recebe: "Recebemos seu pagamento de
R$ 19,90. Seu acesso segue até 23/09/2026, e sua assinatura não será renovada depois disso."
Isso é coerente com 13.4: ele pagou aquele ciclo, então tem direito a ele. Não é carência.
Se o assinante não quiser o período pago, ele pede reembolso pelo suporte, dentro da política de 12.14.1.
13.12.4 Assinatura na Asaas sem assinante correspondente #
Causas: assinatura criada manualmente no painel da Asaas; migração malfeita; nossa linha apagada por erro operacional.
Tratamento em três tentativas de vínculo, nesta ordem:
externalReferenceda assinatura casa com umsubscriptions.idexistente → vincula.customercasa comsubscribers.asaas_customer_id→ cria a linha local vinculada àquele assinante, emPENDING_PAYMENT, e deixa a recomputação decidir o status real.- Nada casa → grava
payment_events.processing_status = 'ORPHAN_SUBSCRIPTION', cria tarefa administrativa com o payload completo e emite alertabilling_orphan_subscription.
Em nenhuma hipótese o sistema cria um subscribers a partir de dados da Asaas. Assinante
nasce do cadastro com telefone e opt-in (Seção 11), não de um evento financeiro. Um assinante
criado sem telefone e sem opt-in não poderia receber nada, e violaria a regra de opt-in.
O painel administrativo permite vincular manualmente a assinatura órfã a um assinante existente, com auditoria.
13.12.5 Assinante com duas assinaturas ativas: proibido #
Invariante: um assinante tem no máximo uma assinatura em ACTIVE ou PENDING_PAYMENT por
vez. Três defesas independentes:
- Índice único parcial no banco (declarado na Seção 6):
CREATE UNIQUE INDEX subscriptions_one_live_per_subscriber
ON subscriptions (subscriber_id)
WHERE status IN ('PENDING_PAYMENT', 'ACTIVE');- Advisory lock transacional por assinante no checkout, que serializa dois cliques
simultâneos: o segundo espera, vê a linha do primeiro e recebe
409. - Verificação de negócio no início de
POST /api/me/subscription, que devolve409 SUBSCRIPTION_ALREADY_ACTIVEcom o id da assinatura vigente e a data de término.
Se, apesar disso, o estado impossível aparecer — por reconciliação criando linha a partir da
Asaas, por exemplo —, o job billing.reconcile detecta e resolve deterministicamente:
- Mantém a assinatura com
current_period_endmais distante; se empatarem, mantém a decreated_atmais antiga. - A outra vai para
CANCELEDcomend_reason = 'DUPLICATE'e é removida na Asaas comDELETE /v3/subscriptions/{id}. - Se as duas tiverem cobranças pagas no mesmo período, nenhuma é removida sem intervenção:
gera tarefa administrativa
DUPLICATE_PAID_SUBSCRIPTIONcom alerta crítico, porque há cobrança em duplicidade e a decisão correta é reembolsar uma delas — decisão humana, com registro. - Alerta
billing_duplicate_subscriptionsempre é emitido, mesmo quando a resolução é automática.
13.12.6 Retentativa de cartão aprovada após revogação #
A Asaas pode repetir a cobrança de cartão por conta própria depois do vencimento. O sistema
não espera por isso (13.3): revoga no PAYMENT_OVERDUE. Se a retentativa for aprovada dias
depois, chega PAYMENT_CONFIRMED, T17 dispara, o acesso é restaurado e o período é recalculado
a partir da data da confirmação, não da data de vencimento original.
Exemplo: vencimento 10/09, revogação em 11/09 às 00:07, aprovação em 13/09 às 04:22. O novo ciclo vai de 13/09 a 13/10. O assinante perdeu 11/09 e 12/09, e o sistema não compensa nem cobra a diferença. Mensagem: "Recebemos seu pagamento. Seu acesso foi restaurado e vai até 13/10/2026."
13.12.7 Mudança de preço para assinantes existentes #
Ciclos já contratados não são afetados. subscriptions.amount_cents é a cópia do preço no
momento da contratação (13.1.2), e é ele que a Asaas cobra, porque o valor foi enviado na
criação da assinatura.
Ao alterar plans.amount_cents pelo painel administrativo:
- Novos checkouts usam o preço novo, imediatamente.
- Assinaturas existentes continuam no preço antigo indefinidamente, até que o assinante troque de plano por conta própria (13.8) ou cancele e reative (13.10).
- O sistema não propaga o preço novo com
POST /v3/subscriptions/{id}, nem em massa nem individualmente. Não existe rota para isso. Decisão registrada: aumento silencioso de preço em assinatura recorrente é prática abusiva e gera chargeback. - A alteração exige papel
OWNER, justificativa de 10 a 500 caracteres e confirmação por senha, e gravaadmin_audit_logcomaction = 'PLAN_PRICE_CHANGE', valor anterior e novo. - O painel exibe, antes de confirmar, quantas assinaturas vigentes seguem no preço antigo.
Exemplo: em 01/10/2026 o mensal passa de R$ 19,90 para R$ 24,90. Um assinante que contratou em 10/08/2026 continua pagando R$ 19,90 em 10/10, 10/11 e assim por diante. Se ele cancelar em janeiro e reativar em março, paga R$ 24,90.
13.12.8 Outros estados impossíveis e o que o sistema faz #
| Estado impossível | Detecção | Ação |
|---|---|---|
ACTIVE com current_period_end no passado |
Reconciliação e recomputeSubscriptionState |
Vira EXPIRED, tier recalculado, evento DRIFT_FIXED |
tier = 'PAID' sem assinatura vigente nem cortesia |
Reconciliação (12.13.3) | Rebaixa e registra TIER_DRIFT_FIXED |
tier = 'FREE' com assinatura ACTIVE vigente |
Reconciliação | Promove e registra TIER_DRIFT_FIXED |
PENDING_PAYMENT há mais de 24 h sem nenhum evento |
Job billing.abandon-pending, fila billing.lifecycle, de hora em hora |
T5: EXPIRED com end_reason = ABANDONED, DELETE na Asaas |
subscriptions sem asaas_subscription_id há mais de 10 min |
Job de saneamento | Tenta localizar por externalReference; não achando, marca EXPIRED/ABANDONED |
courtesy_until no passado com tier = 'PAID' |
Job billing.lifecycle |
Recalcula tier e envia mensagem |
Dois payments RECEIVED para o mesmo ciclo |
Reconciliação | Alerta crítico billing_double_charge e tarefa administrativa de reembolso |
Assinatura ativa de assinante com deleted_at |
Reconciliação | Cancela na Asaas, encerra a linha, alerta billing_active_for_deleted_subscriber |
13.13 Endpoints do ciclo de vida #
Os endpoints de cancelamento e de opt-out estão na Seção 20.12, porque pertencem àquele fluxo. O endpoint de criação está em 13.7.4 e os de troca de plano em 13.8.3.
Convenção de caminho, seguida sem exceção nesta seção: toda ação sobre a própria conta do
assinante vive sob /api/me/, com nome de ação no singular — /api/me/subscription,
/api/me/subscription/cancel, /api/me/subscription/plan-change, /api/me/subscription/card,
/api/me/payments. Coleções administrativas são substantivos no plural em kebab-case —
/api/admin/subscriptions, /api/admin/subscribers. Não existe /api/subscriptions/current.
Todas as rotas de escrita desta seção são recusadas em sessão de impersonação administrativa, sem exceção: criação de assinatura, troca de plano, troca de cartão, cancelamento e opt-out. A impersonação é integralmente somente leitura, e a recusa acontece no invólucro de API, antes do handler, pelo critério da guarda de autenticação e não pelo prefixo do caminho (Seção 8.13). Um operador de suporte dentro da conta de um assinante não cancela a assinatura dele nem por engano.
13.13.1 GET /api/me/subscription #
- Autenticação: sessão de assinante. Papel:
SUBSCRIBER. - Request: sem corpo, sem query.
- Resposta
200(assinante pago com cancelamento agendado):
{
"data": {
"hasSubscription": true,
"subscriptionId": "sub_01K3QB2D4F6H8K0M2P4R6T8V0X",
"status": "CANCELED",
"planCode": "plan_monthly",
"planName": "Plano mensal",
"amountCents": 1990,
"billingCycle": "MONTHLY",
"billingType": "CREDIT_CARD",
"currentPeriodStart": "2026-08-10T13:37:00.000Z",
"currentPeriodEnd": "2026-09-10T13:37:00.000Z",
"nextDueDate": null,
"cancelAtPeriodEnd": true,
"canceledAt": "2026-08-22T21:14:03.101Z",
"endReason": "USER_REQUEST",
"pendingPlanCode": null,
"card": { "brand": "MASTERCARD", "last4": "8829", "expMonth": 5, "expYear": 2031 },
"entitlements": {
"effectiveTier": "PAID",
"tierSource": "SUBSCRIPTION",
"paidAccessUntil": "2026-09-10T13:37:00.000Z",
"sendFrequency": "DAILY",
"audioEnabled": true,
"archiveWindowDays": null,
"manualResendsPerDay": 3,
"canReceiveMessages": true,
"blockedReason": null
}
},
"meta": { "requestId": "req_01K3QE3P5R7T9V1X3Z5B7D9F1H", "timestamp": "2026-08-25T11:02:07.004Z" }
}Para assinante FREE sem histórico, hasSubscription é false, os campos de assinatura vêm
null e entitlements traz os valores de FREE. O campo planCode vem null, e não
"plan_free": o plano gratuito não é uma linha de plans e não tem código no banco (13.1.1).
Quando a tela precisa exibir o rótulo "Gratuito", ela o monta a partir de
entitlements.effectiveTier, não de um código de plano.
O campo billingCycle é derivado de plans.interval através de plan_id, e não lido de
uma coluna de subscriptions (13.1.2).
- Erros:
UNAUTHENTICATED(401),SUBSCRIBER_DELETED(410). - Efeitos colaterais: nenhum. Idempotência: leitura pura.
- Cache:
Cache-Control: private, no-store. O painel usa TanStack Query comstaleTime: 15_000e refetch ao focar a aba.
13.13.2 GET /api/me/entitlements #
- Autenticação: sessão de assinante. Papel:
SUBSCRIBER. - Resposta
200: o objetoentitlementsde 13.13.1, isolado, commeta.resolvedAt. - Erros:
UNAUTHENTICATED(401),SUBSCRIBER_DELETED(410). - Existe separado porque componentes de UI precisam só disso e a resposta é pequena o bastante para ser buscada com frequência.
13.13.3 GET /api/me/subscription/history #
- Autenticação: sessão de assinante. Papel:
SUBSCRIBER. - Query:
?limit=<1..100>&cursor=<opaque>, paginação por cursor conforme a Seção 7. - Resposta
200: lista de assinaturas comsubscriptionId,planCode,amountCents,status,currentPeriodStart,currentPeriodEnd,endReason, e um resumo de pagamentos (paidCount,totalPaidCents). - Erros:
UNAUTHENTICATED(401),INVALID_CURSOR(400),LIMIT_OUT_OF_RANGE(422). - Idempotência: leitura pura.
13.13.4 GET /api/me/payments #
- Autenticação: sessão de assinante. Papel:
SUBSCRIBER. Retorna somente os próprios pagamentos; osubscriber_idvem da sessão e nunca de parâmetro. - Query:
?limit=<1..100>&cursor=<opaque>&status=<PENDING|OVERDUE|CONFIRMED|RECEIVED|REFUNDED>. - Resposta
200:paymentId,amountCents,status,dueDate,paidAt,billingType,invoiceUrl,receiptUrl. - Erros:
UNAUTHENTICATED(401),INVALID_CURSOR(400),INVALID_STATUS_FILTER(422).
13.13.5 POST /api/me/subscription/card #
Troca do cartão de uma assinatura ativa.
- Autenticação: sessão de assinante. Papel:
SUBSCRIBER. - Request:
export const changeCardSchema = z.object({
cardToken: z.string().uuid(),
});O cardToken vem de POST /api/checkout/card-tokens (Seção 12.6.4). O cartão em si nunca
chega a esta rota.
- Resposta
200:
{
"data": { "card": { "brand": "VISA", "last4": "4321", "expMonth": 11, "expYear": 2030 },
"updatedAt": "2026-08-25T11:20:44.512Z" },
"meta": { "requestId": "req_01K3QF5R7T9V1X3Z5B7D9F1H3K", "timestamp": "2026-08-25T11:20:44.512Z" }
}- Erros:
UNAUTHENTICATED(401),SUBSCRIPTION_NOT_FOUND(404),SUBSCRIPTION_NOT_ACTIVE(409),BILLING_TYPE_NOT_CARD(409),CARD_TOKEN_INVALID(422),CARD_DECLINED(422),CARD_BILLING_BLOCKED(403),PAYMENT_PROVIDER_UNAVAILABLE(503),RATE_LIMITED(429, 5 por hora). - Efeitos colaterais:
POST /v3/subscriptions/{id}com o novocreditCardToken; atualizaasaas_card_token,card_last4,card_brand,card_exp_month,card_exp_year; gravasubscription_eventsCARD_UPDATED; envia confirmação ao assinante. - Idempotência: enviar o mesmo
cardTokenduas vezes é seguro — a segunda chamada detecta queasaas_card_tokenjá é aquele e devolve200sem chamar a Asaas.
13.13.6 GET /api/admin/subscriptions #
- Autenticação: sessão de admin. Papel:
EDITOR(somente leitura),ADMINouOWNER. - Query:
?limit=<1..100>&cursor=<opaque>&status=<...>&billingType=<...>&planCode=<...>&q=<busca>.qbusca por telefone, nome ouasaas_subscription_id. A busca por telefone não compara a coluna cifrada: o servidor normaliza o termo para E.164, calcula o índice cego e consultasubscribers.phone_hmac = $1(Seção 6.3). Nunca há busca por CPF em texto aberto; quando o operador precisar localizar por CPF, o mesmo mecanismo é usado sobresubscriber_profiles.cpf_hmac. - Resposta
200: lista paginada por cursor com os campos de 13.13.1 maissubscriberId, telefone mascarado (+55 11 9****-8829) etierSource. A resposta não traztotal,pagenemtotalPages: contagem agregada é responsabilidade das rotas de métricas (Seção 21.9.3). - Erros:
UNAUTHENTICATED(401),FORBIDDEN_ROLE(403),INVALID_CURSOR(400),LIMIT_OUT_OF_RANGE(422). - Efeitos colaterais: grava
admin_audit_logcomaction = 'SUBSCRIPTION_LIST_VIEW'apenas quandoqé usado, para rastrear busca por assinante específico.
14. Painel do Assinante #
14.1 Por que existe um painel próprio #
A Asaas é o meio de pagamento, não o produto. Ela não oferece um portal do cliente whitelabel onde o assinante possa ver a própria assinatura, trocar forma de pagamento, consultar histórico e cancelar sob a nossa marca. As telas que a Asaas expõe são a fatura avulsa e a página de pagamento; não há uma área logada do consumidor final vinculada ao nosso negócio.
Consequências diretas, que justificam cada tela desta seção:
- Cancelamento precisa ser possível pelo mesmo canal da contratação. Se o assinante contratou pela web, ele precisa cancelar pela web, com o mesmo número de cliques. Sem painel próprio, o único caminho seria e-mail ou WhatsApp, o que é atrito artificial e fonte de reclamação.
- Histórico de pagamentos e comprovantes precisam ser visíveis sem que o assinante
procure e-mails antigos. O painel lê
payments(Seção 6) e expõe o link do comprovante da própria Asaas. - O acervo de devocionais é um entitlement (Seção 13). Ele só faz sentido em uma área logada, com autorização por tier.
- Direitos LGPD (acesso, portabilidade, eliminação) precisam de um canal self-service. Delegar isso a atendimento humano não escala e não cumpre prazo.
- Preferências de recebimento (pausar, reativar, opt-out) precisam de um lugar auditável e explícito, distinto do cancelamento de cobrança.
Decisão registrada: o painel é a única interface de autoatendimento do assinante. O
WhatsApp oferece atalhos (HOJE, PAUSAR, SAIR, VOLTAR, catálogo na Seção 19), mas
nunca substitui o painel para operações que envolvem dinheiro ou dados pessoais.
14.2 Rotas, layout e navegação #
Todas as rotas vivem no route group (app) de apps/web, sob /app, protegidas por
middleware que exige sessão de assinante válida (Seção 8). Todas são noindex, nofollow.
| Rota | Título da aba | Propósito | Requer opt-in confirmado |
|---|---|---|---|
/app |
— | Redireciona 307 para /app/hoje. |
Não |
/app/hoje |
Hoje | Devocional do dia, player, reenvio no WhatsApp. | Não (mostra aviso) |
/app/acervo |
Acervo | Lista paginada dos devocionais disponíveis pelo tier. | Não |
/app/assinatura |
Assinatura | Plano, cobrança, histórico, upgrade, cancelamento. | Não |
/app/perfil |
Perfil | Nome, e-mail, troca de número. | Não |
/app/preferencias |
Preferências | Pausa, reativação, opt-out. | Não |
/app/dados |
Meus dados | Exportação, exclusão, histórico de consentimentos. | Não |
Sub-rotas:
| Rota | Propósito |
|---|---|
/app/acervo/[devotionalId] |
Detalhe de um devocional do acervo. |
/app/assinatura/checkout |
Handoff para o checkout (Seção 12 é a dona). |
/app/perfil/telefone |
Fluxo de troca de número (ver 14.6.2). |
14.2.1 Layout #
>= lg: barra lateral fixa de 240 px à esquerda com a navegação, conteúdo em coluna única de no máximo 720 px, centralizado na área restante. Leitura confortável é prioridade sobre densidade.< lg: cabeçalho fixo com logotipo e avatar; navegação em barra inferior com cinco itens (Hoje,Acervo,Assinatura,Perfil,Mais).Maisabre umSheetcomPreferênciaseMeus dados. Barra inferior porque o painel é usado majoritariamente no celular, logo após a mensagem do WhatsApp.- O item ativo é indicado por cor e por ícone preenchido e por
aria-current="page". - O cabeçalho exibe um
<StatusBadge>com o tier corrente:GratuitoouCompleto. O texto vem deresolveEntitlements()(Seção 13), nunca de literal. - Alvo de toque de 44 px em toda a navegação (Seção 9.9.3 vale para o painel).
14.2.2 Avisos globais persistentes #
Renderizados no topo do conteúdo, em ordem de prioridade, no máximo dois por vez:
| Prioridade | Condição | Texto | Ação |
|---|---|---|---|
| 1 | status = 'VERIFIED_PENDING_OPTIN' |
"Confirme no WhatsApp para começar a receber." | Botão "Reenviar mensagem" (Seção 11.12.6). |
| 2 | optOutAt IS NOT NULL e assinatura paga ainda dentro do período pago |
"Envios pausados, cobrança suspensa. Se você não voltar em 30 dias, a assinatura é encerrada ao fim do período já pago." | Dois botões: "Voltar a receber" e "Cancelar assinatura agora". |
| 3 | Pausa ativa | "Seus envios estão pausados até 11/09/2026; você volta a receber em 12/09/2026." | Botão "Retomar agora". |
| 4 | Pagamento pendente (PENDING_PAYMENT há mais de 1 h) |
"Seu pagamento ainda não foi confirmado." | Link para a fatura/PIX. |
| 5 | Assinatura cancelada com acesso até o fim do ciclo | "Sua assinatura foi cancelada. Você continua com acesso completo até 30/09/2026." | Botão "Reativar". |
14.2.3 Estados de carregamento, vazio e erro #
Padrão único, aplicado em todas as telas:
| Estado | Componente | Regra |
|---|---|---|
| Carregando (primeira vez) | <Skeleton> com a forma exata do conteúdo final |
Nunca spinner de página inteira. Skeleton reserva altura, então CLS ≈ 0. |
| Carregando (revalidação em segundo plano) | Nada visível, exceto um indicador discreto de 2 px no topo | O conteúdo antigo permanece legível (keepPreviousData do TanStack Query). |
| Vazio | <EmptyState> com ícone, título, uma frase e no máximo um CTA |
Texto específico por tela, listado em cada subseção. |
| Erro recuperável | <ErrorState> com mensagem do envelope da Seção 7, botão "Tentar de novo" e o requestId em texto pequeno |
Nunca exibe stack nem code cru; o code vira mensagem em português pelo mapa da Seção 7. |
| Erro de autorização | Redireciona para /login?next=<rota> |
Só para 401. Para 403, mostra <ErrorState> com "Você não tem acesso a este conteúdo." |
| Offline | Faixa fixa no topo: "Você está sem conexão." | Detectado por navigator.onLine + falha de rede. Some ao voltar. |
14.3 Tela "Hoje" (/app/hoje) #
14.3.1 Conteúdo #
Ordem dos elementos:
- Cabeçalho da data: "Segunda-feira, 25 de agosto" — formatado com date-fns em
America/Sao_Paulo, primeira letra maiúscula. - Cartão do devocional:
- Título (
h1). - Referência bíblica e versão (
Salmos 23:1-3 · Almeida 1911). - Texto bíblico em bloco destacado (
<blockquote>). - Reflexão renderizada de
reflection_md(markdown restrito: parágrafo, ênfase, forte, lista, citação; sem HTML bruto, sem imagem, sem link externo — sanitizado no servidor). - Oração em bloco final destacado.
- Título (
- Player de áudio — apenas para tier PAID. Ver 14.3.2.
- Barra de ações: "Reenviar no WhatsApp" e "Copiar texto".
- Rodapé do cartão: "Enviado às 06:02" ou "Ainda não enviado hoje", conforme
message_logs(Seção 6).
14.3.2 Player e estados do áudio #
O player reutiliza o mesmo componente da Seção 9.6, com variant="full" (áudio completo,
não a amostra de 60 s) e recursos adicionais:
- Controle de velocidade:
1x,1.25x,1.5x(<select>acessível, valor persistido emlocalStorage). - Retomar de onde parou: posição salva em
localStoragepordevotionalId, restaurada se a última reprodução foi nas últimas 24 h e passou de 10 s. - Botão "Baixar" com URL assinada (14.4.4).
Estados específicos desta tela:
| Condição | O que aparece |
|---|---|
| Tier FREE | Bloco bloqueado no lugar do player: ícone de cadeado, texto "O áudio narrado faz parte do plano completo." e botão "Conhecer o plano completo" → /app/assinatura. Nunca um player quebrado. |
Tier PAID, audio_assets com status READY |
Player normal. |
Tier PAID, devocional em AUDIO_PENDING |
Aviso: "O áudio de hoje está sendo preparado. Ele chega em alguns minutos." + skeleton do player. A consulta revalida a cada 30 s enquanto esse estado durar (ver 14.11). |
Tier PAID, geração do áudio falhou (audio_assets.status = 'FAILED') |
Aviso: "Não conseguimos gerar o áudio de hoje. Nossa equipe já foi avisada." Sem botão de retry para o assinante; o retry é operacional (Seção 27). |
| Devocional do dia ainda não publicado | Estado vazio: "O devocional de hoje ainda não foi publicado." + link para o mais recente do acervo. |
14.3.3 Reenvio no WhatsApp #
Ação mais usada da tela. Regras:
- O limite diário vem de
resolveEntitlements(): 1 por dia no FREE, 3 por dia no PAID (Seção 13). O painel nunca codifica esses números. - O botão exibe o saldo: "Reenviar no WhatsApp (2 restantes hoje)". Com saldo zero, fica desabilitado com o texto "Limite de hoje atingido. Volta amanhã às 00:00."
- O contador reseta à meia-noite de
America/Sao_Paulo, não em UTC. - Confirmação em um passo: clique → estado
enviandono próprio botão → toast de sucesso "Enviamos no seu WhatsApp." Sem modal. - Se a janela de atendimento de 24 h estiver fechada, o reenvio usa o template diário (Seção 17). O toast então diz: "Enviamos. Toque no botão da mensagem para receber o texto completo." Essa distinção é importante para não gerar a impressão de que o reenvio falhou.
- Se o assinante estiver em
OPTED_OUTou com pausa ativa, o botão fica desabilitado com explicação e link para/app/preferencias. - O pacote enviado é decidido no momento do disparo, não no do clique: o motor relê
tier,opt_out_at,blocked_atedeleted_atimediatamente antes de chamar o provedor (Seção 18.5). Quem perdeu o acesso pago entre o clique e o disparo recebe apenas o texto do plano gratuito, sem áudio. A revogação é imediata, sem carência. - Contabilizado em
delivery_attemptscomreason = 'MANUAL_RESEND', o que o mantém visível na tela de envios do administrador (Seção 15.8).
14.3.4 "Copiar texto" #
Copia título, referência, texto bíblico, reflexão em texto puro e oração, com uma linha
final de crédito e o endereço do site. Usa navigator.clipboard.writeText; se a API não
existir ou for negada, abre um Dialog com o texto selecionável e a instrução de copiar
manualmente. Emite evento de log devotional_copied.
14.3.5 Estados vazios #
| Situação | Título | Frase | CTA |
|---|---|---|---|
| Nenhum devocional publicado hoje | "Ainda não há devocional para hoje" | "Assim que for publicado, ele aparece aqui e chega no seu WhatsApp." | "Ver o acervo" |
| Assinante ainda sem opt-in | "Confirme no WhatsApp" | "Você ainda não confirmou o recebimento. Sem isso, não podemos enviar." | "Reenviar mensagem" |
| Domingo já passou e o assinante é FREE | Não é estado vazio: FREE vê o devocional do dia normalmente no painel. O limite semanal vale para o envio, não para a leitura no painel. |
Essa última linha é uma decisão explícita: o entitlement de frequência restringe o push no WhatsApp, não a leitura no painel do devocional corrente. O acervo, sim, é limitado por tier (14.4.2).
14.4 Tela "Acervo" (/app/acervo) #
14.4.1 Lista #
- Lista de cartões, um por devocional, em ordem decrescente de
scheduled_for. - Cada cartão: data, título, referência bíblica, teaser em uma linha truncada com reticências por CSS, um ícone de áudio quando existir e um indicador "Ouvido" quando a posição salva localmente passar de 90%.
- Paginação por cursor, conforme a Seção 7. Nunca offset.
limitpadrão 20, máximo - Carregamento incremental com botão "Carregar mais" e
IntersectionObservercomo atalho. O botão existe porque rolagem infinita sem controle quebra navegação por teclado e acessibilidade. - O cursor é opaco (ULID do último item, codificado em base64url). O cliente nunca o interpreta.
14.4.2 Limite por tier #
| Tier | Escopo do acervo | Origem da regra |
|---|---|---|
| FREE | Últimos 7 dias | resolveEntitlements() (Seção 13) |
| PAID | Completo, desde a primeira assinatura paga | resolveEntitlements() (Seção 13) |
O limite é imposto no servidor, não na interface. O predicado de janela entra na
própria consulta SQL de listagem e na leitura de item, antes de qualquer serialização. Um
assinante gratuito que chame a rota de listagem diretamente, com o maior limit aceito, e
percorra todos os cursores, recebe no máximo os 7 dias mais recentes; e a leitura de um
devocional de 30 dias atrás devolve 403 ENTITLEMENT_REQUIRED. Esconder itens só no
componente seria um controle inexistente.
Comportamento na borda do limite, para FREE:
- Os devocionais fora da janela não são omitidos silenciosamente. Após o último item acessível, a lista mostra um cartão de bloqueio: "Há mais 385 devocionais no acervo completo." com o botão "Conhecer o plano completo".
- A contagem é retornada pela API em
meta.lockedCount. Ela é a única informação sobre o conteúdo bloqueado que o cliente recebe — nem título, nem data.lockedCountnão é contagem de paginação: a paginação é por cursor e nunca devolvetotal(Seção 7.8). É um agregado de upsell, calculado por consulta própria e independente da página corrente. - Tentar acessar
/app/acervo/[devotionalId]de um devocional fora do escopo devolve403 ENTITLEMENT_REQUIREDe a interface mostra a tela de upsell, não um erro genérico.
Para PAID, o marco inicial é subscriptions.started_at da primeira assinatura paga do
assinante, não da assinatura corrente. Quem assina, cancela e volta não perde o acervo já
conquistado. Decisão explícita, favorável ao assinante e barata de implementar, e é a mesma
âncora declarada na Seção 13.6.1.
14.4.3 Busca e filtros #
Dois controles, ambos combináveis:
Busca por texto
- Campo único,
type="search", com debounce de 400 ms e mínimo de 3 caracteres. - Busca no servidor, em
title,bible_referenceeteaser. Não busca emreflection_md, para manter o índice pequeno e a latência previsível. - Implementação: índice GIN com
to_tsvector('portuguese', ...)sobre uma coluna gerada, definida na Seção 6. Consulta complainto_tsquery('portuguese', $1). - Acentos são normalizados pelo dicionário
portuguesedo Postgres, então "oração" e "oracao" encontram o mesmo resultado. - Estado vazio: "Nenhum devocional encontrado para paz interior." + botão "Limpar busca".
Filtro por data
- Dois campos de data (
deeaté) com<input type="date">nativo, mais atalhos rápidos:Últimos 30 dias,Este ano,Ano passado. - Validação:
de <= até; intervalo máximo de 5 anos; datas futuras rejeitadas com "Escolha uma data até hoje." - Para FREE, o seletor limita o mínimo a
hoje - 7 diase exibe a explicação abaixo.
Ambos os filtros vão para a query string (?q=&from=&to=), o que torna o estado
compartilhável e recuperável no botão "voltar" do navegador. A busca não é enviada
para analytics com o termo digitado, apenas com o resultCount (Seção 9.13.2).
14.4.4 Detalhe e download #
/app/acervo/[devotionalId] mostra o mesmo cartão da tela "Hoje" (14.3.1), com
navegação "anterior/próximo" dentro do escopo permitido pelo tier.
Download do áudio, apenas PAID:
- Botão "Baixar áudio (MP3)".
- O clique chama
POST /api/devotionals/{devotionalId}/media-links, que devolve uma URL assinada com TTL de 5 minutos eresponse-content-disposition: attachmentno nomepalavra-diaria-2026-08-25.mp3. ÉPOSTe nãoGETporque cada chamada emite uma URL assinada nova; a resposta temCache-Control: no-store. - TTL menor que o dos players (15 minutos, Seção 16) porque o link de download tende a ser compartilhado. Cinco minutos é suficiente para o navegador iniciar a transferência.
- A URL nunca é embutida no HTML renderizado no servidor. Ela só existe após uma ação explícita e autorizada.
- Rate limit: 20 links por assinante por hora. Excedente devolve
429 RATE_LIMITED. - O evento é registrado em log com
subscriberIdedevotionalId, para detectar abuso de compartilhamento (Seção 23).
14.4.5 Estado vazio do acervo #
| Situação | Título | Frase | CTA |
|---|---|---|---|
| Assinante novo, nenhum devocional no período | "Seu acervo começa agora" | "Os devocionais que você receber ficam guardados aqui." | "Ver o de hoje" |
| Busca sem resultado | "Nenhum devocional encontrado" | "Tente outras palavras ou limpe os filtros." | "Limpar filtros" |
| Filtro de data sem resultado | "Nada nesse período" | "Escolha outro intervalo de datas." | "Últimos 30 dias" |
14.5 Tela "Assinatura" (/app/assinatura) #
14.5.1 Cartão do plano atual #
Campos exibidos, todos vindos de GET /api/me/subscription:
| Rótulo | Origem | Exemplo |
|---|---|---|
| Plano | plans.name + tier |
"Plano Completo — Anual" |
| Valor | subscriptions.amount_cents formatado por formatBRL |
"R$ 199,00 por ano" |
| Situação | subscriptions.status traduzido |
"Ativa" |
| Forma de pagamento | billingType + últimos 4 dígitos ou "PIX" |
"Cartão final 4242" ou "PIX" |
| Próxima cobrança | next_due_date em America/Sao_Paulo |
"30 de setembro de 2026" |
| Início | started_at |
"30 de setembro de 2025" |
Todo valor monetário chega da API como inteiro em centavos, no campo com sufixo
AmountCents, e é formatado na apresentação por formatBRL (divisor 100). O painel nunca
recebe nem produz decimal de dinheiro.
Para tier FREE, o cartão vira um bloco de oferta com o <PlanComparison> (Seção 9.4) e o
CTA de assinatura. Nenhum dado de cobrança é exibido, porque não existe. O "Plano Gratuito"
mostrado nesse bloco é o item sintético montado pelo handler (Seção 9.16.2): não há
linha correspondente em plans, porque ser gratuito é a ausência de assinatura vigente
(Seção 13.1).
Tradução de subscriptions.status para o assinante (a máquina de estados é canônica na
Seção 13):
| Estado técnico | Texto exibido | Cor semântica |
|---|---|---|
PENDING_PAYMENT |
"Aguardando pagamento" | Atenção |
ACTIVE |
"Ativa" | Positiva |
CANCELED |
"Cancelada — acesso até 30/09/2026" | Neutra |
EXPIRED |
"Encerrada por falta de pagamento" | Negativa |
REFUNDED |
"Reembolsada" | Neutra |
14.5.2 Histórico de pagamentos #
Tabela em >= md, cartões empilhados em < md. Paginada por cursor, 12 por página.
| Coluna | Conteúdo |
|---|---|
| Data | coalesce(payments.confirmed_at, payments.received_at, payments.due_date), em America/Sao_Paulo |
| Valor | payments.amount_cents formatado por formatBRL |
| Forma | "Cartão" ou "PIX" |
| Situação | Ver mapa abaixo |
| Comprovante | Link "Ver comprovante" quando payments.receipt_url existir |
Mapa de situação exibida:
payments.status |
Texto | Ação disponível |
|---|---|---|
PENDING |
"Aguardando" | "Pagar agora" (abre a fatura/PIX da Asaas em nova aba) |
CONFIRMED / RECEIVED |
"Pago" | "Ver comprovante" |
OVERDUE |
"Vencido" | "Pagar agora" |
REFUNDED |
"Reembolsado" | "Ver comprovante" |
CHARGEBACK_REQUESTED |
"Em contestação" | Nenhuma; texto explicativo |
DELETED |
"Cancelado" | Nenhuma |
O link do comprovante é a URL da própria Asaas, aberta com target="_blank" rel="noopener noreferrer". O painel não hospeda nem gera comprovante.
Estado vazio: "Nenhuma cobrança ainda." com a frase "Quando você assinar, o histórico aparece aqui."
14.5.3 Upgrade #
- Visível para tier FREE e para quem está em plano mensal.
- FREE → PAID: botão "Assinar o plano completo" →
/app/assinatura/checkout?plan=<code>. - Mensal → Anual: botão "Mudar para o plano anual", com um
Dialogque mostra, com números concretos, o que acontece: a assinatura mensal é cancelada na Asaas, uma nova anual é criada comnextDueDateigual à data de hoje, e o valor pago do ciclo mensal corrente não é reembolsado nem creditado — o acesso já usufruído não é devolvido. Exemplo exibido: "Você pagou R$ 19,90 em 10/08. Ao mudar hoje, 25/08, você paga R$ 199,00 agora e sua próxima cobrança será em 25/08/2027." O botão só habilita depois que o assinante marca "Entendi". - Anual → Mensal: não é oferecido no painel. Decisão explícita: o downgrade seria economicamente irracional no meio de um ciclo anual já pago. Quem quiser, cancela e reassina depois do fim do ciclo; a tela de cancelamento explica isso.
- Toda a mecânica de criação e cancelamento de assinatura na Asaas é da Seção 12; o painel apenas chama os endpoints dela.
14.5.4 Cancelamento em duas etapas #
Fluxo obrigatório, sem exceção:
Etapa 1 — Confirmação com consequências explícitas.
Dialog com título "Cancelar sua assinatura?" e um bloco de texto que declara,
nominalmente:
Sua assinatura não será renovada. Você continua com acesso completo — devocional diário com áudio — até 30 de setembro de 2026, que é o fim do período que você já pagou. Depois dessa data, sua conta passa para o plano gratuito e você volta a receber o devocional apenas aos domingos, sem áudio. Nada é cobrado de novo. Você pode reativar quando quiser.
A data exibida é subscriptions.current_period_end formatada por extenso. Se o assinante
estiver em PENDING_PAYMENT (nunca pagou), o texto muda para: "Sua assinatura ainda não
foi paga. Ao cancelar, ela é encerrada agora e nada é cobrado."
Botões: "Voltar" (padrão, com foco inicial) e "Continuar".
Etapa 2 — Pesquisa de motivo.
Obrigatória para concluir, mas respondível em um clique. Campo reason com opções:
| Valor | Rótulo |
|---|---|
TOO_EXPENSIVE |
"Está caro para mim" |
TOO_MANY_MESSAGES |
"Recebo mensagens demais" |
CONTENT_NOT_FOR_ME |
"O conteúdo não é o que eu esperava" |
AUDIO_QUALITY |
"Não gostei do áudio" |
TECHNICAL_ISSUES |
"Tive problemas técnicos" |
TEMPORARY |
"É só uma pausa, volto depois" |
OTHER |
"Outro motivo" |
Campo de comentário livre, opcional, máximo 500 caracteres.
Ofertas contextuais, exibidas uma única vez conforme o motivo escolhido, sem bloquear o botão de cancelar:
| Motivo | Oferta |
|---|---|
TOO_MANY_MESSAGES |
"Prefere pausar por até 30 dias em vez de cancelar?" → link para /app/preferencias |
TEMPORARY |
Mesma oferta de pausa |
TOO_EXPENSIVE |
Se estiver no mensal: "O plano anual sai por R$ 16,58 por mês." → link para o upgrade |
TECHNICAL_ISSUES |
"Quer que a gente ajude antes?" → link para /contato |
| Demais | Nenhuma oferta |
O botão "Cancelar assinatura" fica sempre visível e habilitado. Nenhuma oferta é obrigatória, nenhum passo extra é inserido. Retenção por atrito é proibida.
Resultado. Ao concluir: subscriptions.status = 'CANCELED', canceled_at = now(),
cancel_reason e cancel_comment gravados em subscription_events (Seção 6), chamada de
cancelamento na Asaas (Seção 12), e-mail de confirmação (se houver e-mail verificado) e
mensagem no WhatsApp confirmando a data de fim do acesso. O tier permanece PAID até
current_period_end. Isso não é carência: é o período pago (Seção 13).
Como o cancelamento propaga uma escrita para a Asaas, a chamada exige o cabeçalho
Idempotency-Key (Seção 7.7.1). O botão gera a chave no primeiro clique e a reutiliza em
qualquer nova tentativa da mesma tela.
Tela pós-cancelamento: mensagem de confirmação, a data exata de fim do acesso e um botão "Reativar assinatura" que permanece disponível até aquela data.
14.5.5 Reativação #
- Enquanto
status = 'CANCELED'enow() < current_period_end: o botão "Reativar" simplesmente recria a assinatura na Asaas comnextDueDate = current_period_end, sem nova cobrança imediata. O assinante não perde um dia sequer. - Depois de
current_period_end(já em FREE): "Reativar" é equivalente a uma assinatura nova e passa pelo checkout completo. - A distinção é exibida no rótulo do botão: "Reativar sem nova cobrança agora" no primeiro caso, "Assinar novamente" no segundo.
14.6 Tela "Perfil" (/app/perfil) #
14.6.1 Nome e e-mail #
Formulário simples com dois campos e um botão "Salvar", desabilitado enquanto não houver alteração.
| Campo | Regras |
|---|---|
name |
Mesmas regras de 11.3.3. Alterar o nome não altera nada retroativo; templates futuros usam o novo. |
email |
Opcional. Alterar dispara verificação: um e-mail com link de confirmação (Resend), válido por 24 h. Até confirmar, o campo mostra "Não verificado" e o e-mail não serve como fallback de login (Seção 8). |
Regras do e-mail:
- Remover o e-mail (campo vazio) é permitido e imediato, sem verificação. Um aviso alerta que o fallback de login por e-mail deixará de funcionar.
- Trocar para um e-mail já verificado por outro assinante:
409 EMAIL_ALREADY_IN_USEcom a mensagem "Esse e-mail já está em uso por outra conta." Ver caso E8 da Seção 11.11. - Reenviar o e-mail de verificação: máximo 3 por dia, com 5 minutos de intervalo.
14.6.2 Troca do número de WhatsApp #
Operação sensível: o número é a identidade (Seção 8). Fluxo completo, em rota própria
/app/perfil/telefone:
[1] Tela de aviso
"Trocar o número muda como você entra na conta e para onde enviamos o devocional."
Botão "Continuar"
│
▼
[2] Verificação do número ATUAL
Envia OTP para o número atual. 6 dígitos, 10 min, 5 tentativas.
Motivo: impedir que alguém com a sessão roubada sequestre a conta.
│ código correto
▼
[3] Novo número
Campo de telefone com as mesmas validações da Seção 11.4.
Verificação de disponibilidade em tempo real (debounce 600 ms).
│
▼
[4] Verificação do número NOVO
Envia OTP para o novo número. Mesmos limites.
│ código correto
▼
[5] Aplicação (transação única)
- phone_e164 = novo número (cifrado) e phone_hmac recalculado
- wa_id = NULL e wa_id_hmac = NULL ← redescobertos no próximo contato
- phone_verified_at = now()
- service_window_expires_at = NULL
- opt_in_confirmed_at PRESERVADO (o consentimento é da pessoa, não do número)
- registro em admin_audit_log com action = 'SUBSCRIBER_PHONE_CHANGED'
- TODAS as sessões do assinante são revogadas, INCLUSIVE a atual
- mensagem no WhatsApp do número novo confirmando a troca
- mensagem no WhatsApp do número ANTIGO avisando da troca (última mensagem enviada
para ele; se falhar, ignora)
│
▼
[6] Confirmação e novo login
"Pronto. Seu devocional agora vai para (21) 9****-4321.
Por segurança, entre de novo com o número novo."Regras adicionais:
- Se o novo número já pertencer a um assinante ativo:
409 PHONE_ALREADY_REGISTERED, com a mensagem "Esse número já tem uma conta. Entre com ele ou escolha outro." - Se o novo número pertencer a um assinante com
deleted_atpreenchido: permitido, porque o índice único dephone_hmacé parcial (Seção 11.11, caso E3). - Máximo de 2 trocas por 30 dias. Acima disso,
429 PHONE_CHANGE_LIMITcom a instrução de falar com o suporte. - Abandonar no meio não altera nada: cada etapa só persiste no passo 5.
- Toda a operação gera um registro em
admin_audit_logcomactor_type = 'SUBSCRIBER', porque é uma mudança de identidade e precisa ser auditável (Seção 15.10). - Revogação total das sessões. Trocar o número troca a identidade, e o
wa_idassociado deixa de valer. Por isso todas as sessões caem, inclusive a que executou a troca: a pessoa acabou de verificar dois códigos, então reentrar custa um código a mais e fecha o caminho pelo qual uma sessão roubada sobreviveria à troca. A mesma regra vale para a troca detectada de fora, quando owa_idassociado a um telefone muda sozinho por reciclagem de chip (Seção 11.4.3): sessões revogadas e nova verificação obrigatória antes de qualquer dado pessoal ser devolvido. - A troca não grava
consent_events: o enum de tipos de consentimento é fechado na Seção 6 e não tem valor para mudança de número. A prova da operação é o registro de auditoria, que guarda ator, motivo, antes e depois.
14.6.3 Sair da conta #
- Botão "Sair" encerra a sessão atual (
POST /api/auth/logout, Seção 8). - Botão secundário "Sair de todos os dispositivos" revoga todas as sessões, inclusive a
atual, e redireciona para
/login. Útil quando o celular foi perdido.
14.7 Tela "Preferências" (/app/preferencias) #
Esta tela existe para separar, de forma inequívoca, três coisas diferentes que os assinantes costumam confundir.
14.7.1 A distinção, declarada na própria interface #
Um bloco explicativo no topo da tela, sempre visível:
| Ação | O que para | O que continua |
|---|---|---|
| Pausar | As mensagens no WhatsApp, por um período que você escolhe | A cobrança continua. O acervo continua disponível. As mensagens voltam sozinhas no fim da pausa. |
| Cancelar recebimento (opt-out) | As mensagens no WhatsApp, por tempo indeterminado, e a cobrança do próximo ciclo | O acesso ao acervo continua até o fim do período já pago. Sem reativação em 30 dias, a assinatura é encerrada nessa data. |
| Cancelar assinatura | A cobrança, de imediato | As mensagens continuam até o fim do período já pago. |
Quando o assinante tem plano pago e escolhe opt-out, um Dialog obrigatório reforça:
"Você vai parar de receber as mensagens e sua próxima cobrança fica suspensa. Se você não
voltar em 30 dias, a assinatura é encerrada ao fim do período que você já pagou. Quer
encerrar a assinatura agora?" com dois botões: "Só parar as mensagens" e "Parar e cancelar
a assinatura agora". Essa dupla escolha é a regra canônica da Seção 20 aplicada à
interface; o efeito sobre a cobrança é definido na Seção 13.4.6, e o motivo é
direto: revogado o consentimento, não é lícito prestar o serviço, e cobrar por serviço que
não se pode prestar não se sustenta.
14.7.2 Pausa temporária #
- Controle: seletor de duração com opções rápidas (
7 dias,15 dias,30 dias) e um campo "até a data" com<input type="date">. - Limite: 1 a 30 dias. Menos de 1 dia é rejeitado; mais de 30 é rejeitado com "A pausa pode ser de no máximo 30 dias. Para parar por mais tempo, use Cancelar recebimento."
- Persistência:
subscribers.paused_until(timestamptz, Seção 6).NULLsignifica sem pausa. O valor é sempre 03:00 deAmerica/Sao_Paulodo dia seguinte ao último dia pausado, de modo que o retorno caia antes do envio das 06:00 (Seção 20.6.1). - Semântica exata: o motor de envio pula o assinante enquanto
paused_until > now(). - A pausa termina sozinha: a função de entitlements para de bloquear quando
paused_until <= now. O jobmessaging.pausedas 05:30 é cosmético — limpapaused_untildas pausas encerradas e enfileira a saudação de retorno; se falhar, o assinante recebe o devocional normalmente e apenas não recebe a saudação (Seção 20.6.2). - Enquanto pausado, a tela mostra "Pausado até 11 de setembro de 2026; você volta a receber
em 12 de setembro" e um botão "Retomar agora", que zera
paused_untile faz o próximo envio acontecer normalmente. - Alterar a pausa enquanto ela está ativa é permitido: substitui a anterior, não soma.
A rota devolve
200com a nova data. - Exemplo concreto: assinante PAID pausa em 25/08 por 7 dias.
paused_untilfica2026-09-02T06:00:00Z(03:00 de 02/09 em Brasília). Ele não recebe de 26/08 a 01/09. Recebe normalmente em 02/09 às 06:00, precedido da saudação de retorno às 05:55. Nenhuma cobrança é alterada. - Registro:
consent_eventscomtype = 'PAUSE_STARTED',channel = 'WEB',granted = falseeevidence = {"days": N, "pausedUntil": "..."}. O fim da pausa gravatype = 'PAUSE_ENDED'comgranted = true(Seção 20.6.3). - Enquanto a pausa está ativa,
status = 'PAUSED'(Seção 11.8). Otiernão muda. - A pausa não afeta o painel: ele continua vendo "Hoje" e o acervo.
14.7.3 Cancelar recebimento (opt-out) #
- Botão destrutivo, com confirmação em
Dialoge o aviso de 14.7.1 quando houver assinatura paga. - Efeito:
opt_out_at = now(),opt_out_reason = 'PANEL',status = 'OPTED_OUT', envios cessam imediatamente (Seção 20), registro emconsent_eventscomtype = 'OPT_OUT',channel = 'WEB'egranted = false, e suspensão da cobrança do ciclo seguinte quando houver assinatura paga vigente (Seção 13.4.6). - Nenhuma mensagem é enviada no WhatsApp. Quando a origem do opt-out é o painel, a
confirmação aparece na própria tela — informando que a próxima cobrança está suspensa e
como voltar (
VOLTARno WhatsApp ou o botão do painel) — e, quando houver e-mail verificado, também por e-mail. Mandar mais uma mensagem no WhatsApp para quem acabou de pedir silêncio, por um canal que não é o WhatsApp, é contraditório (Seção 20.4.1). - A tela passa a exibir, no lugar dos controles, um bloco "Você não está recebendo as mensagens" com o botão "Voltar a receber".
14.7.4 Reativar recebimento #
- Botão "Voltar a receber" →
Dialogcom o texto de consentimento vigente (não o antigo) e um checkbox obrigatório. A rota éPOST /api/me/opt-in(Seção 20.12.2), que é a única forma de desfazer o opt-out. - Efeito:
opt_out_at = NULL,statusvolta paraACTIVE_FREEouACTIVE_PAIDconforme otiercorrente, registro emconsent_eventscomtype = 'RE_OPT_IN'e apolicy_versionvigente. Havendo assinatura paga suspensa por opt-out, a cobrança volta a correr no ciclo seguinte. - Não dispara
welcome_backfill(regra 1 de 11.10). O próximo devocional chega no ciclo normal.
14.7.5 Outras preferências #
| Preferência | Controle | Padrão | Observação |
|---|---|---|---|
| Receber o e-mail de resumo semanal | Interruptor | Desligado | Só habilitado se houver e-mail verificado. Conteúdo definido na Seção 19. |
| Receber avisos de cobrança por e-mail | Interruptor | Ligado | Não pode ser desligado por quem tem assinatura ativa: são comunicações transacionais obrigatórias. O interruptor aparece desabilitado com explicação. |
Não existe preferência de horário de envio: horário personalizado está fora de escopo (Seção 2). A tela declara isso em uma frase, para evitar tíquetes de suporte.
14.8 Tela "Meus dados" (/app/dados) #
Implementa os direitos do titular previstos na LGPD. Os prazos e as bases legais são canônicos na Seção 22; esta seção especifica a interface e os contratos.
14.8.1 Exportação de dados (portabilidade e acesso) #
- Botão "Exportar meus dados (JSON)".
- Processamento assíncrono: a solicitação enfileira o job de exportação na fila
maintenance.cleanup(Seção 18 é a dona da lista de filas). A tela passa a mostrar "Preparando seu arquivo..." e revalida a cada 10 s. - O arquivo nunca é entregue por URL portadora. Ele é gravado cifrado no storage, no
prefixo
exports/, que fica fora de qualquer domínio público. Quando fica pronto, o assinante recebe apenas um aviso por e-mail (se houver e-mail verificado); o e-mail não contém link de download. O download acontece dentro do painel, sob sessão plena autenticada — nunca sob sessão restrita (Seção 8.15.1.1) —, por uma URL assinada de 15 minutos gerada no momento do clique e vinculada à sessão. - Motivo: um link válido por horas é a credencial de acesso ao dossiê completo do titular, e mensagens são encaminhadas o tempo todo. Quem encaminha o aviso não entrega os dados.
- Cada download é registrado em
admin_audit_logcomaction = 'DATA_EXPORT_DOWNLOADED'. - Limite de downloads do mesmo arquivo: 3. Depois disso é preciso pedir nova exportação.
- Prazo declarado na interface: até 24 horas. Na prática o job leva segundos; o prazo declarado tem folga.
- Limite de geração: 2 pedidos por assinante por mês. Excedente:
429 EXPORT_RATE_LIMITEDcom o horário de liberação. - O arquivo expira em 7 dias e é apagado do storage por job de manutenção.
- O pedido grava
consent_eventscomtype = 'DATA_EXPORT_REQUESTED'.
Estrutura do JSON exportado (contrato estável, versionado):
{
"exportVersion": "1",
"generatedAt": "2026-08-25T14:02:11.000Z",
"subscriber": {
"id": "sub_01HZX...", "name": "Maria das Graças",
"phoneE164": "+5511912345678", "email": "maria@exemplo.com.br",
"emailVerified": true, "tier": "PAID", "status": "ACTIVE_PAID",
"createdAt": "2025-09-30T12:00:00.000Z",
"optInConfirmedAt": "2025-09-30T12:04:00.000Z",
"optOutAt": null, "pausedUntil": null
},
"consents": [
{ "type": "OPT_IN_WEB", "policyVersion": "2025-09-01", "channel": "WEB",
"text": "Concordo em receber...", "ip": "189.x.x.x",
"userAgent": "Mozilla/5.0 ...", "createdAt": "2025-09-30T12:00:00.000Z" }
],
"subscriptions": [
{ "id": "sbs_01HZX...", "plan": "plan_annual", "status": "ACTIVE",
"amountCents": 19900, "currency": "BRL", "billingType": "CREDIT_CARD",
"startedAt": "2025-09-30T12:10:00.000Z", "currentPeriodEnd": "2026-09-30T12:10:00.000Z" }
],
"payments": [
{ "id": "pay_01HZX...", "amountCents": 19900, "status": "RECEIVED",
"dueDate": "2025-09-30", "confirmedAt": "2025-09-30T12:11:00.000Z",
"billingType": "CREDIT_CARD", "receiptUrl": "https://..." }
],
"messages": [
{ "direction": "OUTBOUND", "kind": "DEVOTIONAL_TEXT", "devotionalId": "dev_01HZX...",
"status": "read", "sentAt": "2026-08-25T09:00:12.000Z" }
],
"devotionalsReceived": [
{ "id": "dev_01HZX...", "date": "2026-08-25", "title": "O Senhor é o meu pastor" }
]
}Regras do conteúdo exportado:
- Não inclui dados de cartão. Nem token, nem bandeira, nem últimos dígitos — os últimos dígitos são exibidos no painel mas não exportados, por minimização.
- Não inclui o CPF em texto: inclui apenas
"cpfCnpj": "***.***.789-**"mascarado, porque o dado original está na Asaas e a exportação não deve criar uma segunda cópia completa de documento. - Inclui o texto integral de cada consentimento, porque é o que dá valor probatório ao registro para o próprio titular.
messagestraz metadados, não o conteúdo das mensagens: o conteúdo é o devocional, que já vai emdevotionalsReceivedpor referência.
14.8.2 Solicitação de exclusão #
Fluxo em duas etapas, deliberadamente mais rígido que o cancelamento:
Etapa 1 — Dialog com a lista do que será perdido, em texto direto:
Ao excluir sua conta: você para de receber os devocionais; seu acervo e seu histórico ficam indisponíveis; sua assinatura paga é cancelada e não haverá reembolso do período em curso; seus dados pessoais são anonimizados em até 30 dias. Registros de pagamento e de consentimento são mantidos pelo prazo legal de 5 anos, sem seus dados de contato. Essa ação não pode ser desfeita.
Etapa 2 — Confirmação por digitação: o assinante precisa digitar EXCLUIR em um campo
de texto. Só então o botão destrutivo habilita.
Efeito imediato ao confirmar:
subscribers.deleted_at = now(). Ostatusnão viraDELETED: esse valor não existe no enum (Seção 11.8). A conta excluída é reconhecida pordeleted_at IS NOT NULL, que vence qualquer status em toda leitura de fluxo.- Todas as sessões revogadas; o navegador é redirecionado para
/com uma mensagem. - Assinatura ativa cancelada na Asaas (Seção 12).
- Envios cessam na hora.
- Job de pseudonimização agendado para
now() + 30 dias, na filamaintenance.cleanup, conforme a retenção da Seção 22. Ele alcança todas as tabelas que guardam identificador pessoal do titular, e não apenassubscribers:display_name,phone_e164/phone_hmac,wa_id/wa_id_hmaceemail/email_hmacdo assinante, e também as colunas de telefone,wa_ide trecho de conteúdo dos registros de mensagem (message_logs,inbound_messages) e das tentativas de entrega. Nenhuma tabela retém telefone,wa_id, CPF ou e-mail em claro depois disso. O que a lei obriga a reter — registros fiscais e de consentimento — é retido de forma pseudonimizada, ligado apenas ao identificador interno. A regra completa, os dois estágios e o script são da Seção 22.9. - Registro em
consent_eventscomtype = 'DATA_DELETION_REQUESTED'. O gatilho append-only dessa tabela reconhece o caminho privilegiado e auditado da anonimização exigida por lei (Seção 22.7.3): um controle de integridade não pode impedir o cumprimento de um direito do titular. - E-mail de confirmação, se houver e-mail verificado.
Cancelar a exclusão: possível apenas dentro das primeiras 72 horas, e apenas por contato com o suporte, que executa a reversão pelo painel administrativo (Seção 15.7.3). O painel do assinante não oferece autoatendimento para isso, porque a conta já está com sessões revogadas. A interface declara esse prazo e o caminho.
14.8.3 Histórico de consentimentos #
Lista somente leitura, em ordem decrescente de data. Cada item mostra:
- Tipo traduzido, um rótulo por valor do enum da Seção 6: "Cadastro no site"
(
OPT_IN_WEB), "Confirmação no WhatsApp" (OPT_IN_WHATSAPP), "Nova autorização" (RE_OPT_IN), "Cancelamento de recebimento" (OPT_OUT), "Início de pausa" (PAUSE_STARTED), "Fim de pausa" (PAUSE_ENDED), "Pedido de exportação" (DATA_EXPORT_REQUESTED), "Pedido de exclusão" (DATA_DELETION_REQUESTED). A troca de número não aparece aqui: ela é auditada emadmin_audit_log(14.6.2). - Data e hora em
America/Sao_Paulo. - Canal: "Site" ou "WhatsApp".
- Versão do texto e um botão "Ver o texto que você aceitou", que abre um
Dialogcom o texto integral gravado.
Essa lista é a prova, para o próprio assinante, de que o duplo opt-in aconteceu. Ela nunca
é editável nem apagável pela interface: consent_events é append-only (Seção 6).
14.9 Regras de autorização por propriedade #
Regra geral, sem exceção: todo recurso do painel é filtrado pelo subscriberId da
sessão, no servidor, dentro da própria consulta ao banco. Nunca por verificação após a
leitura.
// apps/web/src/server/authz/subscriber-scope.ts
export async function requireSubscriber(): Promise<SubscriberSession> {
const session = await getSession(); // Seção 8
if (!session || session.role !== 'SUBSCRIBER') throw new UnauthenticatedError();
if (session.subscriberDeletedAt !== null) throw new UnauthenticatedError();
return session;
}
/** Toda query do painel usa este predicado. Não existe leitura sem ele. */
export function scopeToSubscriber(subscriberId: string) {
return { subscriberId, subscriber: { deletedAt: null } };
}Matriz de autorização das operações do painel:
| Operação | Regra | Violação devolve |
|---|---|---|
| Ler devocional do dia | Público para qualquer assinante autenticado | — |
| Ler devocional do acervo | Precisa estar dentro do escopo do tier (14.4.2) | 403 ENTITLEMENT_REQUIRED |
| Gerar link de mídia | Tier PAID e devocional dentro do escopo | 403 ENTITLEMENT_REQUIRED |
| Reenviar no WhatsApp | Saldo diário do tier > 0, opt-in confirmado, sem pausa, sem opt-out | 429 RESEND_LIMIT_REACHED / 409 OPTIN_REQUIRED / 409 SENDING_PAUSED |
| Ler assinatura | subscriptions.subscriber_id = session.subscriberId |
404 SUBSCRIPTION_NOT_FOUND (nunca 403, para não confirmar existência) |
| Ler pagamento | payments.subscription.subscriber_id = session.subscriberId |
404 PAYMENT_NOT_FOUND |
| Cancelar assinatura | Mesma regra de propriedade e status IN ('ACTIVE','PENDING_PAYMENT') |
409 ILLEGAL_STATE_TRANSITION |
| Trocar telefone | Duplo OTP (14.6.2) e sessão plena | 403 REVERIFICATION_REQUIRED |
| Exportar dados | Dois pedidos por mês e sessão plena | 429 EXPORT_RATE_LIMITED / 403 REVERIFICATION_REQUIRED |
| Baixar a exportação pronta | Sessão plena, propriedade do exportId e saldo de downloads |
404 EXPORT_NOT_FOUND / 429 EXPORT_RATE_LIMITED |
| Excluir conta | Confirmação por digitação, validada no servidor, e sessão plena | 422 CONFIRMATION_MISMATCH / 403 REVERIFICATION_REQUIRED |
Seis regras de defesa adicionais:
- Enumeração: acessar um
devotionalIdque existe mas está fora do escopo devolve403 ENTITLEMENT_REQUIRED, enquanto umdevotionalIdinexistente devolve404 DEVOTIONAL_NOT_FOUND. Essa distinção é intencional e segura: o acervo não é segredo, o que é controlado é o acesso ao conteúdo. - Recursos de terceiros (assinatura, pagamento, sessão, exportação) sempre devolvem
404, nunca403, para não confirmar existência. UmexportIdde outro assinante é indistinguível de umexportIdinexistente. - CSRF: todas as mutações exigem o cabeçalho
X-CSRF-Tokencasado com o cookie__Host-csrf(Seção 8), que é o único nome de cookie de CSRF do documento. Requisições sem ele devolvem403 CSRF_INVALID. - Impersonação é somente leitura, sem exceção. A sessão de suporte que impersona um
assinante carrega
scope = 'IMPERSONATION_READONLY', e o invólucrowithApirecusa qualquer método não seguro nessa sessão antes de chegar ao handler. A trava cobre todas as rotas de escrita executáveis sob a guardarequireSubscriber— o critério é a guarda, não o prefixo do caminho —, incluindo cancelamento de assinatura, troca de forma de pagamento, troca de plano, reativação, reenvio, pausa, opt-out, pedido de exportação e pedido de eliminação. Um teste de arquitetura enumera todoPOST,PATCH,PUTeDELETEguardado porrequireSubscribere falha o build se algum não chamarassertNotImpersonating. Critério por prefixo de rota é insuficiente e está proibido. - Sessão restrita por dormência. A posse do número é o único fator do assinante, e
números brasileiros são reemitidos pelas operadoras. Quando a conta está dormente — sem
mensagem de entrada nem login bem-sucedido nos últimos 90 dias, ou vinda de bloqueio por
erro reincidente da Meta, ou com opt-out por palavra-chave sem reativação —, a
verificação por código cria uma sessão com
scope = 'RESTRICTED'(Seção 8.15.1.1). Essa sessão permite exclusivamente ler o devocional do dia, executar opt-out e cancelar a assinatura. Ela recusa com403 REVERIFICATION_REQUIREDa leitura da conta, o acervo, a tela de assinatura, a de pagamentos, a troca de telefone, a exportação e a exclusão de conta. A elevação para sessão plena exige o link mágico por e-mail verificado ou a conferência de titularidade pelo suporte. - Troca de
wa_idderruba a sessão. Se owa_idassociado ao telefone do assinante mudar (Seção 11.4.3), todas as sessões dele são revogadas e uma nova verificação por código é exigida antes de qualquer dado pessoal ser devolvido. Ter respondido no WhatsApp nunca é, sozinho, prova de titularidade.
14.10 Endpoints do painel #
Todos exigem sessão de assinante e papel SUBSCRIBER, seguem o envelope da Seção 7 e
ecoam X-Request-Id. Erros comuns a todos, omitidos das tabelas individuais:
401 UNAUTHENTICATED, 403 CSRF_INVALID (mutações), 403 REVERIFICATION_REQUIRED
(sessão restrita, 14.9), 500 INTERNAL_ERROR.
Convenção de caminho desta seção, aplicada sem exceção: coleções são substantivos no
plural em kebab-case (/api/devotionals, /api/me/phone-changes); ações sobre a própria
conta do assinante ficam sob /api/me/, com nome de ação no singular
(/api/me/opt-out, /api/me/pause, /api/me/deletion-request,
/api/me/subscription/cancel). Nenhuma rota do painel do assinante vive fora de
/api/me/ e de /api/devotionals/.
Nenhuma resposta paginada devolve total. A paginação é por cursor (nextCursor,
hasMore, limit); contagens agregadas vêm das rotas de métricas (Seção 21.9).
Toda mutação que propaga escrita para a Asaas ou para a plataforma de mensagens exige o
cabeçalho Idempotency-Key (Seção 7.7.1).
14.10.1 GET /api/me #
- Efeitos colaterais: nenhum. Idempotente.
Cache-Control: no-store. - Uso: dado base do layout (nome, tier, avisos globais).
{
"data": {
"id": "sub_01HZX9C7Q0S6M2R4T8V1Y3B5N7",
"name": "Maria das Graças",
"phoneMasked": "(11) 9****-5678",
"email": "maria@exemplo.com.br",
"emailVerified": true,
"tier": "PAID",
"status": "ACTIVE_PAID",
"optInConfirmedAt": "2025-09-30T15:04:00.000Z",
"optOutAt": null,
"pausedUntil": null,
"entitlements": {
"audio": true, "fullText": true,
"archiveScope": "SINCE_SUBSCRIPTION",
"manualResendPerDay": 3, "sendFrequency": "DAILY"
},
"resendsUsedToday": 1,
"notices": ["SUBSCRIPTION_CANCELED_ACCESS_UNTIL"],
"accessUntil": "2026-09-30T03:00:00.000Z"
},
"meta": { "requestId": "req_01HZXC1A2B3C4D5E6F7G8H9J0K", "timestamp": "2026-08-25T14:00:00.000Z" }
}O objeto entitlements é a serialização direta de resolveEntitlements() (Seção 13). O
cliente nunca deriva permissão de tier; ele lê entitlements.
Erros: 404 SUBSCRIBER_NOT_FOUND (sessão válida cuja conta foi excluída entre a emissão e
o uso).
14.10.2 GET /api/devotionals/today #
- Efeitos colaterais: nenhum. Idempotente.
Cache-Control: private, no-store.
Resposta 200 (PAID):
{
"data": {
"id": "dev_01HZX9C7Q0S6M2R4T8V1Y3B5N7",
"date": "2026-08-25",
"title": "O Senhor é o meu pastor",
"bibleReference": "Salmos 23:1-3",
"bibleVersion": "Almeida 1911",
"bibleText": "O SENHOR é o meu pastor; nada me faltará...",
"reflectionHtml": "<p>Quando o pastor guia...</p>",
"prayer": "Senhor, ensina-me a confiar...",
"audio": {
"available": true,
"state": "READY",
"durationSec": 214,
"streamUrl": "https://media.palavradiaria.com.br/...?X-Amz-Expires=900&...",
"mimeType": "audio/mpeg"
},
"delivery": { "sentAt": "2026-08-25T09:00:12.000Z", "status": "read" }
},
"meta": { "requestId": "req_01HZXC2B3C4D5E6F7G8H9J0K1L", "timestamp": "2026-08-25T14:00:00.000Z" }
}Para FREE, audio vem como { "available": false, "state": "LOCKED" } e nenhuma URL é
gerada. Estados possíveis de audio.state: READY, PENDING, FAILED, LOCKED,
NONE.
Erros: 404 DEVOTIONAL_NOT_FOUND (nada publicado hoje).
14.10.3 GET /api/devotionals #
- Query:
?limit=<1..100>&cursor=<opaque>&q=<texto>&from=<YYYY-MM-DD>&to=<YYYY-MM-DD> - Efeitos colaterais: nenhum. Idempotente.
export const archiveQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
cursor: z.string().max(64).optional(),
q: z.string().trim().min(3).max(80).optional(),
from: z.iso.date().optional(),
to: z.iso.date().optional(),
}).refine((v) => !v.from || !v.to || v.from <= v.to, {
message: 'A data inicial precisa ser anterior à final.', path: ['from'],
});Resposta 200:
{
"data": [
{ "id": "dev_01HZX...", "date": "2026-08-25", "title": "O Senhor é o meu pastor",
"bibleReference": "Salmos 23:1-3", "teaser": "Quando o pastor guia...",
"hasAudio": true, "locked": false }
],
"meta": {
"requestId": "req_01HZXC3C4D5E6F7G8H9J0K1L2M",
"timestamp": "2026-08-25T14:00:00.000Z",
"nextCursor": "MDFIWlg5Qzd...", "hasMore": true,
"lockedCount": 385, "archiveScope": "LAST_7_DAYS"
}
}Erros: 422 VALIDATION_ERROR (inclui from > to e limite fora da faixa),
429 RATE_LIMITED (acima de 60 consultas por minuto).
14.10.4 GET /api/devotionals/{devotionalId} #
- Efeitos colaterais: nenhum. Idempotente.
- Mesmo formato de
GET /api/devotionals/today.
Erros: 404 DEVOTIONAL_NOT_FOUND, 403 ENTITLEMENT_REQUIRED (fora do escopo do tier;
details.archiveScope informa o escopo permitido).
14.10.5 POST /api/devotionals/{devotionalId}/media-links #
- Efeitos colaterais: gera URL assinada. Não escreve no banco; registra log.
- Idempotência: cada chamada gera uma URL nova. Chamadas repetidas são seguras.
Request: { "purpose": "download" | "stream" }.
Resposta 200:
{
"data": {
"url": "https://media.palavradiaria.com.br/devotionals/2026/08/dev_01HZX.../voice.mp3?X-Amz-Expires=300&...",
"expiresAt": "2026-08-25T14:07:00.000Z",
"mimeType": "audio/mpeg",
"sizeBytes": 3421884,
"fileName": "palavra-diaria-2026-08-25.mp3"
},
"meta": { "requestId": "req_01HZXC4D5E6F7G8H9J0K1L2M3N", "timestamp": "2026-08-25T14:02:00.000Z" }
}TTL: 300 s para download, 900 s para stream.
A resposta é sempre Cache-Control: no-store: cada chamada emite uma URL assinada nova, e
uma URL assinada é credencial, não endereço. Ela nunca é registrada em log — os registros
referem-se à mídia por mediaKey (Seção 23.1.4).
Erros: 403 ENTITLEMENT_REQUIRED (tier FREE ou fora do escopo), 404 AUDIO_NOT_FOUND
(devocional sem áudio pronto), 429 RATE_LIMITED (mais de 20 por hora),
500 STORAGE_UNAVAILABLE.
14.10.6 POST /api/devotionals/{devotionalId}/resends #
- Efeitos colaterais: enfileira envio no WhatsApp, grava
delivery_attemptscomreason = 'MANUAL_RESEND', consome uma unidade do saldo diário. - Idempotência: protegida por
Idempotency-Keyobrigatório (ULID gerado pelo cliente no clique). Repetição com a mesma chave em até 10 minutos devolve200com o mesmo corpo e não consome saldo nem envia de novo.
Resposta 200:
{
"data": {
"queued": true,
"channel": "FREE_FORM",
"remainingToday": 1,
"resetsAt": "2026-08-26T03:00:00.000Z"
},
"meta": { "requestId": "req_01HZXC5E6F7G8H9J0K1L2M3N4P", "timestamp": "2026-08-25T14:05:00.000Z" }
}channel é FREE_FORM quando a janela de 24 h está aberta e TEMPLATE quando fechada.
A interface usa esse campo para escolher o texto do toast (14.3.3).
Erros:
| HTTP | code |
Quando |
|---|---|---|
| 429 | RESEND_LIMIT_REACHED |
Saldo diário zerado; details.resetsAt |
| 409 | OPTIN_REQUIRED |
opt_in_confirmed_at nulo |
| 409 | SUBSCRIBER_OPTED_OUT |
opt_out_at preenchido |
| 409 | SENDING_PAUSED |
paused_until > now(); details.pausedUntil |
| 403 | ENTITLEMENT_REQUIRED |
Devocional fora do escopo do tier |
| 404 | DEVOTIONAL_NOT_FOUND |
— |
| 400 | IDEMPOTENCY_KEY_REQUIRED |
Cabeçalho ausente |
14.10.7 GET /api/me/subscription #
- Efeitos colaterais: nenhum. Idempotente.
no-store.
Resposta 200 (PAID ativo):
{
"data": {
"id": "sbs_01HZX...",
"plan": { "code": "plan_annual", "name": "Anual", "cycle": "YEARLY",
"amountCents": 19900, "priceLabel": "R$ 199,00/ano" },
"status": "ACTIVE",
"statusLabel": "Ativa",
"billingType": "CREDIT_CARD",
"cardLast4": "4242",
"cardBrand": "VISA",
"startedAt": "2025-09-30T12:10:00.000Z",
"nextDueDate": "2026-09-30",
"currentPeriodEnd": "2026-09-30T02:59:59.000Z",
"canceledAt": null,
"canUpgradeToAnnual": false,
"canReactivateWithoutCharge": false
},
"meta": { "requestId": "req_01HZXC6F7G8H9J0K1L2M3N4P5Q", "timestamp": "2026-08-25T14:00:00.000Z" }
}Para FREE: { "data": null, "meta": { ... } } com HTTP 200. Ausência de assinatura não
é erro — e é exatamente o que significa ser gratuito (Seção 13.1). A tela monta o cartão
do plano gratuito a partir do item sintético de GET /api/public/plans (Seção 9.16.2);
esta rota nunca devolve um plan_free.
14.10.8 GET /api/me/payments #
- Query:
?limit=<1..50>&cursor=<opaque> - Efeitos colaterais: nenhum. Idempotente.
{
"data": [
{ "id": "pay_01HZX...", "amountCents": 19900, "currency": "BRL", "status": "RECEIVED",
"statusLabel": "Pago", "billingType": "CREDIT_CARD", "dueDate": "2025-09-30",
"confirmedAt": "2025-09-30T12:11:00.000Z", "receivedAt": "2025-09-30T12:11:00.000Z",
"receiptUrl": "https://www.asaas.com/comprovante/...", "invoiceUrl": null }
],
"meta": { "requestId": "req_01HZXC7G8H9J0K1L2M3N4P5Q6R",
"timestamp": "2026-08-25T14:00:00.000Z",
"nextCursor": null, "hasMore": false }
}Erros: 422 VALIDATION_ERROR.
14.10.9 POST /api/me/subscription/cancel #
- Efeitos colaterais: cancela na Asaas, atualiza
subscriptions, gravasubscription_events, dispara e-mail e mensagem no WhatsApp. - Idempotência:
Idempotency-Keyobrigatório (Seção 7.7.1), porque a operação propaga uma escrita para a Asaas. Replay da mesma chave devolve a resposta gravada comIdempotent-Replay: true. Chamada sem a chave devolve400 IDEMPOTENCY_KEY_REQUIRED. Repetição com chave nova depois do sucesso devolve409 SUBSCRIPTION_NOT_ACTIVE, comstatus: 'CANCELED'e a mesmaaccessUntil; a Asaas não é chamada de novo.
Request:
export const cancelSubscriptionSchema = z.object({
reason: z.enum(['TOO_EXPENSIVE','TOO_MANY_MESSAGES','CONTENT_NOT_FOR_ME',
'AUDIO_QUALITY','TECHNICAL_ISSUES','TEMPORARY','OTHER']),
comment: z.string().trim().max(500).optional(),
confirmed: z.literal(true),
});Resposta 200:
{
"data": {
"status": "CANCELED",
"accessUntil": "2026-09-30T02:59:59.000Z",
"accessUntilLabel": "30 de setembro de 2026",
"canReactivateWithoutCharge": true,
"refund": null
},
"meta": { "requestId": "req_01HZXC8H9J0K1L2M3N4P5Q6R7S", "timestamp": "2026-08-25T14:10:00.000Z" }
}Erros: 404 SUBSCRIPTION_NOT_FOUND, 409 SUBSCRIPTION_NOT_ACTIVE (já cancelada),
409 ILLEGAL_STATE_TRANSITION (já EXPIRED ou REFUNDED), 422 VALIDATION_ERROR,
400 IDEMPOTENCY_KEY_REQUIRED, 422 IDEMPOTENCY_KEY_REUSED, 502 ASAAS_ERROR (a Asaas
recusou; o estado local não é alterado e a interface pede para tentar de novo).
14.10.10 POST /api/me/subscription/reactivate #
- Efeitos colaterais: recria a assinatura na Asaas com
nextDueDate = current_period_end, atualiza estado local. - Idempotência:
Idempotency-Keyobrigatório.
Resposta 200: mesmo formato de GET /api/me/subscription.
Erros: 404 SUBSCRIPTION_NOT_FOUND, 409 REACTIVATION_WINDOW_CLOSED
(now() >= current_period_end; a interface então leva ao checkout normal),
502 ASAAS_ERROR.
14.10.11 PATCH /api/me #
- Efeitos colaterais: atualiza
namee/ouemail; troca de e-mail dispara verificação. - Idempotência: sim; enviar os mesmos valores é no-op.
export const updateMeSchema = z.object({
name: z.string().trim().min(2).max(80).regex(NAME_ALLOWED).optional(),
email: z.union([z.literal(''), z.email().max(160)]).optional(),
}).refine((v) => Object.keys(v).length > 0, { message: 'Nada para atualizar.' });Resposta 200: mesmo corpo de GET /api/me, com emailVerified: false quando o e-mail
mudou.
Erros: 422 VALIDATION_ERROR, 409 EMAIL_ALREADY_IN_USE, 429 RATE_LIMITED
(mais de 10 alterações por hora).
14.10.12 PUT /api/me/preferences #
- Efeitos colaterais: grava os interruptores de e-mail. Não grava
paused_until: a pausa tem endpoint próprio (14.10.12.1), porque é uma ação de estado com registro de consentimento, e não uma preferência. - Idempotência: sim; é um PUT com o estado completo.
export const preferencesSchema = z.object({
weeklyEmailDigest: z.boolean(),
billingEmails: z.boolean(), // travado em true enquanto houver assinatura ativa
});Resposta 200:
{
"data": { "weeklyEmailDigest": false, "billingEmails": true },
"meta": { "requestId": "req_01HZXC9J0K1L2M3N4P5Q6R7S8T", "timestamp": "2026-08-25T14:15:00.000Z" }
}Erros: 422 VALIDATION_ERROR.
14.10.12.1 POST /api/me/pause e DELETE /api/me/pause #
POST inicia ou substitui a pausa: grava paused_until (03:00 de
America/Sao_Paulo do dia seguinte ao último dia pausado, 14.7.2),
status = 'PAUSED', e consent_events com type = 'PAUSE_STARTED'.
export const pauseSchema = z.object({
days: z.number().int().min(1).max(30).optional(),
until: z.iso.date().optional(),
}).refine((v) => Boolean(v.days) !== Boolean(v.until), {
message: 'Informe a duração em dias ou a data final, nunca as duas.',
}).superRefine((v, ctx) => {
if (!v.until) return;
const days = differenceInCalendarDays(parseISO(v.until), todayInSaoPaulo());
if (days < 1) ctx.addIssue({ code: 'custom', path: ['until'],
message: 'Escolha uma data a partir de amanhã.' });
if (days > 30) ctx.addIssue({ code: 'custom', path: ['until'],
message: 'A pausa pode ser de no máximo 30 dias.' });
});Resposta 200:
{
"data": { "pausedUntil": "2026-09-02T06:00:00.000Z",
"lastPausedDayLabel": "1 de setembro de 2026",
"resumesOnLabel": "2 de setembro de 2026" },
"meta": { "requestId": "req_01HZXC9J0K1L2M3N4P5Q6R7S8T", "timestamp": "2026-08-25T14:15:00.000Z" }
}Pausa sobre pausa substitui a anterior; não soma. A rota devolve 200 com a nova data.
DELETE encerra a pausa agora: paused_until = NULL, status volta ao correspondente
ao tier, consent_events com type = 'PAUSE_ENDED'.
Erros de ambos: 422 VALIDATION_ERROR, 429 PAUSE_LIMIT_EXCEEDED,
409 SUBSCRIBER_OPTED_OUT (não faz sentido pausar quem já saiu; a interface oferece
reativar primeiro), 409 ILLEGAL_STATE_TRANSITION (DELETE sem pausa ativa).
14.10.13 POST /api/me/opt-out #
POST registra o opt-out: opt_out_at, status = 'OPTED_OUT', consent_events com
type = 'OPT_OUT' e channel = 'WEB', suspensão da cobrança do ciclo seguinte quando
houver assinatura paga vigente (Seção 13.4.6), e confirmação por e-mail quando houver e-mail
verificado — não se envia WhatsApp quando a origem é o painel (Seção 20.4.1).
Request: { "reason": "PANEL", "alsoCancelSubscription": false }.
Quando alsoCancelSubscription é true, o mesmo handler executa o cancelamento de
14.10.9 com reason = 'TOO_MANY_MESSAGES' na mesma transação lógica, e aí o
Idempotency-Key também é obrigatório.
Resposta 200: { "data": { "optedOutAt": "...", "billingSuspended": true, "subscriptionCanceled": false, "autoCancelAt": "2026-09-24T14:20:00.000Z" }, "meta": {...} }.
Não existe DELETE /api/me/opt-out. A reativação do recebimento é
POST /api/me/opt-in (Seção 20.12.2), que exige o consentimento vigente e grava
consent_events com type = 'RE_OPT_IN'; a cobrança volta a correr no ciclo seguinte. A
escolha é deliberada: reativar consentimento é ato positivo do titular, não remoção de
recurso, e a Seção 20 é a dona do opt-in e do opt-out.
Erros: 409 ILLEGAL_STATE_TRANSITION (já em OPTED_OUT), 422 VALIDATION_ERROR,
400 IDEMPOTENCY_KEY_REQUIRED e 502 ASAAS_ERROR — os dois últimos apenas quando
alsoCancelSubscription é true; nesse caso o opt-out em si é aplicado e o cancelamento na
Asaas é reenfileirado. Falha no envio da confirmação por e-mail não impede a mudança de
estado nem aparece na resposta: a confirmação já foi dada na tela e o e-mail é reenfileirado.
14.10.14 POST /api/me/phone-changes (fluxo de três chamadas) #
Três endpoints encadeados, conforme 14.6.2:
| Passo | Endpoint | Request | Resposta |
|---|---|---|---|
| 1 | POST /api/me/phone-changes |
{} |
{ "changeId": "phc_01H...", "otpSentTo": "(11) 9****-5678", "expiresAt": "..." } |
| 2 | POST /api/me/phone-changes/{changeId}/current-verifications |
{ "code": "123456" } |
{ "step": "NEW_PHONE" } |
| 3 | POST /api/me/phone-changes/{changeId}/new-phone |
{ "phone": "(21) 98765-4321" } |
{ "otpSentTo": "(21) 9****-4321", "expiresAt": "..." } |
| 4 | POST /api/me/phone-changes/{changeId}/new-verifications |
{ "code": "654321" } |
{ "applied": true, "phoneMasked": "(21) 9****-4321", "sessionsRevoked": 3, "reloginRequired": true } |
- Idempotência: cada
changeIdé de uso único e expira em 30 minutos. - Efeitos colaterais: apenas no passo 4, tudo em uma transação.
sessionsRevokedconta todas as sessões, inclusive a que executou a troca;reloginRequiredé sempretruee a interface leva a pessoa ao login com o número novo (14.6.2).
Erros: 422 VALIDATION_ERROR, 422 OTP_INVALID, 410 OTP_EXPIRED,
429 OTP_ATTEMPTS_EXCEEDED, 409 PHONE_ALREADY_REGISTERED, 429 PHONE_CHANGE_LIMIT,
404 PHONE_CHANGE_NOT_FOUND (expirado), 409 ILLEGAL_STEP (chamar o passo 4 antes do 3).
14.10.15 POST /api/me/data-exports, GET /api/me/data-exports/current e POST /api/me/data-exports/current/download-links #
POST /api/me/data-exports enfileira o job de exportação na fila
maintenance.cleanup e grava consent_events com type = 'DATA_EXPORT_REQUESTED'.
Resposta 202: { "data": { "exportId": "exp_01H...", "state": "QUEUED", "estimatedSeconds": 30 }, "meta": {...} }.
GET /api/me/data-exports/current consulta o estado. Ele não devolve URL: o
arquivo não é entregue por link portador (14.8.1). Resposta 200:
{
"data": { "exportId": "exp_01H...", "state": "READY",
"readyAt": "2026-08-25T14:17:00.000Z",
"expiresAt": "2026-09-01T14:17:00.000Z",
"downloadsUsed": 0, "downloadsAllowed": 3, "sizeBytes": 18422 },
"meta": { "requestId": "req_01HZXD1K2L3M4N5P6Q7R8S9T0U", "timestamp": "2026-08-25T14:17:00.000Z" }
}Estados: QUEUED, PROCESSING, READY, FAILED, EXPIRED.
POST /api/me/data-exports/current/download-links emite a URL assinada no momento do
clique, com TTL de 15 minutos, vinculada à sessão, e incrementa downloadsUsed. Exige
sessão plena (nunca restrita, 14.9) e registra admin_audit_log com
action = 'DATA_EXPORT_DOWNLOADED'. Resposta Cache-Control: no-store.
Resposta 200: { "data": { "url": "https://media.palavradiaria.com.br/exports/...?X-Amz-Expires=900&...", "expiresAt": "2026-08-25T14:32:00.000Z", "downloadsRemaining": 2 }, "meta": {...} }.
Erros: 429 EXPORT_RATE_LIMITED (details.retryAfterSeconds; até 2 pedidos por mês, e
até 3 downloads por arquivo), 404 EXPORT_NOT_FOUND (inexistente, expirado ou de outro
assinante — a resposta é a mesma nos três casos), 403 REVERIFICATION_REQUIRED
(sessão restrita), 500 STORAGE_UNAVAILABLE.
14.10.16 POST /api/me/deletion-request #
- Efeitos colaterais: tudo o que está em 14.8.2.
- Idempotência: chamar de novo em conta com
deleted_atjá preenchido devolve200sem novo efeito.
Request:
export const deleteAccountSchema = z.object({
confirmation: z.literal('EXCLUIR', { error: 'Digite EXCLUIR para confirmar.' }),
reason: z.string().trim().max(500).optional(),
});Resposta 200:
{
"data": { "deletedAt": "2026-08-25T14:20:00.000Z",
"anonymizationScheduledFor": "2026-09-24T14:20:00.000Z",
"subscriptionCanceled": true,
"reversalDeadline": "2026-08-28T14:20:00.000Z" },
"meta": { "requestId": "req_01HZXD2L3M4N5P6Q7R8S9T0U1V", "timestamp": "2026-08-25T14:20:00.000Z" }
}Erros: 422 CONFIRMATION_MISMATCH, 502 ASAAS_ERROR (a exclusão prossegue mesmo
assim; o cancelamento na Asaas é reenfileirado e um alerta operacional é criado — nunca
travar um direito LGPD por falha de terceiro).
14.10.17 GET /api/me/consents #
- Efeitos colaterais: nenhum. Idempotente.
- Query:
?limit=<1..100>&cursor=<opaque>
{
"data": [
{ "id": "cev_01HZX...", "type": "OPT_IN_WHATSAPP", "typeLabel": "Confirmação no WhatsApp",
"channel": "WHATSAPP", "policyVersion": "2025-09-01",
"text": "Ao confirmar, você concorda em receber...",
"createdAt": "2025-09-30T15:04:00.000Z" }
],
"meta": { "requestId": "req_01HZXD3M4N5P6Q7R8S9T0U1V2W",
"timestamp": "2026-08-25T14:00:00.000Z",
"nextCursor": null, "hasMore": false }
}O campo ip não é retornado ao assinante nesta rota, apenas na exportação completa
(14.8.1), para reduzir exposição em tela.
14.11 Comportamento em tempo real #
Decisão: não há WebSocket, não há Server-Sent Events, não há long polling. O painel é um leitor de estado que muda poucas vezes por dia. Manter uma conexão persistente por assinante custaria memória e complexidade operacional (sticky sessions no proxy, reconexão, autenticação de socket) sem benefício proporcional.
A atualização usa revalidação do TanStack Query com intervalos explícitos.
14.11.1 Tabela de configuração por consulta #
| Chave da query | staleTime |
refetchInterval |
refetchOnWindowFocus |
Justificativa |
|---|---|---|---|---|
['me'] |
60 s | Nenhum | Sim | Muda por ação do próprio usuário. |
['devotional','today'] |
5 min | 30 s somente enquanto audio.state === 'PENDING'; caso contrário nenhum |
Sim | O único estado que muda sozinho é o áudio sendo gerado. |
['devotionals', filtros] |
5 min | Nenhum | Não | Conteúdo histórico é imutável. |
['devotional', id] |
30 min | Nenhum | Não | Imutável depois de publicado. |
['subscription','current'] |
30 s | 20 s somente enquanto status === 'PENDING_PAYMENT'; caso contrário nenhum |
Sim | Espera de confirmação de PIX é o único caso de mudança externa observada em tela. |
['payments', cursor] |
60 s | Nenhum | Sim | — |
['consents', cursor] |
5 min | Nenhum | Não | Append-only e raro. |
['dataExport','current'] |
0 | 10 s enquanto state ∈ {QUEUED, PROCESSING} |
Sim | Job curto. |
['signup','current'] |
0 | 5 s nos 2 primeiros minutos, 15 s depois, parada total em 10 min | Sim | Espera do opt-in (Seção 11.6.3). |
14.11.2 Regras gerais de revalidação #
- Todo
refetchIntervalé condicional ao estado e desligado assim que o estado alvo é alcançado. Nenhum polling perpétuo existe no produto. - Todo polling para completamente após 10 minutos de tela aberta sem mudança, e a interface passa a exibir um botão "Atualizar". Isso protege contra abas esquecidas abertas por dias.
refetchOnReconnect: trueglobalmente: voltar da falta de conexão revalida.- Mutações invalidam explicitamente as chaves afetadas. Exemplos normativos:
- Cancelar assinatura → invalida
['subscription','current'],['me'],['payments']. - Reenviar no WhatsApp → invalida
['me'](para atualizarresendsUsedToday). - Pausar → invalida
['me']. - Trocar telefone → invalida tudo (
queryClient.clear()), porque a sessão mudou.
- Cancelar assinatura → invalida
- Atualização otimista é usada apenas em
PUT /api/me/preferencese no interruptor de e-mail, com rollback em erro. Nunca em operações de dinheiro: cancelamento, upgrade e reativação sempre esperam a resposta do servidor. retry: 2 tentativas com backoff exponencial (1 s, 3 s) paraGET; zero tentativas automáticas paraPOST/PATCH/PUT/DELETE, para não duplicar efeito. A idempotência porIdempotency-Keyexiste para o caso de o usuário clicar de novo, não para retry automático.gcTimede 10 minutos, para que voltar a uma tela recém-visitada seja instantâneo.
15. Painel Administrativo e Calendário Editorial #
15.1 Escopo, rotas e layout #
O painel administrativo é a ferramenta de trabalho da equipe editorial e de operação. Ele cobre cinco atividades: produzir conteúdo, agendar conteúdo, acompanhar envios, atender assinantes e configurar o sistema.
Ele vive no route group (admin) de apps/web, sob /admin, servido em
app.palavradiaria.com.br. Exige sessão de administrador com TTL de 12 horas e TOTP
obrigatório (Seção 8). Todas as rotas são noindex, nofollow e bloqueadas em
robots.txt (Seção 9.11.2).
| Rota | Propósito | Papel mínimo |
|---|---|---|
/admin |
Painel inicial: contadores do dia, alertas e atalhos. | EDITOR |
/admin/devocionais |
Lista e busca de devocionais; criação e edição. | EDITOR |
/admin/devocionais/[id] |
Editor de devocional. | EDITOR |
/admin/devocionais/[id]/revisoes |
Histórico de revisões, comparação e restauração. | EDITOR |
/admin/calendario |
Calendário editorial mensal. | EDITOR |
/admin/assinantes |
Busca e gestão de assinantes. | ADMIN |
/admin/assinantes/[id] |
Ficha completa do assinante. | ADMIN |
/admin/envios |
Lote do dia, progresso, falhas, reprocessamento. | EDITOR (leitura) / ADMIN (ações) |
/admin/envios/[batchId] |
Detalhe de um lote. | EDITOR (leitura) / ADMIN (ações) |
/admin/templates |
Templates do WhatsApp e status na Meta. | ADMIN |
/admin/configuracoes |
Planos, preços, chaves de configuração, feature flags, administradores. | ADMIN para abrir; abas de Planos e de Administradores exigem OWNER (15.11) |
/admin/auditoria |
Trilha de auditoria com filtros e exportação. | ADMIN (exportar exige OWNER) |
/admin/metricas |
Visão geral das métricas do produto. | ADMIN |
/admin/metricas/crescimento |
Métricas de crescimento e funil. | ADMIN |
/admin/metricas/receita |
Métricas de receita e assinaturas. | ADMIN |
/admin/metricas/entrega |
Métricas de entrega e qualidade do canal. | ADMIN |
/admin/metricas/conteudo |
Métricas de conteúdo e engajamento editorial. | EDITOR |
/admin/metricas/custos |
Métricas de custo por mensagem, por áudio e por assinante. | ADMIN |
As telas de métricas são especificadas na Seção 21.8; esta tabela é o mapa completo de rotas do painel e nenhuma seção acrescenta rota fora dela. Os caminhos são em português porque toda a interface, inclusive o painel administrativo, é em português do Brasil (decisão Q20, Seção 1.22).
A matriz completa de permissões por papel (SUBSCRIBER, EDITOR, ADMIN, OWNER) é
canônica na Seção 3.8. Esta seção usa os papéis; não os redefine. Onde a tabela acima
distingue leitura de ação, a distinção vem da Seção 3.8 e vale também para os endpoints
correspondentes em 15.12.
15.1.1 Layout #
- Barra lateral fixa de 260 px, colapsável para 64 px (ícones), com o estado salvo em
localStorage. Em< lg, viraSheet. - Cabeçalho com: busca global (
Cmd/Ctrl + K), seletor de ambiente com cor de fundo distinta emstaging(faixa amarela com o texto "AMBIENTE DE TESTE"), sino de alertas e menu do usuário. - Densidade de informação alta: tabelas com linhas de 40 px, tipografia de 14 px. O painel administrativo é uma ferramenta de uso repetido, não uma superfície de marketing.
- Atalhos de teclado globais:
Cmd/Ctrl + K(busca),gd(devocionais),gc(calendário),ga(assinantes),ge(envios),?(lista de atalhos). - A busca global procura, em paralelo: devocionais por título, assinantes por telefone,
nome ou e-mail (apenas para
ADMIN+), e lotes por data.
15.1.2 Painel inicial (/admin) #
Cartões, em ordem:
- Envio de hoje: status do lote (
planejado,em andamento,concluído,falhou), contagem por status e barra de progresso. Link para/admin/envios. - Conteúdo agendado: quantos dias de devocional já existem à frente. Cor negativa quando faltarem menos de 7 dias (15.5.4).
- Assinantes: total ativo, novos nos últimos 7 dias, FREE × PAID.
- Falhas recentes: últimas 10 falhas de envio com motivo, se houver.
- Alertas abertos: qualidade do número do WhatsApp, templates rejeitados, tier de mensagens próximo do limite, job travado. Fonte na Seção 23.
Os gráficos ficam nas telas de métricas /admin/metricas* (Seção 21.8); o painel inicial
mostra apenas números e estados operacionais. Os contadores agregados vêm de
GET /api/admin/metrics/overview (Seção 21.9), nunca de um total embutido em resposta
paginada.
15.2 Editor de devocional #
Rota /admin/devocionais/[id]. Layout em duas colunas em >= xl: formulário à esquerda,
pré-visualização do WhatsApp à direita, sticky. Em telas menores, a pré-visualização vira
uma aba.
15.2.1 Campos #
| Campo | Rótulo | Tipo | Obrigatório para READY |
Limite | Contador |
|---|---|---|---|---|---|
title |
Título | texto | Sim | 3–80 caracteres | Sim, a partir de 60 |
bible_reference |
Referência bíblica | texto | Sim | 3–60 caracteres | Não |
bible_version |
Versão | select | Sim | — | — |
bible_text |
Texto bíblico | textarea | Sim | 10–1200 caracteres | Sim, a partir de 900 |
reflection_md |
Reflexão | editor markdown | Sim | 200–3500 caracteres | Sim, sempre |
prayer |
Oração | textarea | Sim | 20–800 caracteres | Sim, a partir de 600 |
teaser |
Teaser | texto de uma linha | Sim | 40–300 caracteres | Sim, sempre |
scheduled_for |
Data de envio | date | Sim | — | — |
internal_notes |
Notas internas | textarea | Não | 0–1000 caracteres | Não |
O select de bible_version lista apenas versões de domínio público ou de uso livre, a
partir da lista fechada mantida em settings sob content.allowed_bible_versions, cujo
valor padrão é ["ALMEIDA_1911","BIBLIA_LIVRE"]. O padrão do editor é ALMEIDA_1911, com
BIBLIA_LIVRE como alternativa. A sigla ARC é proibida como código e como
atribuição: no mercado brasileiro ela identifica a Almeida Revista e Corrigida em edição
revisada por editora ativa, que é obra protegida, e não a edição de 1911 em domínio
público. A atribuição impressa em toda entrega é Salmos 23:1-3 (Almeida 1911) ou
Salmos 23:1-3 (Bíblia Livre), nunca uma sigla ambígua. Versões licenciadas não aparecem
na lista, e o risco jurídico de usá-las está documentado na Seção 22.10.3. Escolher uma
versão fora da lista não é possível pela interface, e o editor nunca cola versículo de
fonte externa: ele seleciona a referência e o sistema busca o texto na base importada.
O campo internal_notes nunca é enviado ao assinante nem exportado.
15.2.2 Contadores de caracteres e os limites reais do WhatsApp #
Os contadores não são decorativos: cada um corresponde a um limite técnico real da
WhatsApp Cloud API. A interface explica o motivo em um tooltip acessível
(aria-describedby, não apenas title).
| Campo | Limite exibido | Limite técnico de origem | O que acontece se estourar |
|---|---|---|---|
teaser |
40 a 300 | Parâmetro de corpo de template; o corpo inteiro tem 1024 caracteres e precisa caber com o título. O mínimo de 40 é o mesmo do CHECK do banco (Seção 6.30), então editor e banco nunca discordam |
Bloqueia salvar como READY |
title |
80 | Também vai como parâmetro {{1}} do template diário |
Bloqueia salvar como READY |
| Mensagem completa montada | 4096 | Limite de mensagem de texto free-form | Bloqueia a transição para READY |
reflection_md |
3500 | Margem para que a mensagem montada caiba em 4096 | Aviso em 3500, bloqueio quando a montagem estourar 4096 |
O contador da mensagem montada é o mais importante e fica em destaque acima da
pré-visualização. Ele roda a mesma função de montagem que o motor de envio usa
(buildDevotionalMessage() em packages/core), então o número exibido é exatamente o
número real. Formato: 3.412 / 4.096 caracteres. Passa a laranja em 3.700 e a vermelho em
4.096.
Contador do teaser com a validação de sanitização junto:
Teaser: 187 / 300 · mínimo 40 · sem quebras de linha · sem espaços duplicadosCada uma das quatro condições é um indicador com estado próprio (neutro, ok, erro), com texto e ícone, nunca só cor.
15.2.3 Pré-visualização fiel do WhatsApp #
A pré-visualização reproduz, com fidelidade visual, as três mensagens que o assinante recebe. Ela é a principal ferramenta de revisão editorial.
Aba 1 — Template (janela fechada). Renderiza a bolha do template
devocional_diario_v1 com:
- Header TEXT estático.
- Corpo com
{{1}}= título e{{2}}= teaser, já substituídos. - Footer estático.
- Os dois botões de resposta rápida.
- Um selo "Template — enviado quando a janela de 24 h está fechada".
Aba 2 — Mensagem completa (janela aberta). Renderiza a bolha de texto livre com o devocional montado, aplicando a formatação real do WhatsApp:
*negrito*renderizado em negrito,_itálico_em itálico,~riscado~riscado,```mono```em monoespaçado.- Quebras de linha e parágrafos exatamente como serão enviados.
- Contador de caracteres da bolha.
- Aviso quando a mensagem passar de 4096.
Aba 3 — Áudio. Renderiza a bolha de mensagem de voz com a duração real do
audio_assets quando existir, ou um estado "áudio ainda não gerado".
Regras de fidelidade obrigatórias:
- A conversão de markdown para a sintaxe do WhatsApp usa a mesma função do motor de
envio (
markdownToWhatsApp()empackages/core). A pré-visualização nunca reimplementa a conversão. - Elementos de markdown que o WhatsApp não suporta (títulos
#, links[x](y), tabelas, imagens) são convertidos de forma determinística e a pré-visualização mostra um aviso inline explicando o que aconteceu. Exemplo:## Reflexãovira*Reflexão*e o aviso diz "Títulos viram negrito no WhatsApp." - A pré-visualização respeita
prefers-color-scheme, com os dois temas do WhatsApp, para que o editor confira contraste. - Um botão "Enviar teste para meu WhatsApp" dispara o pacote completo para o número cadastrado do administrador logado. Limite: 10 testes por administrador por dia. Registrado em auditoria.
15.2.4 Geração assistida do teaser #
Regra determinística, sem IA. A geração de devocional por IA está fora de escopo (Seção 2), e o teaser é conteúdo enviado ao assinante — precisa ser previsível e reproduzível.
Algoritmo, executado no cliente e revalidado no servidor:
// packages/core/src/content/teaser.ts
const MIN_TEASER = 40;
const MAX_TEASER = 300;
/**
* Devolve `null` quando não é possível produzir um teaser válido a partir da reflexão.
* `null` obriga o editor a escrever o teaser à mão; nunca devolve texto abaixo do mínimo,
* porque o banco e o schema de escrita rejeitam menos de 40 caracteres (15.2.5).
*/
export function generateTeaser(reflectionMd: string): string | null {
// 1. Remover a marcação de markdown, preservando o texto.
let text = stripMarkdown(reflectionMd);
// 2. Sanitizar: remover quebras de linha, tabulações, zero-width e
// colapsar qualquer sequência de espaços em um único espaço.
text = text
.replace(/[\r\n\t
]+/g, ' ')
.replace(/[-]/g, '')
.replace(/ {2,}/g, ' ')
.trim();
// 3. Piso duro: reflexão curta demais não produz teaser. Quem escreve é o editor.
if (text.length < MIN_TEASER) return null;
if (text.length <= MAX_TEASER) return text;
// 4. Cortar no último FIM DE FRASE antes do limite.
const window = text.slice(0, MAX_TEASER);
const lastSentence = Math.max(
window.lastIndexOf('. '), window.lastIndexOf('! '),
window.lastIndexOf('? '), window.lastIndexOf('… '),
);
if (lastSentence >= 120) return window.slice(0, lastSentence + 1).trim();
// 5. Sem fim de frase utilizável: cortar na última PALAVRA e usar reticências.
const hardLimit = MAX_TEASER - 1; // reserva o caractere do "…"
const cut = text.slice(0, hardLimit);
const lastSpace = cut.lastIndexOf(' ');
const base = (lastSpace >= 120 ? cut.slice(0, lastSpace) : cut).replace(/[,;:.\-–—]+$/, '');
const result = `${base.trim()}…`;
return result.length >= MIN_TEASER ? result : null;
}Propriedades garantidas, verificadas por teste de propriedade (Seção 24):
- O resultado nunca passa de 300 caracteres.
- O resultado, quando não é
null, nunca tem menos de 40 caracteres. Esse é o mesmo piso doteaserSchema(15.2.5) e doCHECKdo banco (Seção 6.30): a função jamais produz um valor que a escrita rejeitaria em seguida. - O resultado nunca contém
\n,\r,\tnem dois espaços seguidos. - O resultado nunca termina em pontuação solta (
,,;,:,-). - A função é pura: a mesma reflexão sempre produz o mesmo teaser, ou sempre
null. - O corte prefere fim de frase; só usa reticências quando o fim de frase cairia antes de 120 caracteres, o que produziria um teaser curto demais para atrair.
Interface: botão "Gerar do texto" ao lado do campo teaser. Se o campo já tiver conteúdo,
o clique abre uma confirmação ("Substituir o teaser atual?"). Quando a função devolve
null — reflexão curta demais —, o botão não escreve nada e a interface explica: "A
reflexão ainda é curta demais para gerar um teaser. Escreva o teaser à mão." O teaser
gerado é sempre editável à mão: a geração é uma sugestão, não uma imposição.
Exemplo concreto:
Reflexão (início): "## Confiança\n\nO salmo 23 começa com uma declaração de posse.
Davi não diz que o Senhor é *um* pastor. Ele diz que é **o meu** pastor.
A diferença é enorme: uma é teologia, a outra é biografia.\n\nQuando..."
Teaser gerado: "Confiança O salmo 23 começa com uma declaração de posse. Davi não diz
que o Senhor é um pastor. Ele diz que é o meu pastor. A diferença é enorme: uma é
teologia, a outra é biografia." (198 caracteres, corte em fim de frase)15.2.5 Validação do teaser #
Validação no servidor, aplicada em toda escrita, não só na geração:
export const teaserSchema = z.string()
.trim()
.min(40, 'O teaser precisa de pelo menos 40 caracteres.')
.max(300, 'O teaser não pode passar de 300 caracteres.')
.refine((v) => !/[\r\n\t]/.test(v), 'O teaser não pode ter quebras de linha.')
.refine((v) => !/ {2,}/.test(v), 'O teaser não pode ter espaços duplicados.')
.refine((v) => !/[-]/.test(v), 'O teaser contém caracteres invisíveis.')
.refine((v) => !/ {4,}/.test(v), 'O teaser não pode ter 4 ou mais espaços seguidos.');Na interface, o campo teaser é um <input> de linha única, não um <textarea>, o
que impede fisicamente a digitação de Enter. Colar texto com quebras dispara uma
normalização automática no onPaste, com um aviso discreto: "Quebras de linha foram
removidas."
Por que tanto rigor: parâmetro de template do WhatsApp rejeita quebra de linha, tabulação
e 4 ou mais espaços consecutivos. Um teaser inválido faz o envio do dia inteiro falhar com
erro de template (faixa 132xxx), afetando todos os assinantes. A validação é barata; a
falha é cara.
15.2.6 Salvamento #
- Rascunho automático a cada 20 segundos quando houver alteração, sem sair de
DRAFT. O indicador mostra "Salvo às 14:32". Cmd/Ctrl + Ssalva imediatamente.- Cada salvamento cria uma revisão (15.6).
- Bloqueio otimista: o cliente envia
expectedVersion(oversioninteiro do registro). Se o servidor tiver versão maior, devolve409 STALE_WRITEcom o nome de quem editou e quando. A interface oferece "Ver diferenças" e "Sobrescrever mesmo assim". - Aviso de edição concorrente: um indicador leve mostra "Ana está editando este devocional"
quando outra sessão administrativa abriu o mesmo registro nos últimos 2 minutos.
Implementado com uma chave Redis
editing:{devotionalId}:{adminId}com TTL de 120 s, atualizada por batida a cada 45 s. Não é bloqueio; é informação. - Sair da página com alterações não salvas dispara
beforeunloade umDialogna navegação interna.
15.3 Máquina de estados editorial #
Enum DevotionalStatus: DRAFT, READY, AUDIO_PENDING, AUDIO_READY, PUBLISHED,
SENT.
┌─────────┐ validação completa ┌─────────┐
│ DRAFT │──────────────────────▶│ READY │
└─────────┘ └────┬────┘
▲ │ enfileira tts.generate
│ voltar para edição │ (automático)
│ (EDITOR, só antes de PUBLISHED) ▼
│ ┌───────────────┐ falha após 3 tentativas
├──────────────────────────│ AUDIO_PENDING │──────────────┐
│ └───────┬───────┘ │
│ │ áudio gerado e ▼
│ │ transcodificado (permanece AUDIO_PENDING,
│ ▼ alerta operacional)
│ ┌───────────────┐
│ │ AUDIO_READY │
│ └───────┬───────┘
│ │ publicar (ADMIN)
│ ▼
│ ┌───────────────┐
└──────────────────────────│ PUBLISHED │
despublicar (ADMIN, └───────┬───────┘
só antes de SENT) │ motor de envio conclui o lote
▼
┌───────────────┐
│ SENT │ (terminal)
└───────────────┘15.3.1 Tabela de transições #
| De | Para | Quem pode | Guardas | Efeitos |
|---|---|---|---|---|
| — | DRAFT |
EDITOR |
scheduled_for livre naquela data |
Cria devotionals e a revisão 1. |
DRAFT |
READY |
EDITOR |
Todos os campos obrigatórios preenchidos e válidos; teaser válido (15.2.5); mensagem montada ≤ 4096; scheduled_for não está no passado |
Enfileira tts.generate; transição automática para AUDIO_PENDING no mesmo commit. |
READY |
AUDIO_PENDING |
Sistema | — | Job enfileirado. |
AUDIO_PENDING |
AUDIO_READY |
Sistema | audio_assets com OGG e MP3 prontos e dentro do limite de tamanho (Seção 16) |
Notifica o canal editorial (Seção 23). |
AUDIO_PENDING |
AUDIO_PENDING |
Sistema | Falha do provedor | Retentativa; após 3 falhas, alerta e audio_assets.status = 'FAILED'. |
AUDIO_READY |
PUBLISHED |
ADMIN |
Guarda dura: ver 15.3.2 | published_at; revalida / e o cache público (Seção 9.12.2). |
READY | AUDIO_PENDING |
PUBLISHED |
ADMIN |
Só se não houver assinantes PAID ativos (15.3.2) | Idem. |
PUBLISHED |
SENT |
Sistema | Lote do dia concluído | sent_at; send_batches fechado. |
DRAFT | READY | AUDIO_PENDING | AUDIO_READY |
DRAFT |
EDITOR |
Não pode estar PUBLISHED nem SENT |
Cancela jobs de TTS pendentes. |
PUBLISHED |
AUDIO_READY |
ADMIN |
sent_at IS NULL e o lote do dia ainda não começou |
Despublica; revalida cache; auditoria obrigatória com motivo. |
SENT |
qualquer | — | Proibido. Terminal. | — |
| qualquer | (excluído) | EDITOR quando status = 'DRAFT'; OWNER nos demais casos |
Qualquer estado diferente de DRAFT exige despublicar antes |
Soft delete (deleted_at). O papel é resolvido pelo estado do recurso, não pela rota (Seção 3.8). |
Toda transição é registrada em admin_audit_log com ator, estado de origem, estado de
destino e o motivo quando exigido.
15.3.2 A guarda de publicação #
Regra: não é possível publicar sem áudio pronto quando existirem assinantes pagos.
// packages/core/src/editorial/publish-guard.ts
export function canPublish(input: {
status: DevotionalStatus;
audioState: 'READY' | 'PENDING' | 'FAILED' | 'NONE';
activePaidSubscribers: number;
actorRole: AdminRole;
overrideReason?: string;
}): { allowed: boolean; code?: string; message?: string } {
if (!['READY', 'AUDIO_PENDING', 'AUDIO_READY'].includes(input.status)) {
return { allowed: false, code: 'ILLEGAL_STATE_TRANSITION',
message: 'Este devocional não pode ser publicado a partir do estado atual.' };
}
if (input.audioState === 'READY') return { allowed: true };
if (input.activePaidSubscribers === 0) return { allowed: true };
// Existem assinantes pagos e o áudio não está pronto.
if (input.actorRole === 'OWNER' && input.overrideReason && input.overrideReason.length >= 20) {
return { allowed: true }; // exceção registrada, ver abaixo
}
return {
allowed: false, code: 'AUDIO_REQUIRED',
message: 'Há assinantes do plano completo. Publique somente depois que o áudio estiver pronto.',
};
}Detalhes obrigatórios:
activePaidSubscribersé contado no momento da tentativa, comtier = 'PAID' AND opt_out_at IS NULL AND deleted_at IS NULL.- A única exceção é o papel
OWNER, e ela exige um motivo escrito de no mínimo 20 caracteres, que vai paraadmin_audit_loge para o alerta operacional. Isso existe para o cenário real de o provedor de TTS estar fora do ar e ser preferível entregar só o texto a não entregar nada. - Quando a exceção é usada, os assinantes PAID recebem o texto e uma linha adicional na mensagem de fechamento: "O áudio de hoje não ficou pronto a tempo. Pedimos desculpas." Esse texto é do catálogo da Seção 19.
- A interface mostra a guarda como um bloqueio explicativo no botão "Publicar", não como erro após o clique: o botão fica desabilitado com o texto do motivo ao lado.
15.3.3 Publicação agendada #
O botão "Publicar" tem uma segunda opção no menu: "Publicar automaticamente quando o áudio
ficar pronto". Ao marcar, devotionals.auto_publish = true e a transição
AUDIO_READY → PUBLISHED passa a ser executada pelo sistema, sem intervenção. A guarda de
15.3.2 continua valendo — só que ela é satisfeita por construção, já que o gatilho é o
áudio ficar pronto. É o modo recomendado e o padrão para conteúdo criado pelo calendário.
15.4 Lista de devocionais (/admin/devocionais) #
- Tabela com colunas: data agendada, título, status (badge com ícone e texto), áudio (ícone com estado), autor da última revisão, atualizado em.
- Filtros: status (multiseleção), intervalo de datas, autor, "com áudio pendente", "sem agendamento".
- Busca por título e referência bíblica, mesma implementação do acervo (Seção 14.4.3).
- Ordenação por data agendada (padrão, decrescente) ou por atualização.
- Paginação por cursor (Seção 7).
- Ações em massa, com confirmação e auditoria: mudar status para
READY, atribuir data, excluir rascunhos. Limite de 50 itens por operação em massa. - Botão "Novo devocional" abre o editor com
scheduled_forpré-preenchido com a primeira data livre no futuro.
15.5 Calendário editorial (/admin/calendario) #
15.5.1 Visão mensal #
- Grade de 7 colunas (segunda a domingo) × 5 ou 6 linhas, cobrindo o mês inteiro com os dias adjacentes em tom apagado.
- Cada célula mostra: número do dia, um cartão compacto do devocional (título truncado em duas linhas), um ponto colorido do status e um ícone de áudio.
- Domingos recebem uma marca discreta, porque é o dia de envio do tier FREE. O dia da
semana vem da chave de configuração
send.free_tier_weekday(Seção 26.8.1), e não de literal no código. Um rótulo "FREE + PAID" aparece no cabeçalho da coluna do dia configurado; os demais dias mostram "PAID". - Navegação: mês anterior/próximo, "Hoje", e um seletor de mês/ano. O mês corrente é o
padrão. A URL carrega o mês (
?mes=2026-08), tornando o estado compartilhável. - Legenda fixa com as seis cores de status e seus nomes, sempre com texto (nunca só cor).
15.5.2 Dias vazios #
- Célula sem devocional exibe um contorno tracejado e um botão "+" que abre o editor com a data pré-preenchida.
- Dias vazios no futuro dentro dos próximos 14 dias recebem destaque negativo (fundo levemente avermelhado e ícone de atenção).
- Dias vazios no passado recebem apenas o contorno tracejado, sem alarme: não há o que fazer sobre eles.
- Um contador no cabeçalho mostra "3 dias sem conteúdo nos próximos 14".
15.5.3 Arrastar para reagendar #
- Arrastar o cartão de um dia para outro reagenda o devocional (
scheduled_for). - Implementado com a HTML Drag and Drop API sobre um wrapper acessível.
- Alternativa obrigatória sem arrasto (WCAG 2.5.7, ver Seção 9.10.1): cada cartão tem
um menu de contexto (botão de três pontos, focável) com "Mover para outra data...", que
abre um seletor de data. Além disso, com o cartão focado,
Espaçoentra em modo de movimentação, as setas navegam entre células,Espaçoconfirma eEsccancela — com anúncio emaria-livea cada passo ("Movendo para 12 de setembro"). - Guardas do reagendamento:
| Situação | Comportamento |
|---|---|
| Destino já ocupado | Rejeita com 409 DATE_ALREADY_TAKEN e a mensagem "Já existe um devocional em 12/09. Troque as datas ou escolha outro dia." Oferece o botão "Trocar as duas datas". |
Devocional em SENT |
Não é arrastável. Cursor not-allowed, aria-disabled, e o menu de contexto explica: "Devocionais já enviados não podem ser reagendados." |
Devocional em PUBLISHED com lote do dia iniciado |
Bloqueado com a mesma mensagem. |
| Destino no passado | Permitido apenas para DRAFT, com confirmação: "Essa data já passou. O devocional não será enviado." |
| Origem e destino iguais | No-op silencioso. |
- Atualização otimista com rollback: o cartão move na hora; se a API falhar, ele volta e um
toast de erro aparece. Toda movimentação gera registro em
admin_audit_log.
15.5.4 Alerta de cobertura #
Regra: alerta quando faltarem menos de 7 dias de conteúdo agendado à frente.
Definição precisa de "dias de conteúdo à frente": a maior sequência contígua de dias,
a partir de amanhã, em que existe um devocional com status diferente de DRAFT. Um
rascunho não conta como cobertura, porque não pode ser enviado.
export function computeCoverageDays(input: {
today: Date; // em America/Sao_Paulo
scheduled: ReadonlyMap<string, DevotionalStatus>; // "YYYY-MM-DD" → status
}): number {
let days = 0;
for (let i = 1; i <= 60; i++) {
const key = formatISODate(addDays(input.today, i));
const status = input.scheduled.get(key);
if (!status || status === 'DRAFT') break;
days++;
}
return days;
}Onde o alerta aparece:
| Local | Forma |
|---|---|
/admin/calendario |
Faixa fixa no topo: "Restam 4 dias de conteúdo agendado. Programe até 29/08." |
/admin |
Cartão "Conteúdo agendado" em cor negativa. |
| E-mail diário às 09:00 | Enviado a todos os EDITOR e ADMIN quando a cobertura for < 7. |
| Alerta operacional | Severidade warning em < 7 dias, critical em < 3 dias (Seção 23). |
Exemplo concreto: hoje é 25/08. Existem devocionais PUBLISHED em 26, 27, 28 e 29/08, um
DRAFT em 30/08 e um READY em 31/08. A cobertura é 4, não 6, porque a sequência
contígua quebra no rascunho de 30/08. O alerta dispara.
15.5.5 Duplicar devocional #
- Ação no menu de contexto de qualquer cartão: "Duplicar".
- Abre um
Dialogpedindo a nova data (padrão: primeira data livre no futuro). - O que é copiado:
title(com o sufixo " (cópia)"),bible_reference,bible_version,bible_text,reflection_md,prayer,teaser,internal_notes. - O que não é copiado:
status(a cópia nasceDRAFT),audio_assets,published_at,sent_at, revisões,whatsapp_media_id. - Justificativa de não copiar o áudio: o áudio é derivado do texto e o texto quase sempre muda na cópia. Copiar áudio criaria um par texto/áudio divergente, que é a pior falha possível para o assinante.
- Se a data escolhida estiver ocupada:
409 DATE_ALREADY_TAKEN.
15.5.6 Importação em lote via CSV #
Ferramenta para carregar um mês inteiro de conteúdo de uma vez.
Formato aceito. UTF-8, com ou sem BOM. Separador , ou ; (detectado
automaticamente pela primeira linha). Aspas duplas para campos com separador ou quebra de
linha. Primeira linha obrigatoriamente o cabeçalho, com estes nomes exatos:
scheduled_for,title,bible_reference,bible_version,bible_text,reflection_md,prayer,teaser
2026-09-01,"O Senhor é o meu pastor","Salmos 23:1-3",ALMEIDA_1911,"O SENHOR é o meu pastor...","## Confiança\n\nO salmo 23 começa...","Senhor, ensina-me...","Quando o pastor guia, a ovelha não precisa saber o caminho inteiro."teaseré opcional: se vier vazio, é gerado porgenerateTeaser()(15.2.4). Se a geração devolvernull, a linha vira erroTEASER_TOO_SHORTe não é importada.bible_versioné opcional; o padrão éALMEIDA_1911. Valores aceitos são apenas os decontent.allowed_bible_versions.- Quebras de linha dentro de
reflection_mdpodem vir como\nliteral ou como quebra real dentro de aspas. As duas formas são aceitas e normalizadas. - Limites: máximo de 200 linhas e 2 MB por arquivo.
Fluxo da interface, em três passos:
[1] Enviar arquivo
Área de arrastar-e-soltar + botão "Escolher arquivo".
Link "Baixar modelo CSV" com um arquivo de exemplo de 3 linhas.
│
▼
[2] Pré-visualização e validação linha a linha
Tabela com TODAS as linhas do arquivo, uma por linha, com uma coluna
"Situação" à esquerda:
ok — será importada
aviso — será importada com ajuste (ex.: teaser gerado)
erro — NÃO será importada
Cada erro e aviso aparece na própria linha, com o nome do campo.
Cabeçalho: "182 de 200 linhas prontas · 12 avisos · 18 erros".
Botões: "Importar as 182 linhas válidas" e "Cancelar".
Interruptor: "Interromper se houver qualquer erro" (padrão: desligado).
│
▼
[3] Relatório
"182 devocionais criados. 18 linhas ignoradas."
Botão "Baixar relatório de erros (CSV)" com as linhas rejeitadas,
o número da linha original e o motivo, para correção e reenvio.Catálogo de validações por linha:
| Código | Gravidade | Condição | Mensagem no relatório |
|---|---|---|---|
MISSING_COLUMN |
erro (arquivo inteiro) | Falta uma coluna obrigatória no cabeçalho | "Coluna obrigatória ausente: title." |
INVALID_DATE |
erro | scheduled_for não é YYYY-MM-DD válida |
"Data inválida: 2026-13-45." |
DATE_IN_PAST |
erro | Data anterior a hoje | "A data 2026-08-01 já passou." |
DATE_TAKEN_DB |
erro | Já existe devocional não excluído naquela data | "Já existe devocional em 2026-09-01." |
DATE_DUPLICATE_FILE |
erro | Duas linhas do mesmo arquivo com a mesma data | "Data repetida na linha 14 e na linha 37." |
TITLE_LENGTH |
erro | Fora de 3–80 | "Título com 92 caracteres (máximo 80)." |
REFERENCE_LENGTH |
erro | Fora de 3–60 | "Referência bíblica muito longa." |
BIBLE_TEXT_LENGTH |
erro | Fora de 10–1200 | "Texto bíblico com 1340 caracteres (máximo 1200)." |
REFLECTION_LENGTH |
erro | Fora de 200–3500 | "Reflexão com 120 caracteres (mínimo 200)." |
PRAYER_LENGTH |
erro | Fora de 20–800 | "Oração muito curta." |
MESSAGE_TOO_LONG |
erro | Mensagem montada > 4096 | "A mensagem completa ficaria com 4210 caracteres (máximo 4096)." |
TEASER_INVALID |
erro | Teaser informado falha em teaserSchema |
"O teaser tem quebra de linha." |
TEASER_TOO_SHORT |
erro | Teaser informado com menos de 40 caracteres, ou teaser vazio e reflexão curta demais para gerar um válido |
"O teaser precisa de pelo menos 40 caracteres. Escreva-o na planilha." |
UNKNOWN_BIBLE_VERSION |
erro | Versão fora de content.allowed_bible_versions |
"Versão bíblica não permitida. Use ALMEIDA_1911 ou BIBLIA_LIVRE." A mensagem nunca repete de volta o código recusado, para que uma sigla proibida não circule em relatório de erro (15.2.1) |
TEASER_GENERATED |
aviso | teaser vazio |
"Teaser gerado a partir da reflexão." |
TEASER_TRUNCATED |
aviso | Teaser informado com mais de 300 e truncável sem perda de sentido | "Teaser truncado de 340 para 298 caracteres." |
MARKDOWN_UNSUPPORTED |
aviso | Reflexão contém link, imagem ou tabela | "Elementos não suportados pelo WhatsApp foram convertidos." |
EXTRA_COLUMN |
aviso | Coluna desconhecida no cabeçalho | "Coluna ignorada: autor." |
Semântica da importação:
- Todas as linhas válidas são criadas em uma única transação. Se o banco falhar no meio, nada é criado. Não existe importação parcial por falha técnica — apenas por rejeição de validação, que é decidida antes.
- Todos os registros nascem em
DRAFT. A importação nunca publica nem enfileira TTS. Idempotency-Keyobrigatório: reenviar o mesmo arquivo com a mesma chave em até 1 hora devolve o relatório original sem criar nada.- A importação inteira gera um registro em
admin_audit_logcom o nome do arquivo, o hash SHA-256 do conteúdo, a contagem de criados e a de rejeitados. - Limite: 5 importações por administrador por dia.
15.6 Versionamento e revisões #
15.6.1 Como as revisões são criadas #
Cada salvamento — manual, automático ou por importação — cria uma linha em
devotional_revisions (Seção 6) com o snapshot completo dos campos de conteúdo, o
autor, o timestamp e o número sequencial da revisão.
Decisões:
- Snapshot completo, não diff. O conteúdo é pequeno (poucos KB) e snapshot elimina toda uma classe de bugs de reconstrução. A restauração vira uma cópia, não um replay.
- O salvamento automático não cria revisão nova se nada mudou. A comparação é feita por hash SHA-256 dos campos de conteúdo concatenados; hash igual, nenhuma revisão.
- Salvamentos automáticos consecutivos do mesmo autor dentro de 5 minutos são
coalescidos: a revisão mais recente é atualizada no lugar de criar outra. Isso evita
120 revisões por hora de digitação. Salvamento manual (
Cmd/Ctrl + S) nunca é coalescido. - Retenção: as 50 revisões mais recentes por devocional, mais a primeira e a última
antes de cada
PUBLISHED, que são preservadas para sempre. Um job de manutenção poda o excedente.
15.6.2 Tela de revisões #
/admin/devocionais/[id]/revisoes:
- Lista à esquerda: número da revisão, autor, data e hora, e um selo quando aquela revisão corresponde a uma publicação.
- Painel à direita: comparação. Dois seletores (
DeeAté), padrão comparando a revisão anterior com a atual. - Comparação em duas visões, alternáveis:
- Lado a lado: duas colunas, com destaque de linhas alteradas.
- Inline: uma coluna, com inserções em verde e remoções em vermelho riscado. Cada
marcação tem texto acessível (
<ins>e<del>reais, comaria-label).
- Diff por palavra dentro de linhas alteradas, não só por linha, porque as edições editoriais tendem a ser de poucas palavras.
- Campos comparados:
title,bible_reference,bible_version,bible_text,reflection_md,prayer,teaser.internal_notestambém é versionado e comparável. - Um resumo no topo: "3 campos alterados · +148 −92 caracteres".
15.6.3 Restauração #
- Botão "Restaurar esta revisão" em cada item da lista.
Dialogde confirmação mostrando o diff entre a revisão escolhida e o conteúdo atual.- Efeito: os campos de conteúdo do devocional voltam ao valor da revisão, e uma nova
revisão é criada representando a restauração (com
restored_from_revisionpreenchido). A revisão original permanece intacta. Nada é apagado. - Guarda: restaurar não é permitido quando
status = 'SENT'. Quandostatus = 'PUBLISHED', é permitido apenas com despublicação antes, e a interface encadeia as duas ações em um único fluxo com confirmação única. - Se a restauração alterar
reflection_md,titleoubible_textem um devocional que já tem áudio gerado, a interface avisa: "O áudio atual foi gerado a partir do texto anterior. Gere o áudio novamente." e oferece o botão para reenfileirartts.generate(Seção 16). O status volta paraREADY. - Toda restauração é auditada com o número da revisão de origem.
15.7 Gestão de assinantes (/admin/assinantes) #
Papel mínimo: ADMIN. Todo acesso à ficha de um assinante é registrado em
admin_audit_log com action = 'SUBSCRIBER_VIEWED' — leitura de dado pessoal é evento
auditável.
15.7.1 Busca e lista #
- Campo de busca único que aceita telefone (em qualquer formato), nome ou e-mail. O tipo é
detectado: se contiver 8 ou mais dígitos, é tratado como telefone e normalizado por
normalizeBrazilPhone()(Seção 11.4) antes da consulta, com a mesma cascata de variantes com e sem o nono dígito. Isso faz a busca funcionar mesmo quando o operador digita o número do jeito que o assinante mandou. - A consulta nunca compara a coluna cifrada. Telefone,
wa_id, CPF e e-mail vivem cifrados; a busca por igualdade é feita pelas colunas de índice cego correspondentes —phone_hmac,wa_id_hmac,cpf_hmac,email_hmac(Seções 6.3 e 6.4). A busca por nome usadisplay_name. - Filtros: tier, status, com opt-out, pausados, com assinatura vencida, cadastrados em um intervalo, sem opt-in confirmado.
- Colunas: nome, telefone mascarado, tier, status, cadastro, último envio, último contato.
- Mascaramento por padrão: telefone e e-mail aparecem parcialmente ocultos
(
(11) 9****-5678). Um botão "Revelar" mostra o valor completo por 30 segundos e gera registro em auditoria comaction = 'PII_REVEALED'. Reduz exposição em tela compartilhada e cria trilha de quem viu o quê. - Exportação da lista em CSV: apenas
OWNER, com limite de 5.000 linhas, sempre auditada, e com um aviso na interface sobre a responsabilidade do arquivo gerado.
15.7.2 Ficha do assinante #
/admin/assinantes/[id], organizada em abas:
Aba "Resumo"
Identificação, tier, status, datas (cadastro, verificação, opt-in, opt-out, pausa), origem
(source do cadastro), entitlements resolvidos, saldo de reenvios do dia, estado da janela
de atendimento de 24 h com o horário de expiração.
Aba "Mensagens"
Linha do tempo unificada de message_logs e inbound_messages, ordem decrescente,
paginada por cursor. Cada item mostra: direção, tipo (template, texto, áudio, entrada),
o devocional relacionado quando houver, status (sent/delivered/read/failed) com os
respectivos timestamps, e, em falha, o código de erro da Meta com a descrição em português
(catálogo na Seção 27). O conteúdo de mensagens de entrada é exibido; o de saída é
exibido por referência ao devocional, não duplicado.
Aba "Pagamentos"
Assinaturas e pagamentos, com os mesmos dados da Seção 14.5.2 mais os campos internos:
asaas_subscription_id, asaas_payment_id, externalReference, e o histórico de
payment_events recebidos, com o payload bruto disponível em um Dialog (útil para
depurar divergência de webhook).
Aba "Consentimentos"
Todos os consent_events, com texto integral, canal, IP e user-agent. Somente leitura.
Aba "Auditoria" Todas as ações administrativas executadas sobre este assinante.
15.7.3 Ações administrativas #
Todas exigem: confirmação em Dialog, motivo escrito obrigatório (mínimo 10
caracteres) e geram registro em admin_audit_log com ator, alvo, ação, motivo, valores
antes e depois, IP e user-agent. Sem exceção.
| Ação | Papel | O que faz | Guardas | Reversível |
|---|---|---|---|---|
| Reenviar devocional | ADMIN |
Enfileira o envio de um devocional escolhido para este assinante. | Não consome o saldo diário do assinante; usa reason = 'ADMIN_RESEND'. Máximo de 5 por assinante por dia. Respeita opt-out (bloqueado) e pausa (avisa e pede confirmação extra). |
Não |
| Forçar re-sincronização com a Asaas | ADMIN |
Busca a assinatura e os pagamentos na Asaas e reconcilia o estado local. | A Asaas é a fonte de verdade (Seção 12). Se houver divergência, o estado local é corrigido e as diferenças aparecem no resultado. Limite: 10 por hora por administrador. | Não se aplica |
| Conceder acesso pago por N dias (cortesia) | ADMIN |
Define tier = 'PAID' com courtesy_until = now() + N dias (coluna da Seção 6.3), sem criar assinatura na Asaas. |
N entre 1 e 365. Motivo obrigatório e Idempotency-Key obrigatório. Não cria cobrança. Ao expirar, o job billing.lifecycle das 00:05 rebaixa para FREE, exceto se houver assinatura paga ativa. |
Sim: revogar cortesia |
| Revogar cortesia | ADMIN |
Zera courtesy_until e recalcula o tier. |
— | Sim |
| Aplicar opt-out | ADMIN |
Mesmo efeito de 14.7.3, com opt_out_reason = 'ADMIN'. |
Usado quando o assinante pede por telefone ou e-mail. Suspende a cobrança do ciclo seguinte, como o opt-out do próprio assinante (Seção 13.4.6); a interface avisa e oferece encerrar a assinatura junto. | Sim: reativar |
| Reativar recebimento | ADMIN |
Desfaz o opt-out. | Exige registro do consentimento obtido por outro canal, com o campo "como o consentimento foi obtido" preenchido. | Sim |
| Cancelar assinatura | ADMIN |
Executa o mesmo fluxo de 14.10.9. | Motivo obrigatório. | Sim, dentro do período pago |
| Excluir por solicitação LGPD | OWNER |
Mesmo efeito de 14.8.2. | Confirmação por digitação do telefone completo do assinante. Registra o canal da solicitação e a data. | Só nas primeiras 72 h |
| Reverter exclusão | OWNER |
Restaura deleted_at = NULL e o status anterior. |
Só dentro de 72 h e só se a pseudonimização ainda não rodou. | Não |
| Trocar telefone | OWNER |
Altera phone_e164 e recalcula phone_hmac, sem duplo OTP. |
Uso excepcional (assinante perdeu o número). Exige o motivo e a evidência registrada. Revoga todas as sessões e zera wa_id/wa_id_hmac. Limite de 3 por dia no sistema inteiro, com alerta. |
Não |
| Impersonar assinante | ADMIN |
Abre uma sessão de suporte somente leitura dentro da conta, para reproduzir o que o assinante vê. | Motivo obrigatório. Duração máxima de 30 minutos, com alerta acima disso. Registra ADMIN_IMPERSONATION_STARTED e ADMIN_IMPERSONATION_ENDED. |
Encerrar a sessão |
Três regras transversais:
- Nenhuma ação administrativa pode criar consentimento do nada. Reativar recebimento
exige declarar como o consentimento foi obtido, e esse texto vai para
consent_eventscomchannel = 'ADMIN'. Isso mantém a trilha honesta. - Nenhuma ação administrativa lê ou escreve dados de cartão. O painel nunca exibe número de cartão, token ou CVV; apenas bandeira e últimos quatro dígitos vindos da Asaas.
- Impersonação é somente leitura, sem exceção. A sessão de impersonação carrega
scope = 'IMPERSONATION_READONLY'e o invólucro de API recusa qualquer método não seguro nela, antes de chegar ao handler. A trava vale para todas as rotas que alteram estado do assinante, independentemente do prefixo do caminho — cancelamento de assinatura, reativação, troca de plano, troca de forma de pagamento, reenvio, pausa, opt-out, pedido de exportação e pedido de eliminação (Seção 14.9). Quando o suporte precisa alterar algo, ele usa as ações desta tabela, em seu próprio nome, com motivo registrado — nunca dentro da conta do assinante. O titular vê essas sessões na própria tela "Meus dados", em uma lista separada de acessos do suporte, com data, duração e justificativa.
15.8 Tela de envios (/admin/envios) #
Papel mínimo: EDITOR para leitura. As ações desta tela — reprocessar falhas e cancelar
lote — exigem ADMIN e ficam desabilitadas com explicação para quem tem apenas EDITOR
(Seção 3.8).
15.8.1 Lote do dia #
Cabeçalho com o lote corrente (send_batches do dia, Seção 6):
| Campo | Exemplo |
|---|---|
| Data | 25/08/2026 |
| Devocional | "O Senhor é o meu pastor" (link para o editor) |
| Estado do lote | PLANNED, RUNNING, COMPLETED, COMPLETED_WITH_ERRORS, CANCELED, FAILED |
| Planejados | 3.041 |
| Iniciado em | 06:00:04 |
| Concluído em | 06:07:12 |
| Duração | 7 min 8 s |
Barra de progresso com segmentos por status e uma tabela de contagem:
| Status | Contagem | % |
|---|---|---|
| Enfileirados | 3.041 | 100% |
Enviados (sent) |
3.041 | 100% |
Entregues (delivered) |
2.978 | 97,9% |
Lidos (read) |
1.412 | 46,4% |
Falharam (failed) |
63 | 2,1% |
Adiados (131049) |
0 | 0% |
A contagem de "Adiados" é separada de "Falharam" por decisão canônica: o erro 131049
(limitação de entrega por saúde do ecossistema) não conta como falha de entrega; o
assinante entra na lista do dia seguinte (Seção 17).
15.8.2 Progresso ao vivo #
- Sem WebSocket, coerente com a decisão da Seção 14.11.
- Enquanto o lote está em
RUNNING, a consulta revalida a cada 5 segundos. Fora disso, não há polling. - Um indicador de "atualizado há N segundos" fica visível, com botão "Atualizar agora".
- O polling para automaticamente quando o lote sai de
RUNNINGou após 30 minutos de tela aberta.
15.8.3 Falhas #
Tabela de falhas, paginada por cursor:
| Coluna | Conteúdo |
|---|---|
| Assinante | Nome e telefone mascarado, link para a ficha |
| Tipo | Template, texto livre ou áudio |
| Código | Código de erro da Meta (131026, 132015, 130429, ...) |
| Motivo | Descrição em português, do catálogo da Seção 27 |
| Ação sugerida | Texto curto vindo do mesmo catálogo |
| Tentativas | 2 de 3 |
| Horário | 06:03:41 |
Agrupamento: um interruptor "Agrupar por código" resume as falhas por código de erro, com contagem, o que torna óbvio quando 60 das 63 falhas têm a mesma causa.
Taxa de falha ao vivo. Enquanto o lote está em RUNNING, o cabeçalho da tabela mostra
a taxa de falha corrente e o error_code dominante, atualizados no mesmo ciclo de 5
segundos de 15.8.2. Esses dois números são os que o plantonista precisa às 06:03, e são os
mesmos que alimentam os alertas send.batch_failure_rate e send.batch_error_dominant e a
contenção automática em 20% (Seção 18.10 e 18.12). Quando o motor pausa o lote sozinho, o
estado exibido é HALTED_BY_GUARD, com o código dominante ao lado e o botão de retomada
disponível apenas para ADMIN.
Assinantes com falha crônica. Uma aba "Entrega crônica" lista os assinantes que falharam em 3 dias consecutivos, com o código dominante de cada um. Falha concentrada nas mesmas pessoas é invisível em uma taxa agregada e é a que gera cancelamento; a lista é revisada semanalmente.
15.8.4 Reprocessar falhas #
- Botão "Reprocessar falhas" no cabeçalho da tabela, com seleção: todas, ou apenas as de um código específico, ou linhas marcadas individualmente.
Dialogde confirmação mostrando quantos assinantes serão afetados e uma estimativa de duração com base no limite de taxa configurado.- Guardas:
| Guarda | Comportamento |
|---|---|
| Códigos não reprocessáveis | 131026 (não entregável) e 131050 (usuário optou por não receber) não são reprocessados; ficam desabilitados na seleção com a explicação. Reenviar para quem bloqueou piora a qualidade do número. |
| Idempotência | O reprocessamento respeita a chave única send:{subscriberId}:{devotionalDate} de delivery_attempts (Seção 18). Assinante que já recebeu com sucesso nunca recebe de novo, mesmo que apareça na seleção. |
| Janela de tempo | Reprocessar é permitido até 48 horas após a data do lote. Depois disso, o botão fica desabilitado com a explicação de que o conteúdo do dia perdeu a validade. |
| Limite de tamanho | Máximo de 2.000 destinatários por reprocessamento. Acima disso, a interface pede para reprocessar por código. |
- O reprocessamento cria um novo lote com
parent_batch_idapontando para o original ebatch_kind = 'RETRY', o que mantém as métricas do lote original intactas. - Auditado com o número de destinatários e o filtro usado.
15.8.5 Cancelar lote em andamento #
- Botão destrutivo, visível apenas quando o lote está em
RUNNING. Dialogque declara com números o que já aconteceu e o que não vai acontecer: "1.204 mensagens já foram enviadas e não podem ser recolhidas. 1.837 ainda não saíram e serão canceladas."- Efeito: os jobs pendentes daquele lote são removidos da fila BullMQ,
send_batches.status = 'CANCELED'ecanceled_by/canceled_reasonsão gravados. - Mensagens já enviadas não são afetadas: a WhatsApp Cloud API não permite recolher mensagem entregue. A interface é explícita sobre isso, para não criar expectativa falsa.
- Motivo obrigatório, mínimo de 10 caracteres. Auditado com severidade alta e alerta imediato para os demais administradores (Seção 23).
- Após o cancelamento, a tela oferece "Reenviar para os cancelados", que cria um lote
RETRYapenas com quem não recebeu.
15.8.6 Histórico de lotes #
Lista dos últimos lotes, paginada por cursor, com data, devocional, estado, planejados,
entregues, falhas e duração. Filtros por estado e intervalo de datas. Cada linha leva para
/admin/envios/[batchId], que mostra a mesma estrutura de 15.8.1 a 15.8.4 aplicada a um
lote histórico, sem os botões de cancelar.
15.9 Templates do WhatsApp (/admin/templates) #
15.9.1 Lista #
Tabela com os oito templates locais (whatsapp_templates, Seção 6) e o estado deles na
Meta. Os oito são os da Seção 17.5, semeados pela Seção 6.32.3 com components idênticos
aos definidos lá: devocional_diario_v1, devocional_diario_video_v1, codigo_acesso_v1,
boas_vindas_v1, lembrete_pagamento_v1, pagamento_confirmado_v1,
acesso_encerrado_v1 e reativacao_v1.
| Coluna | Conteúdo |
|---|---|
| Nome | devocional_diario_v1 |
| Idioma | pt_BR |
| Categoria | UTILITY, MARKETING ou AUTHENTICATION |
| Categoria efetiva | A categoria que a Meta aplicou de fato (pode diferir da submetida) |
| Status na Meta | APPROVED, PENDING, REJECTED, PAUSED, DISABLED, IN_APPEAL |
| Qualidade | GREEN, YELLOW, RED ou desconhecida |
| Última sincronização | Data e hora |
| Uso | Onde o sistema usa este template |
Quando a categoria efetiva difere da submetida, a linha exibe um aviso: a reclassificação
de UTILITY para MARKETING muda a faixa de custo por mensagem, e o dashboard de custos
(Seção 21) reflete isso automaticamente.
15.9.2 Sincronizar #
- Botão "Sincronizar com a Meta" busca todos os templates da conta
(
GET /{WABA_ID}/message_templates) e atualiza os registros locais: status, categoria efetiva, qualidade, motivo de rejeição e o corpo aprovado. - Templates que existem na Meta e não localmente aparecem como "não gerenciado", com a opção de importar.
- Templates que existem localmente e não na Meta aparecem como "não submetido".
- Sincronização automática: job diário às 05:00, antes do planejamento do envio das 05:40.
Se um template usado pelo envio diário não estiver
APPROVED, um alerta crítico é disparado (Seção 23) e o motor usa o fallback definido na Seção 18. - Limite manual: 20 sincronizações por hora, para não estourar o rate limit da Graph API.
15.9.3 Criar template #
Formulário que espelha o modelo da Cloud API:
| Campo | Regras |
|---|---|
name |
snake_case, 1–512 caracteres, sem espaço. Validado no cliente. |
language |
Fixo em pt_BR no MVP. |
category |
UTILITY, MARKETING ou AUTHENTICATION. |
| Header | NONE, TEXT (60 caracteres), IMAGE, DOCUMENT ou VIDEO. AUDIO não é oferecido, porque a API não aceita header de áudio — a interface explica isso em texto ao lado da opção. |
| Body | Até 1024 caracteres, com parâmetros {{1}}, {{2}}, ... numerados em sequência sem lacuna. |
| Footer | Opcional, até 60 caracteres, sem parâmetros. |
| Botões | Até 3 QUICK_REPLY ou até 2 URL/PHONE_NUMBER. Combinações inválidas são bloqueadas. |
| Exemplos | Obrigatórios para cada parâmetro; a Meta rejeita submissão sem eles. |
Validações locais antes da submissão, que evitam rejeição:
- Parâmetros numerados em sequência a partir de
{{1}}, sem pular número. - Nenhum parâmetro no início nem no fim do corpo sem texto ao redor.
- Dois parâmetros nunca adjacentes (
{{1}} {{2}}é rejeitado pela Meta). - Corpo dentro de 1024 caracteres com os exemplos substituídos, não apenas com os marcadores.
- Nenhum exemplo com quebra de linha, tabulação ou 4 espaços consecutivos.
Pré-visualização idêntica à do editor de devocional (15.2.3), com os exemplos preenchidos.
Submissão: POST /{WABA_ID}/message_templates. O registro local nasce com status
PENDING. A aprovação costuma levar de minutos a 24 horas; a tela informa isso.
15.9.4 Rejeição #
Quando o status é REJECTED, a tela mostra:
- O motivo devolvido pela Meta (
rejected_reason), traduzido para português quando for um dos motivos conhecidos:INVALID_FORMAT,ABUSIVE_CONTENT,INCORRECT_CATEGORY,SCAM,PROMOTIONAL,TAG_CONTENT_MISMATCH. - Uma explicação prática do que costuma causar aquele motivo e o que mudar.
- Botão "Duplicar e corrigir", que cria uma cópia editável com sufixo de versão
(
devocional_diario_v2), porque a Meta não permite editar template rejeitado com o mesmo nome dentro de certos limites. - Botão "Excluir da Meta", que remove o template rejeitado da conta.
Nenhum dos oito templates canônicos da Seção 17.5 pode ser excluído pela interface
enquanto estiver referenciado na configuração de envio. A tentativa devolve
409 TEMPLATE_IN_USE.
15.10 Auditoria (/admin/auditoria) #
15.10.1 O que é registrado #
admin_audit_log (Seção 6.9) recebe uma linha para cada ação sensível. A Seção 6.9 é a
dona única do esquema desta tabela; os nomes abaixo são os dela e nenhuma grafia
alternativa existe em lugar nenhum do documento. Campos:
id, actor_type (ADMIN | SUBSCRIBER | SYSTEM), admin_user_id, actor_role
(EDITOR | ADMIN | OWNER | SYSTEM), action, entity_type, entity_id, reason,
before (JSONB), after (JSONB), changed_fields (text[]), metadata (JSONB), ip,
user_agent, request_id, record_hash, created_at.
Três esclarecimentos sobre esses nomes, porque eles são a origem de confusão frequente:
- Não existe coluna
actor_email. O e-mail do autor é resolvido a partir deadmin_user_idno momento da exportação e da leitura de tela (15.10.3), nunca persistido. admin_user_idé nulo quandoactor_typeéSUBSCRIBERouSYSTEM. É exatamente por isso queactor_typeexiste como coluna própria:admin_user_idnulo, sozinho, não distingue "foi o sistema" de "foi o próprio assinante" — e o painel do assinante grava auditoria comactor_type = 'SUBSCRIBER'na troca de número (14.6.2).actor_roleguarda o papel efetivo no momento da ação, gravado em vez de derivado, porque o papel do administrador muda depois e o registro precisa dizer o que ele podia fazer naquele dia.record_hashencadeia cada linha à anterior e é o que detecta adulteração (Seção 6.9).
Catálogo mínimo de ações auditadas:
| Grupo | Ações |
|---|---|
| Sessão | ADMIN_LOGIN, ADMIN_LOGIN_FAILED, ADMIN_LOGOUT, ADMIN_TOTP_RESET |
| Conteúdo | DEVOTIONAL_CREATED, DEVOTIONAL_UPDATED, DEVOTIONAL_STATUS_CHANGED, DEVOTIONAL_PUBLISHED, DEVOTIONAL_UNPUBLISHED, DEVOTIONAL_DELETED, DEVOTIONAL_RESCHEDULED, DEVOTIONAL_DUPLICATED, REVISION_RESTORED, CSV_IMPORTED, PUBLISH_GUARD_OVERRIDDEN |
| Assinante | SUBSCRIBER_VIEWED, PII_REVEALED, SUBSCRIBER_RESEND, SUBSCRIBER_RESYNCED, MANUAL_ACCESS_GRANTED, MANUAL_ACCESS_REVOKED, SUBSCRIBER_OPTED_OUT, SUBSCRIBER_REACTIVATED, SUBSCRIBER_PHONE_CHANGED, SUBSCRIPTION_CANCELED, SUBSCRIBER_DELETED, SUBSCRIBER_DELETION_REVERTED, SUBSCRIBERS_EXPORTED, ADMIN_IMPERSONATION_STARTED, ADMIN_IMPERSONATION_ENDED, DATA_EXPORT_DOWNLOADED |
| Envio | BATCH_RETRIED, BATCH_CANCELED, TEST_MESSAGE_SENT |
| Template | TEMPLATE_CREATED, TEMPLATE_SYNCED, TEMPLATE_DELETED |
| Configuração | SETTING_CHANGED, PLAN_UPDATED, FEATURE_FLAG_TOGGLED, ADMIN_CREATED, ADMIN_ROLE_CHANGED, ADMIN_DISABLED |
Regras de conteúdo do log:
beforeeafterguardam apenas os campos alterados, não o registro inteiro.- Campos sensíveis são mascarados antes de gravar: telefone vira
+55119****5678, e-mail viram****@exemplo.com.br, CPF nunca é gravado. A exceção éPII_REVEALED, que registra que houve revelação, não o valor revelado. admin_audit_logé append-only: não háUPDATEnemDELETEna aplicação, e o usuário de banco da aplicação não tem essas permissões nessa tabela. O gatilho que impõe isso reconhece dois papéis nomeados e auditados — um para a anonimização exigida por lei e outro para o expurgo por retenção —, verificando coluna a coluna que a operação é exatamente a permitida (Seção 22.7.3). Um controle de integridade não pode impedir o cumprimento de um direito do titular nem o expurgo obrigatório.- Retenção: 5 anos, alinhada à retenção de
consent_eventsepayment_events(Seção 22). - O registro é monitorado, não apenas gravado. Acesso em massa a dado pessoal dispara alerta: mais de 100 fichas abertas ou mais de 20 revelações de dado pessoal em 1 hora pelo mesmo administrador é alerta crítico; abertura de ficha entre 00:00 e 05:00 é alerta de severidade média; impersonação acima de 30 minutos é alerta próprio. As expressões exatas estão no catálogo da Seção 23.8. Controle detectivo sem gatilho é controle inexistente.
15.10.2 Interface #
- Tabela com: data e hora, ator, ação, alvo, motivo, IP.
- Filtros combináveis: intervalo de datas, ator, tipo de ação (multiseleção agrupada pelas
categorias acima),
entity_type,entity_id,request_id, texto livre no motivo. - Clique na linha abre um painel lateral com o
before/afterrenderizado como diff e orequest_idcopiável para correlacionar com os logs da aplicação (Seção 23). - Paginação por cursor. Ordenação sempre decrescente por
created_at. - Estado vazio: "Nenhuma ação encontrada com esses filtros."
15.10.3 Exportação CSV #
- Botão "Exportar CSV" aplica os filtros correntes.
- Limite de 50.000 linhas por exportação; acima disso, a interface pede um intervalo de datas menor.
- Processamento assíncrono quando passar de 5.000 linhas: o job é enfileirado na fila
maintenance.cleanup(Seção 18 é a dona da lista de filas), a tela mostra o progresso e o download aparece quando pronto, com URL assinada de 15 minutos gerada no momento do clique. A URL nunca é enviada por e-mail nem registrada em log. - Colunas do CSV:
created_at_utc,created_at_brt,actor_type,actor_email,action,entity_type,entity_id,reason,ip,request_id,changed_fields. Três dessas colunas são projeções de apresentação da exportação, e não colunas do banco:created_at_utcecreated_at_brtsão o mesmocreated_at—timestamptzem UTC, coluna única da Seção 6.9 — renderizado nas duas zonas, eactor_emailé resolvido na exportação a partir deadmin_user_id. As demais colunas do CSV têm o mesmo nome da coluna correspondente emadmin_audit_log. Os objetosbefore/aftercompletos não vão para o CSV — apenas a lista de campos alterados —, para que o arquivo não vire um vazamento de dados por conveniência. - A própria exportação é auditada com
action = 'AUDIT_EXPORTED'e a contagem de linhas. - Papel exigido:
ADMINpara visualizar,OWNERpara exportar.
15.11 Configurações (/admin/configuracoes) #
Papel exigido para abrir a tela: ADMIN. A aba Planos e preços e a aba
Administradores exigem OWNER e ficam desabilitadas para ADMIN, com a explicação
visível. As abas Chaves de configuração e Feature flags exigem ADMIN, e cada
chave respeita ainda o editable_by da própria linha (Seção 26.8.2). Quatro abas.
Planos e preços. Papel: OWNER. Edição de plans: nome exibido, amount_cents,
ciclo, ativo/inativo. O plano gratuito não aparece aqui, porque não é uma linha em
plans: ser gratuito é a ausência de assinatura vigente (Seção 13.1), e o item exibido na
página de vendas é sintetizado pelo handler (Seção 9.16.2). Alterar o preço não altera
assinaturas existentes: a Asaas mantém o valor contratado até que a assinatura seja
alterada explicitamente. A interface declara isso em destaque acima do formulário. Salvar
dispara revalidatePath('/') e revalidatePath('/planos') (Seção 9.12.2).
Chaves de configuração. Papel: ADMIN, respeitado o editable_by de cada linha.
Edição das chaves tipadas de settings (Seção 6.23), todas no formato grupo.chave. Cada
chave tem nome, descrição, tipo, valor atual, valor padrão e a data da última alteração.
Chaves que afetam o envio — send.free_tier_weekday, send.daily_hour_local,
send.rate_per_second e ops.kill_switch — exibem um aviso de impacto e exigem
confirmação. Chaves marcadas com is_secret = true aparecem na lista, mas com o valor
mascarado (apenas os quatro últimos caracteres) e sem campo de edição para ADMIN; só
OWNER pode editá-las, e o valor nunca é devolvido em texto claro pela API (Seções 3.8 e
6.23). Credenciais de integração e chaves de assinatura não são settings: vivem em
variável de ambiente (Seção 26.2), e a interface diz isso explicitamente em vez de mostrar
um campo vazio.
Feature flags. Papel: ADMIN. Interruptores de feature_flags (Seção 6), com
descrição e a data da última mudança. As chaves de flag não usam prefixo de grupo, ao
contrário das de settings. Cada mudança é auditada.
Administradores. Papel: OWNER para a aba inteira; ADMIN pode criar e editar
usuários cujo papel alvo seja EDITOR, conforme a matriz da Seção 3.8. Lista de
admin_users com papel, último acesso e estado do TOTP. Ações: convidar (cria o registro e
envia e-mail de definição de senha), alterar papel, desativar, resetar TOTP. OWNER não
pode desativar a si mesmo nem rebaixar o próprio papel; a interface bloqueia e o servidor
devolve 409 CANNOT_MODIFY_SELF. É obrigatório existir ao menos um OWNER ativo: a
tentativa de desativar o último devolve 409 LAST_OWNER.
15.12 Endpoints administrativos #
Todos exigem sessão de administrador (Seção 8), seguem o envelope da Seção 7 e ecoam
X-Request-Id. Erros comuns, omitidos das tabelas individuais: 401 UNAUTHENTICATED,
403 FORBIDDEN (papel insuficiente), 403 CSRF_INVALID (mutações),
500 INTERNAL_ERROR. Toda mutação exige o cabeçalho X-CSRF-Token, casado com o cookie
__Host-csrf.
Nenhuma resposta paginada devolve total, page, perPage ou totalPages. A
paginação é por cursor: meta traz apenas requestId, timestamp, nextCursor,
hasMore e limit (Seção 7.8). Onde a tela precisa de um total — os contadores do painel
inicial, por exemplo —, ela usa GET /api/admin/metrics/overview (Seção 21.9), que já
entrega contagens agregadas e não paginadas.
15.12.1 GET /api/admin/devotionals #
- Papel:
EDITOR. Idempotente, sem efeitos colaterais. - Query:
?limit=<1..100>&cursor=&status=<lista>&from=&to=&q=&author=&hasAudio=
Resposta 200:
{
"data": [
{ "id": "dev_01HZX...", "scheduledFor": "2026-08-25",
"title": "O Senhor é o meu pastor", "status": "SENT",
"audioState": "READY", "lastRevisionBy": "ana@palavradiaria.com.br",
"updatedAt": "2026-08-24T18:22:00.000Z", "version": 7 }
],
"meta": { "requestId": "req_01HZXE1A2B3C4D5E6F7G8H9J0K",
"timestamp": "2026-08-25T15:00:00.000Z",
"nextCursor": "MDFIWlg...", "hasMore": true, "limit": 20 }
}Erros: 422 VALIDATION_ERROR.
15.12.2 POST /api/admin/devotionals #
- Papel:
EDITOR. - Efeitos colaterais: cria
devotionalsemDRAFTe a revisão 1; auditoria. - Idempotência:
Idempotency-Keyopcional; sem ela, duas chamadas criam dois rascunhos, o que é aceitável para criação manual.
export const createDevotionalSchema = z.object({
scheduledFor: z.iso.date(),
title: z.string().trim().min(3).max(80),
bibleReference: z.string().trim().min(3).max(60).optional(),
bibleVersion: z.enum(ALLOWED_BIBLE_VERSIONS).default('ALMEIDA_1911'),
bibleText: z.string().trim().max(1200).optional(),
reflectionMd: z.string().trim().max(3500).optional(),
prayer: z.string().trim().max(800).optional(),
teaser: teaserSchema.optional(),
internalNotes: z.string().trim().max(1000).optional(),
});Resposta 201: objeto completo do devocional, com version: 1 e status: "DRAFT".
Erros: 422 VALIDATION_ERROR, 409 DATE_ALREADY_TAKEN (details.devotionalId aponta o
existente).
15.12.3 PATCH /api/admin/devotionals/{id} #
- Papel:
EDITOR. - Efeitos colaterais: atualiza campos, cria ou coalesce revisão, incrementa
version; auditoria. - Idempotência: controlada por
expectedVersion. Reenviar o mesmo corpo com a versão já aplicada devolve409 STALE_WRITE.
Request: qualquer subconjunto dos campos de criação, mais:
export const patchDevotionalSchema = createDevotionalSchema.partial().extend({
expectedVersion: z.number().int().min(1),
autosave: z.boolean().default(false), // true permite coalescer a revisão
});Resposta 200: objeto completo com version incrementado e revisionId.
Erros: 422 VALIDATION_ERROR, 404 DEVOTIONAL_NOT_FOUND, 409 STALE_WRITE
(details traz currentVersion, lastEditedBy, lastEditedAt),
409 DATE_ALREADY_TAKEN, 409 ILLEGAL_STATE_TRANSITION (editar conteúdo de um SENT).
15.12.4 POST /api/admin/devotionals/{id}/status-changes #
- Papel:
EDITORparaDRAFT ↔ READY;ADMINparaPUBLISHEDe despublicação. - Efeitos colaterais: transição de estado, enfileiramento de TTS, revalidação de cache, auditoria.
- Idempotência: pedir o estado atual devolve
200sem efeito.
export const statusChangeSchema = z.object({
to: z.enum(['DRAFT', 'READY', 'PUBLISHED']),
reason: z.string().trim().min(10).max(500).optional(), // obrigatório para despublicar
overrideAudioGuard: z.boolean().default(false), // só OWNER
overrideReason: z.string().trim().min(20).max(500).optional(),
});Resposta 200:
{
"data": { "id": "dev_01HZX...", "status": "AUDIO_PENDING",
"audioJobId": "job_01HZX...", "publishedAt": null },
"meta": { "requestId": "req_01HZXE2B3C4D5E6F7G8H9J0K1L", "timestamp": "2026-08-25T15:05:00.000Z" }
}Erros:
| HTTP | code |
Quando |
|---|---|---|
| 422 | VALIDATION_ERROR |
Campos obrigatórios do devocional faltando para ir a READY; details lista cada campo |
| 422 | MESSAGE_TOO_LONG |
Mensagem montada acima de 4096; details.length |
| 422 | TEASER_INVALID |
Teaser reprovado em teaserSchema |
| 409 | AUDIO_REQUIRED |
Guarda de publicação (15.3.2); details.activePaidSubscribers |
| 403 | OVERRIDE_NOT_ALLOWED |
overrideAudioGuard usado por quem não é OWNER |
| 422 | OVERRIDE_REASON_REQUIRED |
Override sem motivo de 20+ caracteres |
| 409 | ILLEGAL_STATE_TRANSITION |
Transição fora da tabela 15.3.1 |
| 409 | BATCH_ALREADY_STARTED |
Despublicar depois de o lote do dia começar |
| 404 | DEVOTIONAL_NOT_FOUND |
— |
15.12.5 POST /api/admin/devotionals/{id}/reschedules #
- Papel:
EDITOR. Efeitos colaterais: alterascheduled_for; auditoria. - Idempotência: mover para a data atual é no-op com
200.
Request: { "scheduledFor": "2026-09-12", "swapIfTaken": false }.
Quando swapIfTaken é true e a data destino está ocupada, os dois devocionais trocam de
data em uma única transação. Ambos precisam estar em estado reagendável.
Resposta 200: { "data": { "id": "...", "scheduledFor": "2026-09-12", "swappedWith": null }, "meta": {...} }.
Erros: 409 DATE_ALREADY_TAKEN, 409 ILLEGAL_STATE_TRANSITION (SENT ou lote iniciado),
422 VALIDATION_ERROR (data no passado para status diferente de DRAFT),
404 DEVOTIONAL_NOT_FOUND.
15.12.6 POST /api/admin/devotionals/{id}/duplications #
- Papel:
EDITOR. Efeitos colaterais: cria novo devocionalDRAFT; auditoria. - Idempotência:
Idempotency-Keyobrigatório.
Request: { "scheduledFor": "2026-09-20" }.
Resposta 201: objeto completo do novo devocional.
Erros: 409 DATE_ALREADY_TAKEN, 404 DEVOTIONAL_NOT_FOUND, 422 VALIDATION_ERROR.
15.12.7 GET /api/admin/devotionals/{id}/revisions e POST .../revisions/{rid}/restorations #
GET — EDITOR, idempotente, paginado por cursor.
{
"data": [
{ "id": "rev_01HZX...", "number": 7, "authorEmail": "ana@palavradiaria.com.br",
"createdAt": "2026-08-24T18:22:00.000Z", "isPublishSnapshot": true,
"changedFields": ["reflectionMd", "teaser"],
"charDelta": { "added": 148, "removed": 92 } }
],
"meta": { "requestId": "req_01HZXE3C4D5E6F7G8H9J0K1L2M",
"timestamp": "2026-08-25T15:10:00.000Z", "nextCursor": null, "hasMore": false }
}POST /api/admin/devotionals/{id}/revisions/{rid}/restorations — EDITOR.
A revisão de origem vem do caminho ({rid}), como sub-recurso aninhado da revisão; não há
revisionId no corpo.
Request: { "reason": "..." }.
Efeitos: cria nova revisão com o conteúdo restaurado e com restored_from_revision
apontando para o número da revisão {rid}; se o texto mudou e havia áudio, volta o status
para READY e invalida audio_assets; auditoria.
Resposta 200: objeto completo do devocional, com version incrementado e
audioInvalidated: true|false.
Erros: 404 REVISION_NOT_FOUND, 409 ILLEGAL_STATE_TRANSITION (SENT),
409 UNPUBLISH_REQUIRED (PUBLISHED sem despublicar antes).
15.12.8 POST /api/admin/devotionals/imports #
- Papel:
EDITOR.Content-Type:multipart/form-data. - Efeitos colaterais: cria N devocionais
DRAFTem transação única; auditoria. - Idempotência:
Idempotency-Keyobrigatório; repetição em 1 hora devolve o relatório original sem criar nada.
Campos do formulário: file (CSV), dryRun (true para apenas validar),
stopOnError (true para abortar tudo se houver qualquer erro).
Resposta 200 (dryRun: true ou importação concluída):
{
"data": {
"importId": "imp_01HZX...",
"dryRun": true,
"totalRows": 200,
"valid": 182,
"warnings": 12,
"errors": 18,
"created": 0,
"rows": [
{ "line": 2, "scheduledFor": "2026-09-01", "title": "O Senhor é o meu pastor",
"severity": "ok", "issues": [] },
{ "line": 14, "scheduledFor": "2026-09-13", "title": "Descanso",
"severity": "warning",
"issues": [{ "code": "TEASER_GENERATED", "field": "teaser",
"message": "Teaser gerado a partir da reflexão." }] },
{ "line": 37, "scheduledFor": "2026-09-13", "title": "Repouso",
"severity": "error",
"issues": [{ "code": "DATE_DUPLICATE_FILE", "field": "scheduled_for",
"message": "Data repetida na linha 14 e na linha 37." }] }
],
"errorReportUrl": "https://media.palavradiaria.com.br/imports/imp_01HZX.../errors.csv?X-Amz-Expires=900&..."
},
"meta": { "requestId": "req_01HZXE4D5E6F7G8H9J0K1L2M3N", "timestamp": "2026-08-25T15:20:00.000Z" }
}Erros:
| HTTP | code |
Quando |
|---|---|---|
| 413 | FILE_TOO_LARGE |
Acima de 2 MB |
| 422 | TOO_MANY_ROWS |
Acima de 200 linhas |
| 422 | MISSING_COLUMN |
Cabeçalho sem coluna obrigatória; details.columns |
| 422 | INVALID_ENCODING |
Arquivo não é UTF-8 decodificável |
| 422 | IMPORT_ABORTED |
stopOnError: true e houve erro; nada foi criado |
| 400 | IDEMPOTENCY_KEY_REQUIRED |
Cabeçalho ausente |
| 429 | IMPORT_LIMIT |
Mais de 5 importações por administrador por dia |
15.12.9 GET /api/admin/subscribers e GET /api/admin/subscribers/{id} #
- Papel:
ADMINnos dois.EDITORnão tem nenhuma permissão sobre assinantes (Seção 3.8). Idempotentes. - Efeitos colaterais: o
GETda ficha gravaSUBSCRIBER_VIEWEDem auditoria. É a única leitura do sistema com efeito colateral, e é deliberada. - Query da lista:
?limit=&cursor=&q=&tier=&status=&optedOut=&paused=&from=&to=
Resposta da lista 200:
{
"data": [
{ "id": "sub_01HZX...", "name": "Maria das Graças",
"phoneMasked": "(11) 9****-5678", "emailMasked": "m****@exemplo.com.br",
"tier": "PAID", "status": "ACTIVE_PAID",
"createdAt": "2025-09-30T12:00:00.000Z",
"lastDeliveryAt": "2026-08-25T09:00:12.000Z",
"lastInboundAt": "2026-08-25T09:03:44.000Z" }
],
"meta": { "requestId": "req_01HZXE5E6F7G8H9J0K1L2M3N4P",
"timestamp": "2026-08-25T15:30:00.000Z",
"nextCursor": "MDFIWlg...", "hasMore": true, "limit": 20 }
}Erros: 422 VALIDATION_ERROR, 404 SUBSCRIBER_NOT_FOUND.
15.12.10 POST /api/admin/subscribers/{id}/actions #
Endpoint único para as ações administrativas de 15.7.3, com discriminador. Escolha deliberada: uma única superfície facilita garantir que toda ação passe pela mesma auditoria obrigatória.
- Papel: conforme a tabela de 15.7.3.
- Idempotência:
Idempotency-Keyobrigatório paraRESEND_DEVOTIONAL,GRANT_MANUAL_ACCESSeCANCEL_SUBSCRIPTION.
export const adminSubscriberActionSchema = z.discriminatedUnion('action', [
z.object({ action: z.literal('RESEND_DEVOTIONAL'),
devotionalId: z.string().length(30),
reason: z.string().trim().min(10).max(500) }),
z.object({ action: z.literal('RESYNC_ASAAS'),
reason: z.string().trim().min(10).max(500) }),
z.object({ action: z.literal('GRANT_MANUAL_ACCESS'),
days: z.number().int().min(1).max(365),
reason: z.string().trim().min(10).max(500) }),
z.object({ action: z.literal('REVOKE_MANUAL_ACCESS'),
reason: z.string().trim().min(10).max(500) }),
z.object({ action: z.literal('APPLY_OPT_OUT'),
alsoCancelSubscription: z.boolean().default(false),
reason: z.string().trim().min(10).max(500) }),
z.object({ action: z.literal('REACTIVATE'),
consentEvidence: z.string().trim().min(20).max(500),
reason: z.string().trim().min(10).max(500) }),
z.object({ action: z.literal('CANCEL_SUBSCRIPTION'),
reason: z.string().trim().min(10).max(500) }),
z.object({ action: z.literal('DELETE_LGPD'),
phoneConfirmation: z.string().min(8),
requestChannel: z.enum(['EMAIL', 'WHATSAPP', 'PANEL', 'PHONE', 'LEGAL']),
reason: z.string().trim().min(10).max(500) }),
z.object({ action: z.literal('REVERT_DELETION'),
reason: z.string().trim().min(10).max(500) }),
z.object({ action: z.literal('CHANGE_PHONE'),
newPhone: z.string().min(8),
evidence: z.string().trim().min(20).max(500),
reason: z.string().trim().min(10).max(500) }),
]);Resposta 200, formato comum:
{
"data": { "action": "GRANT_MANUAL_ACCESS", "applied": true,
"auditLogId": "aud_01HZX...",
"result": { "tier": "PAID", "courtesyUntil": "2026-09-24T02:59:59.000Z" } },
"meta": { "requestId": "req_01HZXE6F7G8H9J0K1L2M3N4P5Q", "timestamp": "2026-08-25T15:35:00.000Z" }
}Erros:
| HTTP | code |
Quando |
|---|---|---|
| 422 | VALIDATION_ERROR |
Schema, incluindo motivo curto demais |
| 422 | REASON_REQUIRED |
Motivo ausente |
| 422 | PHONE_CONFIRMATION_MISMATCH |
DELETE_LGPD com telefone que não confere |
| 403 | FORBIDDEN |
Papel insuficiente para a ação pedida |
| 409 | ILLEGAL_STATE_TRANSITION |
Ex.: cancelar assinatura inexistente |
| 409 | SUBSCRIBER_OPTED_OUT |
RESEND_DEVOTIONAL para quem deu opt-out |
| 422 | COURTESY_REASON_REQUIRED |
GRANT_MANUAL_ACCESS sem motivo |
| 429 | COURTESY_LIMIT_EXCEEDED |
Limite de cortesias por administrador |
| 409 | DELETION_WINDOW_CLOSED |
REVERT_DELETION após 72 h |
| 409 | PHONE_ALREADY_REGISTERED |
CHANGE_PHONE para número em uso |
| 429 | ACTION_RATE_LIMITED |
Limites de 15.7.3 |
| 502 | ASAAS_ERROR |
Falha em RESYNC_ASAAS ou CANCEL_SUBSCRIPTION |
15.12.11 GET /api/admin/batches e GET /api/admin/batches/{batchId} #
- Papel:
EDITOR(leitura de lotes é permitida aEDITORpela Seção 3.8). Idempotentes.
Resposta do detalhe 200:
{
"data": {
"id": "bat_01HZX...", "date": "2026-08-25",
"devotional": { "id": "dev_01HZX...", "title": "O Senhor é o meu pastor" },
"kind": "DAILY", "parentBatchId": null, "status": "COMPLETED_WITH_ERRORS",
"startedAt": "2026-08-25T09:00:04.000Z", "finishedAt": "2026-08-25T09:07:12.000Z",
"counts": { "planned": 3041, "sent": 3041, "delivered": 2978, "read": 1412,
"failed": 63, "deferred": 0, "skipped": 12 },
"failuresByCode": [ { "code": "131026", "label": "Não foi possível entregar", "count": 58 },
{ "code": "130429", "label": "Limite de taxa", "count": 5 } ]
},
"meta": { "requestId": "req_01HZXE7G8H9J0K1L2M3N4P5Q6R", "timestamp": "2026-08-25T15:40:00.000Z" }
}Erros: 404 BATCH_NOT_FOUND.
15.12.12 POST /api/admin/batches/{batchId}/retries #
- Papel:
ADMIN. Idempotência:Idempotency-Keyobrigatório. - Efeitos colaterais: cria lote
RETRY, enfileira envios, auditoria.
export const batchRetrySchema = z.object({
scope: z.enum(['ALL_FAILED', 'BY_CODE', 'SELECTED']),
codes: z.array(z.string().regex(/^\d{4,6}$/)).max(20).optional(),
deliveryAttemptIds: z.array(z.string().length(30)).max(2000).optional(),
reason: z.string().trim().min(10).max(500),
}).superRefine((v, ctx) => {
if (v.scope === 'BY_CODE' && !v.codes?.length)
ctx.addIssue({ code: 'custom', path: ['codes'], message: 'Informe ao menos um código.' });
if (v.scope === 'SELECTED' && !v.deliveryAttemptIds?.length)
ctx.addIssue({ code: 'custom', path: ['deliveryAttemptIds'], message: 'Selecione ao menos uma linha.' });
});Resposta 202:
{
"data": { "retryBatchId": "bat_01HZY...", "queued": 58, "skipped": 5,
"skippedReasons": { "NON_RETRYABLE_CODE": 3, "ALREADY_DELIVERED": 2 },
"estimatedSeconds": 3 },
"meta": { "requestId": "req_01HZXE8H9J0K1L2M3N4P5Q6R7S", "timestamp": "2026-08-25T15:45:00.000Z" }
}Erros: 404 BATCH_NOT_FOUND, 409 RETRY_WINDOW_CLOSED (mais de 48 h),
422 TOO_MANY_RECIPIENTS (acima de 2.000), 422 VALIDATION_ERROR,
409 BATCH_STILL_RUNNING (o lote original ainda não terminou).
15.12.13 POST /api/admin/batches/{batchId}/cancellations #
- Papel:
ADMIN. Idempotência: cancelar lote já cancelado devolve200sem efeito. - Efeitos colaterais: remove jobs pendentes da fila, fecha o lote, alerta os demais administradores, auditoria de severidade alta.
Request: { "reason": "Erro no texto do devocional publicado por engano." }.
Resposta 200:
{
"data": { "status": "CANCELED", "alreadySent": 1204, "canceled": 1837,
"canceledAt": "2026-08-25T09:03:30.000Z" },
"meta": { "requestId": "req_01HZXE9J0K1L2M3N4P5Q6R7S8T", "timestamp": "2026-08-25T09:03:30.000Z" }
}Erros: 404 BATCH_NOT_FOUND, 409 BATCH_NOT_RUNNING, 422 REASON_REQUIRED.
15.12.14 GET /api/admin/whatsapp-templates e mutações #
GET — ADMIN, idempotente. Lista os templates locais com o estado sincronizado.
POST /api/admin/whatsapp-templates/synchronizations — ADMIN.
Efeitos: consulta a Graph API e atualiza os registros locais. Idempotente por natureza.
Resposta 200: { "data": { "synced": 6, "created": 1, "updated": 4, "unmanaged": 1, "syncedAt": "..." }, "meta": {...} }.
Erros: 429 RATE_LIMITED (mais de 20 por hora), 502 META_API_ERROR
(details.metaCode e details.metaMessage).
POST /api/admin/whatsapp-templates — ADMIN.
Cria localmente e submete à Meta. Validações locais de 15.9.3 antes da chamada.
Resposta 201: { "data": { "id": "wtp_01HZX...", "name": "...", "status": "PENDING", "metaTemplateId": "..." }, "meta": {...} }.
Erros: 422 VALIDATION_ERROR, 422 TEMPLATE_PARAM_SEQUENCE (parâmetros fora de ordem ou
adjacentes), 409 TEMPLATE_NAME_TAKEN, 502 META_API_ERROR.
DELETE /api/admin/whatsapp-templates/{id} — ADMIN.
Erros: 409 TEMPLATE_IN_USE (referenciado na configuração de envio), 404 TEMPLATE_NOT_FOUND, 502 META_API_ERROR.
15.12.15 GET /api/admin/audit-logs e POST /api/admin/audit-logs/exports #
GET — ADMIN, idempotente, cursor.
Query: ?limit=&cursor=&from=&to=&adminUserId=&actions=<lista>&entityType=&entityId=&requestId=&q=
Os nomes dos parâmetros de query espelham as colunas de admin_audit_log (15.10.1):
adminUserId filtra por admin_user_id, entityType e entityId filtram por
entity_type e entity_id. Não existem os parâmetros actorId, targetType nem
targetId.
{
"data": [
{ "id": "aud_01HZX...", "createdAt": "2026-08-25T15:35:00.000Z",
"actorType": "ADMIN", "adminUserId": "adm_01HZX...", "actorRole": "ADMIN",
"actorEmail": "ana@palavradiaria.com.br",
"action": "MANUAL_ACCESS_GRANTED", "entityType": "SUBSCRIBER",
"entityId": "sub_01HZX...", "reason": "Compensação por falha de áudio em 24/08.",
"changedFields": ["tier", "courtesyUntil"],
"ip": "189.x.x.x", "requestId": "req_01HZXE6F..." }
],
"meta": { "requestId": "req_01HZXF1K2L3M4N5P6Q7R8S9T0U",
"timestamp": "2026-08-25T16:00:00.000Z",
"nextCursor": "MDFIWlg...", "hasMore": true }
}actorEmail não é coluna: ele é resolvido a partir de adminUserId na montagem da
resposta, pela mesma regra da exportação (15.10.1 e 15.10.3).
POST .../exports — OWNER. Aplica os mesmos filtros e devolve 202 com exportId,
ou 200 com downloadUrl quando o resultado tiver menos de 5.000 linhas.
Erros: 422 VALIDATION_ERROR, 422 EXPORT_TOO_LARGE (acima de 50.000 linhas;
details.count), 429 RATE_LIMITED.
15.12.16 PUT /api/admin/settings/{key}, PATCH /api/admin/plans/{code} e PATCH /api/admin/feature-flags/{key} #
- Papel:
ADMINparasettingsefeature-flags, respeitado ainda oeditable_byda própria linha;OWNERparaplanse para qualquer chave comis_secret = true(Seção 3.8). Idempotentes: enviar o valor atual é no-op. - Método:
PUTemsettings, porque o corpo substitui integralmente o valor da chave;PATCHemplanse emfeature-flags, que aceitam subconjunto de campos. - Efeitos colaterais: gravam o novo valor, auditam com
before/aftere, no caso deplans, revalidam as rotas públicas/e/planos(Seção 9.12.2).
Request de settings: { "value": <tipado conforme a chave>, "reason": "..." }. A chave
segue sempre o formato grupo.chave e precisa existir no catálogo semeado (Seção 26.8.1);
chave fora do catálogo devolve 404 SETTING_NOT_FOUND.
Request de plans: { "name": "Anual", "amountCents": 19900, "active": true, "reason": "..." }.
Request de feature-flags: { "enabled": true, "reason": "..." }.
Resposta 200: o registro atualizado mais auditLogId.
Erros: 404 SETTING_NOT_FOUND / PLAN_NOT_FOUND / FLAG_NOT_FOUND,
422 VALIDATION_ERROR (valor incompatível com o tipo declarado da chave),
422 REASON_REQUIRED, 409 IMMUTABLE_SETTING (chaves marcadas como somente leitura,
como as que espelham variáveis de ambiente).
15.12.17 POST /api/admin/admin-users e PATCH /api/admin/admin-users/{id} #
- Papel:
ADMINquando o papel alvo éEDITOR;OWNERnos demais casos — criar ou promover aADMINou aOWNER, e qualquer alteração sobre umOWNER. O papel exigido é resolvido pelo papel alvo da operação, não pela rota (Seção 3.8). - Efeitos colaterais: cria
admin_users, envia e-mail de definição de senha, audita.
Request de criação: { "email": "novo@palavradiaria.com.br", "name": "Novo Editor", "role": "EDITOR" }.
Request de alteração: { "role": "ADMIN" } ou { "active": false } ou { "resetTotp": true }.
Resposta 201/200: o registro do administrador, sem hash de senha e sem segredo TOTP.
Erros: 409 EMAIL_ALREADY_IN_USE, 409 CANNOT_MODIFY_SELF (rebaixar ou desativar a si
mesmo), 409 LAST_OWNER (desativar ou rebaixar o último OWNER ativo),
422 VALIDATION_ERROR.
16. Geração de Áudio (TTS) e Pipeline de Mídia #
16.1 Escopo e princípios #
Esta seção especifica como um devocional aprovado vira áudio pronto para entrega no WhatsApp, e como esse áudio é armazenado, transcodificado, enviado à Media API da Meta e invalidado quando o texto muda. Cinco princípios governam o pipeline e explicam quase todas as decisões abaixo:
- Uma geração por devocional, nunca por assinante. 3.000 assinantes consomem uma chamada de TTS, uma transcodificação e um upload. O custo é função de 30 devocionais/mês, não da base — por isso a escolha de qualidade de voz é livre (Seção 16.14).
- Nada incompleto é publicado. Só há transição para
AUDIO_READYapós as verificações da Seção 16.13. Áudio truncado é pior que áudio nenhum: o assinante ouve metade de uma oração. - Idempotência. Todo job roda duas vezes sem duplicar arquivo, custo ou estado. Chave:
(devotionalId, scriptHash, voiceId). - Dois provedores atrás de uma interface. Falha de fornecedor não é falha do produto (Seção 16.4).
- O texto é a verdade; o áudio é derivado. Texto mudou, áudio derivado é inválido por definição (Seção 16.12).
Fora do escopo: edição de texto e transições DRAFT → READY (Seção 15); envio das mensagens (Seções 17 e 18); os
textos das mensagens (Seção 19).
16.2 Visão geral do pipeline #
EDITOR aprova (Seção 15): devotionals.status DRAFT -> READY
|
v (1) ENFILEIRAMENTO status -> AUDIO_PENDING; jobId = tts:{devotionalId}:{scriptHash}
|
v (2) buildNarrationScript() [Seção 16.3]
| title + reference + bible_text + reflection(sem markdown) + prayer
| saída: { plain, ssml, blocks, billableCharacterCount, scriptHash }
|
v (3) SÍNTESE via TtsProvider [Seção 16.4]
| primário ElevenLabs --ok--> raw.mp3 (44,1 kHz / 128 kbps)
| | 3 falhas ou erro definitivo
| v fallback Google TTS --ok--> raw.mp3
| | falhou também -> job em failed + job_runs.status = 'DEAD'
| -> devocional PERMANECE em AUDIO_PENDING
| -> audio_assets.status = 'FAILED' -> alerta crítico
|
v (4) TRANSCODIFICAÇÃO ffmpeg [Seção 16.7]
| 4a medição loudnorm | 4b OGG/Opus mono 48 kHz 32 kbps (WhatsApp)
| 4c MP3 mono 128 kbps (player web) | 4d ffprobe: duração, codec, canais
|
v (5) VERIFICAÇÕES DE QUALIDADE [Seção 16.13]
| duração em [150 s, 480 s] | sem silêncio/truncamento | <= 12 MB
| reprovou -> tenta fallback 1x -> se reprovar: audio_assets.status = 'FAILED',
| devocional segue em AUDIO_PENDING + alerta
|
v (6) MP4 DE FALLBACK, sob demanda: capa 720x720 + áudio [Seção 16.9]
v (7) STORAGE S3 privado + registro em audio_assets [Seção 16.10]
v (8) UPLOAD Media API (job media.upload) -> whatsapp_media_id [Seção 16.11]
|
v devotionals.status -> AUDIO_READY (pronto para PUBLISHED e para a Seção 18)Tempo de ponta a ponta para 4.000 caracteres: 35 a 90 s (síntese 25–70 s, transcodificação 2–5 s, upload 1–4 s). O pipeline roda com dias de antecedência (Seção 18.2), então essa latência nunca fica no caminho crítico das 06:00.
16.3 Montagem do roteiro de narração (buildNarrationScript) #
16.3.1 Entradas e ordem fixa #
buildNarrationScript() vive em packages/core/src/narration/ e é a única função autorizada a produzir texto para
TTS. A ordem dos blocos é fixa e reproduz a ordem em que o assinante lê o devocional no WhatsApp.
| # | Bloco | Origem | Texto de ligação | Pausa após |
|---|---|---|---|---|
| 1 | Abertura | constante | Palavra Diária. Devocional de <data por extenso>. |
0,7 s |
| 2 | Título | devotionals.title |
— | 0,7 s |
| 3 | Referência | devotionals.bible_reference |
Leitura de hoje: … |
0,4 s |
| 4 | Texto bíblico | devotionals.bible_text |
— | 0,9 s |
| 5 | Ponte | constante | Reflexão. |
0,5 s |
| 6 | Reflexão | devotionals.reflection_md |
— | 0,9 s |
| 7 | Ponte | constante | Vamos orar. |
0,5 s |
| 8 | Oração | devotionals.prayer |
— | 0,7 s |
| 9 | Fechamento | constante | Que Deus abençoe o seu dia. Até amanhã. |
— |
Decisões registradas: a versão bíblica não é narrada — ler "Almeida 1911" no meio da leitura
quebra o ritmo, e a atribuição continua no texto escrito (Seção 19); a data é narrada por extenso
(terça-feira, 8 de setembro de 2026), formatada em pt-BR no fuso America/Sao_Paulo, o que dá contexto a quem
ouve pelo acervo dias depois; não existe variante de roteiro por tier, porque o plano gratuito não recebe áudio
(Seção 7).
16.3.2 Remoção de markdown #
reflection_md é markdown; a narração precisa de texto puro. A transformação é determinística e própria, não uma
biblioteca genérica, porque precisamos decidir o que vira pausa e o que vira silêncio.
| Construção | Entrada | Saída falada | Construção | Entrada | Saída falada |
|---|---|---|---|---|---|
| Negrito/itálico | **graça**, _graça_ |
graça (ênfase é papel da voz) |
Citação | > Ele é fiel |
Ele é fiel, pausa 0,5 s antes e depois |
| Código inline | `Sola fide` |
Sola fide |
Link | [o Salmo 23](https://…) |
o Salmo 23 — URL nunca é falada |
| Bloco de código | ``` … ``` |
removido inteiro | Imagem |  |
removido |
| Título H1–H6 | ## O peso da espera |
texto + pausa 0,7 s | Regra horizontal | --- |
pausa 0,9 s |
| Lista não ordenada | - confiar |
confiar + pausa 0,4 s |
Entidade HTML | , & |
espaço, e |
| Lista ordenada | 1. confiar |
Primeiro: confiar + pausa 0,4 s |
Tag HTML / nota | <br>, [^1] |
quebra de parágrafo / removido |
Pós-processamento: espaços múltiplos colapsam; 3+ quebras viram 2; espaço antes de pontuação é removido; aspas tipográficas viram retas antes da contagem de caracteres, porque provedores cobram por caractere.
16.3.3 Leitura da referência bíblica por extenso #
Regra de maior impacto perceptível: Jo 3.16 sem tratamento é lido como "Jô três ponto dezesseis". A expansão é
obrigatória no bloco 3 e também dentro da reflexão, onde referências inline são comuns. Formato de saída:
<Livro>, capítulo <N>, versículo <M> / versículos <M> a <P> / versículos <M>, <P> e <Q>.
| Entrada | Saída falada | Entrada | Saída falada |
|---|---|---|---|
Jo 3.16 / Jo 3:16 |
João, capítulo 3, versículo 16 | Fp 4.6,7 |
Filipenses, capítulo 4, versículos 6 e 7 |
Sl 23.1-6 |
Salmos, capítulo 23, versículos 1 a 6 | Pv 3.5-6,11 |
Provérbios, capítulo 3, versículos 5 a 6 e 11 |
1Co 13.4-7 |
Primeira carta aos Coríntios, capítulo 13, versículos 4 a 7 | Is 40 |
Isaías, capítulo 40 |
2Tm 1.7 |
Segunda carta a Timóteo, capítulo 1, versículo 7 | Jd 3 |
Judas, versículo 3 |
Separadores aceitos: . ou : entre capítulo e versículo; - ou – em intervalo; , em lista. Livros de
capítulo único (ob, fm, 2jo, 3jo, jd) tratam Jd 3 como versículo. Abreviação fora do mapa: mantém o
texto original, registra warn narration.unknown_book_abbrev e segue — nunca falha o job, porque uma referência
lida de forma imperfeita é melhor que nenhum áudio.
16.3.4 Expansão de números, símbolos e abreviações #
| Categoria | Entrada → saída falada | Categoria | Entrada → saída falada |
|---|---|---|---|
| Ordinais | 1º, 2ª → primeiro, segunda (até 100) |
Porcentagem | 40% → quarenta por cento |
| Versículo/capítulo | v. 7, vv. 7-9, cap. 4 → versículo 7, versículos 7 a 9, capítulo 4 |
Moeda | R$ 19,90 → dezenove reais e noventa centavos |
| Era | a.C., d.C. → antes/depois de Cristo |
Intervalo, barra | 2-3 vezes → 2 a 3 vezes; 24/7 → 24 por 7 |
| Século | séc. XIX → século dezenove (romanos até XXX) |
Reticências, travessão | ... → … + 0,4 s; — → vírgula falada |
| Tratamentos | Sr., Sra., Dr., Pe., Pr. → por extenso |
Aspas, parênteses | aspas removidas; parênteses viram aposto |
| Genéricos | etc. → etcétera; p. ex. → por exemplo |
Sigla, e comercial | AT/NT → Antigo/Novo Testamento; & → e |
| URL, e-mail, emoji | removidos — nunca ler URL em voz | Controle | removidos, registrados em warnings |
Cardinais em dígitos não são expandidos: tanto o modelo multilíngue primário quanto as vozes Neural2 leem
3.000 e 2026 corretamente, e expandir manualmente introduz erros de concordância. As exceções são as
categorias acima e o contexto de versículo da Seção 16.3.3.
16.3.5 Pausas e prosódia #
O roteiro é produzido em duas representações simultâneas: plain (texto puro, pausas por pontuação e linha em
branco; usado pelo primário) e ssml (<speak>/<p>/<break>; usado pelo fallback quando a voz suporta SSML).
É por isso que NarrationScript carrega as duas desde o começo.
No plain, pausas maiores que a pontuação usam <break time="0.9s"/>, reconhecida pelo modelo multilíngue
primário. Limite duro de 6 marcas por requisição: acima disso o modelo fica instável e pode pular trechos. Como
o roteiro tem 8 pausas estruturais, as duas menores (0,4 s) são rebaixadas para vírgula + nova linha pelo próprio
buildNarrationScript, que registra BREAK_LIMIT_DOWNGRADED. Regras adicionais: toda sentença termina com
pontuação forte, sem o que o TTS emenda frases; sentenças acima de 220 caracteres são divididas no conectivo mais
próximo do meio (, e, , mas, , porque, ; ); o bloco de oração recebe speakingRate 5% menor no fallback e
<break time="0.7s"/> no primário, que não expõe controle de velocidade por trecho.
16.3.6 Implementação #
// packages/core/src/narration/types.ts
export type NarrationBlockKind = 'OPENING' | 'TITLE' | 'REFERENCE' | 'BIBLE_TEXT'
| 'REFLECTION_BRIDGE' | 'REFLECTION' | 'PRAYER_BRIDGE' | 'PRAYER' | 'CLOSING';
export type NarrationWarningCode = 'UNKNOWN_BOOK_ABBREV' | 'SENTENCE_TOO_LONG'
| 'SCRIPT_TOO_SHORT' | 'SCRIPT_TOO_LONG' | 'BREAK_LIMIT_DOWNGRADED';
export interface NarrationBlock { kind: NarrationBlockKind; text: string; pauseAfterSeconds: number }
export interface NarrationWarning { code: NarrationWarningCode; detail: string }
export interface NarrationScript {
devotionalId: string; blocks: NarrationBlock[];
plain: string; // provedores de texto puro
ssml: string; // provedores com SSML
billableCharacterCount: number; // length de `plain` sem as marcas <break>
estimatedSeconds: number; // billable / 14,2 caracteres por segundo
scriptHash: string; // sha256 hex de `plain` — chave de invalidação
warnings: NarrationWarning[];
}// packages/core/src/narration/bible-books.ts — chaves normalizadas: minúsculas, sem acento, sem ponto/espaço
export const BIBLE_BOOKS_SPOKEN: Record<string, string> = {
gn: 'Gênesis', ex: 'Êxodo', lv: 'Levítico', nm: 'Números', dt: 'Deuteronômio', js: 'Josué', jz: 'Juízes',
rt: 'Rute', '1sm': 'Primeiro livro de Samuel', '2sm': 'Segundo livro de Samuel', '1rs': 'Primeiro livro dos Reis',
'2rs': 'Segundo livro dos Reis', '1cr': 'Primeiro livro das Crônicas', '2cr': 'Segundo livro das Crônicas',
ed: 'Esdras', ne: 'Neemias', et: 'Ester', job: 'Jó', sl: 'Salmos', pv: 'Provérbios', ec: 'Eclesiastes',
ct: 'Cânticos dos Cânticos', is: 'Isaías', jr: 'Jeremias', lm: 'Lamentações de Jeremias', ez: 'Ezequiel',
dn: 'Daniel', os: 'Oseias', jl: 'Joel', am: 'Amós', ob: 'Obadias', jn: 'Jonas', mq: 'Miqueias', na: 'Naum',
hc: 'Habacuque', sf: 'Sofonias', ag: 'Ageu', zc: 'Zacarias', ml: 'Malaquias', mt: 'Mateus', mc: 'Marcos',
lc: 'Lucas', jo: 'João', at: 'Atos dos Apóstolos', rm: 'Romanos', '1co': 'Primeira carta aos Coríntios',
'2co': 'Segunda carta aos Coríntios', gl: 'Gálatas', ef: 'Efésios', fp: 'Filipenses', cl: 'Colossenses',
'1ts': 'Primeira carta aos Tessalonicenses', '2ts': 'Segunda carta aos Tessalonicenses',
'1tm': 'Primeira carta a Timóteo', '2tm': 'Segunda carta a Timóteo', tt: 'Tito', fm: 'Filemom', hb: 'Hebreus',
tg: 'Tiago', '1pe': 'Primeira carta de Pedro', '2pe': 'Segunda carta de Pedro', '1jo': 'Primeira carta de João',
'2jo': 'Segunda carta de João', '3jo': 'Terceira carta de João', jd: 'Judas', ap: 'Apocalipse',
};
export const SINGLE_CHAPTER_BOOKS = new Set(['ob', '2jo', '3jo', 'jd', 'fm']);// packages/core/src/narration/expand-reference.ts
const REF = /\b([1-3]?\s?[A-Za-zÀ-ÿ]{2,20})\.?\s*(\d{1,3})(?:\s*[.:]\s*(\d{1,3}(?:\s*[-–]\s*\d{1,3})?(?:\s*,\s*\d{1,3}(?:\s*[-–]\s*\d{1,3})?)*))?\b/g;
export const normalizeBookKey = (raw: string) =>
raw.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[.\s]/g, '');
function spokenVerses(spec: string): string { // "5-6,11" -> "versículos 5 a 6 e 11"
const parts = spec.split(',').map((p) => p.trim());
const s = parts.map((p) => (/[-–]/.test(p) ? p.split(/[-–]/).map((n) => n.trim()).join(' a ') : p));
const label = parts.length > 1 || /[-–]/.test(spec) ? 'versículos' : 'versículo';
return `${label} ${s.length === 1 ? s[0] : `${s.slice(0, -1).join(', ')} e ${s[s.length - 1]}`}`;
}
export function expandBibleReferences(input: string, warn: (c: 'UNKNOWN_BOOK_ABBREV', d: string) => void) {
return input.replace(REF, (match, book, chapter, verses) => {
const key = normalizeBookKey(String(book));
const name = BIBLE_BOOKS_SPOKEN[key];
if (!name) { warn('UNKNOWN_BOOK_ABBREV', match); return match; } // preserva o original
if (SINGLE_CHAPTER_BOOKS.has(key) && !verses) return `${name}, versículo ${chapter}`;
return verses ? `${name}, capítulo ${chapter}, ${spokenVerses(String(verses))}`
: `${name}, capítulo ${chapter}`;
});
}// packages/core/src/narration/build-narration-script.ts
const MAX_BREAK_TAGS = 6, CHARS_PER_SECOND = 14.2; // 14,2 medido com a voz padrão em pt-BR
export function buildNarrationScript(input: BuildNarrationScriptInput): NarrationScript {
const warnings: NarrationWarning[] = [];
const warn = (code: NarrationWarningCode, detail: string) => warnings.push({ code, detail });
const clean = (raw: string) =>
splitLongSentences(expandAbbreviations(expandBibleReferences(stripMarkdownForSpeech(raw), warn)), 220, warn);
const d = formatInTimeZone(input.scheduledFor, 'America/Sao_Paulo', "EEEE, d 'de' MMMM 'de' yyyy", { locale: ptBR });
const blocks: NarrationBlock[] = [
{ kind: 'OPENING', text: `Palavra Diária. Devocional de ${d}.`, pauseAfterSeconds: 0.7 },
{ kind: 'TITLE', text: clean(input.title), pauseAfterSeconds: 0.7 },
{ kind: 'REFERENCE', text: `Leitura de hoje: ${clean(input.bibleReference)}.`, pauseAfterSeconds: 0.4 },
{ kind: 'BIBLE_TEXT', text: clean(input.bibleText), pauseAfterSeconds: 0.9 },
{ kind: 'REFLECTION_BRIDGE', text: 'Reflexão.', pauseAfterSeconds: 0.5 },
{ kind: 'REFLECTION', text: clean(input.reflectionMd), pauseAfterSeconds: 0.9 },
{ kind: 'PRAYER_BRIDGE', text: 'Vamos orar.', pauseAfterSeconds: 0.5 },
{ kind: 'PRAYER', text: clean(input.prayer), pauseAfterSeconds: 0.7 },
{ kind: 'CLOSING', text: 'Que Deus abençoe o seu dia. Até amanhã.', pauseAfterSeconds: 0 },
];
for (const b of blocks) if (!b.text.trim()) throw new NarrationError('NARRATION_BLOCK_EMPTY', b.kind);
// Orçamento de <break>: só as maiores pausas viram tag; as demais viram pontuação.
const ranked = blocks.map((b, i) => ({ i, p: b.pauseAfterSeconds })).filter((x) => x.p > 0).sort((a, b) => b.p - a.p);
const withBreak = new Set(ranked.slice(0, MAX_BREAK_TAGS).map((x) => x.i));
if (ranked.length > MAX_BREAK_TAGS) warn('BREAK_LIMIT_DOWNGRADED', `${ranked.length - MAX_BREAK_TAGS} pausas`);
const plain = blocks.map((b, i) => b.pauseAfterSeconds === 0 ? b.text
: withBreak.has(i) ? `${b.text}\n<break time="${b.pauseAfterSeconds.toFixed(1)}s"/>\n` : `${b.text}\n`)
.join('\n').replace(/\n{3,}/g, '\n\n').trim();
const ssml = `<speak>${blocks.map((b) => `<p>${escapeSsml(b.text)}</p>` +
(b.pauseAfterSeconds > 0 ? `<break time="${b.pauseAfterSeconds.toFixed(1)}s"/>` : '')).join('')}</speak>`;
const billable = plain.replace(/<break[^>]*\/>/g, '').length;
const estimatedSeconds = Math.round(billable / CHARS_PER_SECOND);
if (estimatedSeconds < 150) warn('SCRIPT_TOO_SHORT', `${estimatedSeconds}s`);
if (estimatedSeconds > 480) warn('SCRIPT_TOO_LONG', `${estimatedSeconds}s`);
if (billable > 20_000) throw new NarrationError('NARRATION_SCRIPT_TOO_LARGE', String(billable));
return { devotionalId: input.devotionalId, blocks, plain, ssml, billableCharacterCount: billable,
estimatedSeconds, scriptHash: createHash('sha256').update(plain, 'utf8').digest('hex'), warnings };
}16.3.7 Limites do roteiro #
| Verificação | Limite | Ação |
|---|---|---|
| Mínimo faturável | 900 caracteres | Aviso SCRIPT_TOO_SHORT; job segue; alerta amarelo no painel do editor |
| Máximo faturável | 9.000 caracteres | Aviso SCRIPT_TOO_LONG; job segue com divisão em partes (Seção 16.5) |
| Máximo absoluto | 20.000 caracteres | NARRATION_SCRIPT_TOO_LARGE; o devocional permanece em AUDIO_PENDING e audio_assets.status passa a FAILED |
| Bloco vazio após limpeza | qualquer dos 5 campos narrados | NARRATION_BLOCK_EMPTY; é defeito editorial, não de infraestrutura |
Marcas <break> |
6 | Excedentes viram pontuação |
Exemplo de referência usado em toda esta seção: título 90, referência 40, texto bíblico 380, reflexão 2.900, oração 480, blocos fixos 140 → 4.030 caracteres faturáveis → 4.030 / 14,2 = 284 s (4 min 44 s), dentro do alvo de 3 a 6 minutos da Seção 16.8.
16.4 Interface TtsProvider #
Os provedores ficam atrás de uma interface única em packages/integrations/src/tts/. Worker, painel e CLI conhecem
apenas a interface; trocar ou adicionar provedor não toca em nenhuma outra parte do sistema.
export type TtsProviderName = 'ELEVENLABS' | 'GOOGLE';
export type TtsErrorCode = 'TTS_UNAUTHORIZED' | 'TTS_QUOTA_EXCEEDED' | 'TTS_RATE_LIMITED' | 'TTS_INVALID_VOICE'
| 'TTS_INVALID_INPUT' | 'TTS_TIMEOUT' | 'TTS_UPSTREAM_ERROR' | 'TTS_EMPTY_AUDIO' | 'TTS_NETWORK_ERROR';
export class TtsError extends Error {
constructor(readonly code: TtsErrorCode, readonly provider: TtsProviderName, readonly retryable: boolean,
message: string, readonly httpStatus?: number, readonly retryAfterSeconds?: number) { super(message); }
}
export interface TtsSynthesizeInput {
devotionalId: string; script: NarrationScript; voiceId: string; requestId: string; signal: AbortSignal;
}
export interface TtsSynthesizeResult {
provider: TtsProviderName; audio: Buffer; mimeType: 'audio/mpeg'; // formato intermediário único
voiceId: string; modelId: string; billedCharacters: number;
costMicroUsd: number; // unidade do provedor; convertido para BRL em audio_assets.cost_micros
latencyMs: number; providerRequestId: string | null; chunkCount: number;
}
export interface TtsProvider {
readonly name: TtsProviderName;
synthesize(input: TtsSynthesizeInput): Promise<TtsSynthesizeResult>; // faz o chunking internamente
healthCheck(): Promise<{ healthy: boolean; detail: string }>;
estimateCostMicroUsd(billableCharacters: number): number;
defaultVoiceId(): string;
usesSsml(): boolean; // true -> consome script.ssml; false -> consome script.plain
}// packages/integrations/src/tts/synthesize-with-failover.ts — primaryAttempts 3, backoff [2s, 8s, 20s]
export async function synthesizeWithFailover([primary, fallback]: [TtsProvider, TtsProvider],
input: TtsSynthesizeInput, opts: { primaryAttempts: number; backoffMs: number[]; logger: Logger },
): Promise<TtsSynthesizeResult> {
for (let attempt = 1; attempt <= opts.primaryAttempts; attempt++) {
try { return await primary.synthesize(input); }
catch (err) {
const e = err as TtsError;
opts.logger.warn({ provider: primary.name, attempt, code: e?.code }, 'tts.primary_attempt_failed');
if (e instanceof TtsError && !e.retryable) break; // voz inválida/quota não melhora com retry
if (attempt < opts.primaryAttempts)
await sleep((e?.retryAfterSeconds ?? 0) * 1000 || opts.backoffMs[attempt - 1] || 20_000, input.signal);
}
}
opts.logger.error({ from: primary.name, to: fallback.name }, 'tts.failover_engaged');
return fallback.synthesize({ ...input, voiceId: fallback.defaultVoiceId() });
}Regra registrada: o failover troca a voz. Não existe voz "equivalente" entre provedores. O sistema aceita a
diferença audível, registra em audio_assets.provider e não avisa o assinante — ele não é notificado de troca
de provedor em nenhum canal.
Registro do provedor usado. Toda síntese bem-sucedida grava em audio_assets (Seção 6): provider,
voice_id, model_id, narration_script_hash, char_count, cost_micros, chunk_count, status e
duration_seconds —
este último medido por ffprobe após a transcodificação, nunca estimado. O painel administrativo (Seção 15)
mostra ao editor um rótulo neutro ("Voz A" / "Voz B") ao lado do player de pré-escuta; o nome técnico aparece só
para ADMIN e OWNER. O dashboard de custos (Seção 21) soma cost_micros por mês e por provedor.
Unidade de custo, obrigatória. audio_assets.cost_micros é inteiro em milionésimos de BRL, nunca em
dólar e nunca em ponto flutuante. O provedor cobra em dólar; a conversão para BRL acontece uma única vez, no
momento da gravação, pelo câmbio de referência billing.usd_brl_reference_rate em settings, e o valor em dólar
não é persistido. O divisor para exibição é 1.000.000 (Seção 21). Valores de assinatura e pagamento usam a outra
unidade do sistema, *_amount_cents (centavos de BRL, divisor 100); as duas unidades nunca entram na mesma
fórmula sem conversão explícita.
16.5 Provedor primário — ElevenLabs #
O cliente usa o SDK oficial declarado na Seção 4, mas o contrato do qual o sistema depende é o HTTP abaixo.
POST https://api.elevenlabs.io/v1/text-to-speech/{voice_id}?output_format=mp3_44100_128
xi-api-key: <ELEVENLABS_API_KEY>
Content-Type: application/json
Accept: audio/mpeg{
"text": "Palavra Diária. Devocional de terça-feira, 8 de setembro de 2026.\n<break time=\"0.7s\"/>\n\nO peso da espera\n…",
"model_id": "eleven_multilingual_v2", "language_code": "pt",
"voice_settings": { "stability": 0.45, "similarity_boost": 0.75, "style": 0.15, "use_speaker_boost": true },
"apply_text_normalization": "auto", "previous_request_ids": []
}| Parâmetro | Valor | Justificativa |
|---|---|---|
model_id |
eleven_multilingual_v2 (via TTS_MODEL_ID) |
Melhor prosódia em pt-BR entre os multilíngues; sucessor entra por variável de ambiente |
voice_id |
TTS_VOICE_ID (Seção 26) |
Voz pt-BR da curadoria; sem hardcode |
output_format |
mp3_44100_128 |
Formato intermediário; toda conversão final é do ffmpeg (Seção 16.7), nunca do provedor |
stability |
0.45 |
Abaixo de 0,35 varia demais entre parágrafos; acima de 0,60 fica monótona |
similarity_boost |
0.75 |
Timbre consistente entre dias diferentes |
style |
0.15 |
Expressividade baixa e deliberada; devocional não é locução publicitária |
use_speaker_boost |
true |
Clareza em fone de ouvido de celular, o cenário real |
apply_text_normalization |
auto |
Rede de segurança para números que escaparam da Seção 16.3.4 |
language_code |
pt |
Trava o idioma; sem isso, nomes hebraicos recebem fonética errada |
Limites operacionais: 5.000 caracteres por requisição; timeout de 120 s (AbortSignal; excedido vira
TTS_TIMEOUT retentável); 2 requisições simultâneas, alinhado à concorrência da fila (Seção 16.14); resposta
acima de 25 MB vira TTS_UPSTREAM_ERROR, porque indica defeito e não conteúdo.
Divisão em partes. Roteiros acima de 5.000 caracteres são divididos em limites de bloco (Seção 16.3.1),
nunca no meio de frase: acumulam-se blocos até que o próximo estoure o limite; um bloco isolado maior que 5.000
(tipicamente a reflexão) divide-se em limites de parágrafo, e um parágrafo isolado maior, em limites de sentença.
Da segunda requisição em diante envia-se previous_request_ids com os 3 identificadores mais recentes, o que
instrui o modelo a manter entonação e ritmo — sem isso a emenda é audível, com a voz "reiniciando" com energia
diferente. As partes são unidas pelo demuxer concat do ffmpeg (Seção 16.7), nunca por concatenação binária de
MP3, que produz estouros nas junções. Exemplo: roteiro de 8.400 caracteres com reflexão de 7.100 → reflexão
dividida em 3.600 + 3.500 → 3 requisições, chunkCount = 3, custo idêntico ao de uma requisição única de
8.400 caracteres, porque a cobrança é por caractere e não por chamada.
| HTTP / condição | TtsErrorCode |
Retentável | Ação |
|---|---|---|---|
401 invalid_api_key |
TTS_UNAUTHORIZED |
Não | Alerta crítico; failover na mesma execução |
402/401 quota_exceeded |
TTS_QUOTA_EXCEEDED |
Não | Alerta crítico; failover; painel mostra saldo esgotado |
422 voice_not_found |
TTS_INVALID_VOICE |
Não | Falha o job; é erro de configuração |
422 invalid_content |
TTS_INVALID_INPUT |
Não | Falha o job; roteiro vai para o log em debug |
| 429 | TTS_RATE_LIMITED |
Sim | Respeita retry-after; senão backoff [2s, 8s, 20s] |
| 500/502/503/504 | TTS_UPSTREAM_ERROR |
Sim | Backoff padrão |
ECONNRESET, ETIMEDOUT, DNS |
TTS_NETWORK_ERROR |
Sim | Backoff padrão |
| 200 com corpo < 8 KB | TTS_EMPTY_AUDIO |
Sim | 8 KB de MP3 é menos de 1 s de áudio; trata como falha de rede |
Custo por caractere, mantido em settings sob tts.cost_per_1k_chars_micros para o painel não depender de
constante em código: Creator US$ 0,22/1.000 (US$ 0,89 por devocional típico), Pro US$ 0,12 (US$ 0,48),
Scale US$ 0,08 (US$ 0,32). Decisão: iniciar no plano Creator — ele cobre 30 devocionais/mês com folga para
regerações, e migrar para Pro só se justifica acima de 200.000 caracteres/mês, cerca de 50 devocionais mensais,
cenário que não existe com um devocional por dia.
16.6 Provedor de fallback — Google Cloud Text-to-Speech #
Aciona em exatamente três situações: três tentativas retentáveis consecutivas do primário falharam; uma tentativa
falhou com TTS_UNAUTHORIZED ou TTS_QUOTA_EXCEEDED, casos em que repetir não adianta; ou o operador ligou
tts.force_fallback_provider em feature_flags durante um incidente conhecido. Nunca aciona por preferência
de qualidade: existe para que a entrega das 06:00 não dependa de um único fornecedor.
POST https://texttospeech.googleapis.com/v1/text:synthesize
Authorization: Bearer <token OAuth2 da service account>
Content-Type: application/json{
"input": { "ssml": "<speak><p>Palavra Diária. Devocional de terça-feira, 8 de setembro de 2026.</p><break time=\"0.7s\"/><p>O peso da espera</p>…</speak>" },
"voice": { "languageCode": "pt-BR", "name": "pt-BR-Neural2-C" },
"audioConfig": { "audioEncoding": "MP3", "sampleRateHertz": 44100, "speakingRate": 0.95,
"pitch": -1.0, "volumeGainDb": 0, "effectsProfileId": ["headphone-class-device"] }
}| Voz | Gênero | SSML | Decisão |
|---|---|---|---|
pt-BR-Neural2-C |
Feminino | Sim | Padrão do fallback — SSML preserva as pausas estruturais do roteiro |
pt-BR-Neural2-B / -A |
Masc. / Fem. | Sim | Alternativas por TTS_FALLBACK_VOICE_ID |
pt-BR-Chirp3-HD-* |
Ambos | Não | Qualidade superior, mas não aceita SSML. Se configurada, envia-se input.text com script.plain e removem-se as marcas <break> por regex, senão elas seriam faladas |
usesSsml() resolve isso: true para Neural2/Studio/Wavenet, false para Chirp — é a razão de
NarrationScript carregar plain e ssml simultaneamente. speakingRate: 0.95 porque as vozes Neural2 pt-BR
falam rápido demais para conteúdo devocional; pitch: -1.0 para timbre mais sereno; effectsProfileId otimiza
para fone de ouvido; audioEncoding: MP3 mantém um único caminho de ffmpeg no sistema, idêntico ao do
primário.
| Aspecto | Primário | Fallback | Consequência |
|---|---|---|---|
| Limite por requisição | 5.000 caracteres | 5.000 bytes, incluindo SSML | O chunking conta bytes do ssml; o SSML infla ~12%, então o corte prático fica em ~4.300 caracteres úteis |
| Continuidade entre partes | previous_request_ids |
Não existe | Emenda levemente audível; mitigada cortando só em limite de bloco, onde já há pausa |
| Controle de pausa | <break>, máximo 6 |
<break> SSML, sem limite prático |
As 8 pausas estruturais são preservadas |
| Naturalidade | Superior | Boa, mais "sintética" | Registrada em audio_assets.provider; sem comunicação ao assinante |
| Custo por 1M caracteres | US$ 80 a 220 | US$ 16 (Neural2) / US$ 30 (Chirp3-HD) | O fallback é mais barato e ainda assim não vira padrão: a voz é o diferencial do plano pago |
| Erro | TtsErrorCode |
Retentável | Ação |
|---|---|---|---|
| 401 / 403 | TTS_UNAUTHORIZED |
Não | Ambos fora: job falha, audio_assets.status = 'FAILED', devocional segue em AUDIO_PENDING, alerta crítico com plantonista |
400 INVALID_ARGUMENT |
TTS_INVALID_INPUT |
Não | Quase sempre SSML malformado; reenvia uma vez como input.text com script.plain |
429 RESOURCE_EXHAUSTED |
TTS_RATE_LIMITED |
Sim | Backoff [2s, 8s, 20s] |
500/503 UNAVAILABLE, 504 DEADLINE_EXCEEDED |
TTS_UPSTREAM_ERROR / TTS_TIMEOUT |
Sim | Backoff padrão |
16.7 Transcodificação com ffmpeg #
O binário vem na imagem do container worker (Seção 4) e é executado como subprocesso com argumentos em array,
nunca por string de shell, o que elimina injeção via nome de arquivo. Entradas e saídas são arquivos temporários
com nomes aleatórios em um diretório de trabalho por job, removido no finally inclusive em falha.
Passo 1 — medição. Duas passagens são obrigatórias: loudnorm em passagem única faz normalização dinâmica e
produz "bombeamento" audível nos silêncios longos — exatamente o que o roteiro tem, por causa das pausas
estruturais.
# Mede loudness integrado, true peak, faixa dinâmica e offset.
# -f null - descarta a saída de áudio; só interessa o relatório JSON no stderr.
ffmpeg -hide_banner -nostats -i raw.mp3 \
-af loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json -f null - 2> loudnorm-measure.jsonAlvos: I=-16 LUFS (padrão de fato para fala em plataformas móveis; o WhatsApp não normaliza o áudio recebido, e
sem isso o assinante mexe no volume todo dia); TP=-1.5 dBTP (margem para o Opus não gerar clipping intersample);
LRA=11 LU (faixa maior fica inaudível em ônibus ou cozinha, o contexto real das 06:00).
Passo 2 — OGG/Opus para o WhatsApp. Contêiner OGG, codec Opus, nada mais: é essa combinação que faz o WhatsApp renderizar a mensagem como nota de voz, com player inline, forma de onda e controle de velocidade. Qualquer outro contêiner ou codec vira anexo de áudio genérico, com experiência sensivelmente pior.
# OGG/Opus mono 48 kHz 32 kbps, loudness em 2ª passagem, fade-in 0,25 s e fade-out 0,40 s.
# Os measured_* vêm do JSON do Passo 1. st=283.6 é (duração_total - 0.40),
# obtido por ffprobe sobre raw.mp3 — nunca é um valor fixo.
ffmpeg -y -hide_banner -i raw.mp3 \
-af "loudnorm=I=-16:TP=-1.5:LRA=11:measured_I=-19.7:measured_TP=-3.2:measured_LRA=8.4:\
measured_thresh=-30.1:offset=0.4:linear=true:print_format=summary,\
afade=t=in:st=0:d=0.25,afade=t=out:st=283.6:d=0.40,aresample=48000:resampler=soxr:precision=28" \
-ac 1 -ar 48000 -c:a libopus -b:a 32k -vbr on -compression_level 10 \
-application audio -frame_duration 60 -map_metadata -1 -metadata title="Palavra Diária" \
-f ogg narration.ogg| Argumento | Razão |
|---|---|
-ac 1 |
Voz não se beneficia de estéreo; mono corta o tamanho pela metade |
-ar 48000 + aresample=soxr |
Opus opera internamente a 48 kHz; entregar já em 48 kHz evita reamostragem interna pobre |
-c:a libopus |
Único codec aceito pelo WhatsApp dentro de OGG |
-b:a 32k -vbr on |
Fala mono em Opus a 32 kbps é transparente ao ouvido; 5 min ocupam ~1,2 MB |
-compression_level 10 |
Máxima eficiência; custa CPU (30 execuções/mês, irrelevante) e economiza bytes |
-application audio |
voip otimiza latência e degrada faixas de frequência; aqui não há latência a otimizar |
-frame_duration 60 |
Menos overhead de contêiner por segundo de áudio |
-map_metadata -1 |
Remove tags da origem; evita vazar nome de modelo/voz no arquivo entregue |
afade |
Elimina o clique de início e o corte abrupto de fim, que soam como falha de gravação |
Passo 3 — MP3 para o player web. Mesma cadeia de loudnorm/afade, para que web e WhatsApp soem idênticos.
ffmpeg -y -hide_banner -i raw.mp3 \
-af "loudnorm=I=-16:TP=-1.5:LRA=11:measured_I=-19.7:measured_TP=-3.2:measured_LRA=8.4:\
measured_thresh=-30.1:offset=0.4:linear=true,afade=t=in:st=0:d=0.25,afade=t=out:st=283.6:d=0.40" \
-ac 1 -ar 44100 -c:a libmp3lame -b:a 128k -write_xing 1 -id3v2_version 3 -map_metadata -1 \
-metadata title="O peso da espera" -metadata artist="Palavra Diária" -metadata date="2026-09-08" \
narration.mp3-write_xing 1 grava o cabeçalho com a duração exata; sem ele o elemento <audio> estima a duração pelo bitrate e
a barra de progresso "pula" durante a reprodução. Os metadados são preenchidos porque este arquivo pode ser
baixado pelo assinante a partir do painel.
Concatenação e verificação.
# parts.txt: uma linha "file 'part-000.mp3'" por parte, na ordem. O demuxer concat com -c copy
# junta os quadros sem recodificar; a recodificação única ocorre nos passos 2 e 3.
ffmpeg -y -hide_banner -f concat -safe 0 -i parts.txt -c copy raw.mp3
ffprobe -v error -hide_banner -show_entries format=duration,size,bit_rate,format_name \
-show_entries stream=codec_name,channels,sample_rate -of json narration.ogg{ "streams": [{ "codec_name": "opus", "sample_rate": "48000", "channels": 1 }],
"format": { "format_name": "ogg", "duration": "284.041000", "size": "1187432", "bit_rate": "33443" } }A saída é validada com Zod. codec_name !== 'opus', channels !== 1 ou sample_rate !== '48000' produzem
MEDIA_TRANSCODE_INVALID — sintoma de build de ffmpeg sem libopus, que precisa de intervenção humana, não de
retry. Guardas do subprocesso: timeout de 180 s por invocação (SIGKILL, MEDIA_TRANSCODE_TIMEOUT,
retentável); entrada máxima de 60 MB (falha antes de invocar); código de saída ≠ 0 vira MEDIA_TRANSCODE_FAILED
com as últimas 40 linhas de stderr em nível error; stderr capturado com teto de 64 KB.
16.8 Regras de tamanho e duração #
| Regra | Valor | Origem |
|---|---|---|
| Limite duro de mídia no WhatsApp | 16 MB | Restrição da plataforma: ultrapassar é falha de upload, não degradação |
| Alvo prático de duração | 3 a 6 min (180–360 s) | Decisão de produto: abaixo de 3 min soa apressado; acima de 6 min perde o consumo matinal |
| Faixa aceita sem intervenção | 150 a 480 s | Fora disso, alerta ao editor; não bloqueia |
| Limiar de re-encode | 12 MB no OGG final | Margem de 25% sobre o limite duro |
| Bitrate de re-encode | 24 kbps | Reduz ~25% mantendo inteligibilidade |
| Falha definitiva | OGG > 16 MB após re-encode | MEDIA_TOO_LARGE; escalonamento abaixo |
Dimensionamento real, para deixar claro que 12 MB é defesa e não restrição operacional: a 32 kbps, 12 MB são
50 minutos de áudio; um devocional de 6 minutos ocupa 1,44 MB. O limiar só é alcançado com algo profundamente
errado — por exemplo, 40.000 caracteres colados por engano em reflection_md.
Escalonamento acima de 16 MB mesmo a 24 kbps: o job não reduz mais o bitrate, porque abaixo de 24 kbps a fala
fica metálica e o produto perde mais do que ganha; o devocional permanece em AUDIO_PENDING e
audio_assets.status passa a FAILED — não existe estado AUDIO_FAILED em DevotionalStatus (Seção 15.3.1) —,
a guarda de publicação da Seção 15.3.2 impede a publicação enquanto for assim, e o motor de envio trata o
devocional como não pronto, acionando a reserva da Seção 18.4; o alerta media.oversized de severidade alta traz
devotionalId, duração e tamanho; o painel mostra ao editor "O áudio deste devocional ficou com X minutos.
Reduza a reflexão para até 3.500 caracteres e salve novamente." — 3.500 é exatamente o limite de reflection_md
já declarado na Seção 15.2.3, e repetir aqui um número diferente daria ao editor uma instrução que não resolve.
16.9 Fallback de vídeo — geração do MP4 #
O caminho do 4º dia sem interação (Seção 17.3, etapa 4) usa um template com header VIDEO, porque header de template não aceita áudio (Seção 17.2). O MP4 é o único jeito de entregar a narração a quem nunca abre a janela.
# Capa 720x720: cor sólida + texto. O título vai por textfile= e não por text=, porque títulos reais
# contêm apóstrofos e dois-pontos, cujo escaping dentro do grafo de filtros é frágil.
ffmpeg -y -hide_banner -f lavfi -i "color=c=0x0F172A:s=720x720:d=1" \
-vf "drawtext=fontfile=/usr/share/fonts/brand/Inter-SemiBold.ttf:text='PALAVRA DIÁRIA':\
fontcolor=0x94A3B8:fontsize=24:x=(w-tw)/2:y=64,\
drawtext=fontfile=/usr/share/fonts/brand/Inter-Bold.ttf:textfile=title.txt:fontcolor=white:\
fontsize=52:line_spacing=14:x=(w-tw)/2:y=(h-th)/2-40,\
drawtext=fontfile=/usr/share/fonts/brand/Inter-Regular.ttf:text='Salmos 23.1-6':\
fontcolor=0xCBD5E1:fontsize=30:x=(w-tw)/2:y=(h/2)+90,\
drawtext=fontfile=/usr/share/fonts/brand/Inter-Regular.ttf:text='8 de setembro de 2026':\
fontcolor=0x64748B:fontsize=24:x=(w-tw)/2:y=h-88" -frames:v 1 cover.png
# MP4 720x720: imagem estática em loop + faixa de áudio; -shortest encerra junto com o áudio.
ffmpeg -y -hide_banner -loop 1 -framerate 1 -i cover.png -i narration.mp3 \
-c:v libx264 -tune stillimage -preset veryfast -pix_fmt yuv420p -profile:v baseline -level 3.1 -r 15 -g 30 \
-vf "scale=720:720:force_original_aspect_ratio=increase,crop=720:720,format=yuv420p" \
-c:a aac -b:a 64k -ac 1 -ar 44100 -shortest -movflags +faststart narration.mp4| Escolha | Valor | Razão |
|---|---|---|
| Resolução | 720×720 quadrado | Ocupa bem a tela em retrato, sem tarjas |
| Vídeo | H.264 baseline nível 3.1, 15 fps, GOP 30 | Máxima compatibilidade com Android antigo, parte relevante do público |
-tune stillimage / -pix_fmt yuv420p |
— | Derruba o bitrate sem movimento; sem yuv420p alguns players não reproduzem |
| Áudio | AAC 64 kbps mono | Opus não é aceito dentro de MP4 pelo WhatsApp |
-movflags +faststart |
— | Reprodução começa antes do download completo |
Tamanho medido para 5 minutos: 2,6 a 3,1 MB; alvo declarado ≤ 10 MB. Acima de 14 MB, o áudio cai para
48 kbps e a resolução para 480×480; se ainda exceder 16 MB, o fallback de vídeo é desativado para aquele
devocional — envia-se o template de texto normal e registra-se media.video_fallback_unavailable, de modo que o
assinante recebe o convite e apenas não recebe o vídeo.
Quando o MP4 é gerado. Não para todos os devocionais, porque gerá-lo custa CPU e armazenamento para algo que só
é usado se existirem assinantes no 4º dia sem interação. O job send.plan das 05:40 (Seção 18.3) conta os
elegíveis; havendo ao menos um e não existindo MP4, enfileira tts.generate em modo videoOnly com prioridade
alta. Fica pronto em menos de 60 s, dentro da folga de 20 minutos até as 06:00. Se não ficar pronto até 05:57, os
elegíveis recebem o template de texto padrão naquele dia e continuam elegíveis no dia seguinte. Uma vez gerado, é
reutilizado como qualquer outro asset.
16.10 Armazenamento #
Bucket privado S3-compatível (palavra-diaria-media), sem política de leitura pública, sem website hosting,
com bloqueio de acesso público no nível da conta e versionamento habilitado.
devotionals/{YYYY}/{MM}/{devotionalId}/{voiceId}.raw.mp3 síntese bruta do provedor
devotionals/{YYYY}/{MM}/{devotionalId}/{voiceId}.ogg WhatsApp (nota de voz)
devotionals/{YYYY}/{MM}/{devotionalId}/{voiceId}.mp3 player web
devotionals/{YYYY}/{MM}/{devotionalId}/{voiceId}.mp4 fallback de vídeo
devotionals/{YYYY}/{MM}/{devotionalId}/cover.png capa do vídeo
exemplo: devotionals/2026/09/dev_01K5T8QW3M9Z2X4R7B6C1D0E/voice_pt_br_ana.ogg{YYYY}/{MM} vêm de devotionals.scheduled_for em America/Sao_Paulo, não da data de geração: isso agrupa o
acervo por mês editorial e torna previsível a navegação manual pelo bucket durante um atendimento. {voiceId} é o
identificador lógico da voz (voice_pt_br_ana), não o opaco do provedor — chaves legíveis que sobrevivem à
troca de provedor mantendo a mesma persona.
await s3.send(new PutObjectCommand({
Bucket: env.S3_BUCKET, Key: key, Body: buffer,
ContentType: contentType, // audio/ogg | audio/mpeg | video/mp4 | image/png
CacheControl: 'private, max-age=31536000, immutable',
ContentDisposition: `inline; filename="palavra-diaria-${scheduledFor}.${ext}"`,
ChecksumAlgorithm: 'SHA256', ServerSideEncryption: 'AES256',
Metadata: { 'devotional-id': devotionalId, 'script-hash': scriptHash, 'tts-provider': provider,
'tts-voice-id': providerVoiceId, 'duration-seconds': String(durationSeconds), 'generated-at': generatedAtIso },
}));immutable é seguro porque o conteúdo de uma chave nunca muda: regeração produz novo scriptHash e nova versão
lógica (Seção 16.12).
| Objeto | Transição | Expiração |
|---|---|---|
*.raw.mp3 |
— | 7 dias. Insumo intermediário sem uso após a validação |
*.ogg / *.mp3 |
Classe infrequente após 90 dias | Nunca. É o acervo do produto |
*.mp4 / cover.png |
— | 60 dias. Regeneráveis a partir do MP3 e da capa |
| Versões não correntes | — | 30 dias após deixarem de ser correntes |
Volume: 30 × (1,4 MB OGG + 4,8 MB MP3) = 186 MB/mês permanentes (~2,2 GB/ano), mais ~300 MB de temporários em regime, com expiração automática.
URLs assinadas. O player web nunca recebe URL pública: toda reprodução usa URL pré-assinada com TTL de 15 minutos, emitida por rota autenticada que antes verifica o entitlement (Seção 7) e a propriedade do devocional.
export const SIGNED_URL_TTL_SECONDS = 900;
export const createPlaybackUrl = (key: string, filename: string) =>
getSignedUrl(s3, new GetObjectCommand({ Bucket: env.S3_BUCKET, Key: key,
ResponseContentDisposition: `inline; filename="${filename}"`,
ResponseCacheControl: 'private, max-age=900' }), { expiresIn: SIGNED_URL_TTL_SECONDS });Nunca públicas, por três razões concretas: entitlement — o acervo completo é o principal diferencial pago (Seção 7), e bastaria alguém publicar o link em um grupo para vazá-lo; custo — um link público compartilhado em grupo grande gera tráfego de saída sem relação com a base e sem teto; revogação — quem cancela perde o acervo, e não há como revogar um link público já distribuído, enquanto com TTL de 15 minutos a revogação é automática. O TTL é maior que a duração máxima do áudio (8 min), então nenhuma reprodução iniciada é interrompida; o cliente renova a URL ao dar play em um áudio cuja URL foi emitida há mais de 10 minutos.
16.11 Upload para a Media API da Meta #
O arquivo precisa estar hospedado na Meta. Há duas formas — link público ou upload prévio referenciado por id.
O sistema usa exclusivamente id: não exige URL pública (o que contradiria a Seção 16.10), é mais rápido no
disparo e não depende da disponibilidade do nosso storage às 06:00.
POST https://graph.facebook.com/v26.0/{PHONE_NUMBER_ID}/media
Authorization: Bearer <WHATSAPP_SYSTEM_USER_TOKEN>
Content-Type: multipart/form-data; boundary=----X
------X
Content-Disposition: form-data; name="messaging_product"
whatsapp
------X
Content-Disposition: form-data; name="type"
audio/ogg
------X
Content-Disposition: form-data; name="file"; filename="narration.ogg"
Content-Type: audio/ogg
<bytes>
------X--{ "id": "1234567890123456" }| Uso | type |
Limite | Observação |
|---|---|---|---|
| Nota de voz | audio/ogg |
16 MB | Somente codec Opus. OGG/Vorbis é rejeitado |
| Fallback de vídeo | video/mp4 |
16 MB | H.264 + AAC, uma única faixa de áudio |
| Capa | image/png |
5 MB | Fica só no nosso storage; não é enviada à Meta |
Validade de 30 dias. Todo upload grava em media_uploads (Seção 6): whatsapp_media_id, uploaded_at,
expires_at = uploaded_at + 30 dias, sha256, byte_size, mime_type e a referência a audio_assets. Antes
de cada uso — no send.plan das 05:40, não no disparo — verifica-se expires_at > now + 48 horas; a margem de
48 h evita expiração no meio de um lote. Dentro da margem, media.upload é reenfileirado com prioridade alta, o
arquivo é reenviado do storage e o novo media_id substitui o antigo na mesma transação, com superseded_at na
linha anterior.
GET https://graph.facebook.com/v26.0/{MEDIA_ID}
Authorization: Bearer <WHATSAPP_SYSTEM_USER_TOKEN>{ "url": "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=…", "mime_type": "audio/ogg",
"sha256": "9f2c…", "file_size": 1187432, "id": "1234567890123456", "messaging_product": "whatsapp" }Essa consulta é barata e evita reupload desnecessário quando o relógio local diverge: 404 ou erro 100 confirmam
expiração. O sha256 retornado é comparado com media_uploads.sha256; divergência significa que o media_id
aponta para outro arquivo — estado impossível em operação normal, tratado como corrupção, com invalidação do
registro e novo upload.
A regra dura: um upload por devocional. O upload acontece exatamente uma vez por devocional e por tipo de
mídia, nunca por assinante. Violar isso significaria, para 3.000 assinantes, 3.000 uploads de 1,2 MB por dia —
3,6 GB de saída diária, minutos somados ao lote e rate limit garantido; com a regra respeitada, o custo é de 1,2 MB
por dia. Três mecanismos garantem: jobId determinístico media:{devotionalId}:{kind}:{sha256}, e o BullMQ
descarta jobs com jobId já existente; índice único em media_uploads (audio_asset_id, kind) WHERE superseded_at IS NULL, de modo que um segundo upload concorrente viola a constraint e relê a linha vencedora; e o worker de
envio não tem permissão de chamar upload — ele lê o whatsapp_media_id já resolvido no lote planejado, e se
estiver ausente o item falha com MEDIA_NOT_READY e o áudio daquele assinante é adiado, nunca resolvido ali. A
separação é arquitetural e deliberada.
| Erro do upload | Retentável | Ação |
|---|---|---|
400 code: 100 |
Não | Falha o job; quase sempre type incompatível com o arquivo |
400 code: 131053 |
Sim, 3× | Backoff [5s, 30s, 120s] |
401 code: 190 |
Não | Alerta crítico: token de sistema expirado ou revogado (Seção 17.7) |
| 413 | Não | Devia ter sido pego na Seção 16.8. Falha e alerta |
429 code: 130429 |
Sim | Backoff exponencial; upload às 05:40 não é urgente |
| 5xx / timeout (60 s) | Sim, 5× | Backoff exponencial até 10 min |
16.12 Cache, regeneração e invalidação #
A identidade de um áudio é (devotionalId, scriptHash, voiceId). Consequências diretas: reenfileirar
tts.generate para um texto não alterado encontra o asset com o mesmo scriptHash e retorna imediatamente, com
custo zero; uma correção de vírgula muda o hash e força regeração, o que é intencional, porque não existe
"mudança pequena demais" que justifique áudio dessincronizado do texto; trocar TTS_VOICE_ID gera um novo asset
mantendo o anterior, o que permite comparar vozes lado a lado no painel.
Editor salva alteração em title | bible_reference | bible_text | reflection_md | prayer
|
hook recalcula scriptHash com buildNarrationScript()
|
+--------+--------+
| |
hash igual hash diferente
| |
v v
nada acontece status é AUDIO_READY, PUBLISHED ou SENT?
(mudou só | |
teaser ou não sim
campo não | |
narrado) v v
status segue READY 1. audio_assets corrente -> invalidated_at = now
(áudio nem 2. media_uploads correspondente -> superseded_at = now
começou) 3. status -> AUDIO_PENDING
4. enfileira tts.generate
5. registra devotional_revisions (Seção 15)
6. se era SENT: NÃO reenvia nada. O áudio novo vale
para o acervo e para reenvios manuais.Regras registradas: alterar texto de devocional já enviado não dispara reenvio — corrigir um erro de digitação
não justifica mandar tudo de novo para 3.000 pessoas às 14h, e o painel confirma isso com um diálogo que informa
quantos assinantes já receberam. O teaser não é narrado, então editá-lo não invalida áudio: é por isso que o
scriptHash cobre exclusivamente os cinco campos narrados. Asset invalidado não é apagado na hora — fica 30
dias no storage e o painel permite ouvir a versão anterior, o que salva o operador quando a "correção" foi um erro.
Durante a regeração, o player do assinante mostra o áudio anterior com o rótulo discreto "versão anterior";
remover o áudio seria uma regressão visível para quem já pagou.
Regeração forçada. A ação "Gerar áudio novamente" no painel, para EDITOR, ADMIN e OWNER, existe para
quando o texto está certo mas a narração saiu ruim. Enfileira tts.generate com force: true, ignorando o cache;
a chave vira tts:{devotionalId}:{scriptHash}:r{n}, com n = número de assets existentes para aquele hash mais
um. Limite: 5 regerações forçadas por devocional, depois disso a ação é bloqueada com "Limite de regerações
atingido. Ajuste o texto ou fale com o administrador." O contador reinicia quando o scriptHash muda.
16.13 Fila tts.generate e verificações de qualidade #
A configuração geral das filas está na Seção 18.8; aqui ficam os parâmetros específicos e sua justificativa.
export interface TtsGenerateJobData {
devotionalId: string;
mode: 'full' | 'videoOnly'; // 'full' gera OGG + MP3; 'videoOnly' só monta o MP4
force: boolean; // ignora o cache por scriptHash
voiceIdOverride?: string; // usado na comparação de vozes pelo painel
requestId: string;
}
export const ttsGenerateWorker = new Worker<TtsGenerateJobData>('tts.generate', processTtsGenerate, {
connection, concurrency: 2, limiter: { max: 10, duration: 60_000 },
lockDuration: 300_000, lockRenewTime: 60_000, // 5 min: o job dura minutos legitimamente
});| Parâmetro | Valor | Justificativa |
|---|---|---|
concurrency |
2 | ffmpeg com -compression_level 10 é intensivo em CPU; dois jobs cabem no VPS e o volume é de 1 a 3 devocionais/dia |
limiter |
10/min | Teto contra loop de regeração que estouraria a cota do provedor |
attempts / backoff |
3 / exponencial 30 s | 30 s, 60 s, 120 s — falhas de TTS são transitórias de minutos |
lockDuration |
300 s | Cobre síntese com chunking + duas passagens de ffmpeg + MP4 |
removeOnComplete / removeOnFail |
7 dias / 30 dias | Falhas são raras e valiosas para investigação |
Trabalho morto. Não existe fila de dead-letter separada. Um job que esgota as tentativas fica no estado
failed da própria fila tts.generate e um listener de failed que, quando attemptsMade >= opts.attempts,
insere uma linha em job_runs (Seção 6) com status = 'DEAD', job_name, queue, bull_job_id, attempt,
payload, error_code, error e finished_at. job_runs é a dead-letter do sistema (Seção 18.8).
O devocional permanece em AUDIO_PENDING e o audio_assets da tentativa recebe status = 'FAILED' com
qc_failed_at preenchido quando a causa foi verificação de qualidade. Alertas desta fila: tts.job_dead (alta),
tts.failover_engaged (média),
tts.both_providers_down (crítica, plantonista), tts.audio_not_ready_by_deadline (crítica, Seção 18.4),
tts.quality_check_failed (alta), tts.cost_spike acima de 150% da média de 3 meses (média).
Verificações automáticas, executadas sobre o OGG final antes de qualquer upload. Todas usam ffprobe ou
filtros de análise; nenhuma depende de julgamento humano.
| # | Verificação | Método | Limite | Reprovação |
|---|---|---|---|---|
| 1 | Duração mínima | ffprobe format=duration |
≥ 150 s | Bloqueia — indica roteiro truncado |
| 2 | Duração máxima | ffprobe format=duration |
≤ 480 s | Bloqueia (Seção 16.8) |
| 3 | Coerência com a estimativa | |real − estimatedSeconds| / estimado |
≤ 25% | Bloqueia — provedor pulou ou repetiu trecho |
| 4 | Codec e canais | ffprobe streams |
opus, 1 canal, 48 kHz |
Bloqueia — erro de imagem/ambiente |
| 5 | Tamanho | ffprobe format=size |
≤ 12 MB | Re-encode a 24 kbps; > 16 MB bloqueia |
| 6 | Silêncio total | astats → Overall.RMS_level |
> −60 dB | Bloqueia — arquivo mudo |
| 7 | Silêncio no final | silencedetect=n=-45dB:d=3 na cauda |
nenhum ≥ 3 s nos últimos 5 s | Bloqueia — assinatura de resposta truncada |
| 8 | Silêncio no começo | silencedetect nos primeiros 5 s |
nenhum ≥ 2 s | Bloqueia |
| 9 | Clipping | astats → Peak_count |
< 0,01% das amostras | Aviso |
| 10 | Loudness final | 2ª passagem loudnorm |
−16 ± 1,5 LUFS | Aviso |
| 11 | Silêncio interno | silencedetect=n=-45dB:d=5 |
nenhum ≥ 5 s | Bloqueia — trecho não sintetizado |
# Verificações 6, 9 e 10 em uma passagem.
ffmpeg -hide_banner -nostats -i narration.ogg -af "astats=metadata=1:reset=0,ametadata=print:file=-" \
-f null - 2>/dev/null | grep -E "Overall|RMS_level|Peak_count"
# Verificações 7, 8 e 11: silêncio com limiar e duração mínima.
ffmpeg -hide_banner -nostats -i narration.ogg -af "silencedetect=noise=-45dB:d=3" -f null - 2>&1 | grep silence_Reprovação bloqueante: o asset não vira corrente e recebe qc_failed_at com o código; o job tenta uma vez com
o fallback, mesmo que o primário tenha respondido 200 — isso cobre o caso de áudio truncado devolvido sem
sinalizar erro, que é o modo de falha mais comum e mais perigoso de TTS; se o fallback também reprovar,
audio_assets.status = 'FAILED', o devocional segue em AUDIO_PENDING e sai o alerta
tts.quality_check_failed com a lista de verificações reprovadas.
Revisão humana opcional. A flag audio_human_review em feature_flags controla se
AUDIO_READY → PUBLISHED é automático. Desligada (padrão), o pipeline publica sozinho após as verificações — é o
que permite operar com dias de antecedência. Ligada, o devocional para na fila "Aguardando aprovação de áudio" do
painel; o EDITOR ouve e aprova ou reprova, e reprovar exige motivo registrado e devolve para AUDIO_PENDING com
regeração forçada. Decisão: ligar a flag no primeiro mês e desligá-la após 30 devocionais consecutivos sem
reprovação humana — critério objetivo, registrado aqui para não virar discussão.
Acessibilidade. O texto completo é sempre enviado antes do áudio (Seção 17.3), então nenhuma informação existe apenas em áudio e assinantes surdos recebem o conteúdo integral; o acervo do painel (Seção 14) exibe o texto ao lado do player; a nota de voz do WhatsApp expõe controle de velocidade nativo (1×, 1,5×, 2×), e é o formato OGG/Opus que habilita isso, o que reforça a escolha da Seção 16.7; e o volume normalizado a −16 LUFS elimina o reajuste diário de volume, barreira real para quem opera o aparelho por toque.
16.14 Estimativa de custo mensal do pipeline #
Premissas: 30 devocionais/mês, 4.030 caracteres faturáveis cada, plano Creator, 10% de regerações, MP4 em 40% dos
dias, câmbio de referência R$ 5,40/US$ registrado em settings sob billing.usd_brl_reference_rate e usado
apenas para exibição.
| Item | Cálculo | US$/mês | R$/mês |
|---|---|---|---|
| TTS — devocionais | 120.900 caracteres × US$ 0,00022 | 26,60 | 143,64 |
| TTS — regerações | 12.090 caracteres × US$ 0,00022 | 2,66 | 14,36 |
| TTS — fallback (2% dos jobs) | 8.060 caracteres × US$ 0,000016 | 0,13 | 0,70 |
| Armazenamento permanente (média ano 1: 1,1 GB) + temporário (0,3 GB) | 1,4 × US$ 0,023 | 0,04 | 0,20 |
| Requisições S3 | ~4.000 | 0,02 | 0,11 |
| Saída S3 (30% dos PAID ouvem pelo painel: 900 × 4,8 MB) | 4,3 GB × US$ 0,09 | 0,39 | 2,11 |
| Upload à Meta | 30 × 1,4 MB + 12 × 3 MB = 78 MB | 0,00 | 0,00 |
| CPU do worker (~40 min/mês) | parcela do VPS | 0,00 | 0,00 |
| Total do pipeline de mídia | 29,84 | 161,12 |
Leitura: R$ 161/mês, com 300 ou com 30.000 assinantes. Com 3.000 pagantes a R$ 19,90 (Seção 2.6), o pipeline
consome 0,27% da receita — consequência direta do princípio 1 da Seção 16.1. Custo por assinante pago:
R$ 161 / 3.000 = R$ 0,054/mês, valor que alimenta a métrica custo_por_assinante_mes em daily_metrics
(Seção 21), gravada no formato longo como metric_key = 'custo_por_assinante_mes' com dimension = ''.
Comparação registrada para justificar o provedor primário: operar só com o fallback custaria US$ 2,13/mês em vez de US$ 29,39/mês. A diferença — cerca de R$ 147/mês — compra qualidade de voz no principal artefato do plano pago. A decisão é usar o provedor mais caro como primário.
16.15 Casos de borda #
| # | Situação | Comportamento definido |
|---|---|---|
| 1 | Dois devocionais aprovados para a mesma data | Índice único em devotionals.scheduled_for (Seção 6); o segundo salvamento retorna 409 com code: DEVOTIONAL_DATE_TAKEN |
| 2 | tts.generate concorrente para o mesmo devocional |
jobId determinístico impede; se o dedupe expirar, o segundo encontra o mesmo scriptHash e retorna sem sintetizar |
| 3 | Provedor devolve 200 com 3 segundos de áudio | Verificações 1 e 3 reprovam → tenta fallback → se reprovar, audio_assets.status = 'FAILED' e o devocional segue em AUDIO_PENDING |
| 4 | Provedor devolve WAV em vez de MP3 | ffprobe identifica; ffmpeg aceita a entrada; sem efeito prático |
| 5 | ffmpeg do container sem libopus |
Verificação 4 → MEDIA_TRANSCODE_INVALID, alerta crítico, não retentável: é defeito de imagem |
| 6 | Disco cheio no worker durante a transcodificação | ffmpeg sai ≠ 0, job é retentado; alerta worker.disk_pressure em < 15% livre (Seção 23) |
| 7 | Worker morto no meio do job | Lock expira em 5 min e o job volta à fila; temporários órfãos com mais de 6 h são removidos por maintenance.cleanup (Seção 18.8) |
| 8 | Upload à Meta OK, gravação em media_uploads falha |
O media_id é perdido e um novo upload ocorre na próxima execução. Custo: um upload duplicado. Aceito — transação distribuída não se justifica aqui |
| 9 | media_id expira entre 05:40 e 06:00 |
Impossível pela margem de 48 h (Seção 16.11). Se ocorrer, o erro 100 marca o item como falho e o assinante entra no lote seguinte (Seção 18.10) |
| 10 | Revisão humana ligada e ninguém aprovou | Às 05:00 o motor detecta status != AUDIO_READY e aciona a reserva da Seção 18.4 |
| 11 | Texto editado às 05:55 com áudio pronto | Invalidação leva a AUDIO_PENDING; às 06:00 o motor usa a reserva. O painel avisa antes de salvar: "Faltam X minutos para o envio. Salvar agora vai adiar este devocional para outro dia." |
| 12 | Caractere de controle vindo de colagem | Removido na limpeza (Seção 16.3.2), registrado em warnings |
| 13 | reflection_md contendo apenas uma imagem |
NARRATION_BLOCK_EMPTY; painel mostra o motivo ao editor |
| 14 | Dois provedores fora do ar simultaneamente | tts.both_providers_down, crítica. O operador tem até 05:00 do dia do envio; se não agir, a reserva da Seção 18.4 cobre |
| 15 | Regeração forçada durante um envio em andamento | O lote já planejado carrega o whatsapp_media_id antigo e segue com ele; o áudio novo vale do próximo lote. Todo assinante daquele lote recebe o mesmo arquivo |
| 16 | Assinante abre o painel com asset invalidado | Player mostra a versão anterior rotulada (Seção 16.12) |
| 17 | Bucket indisponível na hora do upload à Meta | media.upload roda logo após a transcodificação, com o buffer ainda em memória; só relê do storage em retentativa, o que reduz a janela de exposição |
| 18 | MP4 pedido para devocional sem áudio corrente (audio_assets.status = 'FAILED') |
O job videoOnly valida a precondição e falha com MEDIA_SOURCE_NOT_READY, sem consumir CPU |
17. Integração WhatsApp Business Cloud API #
17.1 Escopo e princípios #
Esta seção é a dona de tudo que toca a API do WhatsApp: conceitos da plataforma, configuração da conta, definição e submissão de templates, payloads de envio, webhooks de entrada, códigos de erro, qualidade do número, rate limiting e custo. O motor que decide quando enviar está na Seção 18; os textos estão na Seção 19.
Provedor: Meta WhatsApp Business Cloud API direta, sem BSP. A camada WhatsAppProvider (Seção 17.13) permite
trocar por um BSP sem tocar no motor de envio. Base de API: https://graph.facebook.com/v26.0, com a versão
declarada na Seção 4 e injetada por WHATSAPP_GRAPH_VERSION — nenhum caminho de código embute a versão
literalmente.
Três princípios: a plataforma dita o desenho — as restrições da Seção 17.2 não são contornáveis, e todo o
produto foi desenhado em volta delas; webhook responde 200 e enfileira, nunca processa em linha, porque a Meta
desativa a assinatura de endpoints que falham repetidamente; todo envio é registrado em message_logs antes de
sair, de modo que nenhuma mensagem existe sem rastro.
17.2 Conceitos e restrições reais da plataforma #
Janela de atendimento de 24 horas. Qualquer mensagem enviada pelo assinante abre uma janela de 24 horas
contada daquela mensagem. Dentro dela, o negócio envia mensagens free-form de qualquer tipo — texto longo,
áudio, vídeo, imagem, interativa — sem template e sem custo por mensagem. Fora dela, a única forma de iniciar
contato é um template aprovado; texto livre retorna erro 131047. A janela é renovada, não somada: cada
nova mensagem do assinante zera o contador. O sistema persiste isso em subscribers.service_window_expires_at
(Seção 6), atualizado no processamento de todo webhook de mensagem recebida.
Exemplo: assinante toca no botão às 06:04 de terça, logo service_window_expires_at = 06:04 de quarta. Na quarta
às 06:00 a janela ainda está aberta por 4 minutos, e o sistema usa o atalho da etapa 3 (Seção 17.3) — envia o
pacote completo direto, sem template e sem custo. Se ele não interagir na quarta, às 06:00 de quinta a janela já
expirou e o envio volta a usar template.
Categorias de template e custo.
| Categoria | Uso permitido | Custo/mensagem no Brasil (referência) | Onde usamos |
|---|---|---|---|
UTILITY |
Confirmação e atualização de conta ou assinatura solicitada pelo usuário | US$ 0,0080 | Devocional diário, cobrança, confirmações, encerramento |
AUTHENTICATION |
Somente códigos de verificação de uso único | US$ 0,0315 | Login por OTP |
MARKETING |
Promoção, oferta, reengajamento, convite | US$ 0,0625 | Reativação de ex-assinante |
SERVICE |
Não é categoria de template: é a conversa iniciada pelo assinante | US$ 0,00 | Todo o pacote completo entregue dentro da janela |
Os valores ficam em settings sob whatsapp.price_per_message_micros por categoria — inteiro em milionésimos de
BRL, conforme a regra de unidade da Seção 17.12 —, porque a Meta ajusta a tabela periodicamente e o dashboard
(Seção 21) não pode depender de constante em código. A diferença entre UTILITY e
MARKETING é de 7,8 vezes, e é a razão econômica central do desenho da Seção 17.3: submeter o devocional como
UTILITY e entregar tudo o que for possível dentro da janela, onde o custo é zero. Se a Meta reclassificar
devocional_diario_v1 para MARKETING, o sistema continua funcionando sem alteração de código — muda apenas o
custo: a categoria efetiva retornada pela API é gravada em whatsapp_templates.effective_category na sincronização
(Seção 17.6), o dashboard recalcula e o alerta whatsapp.template_recategorized é emitido.
As três restrições de formato que definem o desenho.
(a) O cabeçalho de template não aceita áudio. Tipos aceitos: TEXT, IMAGE, DOCUMENT, VIDEO, LOCATION.
Não existe AUDIO. Consequência inescapável: o áudio nunca pode ser a primeira mensagem do dia, porque a
primeira mensagem do dia é sempre um template (a janela está fechada às 06:00). O áudio só existe dentro da janela,
ou empacotado dentro de um MP4 no header VIDEO (Seção 16.9).
(b) Parâmetros de template não podem conter quebra de linha, tabulação, nem 4 ou mais espaços consecutivos. Isso
vale para o valor do parâmetro no envio, não para o texto fixo da definição — o corpo definido pode ter
parágrafos à vontade. Violar retorna 132000 ou 132012. Consequência: um devocional completo, com parágrafos,
não cabe em um parâmetro de template. Daí a existência do teaser, sanitizado explicitamente (Seção 17.7).
(c) Limites de caracteres.
| Elemento | Limite | Consequência prática |
|---|---|---|
Corpo de template (BODY) |
1.024 | O devocional completo (2.000 a 4.000 caracteres) não cabe |
| Cabeçalho de texto / rodapé | 60 cada | Cabeçalho é rótulo, não conteúdo; rodapé cabe o aviso de opt-out |
| Texto de botão | 25 | "Ler e ouvir agora" tem 17 |
Mensagem de texto livre (type: text) |
4.096 | Cabe o devocional inteiro com parágrafos |
| Legenda de imagem/vídeo/documento | 1.024 | Usada no MP4 do fallback |
| Parâmetros por template | 10 | Usamos no máximo 3 |
| Mídia de áudio/vídeo | 16 MB | Ver Seção 16.8 |
Mensagens de áudio não aceitam legenda. É por isso que o pacote completo tem uma mensagem de fechamento separada (Seção 17.3, etapa 2): não há como anexar texto ao áudio.
17.3 O desenho "Template + Janela" #
Quatro etapas travadas. Cada uma existe por uma restrição concreta da Seção 17.2.
Etapa 1 — Convite por template (janela fechada).
06:00 SISTEMA ASSINANTE
|-- POST /{PHONE_NUMBER_ID}/messages ------------------------>|
| type: template, name: devocional_diario_v1 |
| HEADER TEXT "Devocional de hoje" |
| BODY {{1}} = título {{2}} = teaser (<=300, 1 linha) |
| FOOTER "Responda SAIR para cancelar" |
| BUTTONS [Ler e ouvir agora] [Depois] |
|<-- 200 { messages:[{ id: "wamid.XXX" }] } -------------------|
| message_logs: status=sent, category=UTILITY, custo 0,0080 |
|<== webhook status: delivered / read ========================|Por que assim. Às 06:00 a janela está fechada para praticamente toda a base, então template é a única opção legal;
e como o corpo tem 1.024 caracteres e o parâmetro não aceita quebra de linha, o que cabe ali é um convite com
título e teaser. O que aconteceria de outro jeito: enviar type: text com o devocional completo falharia com
131047 para 100% da base, e colocar o devocional no parâmetro {{2}} falharia com 132000/132012 por causa
das quebras de linha — e, mesmo sanitizado, estouraria o limite de 1.024 do corpo.
Etapa 2 — Entrega completa free-form (janela aberta pela interação).
06:04 ASSINANTE SISTEMA
|-- toca em [Ler e ouvir agora] ----------------------------->|
| webhook: messages[0].type = "button" |
| button.payload = OPEN_DEVOTIONAL:{devotionalId} |
| 200 OK em < 200 ms, enfileira |
| service_window_expires_at = +24 h |
|<-- (1) type: text ------------------------------------------|
| devocional completo, até 4.096 caracteres, com |
| parágrafos e *negrito* moderado (Seção 19) |
|<-- (2) type: audio [somente tier PAID] --------------------|
| { id: whatsapp_media_id } -> nota de voz OGG/Opus, |
| com player e forma de onda (Seção 16.7) |
|<-- (3) type: text ------------------------------------------|
| fechamento curto com a data do próximo envio |
| custo das três: US$ 0,00 (dentro da janela de atendimento) |Ordem obrigatória: texto antes do áudio, por duas razões. Acessibilidade: quem não ouve recebe tudo mesmo assim (Seção 16.13). Uso real: a maioria abre às 06:04 no transporte ou na cozinha e lê primeiro, deixando o áudio para o momento seguinte. Latência alvo entre a interação e a primeira mensagem: p95 < 5 segundos (Seção 18.6). Por que assim: é a única janela em que áudio e texto longo são permitidos, e é gratuita — todo o valor do produto é entregue aqui.
Etapa 3 — Atalho quando a janela já está aberta.
05:40 send.plan avalia cada assinante
service_window_expires_at > (06:00 de hoje) ?
| |
sim não
v v
route = WINDOW_DIRECT route = TEMPLATE_INVITE
06:00 v v
(1) texto (2) áudio (3) fechamento envia devocional_diario_v1
custo US$ 0,00 e espera a interação
zero atrito, zero cliques custo US$ 0,0080, um clique de atritoPor que existe. É o caminho preferencial: reduz custo a zero e elimina o atrito de um toque para o assinante engajado. Quem responde ao devocional todo dia mantém a janela permanentemente aberta e nunca mais recebe template. Efeito colateral desejado: o produto ganha um incentivo econômico direto para conversar com o assinante, que é exatamente o comportamento que queremos. Com 40% da base engajada assim, o custo mensal de mensagens cai 40% (Seção 17.12).
Etapa 4 — Fallback de vídeo no 4º dia sem interação.
Dia 1..3 template ENTREGUE, sem interação até 23:55 -> consecutive_window_misses = 1, 2, 3
Dia 4 assinante PAID com consecutive_window_misses >= 3
v send.plan marca route = TEMPLATE_VIDEO e garante MP4 pronto (Seção 16.9)
(se não estiver pronto até 05:57, cai para TEMPLATE_INVITE)
06:00 v envia devocional_diario_video_v1
HEADER VIDEO { id: whatsapp_media_id do MP4 }
BODY {{1}} título, {{2}} teaser | FOOTER e BUTTONS iguais ao de texto
v o assinante recebe o ÁUDIO (dentro do MP4) sem precisar interagir
interagiu? -> zera consecutive_window_misses, volta ao caminho normal
não? -> mantém no caminho de vídeo por até 7 dias, depois volta a
TEMPLATE_INVITE e entra na régua de reengajamento (Seção 20)Por que existe. Um assinante PAID que nunca abre a janela nunca receberia áudio — pagaria por um benefício que
não recebe. Header VIDEO é a única forma de entregar áudio sem interação, pela restrição (a) da Seção 17.2.
Por que é fallback e não padrão: o MP4 pesa cerca de 3 MB contra 1,2 MB do OGG, consome dados móveis sem que o
assinante tenha pedido, e a experiência de nota de voz (velocidade 1,5×, forma de onda, retomada) é superior à de
um vídeo estático — usar vídeo como padrão pioraria o produto para os 40% engajados a fim de resolver o problema
dos que não interagem.
Regra de contagem: o contador é subscribers.consecutive_window_misses (Seção 6) — este é o único nome; não
existe no_interaction_days. Ele incrementa em 1 uma vez por dia, no fechamento das 23:55 (Seção 18.2),
apenas para o assinante que teve naquele dia ao menos uma mensagem com estado delivered ou read e nenhuma
mensagem de entrada. Dias cuja única saída foi adiada (131049), suprimida (131048) ou falha não
incrementam, porque o assinante não teve oportunidade de interagir — incrementá-los empurraria para a rota de
vídeo exatamente quem a plataforma já está suprimindo, e o MP4 seria suprimido do mesmo jeito, custando mais para
gerar e ocupando o caminho crítico das 06:00. Esses dias alimentam um contador separado,
subscribers.undelivered_days, que é o que aciona a regra de entrega mínima do plano pago (Seção 13). O contador
zera no instante em que qualquer mensagem de entrada é processada, inclusive SAIR. O limiar de troca de rota é
send.window_miss_threshold em settings (padrão 3), ou seja, o quarto dia consecutivo sem interação. Assinante
FREE não entra neste caminho, porque não recebe áudio de todo modo (Seção 7).
17.4 Configuração da conta #
| Item | Valor / procedimento | Onde vive |
|---|---|---|
| WABA | Criada no Business Manager, vinculada à empresa verificada | WHATSAPP_BUSINESS_ACCOUNT_ID |
| App da Meta | Tipo Business, produto WhatsApp adicionado | META_APP_ID, META_APP_SECRET |
| Número | Dedicado, nunca usado no app WhatsApp comum. Portar um número em uso exige apagá-lo do app antes | WHATSAPP_PHONE_NUMBER_ID |
| Verificação de negócio | Obrigatória para sair do limite de teste: CNPJ, comprovante de endereço, site ativo. Prazo típico 2 a 10 dias úteis | — |
| Nome de exibição | "Palavra Diária". Precisa refletir a marca real; rejeições comuns são nome genérico ou de pessoa física | — |
| Foto e perfil comercial | 640×640; definidos via POST /{PHONE_NUMBER_ID}/whatsapp_business_profile. Aumentam confiança e ajudam na avaliação de qualidade |
— |
| PIN de verificação em duas etapas | Obrigatório no registro; guardado no cofre de segredos, nunca em código | WHATSAPP_2FA_PIN |
| Token | Usuário de sistema, longa duração (Seção 17.7) | WHATSAPP_SYSTEM_USER_TOKEN |
Tiers de mensagens. O tier limita quantos clientes únicos o número pode iniciar conversa em 24 horas
móveis: Teste 250 (antes da verificação); 1K (após verificação e registro); 10K, 100K e Ilimitado, cada um
alcançado ao atingir 50% do tier atual em 24 h com quality_rating ≥ MEDIUM, em janela de 7 dias.
O tier sobe automaticamente e a Meta notifica por webhook account_update com event: "PHONE_NUMBER_TIER_UPDATE". O sistema lê o tier em GET /{PHONE_NUMBER_ID}?fields=messaging_limit_tier, quality_rating, grava em settings sob whatsapp.messaging_tier e alerta quando o lote planejado excede 80% do
tier (Seção 18.12) — é esse alerta que evita descobrir o teto no meio do disparo. Planejamento concreto: 10.000
assinantes com envio FREE no domingo significam 10.000 clientes únicos naquele dia, exatamente no teto do tier 10K,
o que torna o tier 100K pré-requisito operacional antes de passar de 8.000 assinantes.
17.5 Templates do sistema #
São oito templates, todos em pt_BR — este número é fixo e vale em todo o documento. Nomes com sufixo de
versão: alterar o corpo de um template aprovado exige nova submissão, então uma mudança de texto cria _v2 e o
_v1 continua servindo até a aprovação. whatsapp_templates (Seção 6) guarda nome, categoria submetida,
categoria efetiva, status, template_id e o corpo submetido.
Esta subseção é a dona da definição dos oito templates. O seed obrigatório do banco (Seção 6.32.3) cria os
oito, com o bloco components idêntico, caractere por caractere, aos JSON abaixo; nenhuma outra seção define
componente de template. param_count conta apenas os parâmetros do corpo: parâmetros de botão URL são numerados
em sequência própria e não entram na contagem.
// 1) devocional_diario_v1 — convite diário. Hidratado no exemplo: 261 caracteres (limite 1.024).
// {{1}} até 120 caracteres (título); {{2}} até 300 (teaser). Pior caso hidratado: 555.
{ "name": "devocional_diario_v1", "language": "pt_BR", "category": "UTILITY", "components": [
{ "type": "HEADER", "format": "TEXT", "text": "Devocional de hoje" },
{ "type": "BODY",
"text": "Bom dia. O devocional de hoje já está pronto para você.\n\n*{{1}}*\n\n{{2}}\n\nToque no botão abaixo para receber o texto completo e o áudio.",
"example": { "body_text": [["O peso da espera", "Quando Deus demora, Ele não esqueceu. Salmos 27 mostra o que fazer no intervalo entre o pedido e a resposta."]] } },
{ "type": "FOOTER", "text": "Responda SAIR para cancelar" },
{ "type": "BUTTONS", "buttons": [
{ "type": "QUICK_REPLY", "text": "Ler e ouvir agora" }, { "type": "QUICK_REPLY", "text": "Depois" } ] } ] }// 2) devocional_diario_video_v1 — convite com áudio embutido no MP4 do header.
// O header_handle vem da API de upload retomável (POST /{APP_ID}/uploads), diferente da Media API
// usada no envio (Seção 16.11): serve apenas para a Meta revisar o template.
{ "name": "devocional_diario_video_v1", "language": "pt_BR", "category": "UTILITY", "components": [
{ "type": "HEADER", "format": "VIDEO", "example": { "header_handle": ["4::aW1hZ2UvcG5n:ARZ..."] } },
{ "type": "BODY",
"text": "Bom dia. O devocional de hoje está no vídeo acima, com o áudio completo.\n\n*{{1}}*\n\n{{2}}\n\nPara receber o texto completo, toque no botão.",
"example": { "body_text": [["O peso da espera", "Quando Deus demora, Ele não esqueceu."]] } },
{ "type": "FOOTER", "text": "Responda SAIR para cancelar" },
{ "type": "BUTTONS", "buttons": [
{ "type": "QUICK_REPLY", "text": "Quero o texto" }, { "type": "QUICK_REPLY", "text": "Depois" } ] } ] }// 3) codigo_acesso_v1 — OTP de login. Corpo fixo definido e traduzido pela Meta:
// "{{1}} é seu código de verificação. Por segurança, não compartilhe este código."
// message_send_ttl_seconds faz a Meta descartar a mensagem em vez de entregar código já expirado.
{ "name": "codigo_acesso_v1", "language": "pt_BR", "category": "AUTHENTICATION",
"message_send_ttl_seconds": 600, "components": [
{ "type": "BODY", "add_security_recommendation": true },
{ "type": "FOOTER", "code_expiration_minutes": 10 },
{ "type": "BUTTONS", "buttons": [{ "type": "OTP", "otp_type": "COPY_CODE", "text": "Copiar código" }] } ] }// 4) boas_vindas_v1 — confirmação de opt-in.
{ "name": "boas_vindas_v1", "language": "pt_BR", "category": "UTILITY", "components": [
{ "type": "HEADER", "format": "TEXT", "text": "Bem-vindo à Palavra Diária" },
{ "type": "BODY",
"text": "Olá, {{1}}. Seu cadastro foi criado.\n\nPara começar a receber o devocional, confirme abaixo. Só depois da sua confirmação enviaremos qualquer conteúdo.",
"example": { "body_text": [["Ana"]] } },
{ "type": "FOOTER", "text": "Você pode cancelar quando quiser" },
{ "type": "BUTTONS", "buttons": [
{ "type": "QUICK_REPLY", "text": "Sim, quero receber" }, { "type": "QUICK_REPLY", "text": "Agora não" } ] } ] }// 5) lembrete_pagamento_v1 — serve às três réguas PIX (D-3, D-1, D0) e ao aviso de cartão vencendo.
// Um único template evita três aprovações separadas e três pontos de manutenção.
{ "name": "lembrete_pagamento_v1", "language": "pt_BR", "category": "UTILITY", "components": [
{ "type": "HEADER", "format": "TEXT", "text": "Sua assinatura" },
{ "type": "BODY",
"text": "Olá, {{1}}. Sua assinatura da Palavra Diária vence em {{2}}.\n\nValor: {{3}}\n\nPague pelo link abaixo para manter o áudio diário ativo.",
"example": { "body_text": [["Ana", "11/09/2026", "R$ 19,90"]] } },
{ "type": "FOOTER", "text": "Pagamento processado pela Asaas" },
{ "type": "BUTTONS", "buttons": [ { "type": "URL", "text": "Pagar agora",
"url": "https://app.palavradiaria.com.br/pagar/{{1}}",
"example": ["https://app.palavradiaria.com.br/pagar/pay_01K5T8QW3M"] } ] } ] }// 6) pagamento_confirmado_v1
{ "name": "pagamento_confirmado_v1", "language": "pt_BR", "category": "UTILITY", "components": [
{ "type": "HEADER", "format": "TEXT", "text": "Pagamento confirmado" },
{ "type": "BODY",
"text": "Pronto, {{1}}. Recebemos seu pagamento de {{2}}.\n\nA partir de amanhã, às 6h, você recebe o devocional em texto e áudio todos os dias. Sua próxima cobrança é em {{3}}.",
"example": { "body_text": [["Ana", "R$ 19,90", "08/10/2026"]] } },
{ "type": "FOOTER", "text": "Obrigado por assinar" },
{ "type": "BUTTONS", "buttons": [{ "type": "QUICK_REPLY", "text": "Ver minha conta" }] } ] }// 7) acesso_encerrado_v1 — implementa a regra sem carência da Seção 13.3: revogação no mesmo dia,
// dita sem rodeios. Não há promessa de prazo extra, porque não existe prazo extra.
{ "name": "acesso_encerrado_v1", "language": "pt_BR", "category": "UTILITY", "components": [
{ "type": "HEADER", "format": "TEXT", "text": "Sua assinatura foi encerrada" },
{ "type": "BODY",
"text": "Olá, {{1}}. Não conseguimos confirmar o pagamento da sua assinatura, e o acesso ao áudio diário foi encerrado hoje.\n\nVocê continua recebendo o devocional em texto aos domingos, sem custo. Para voltar a receber todos os dias com áudio, reative abaixo.",
"example": { "body_text": [["Ana"]] } },
{ "type": "FOOTER", "text": "Responda SAIR para não receber mais nada" },
{ "type": "BUTTONS", "buttons": [ { "type": "URL", "text": "Reativar assinatura",
"url": "https://app.palavradiaria.com.br/reativar",
"example": ["https://app.palavradiaria.com.br/reativar"] } ] } ] }// 8) reativacao_v1 — ÚNICO template MARKETING do sistema. É o único sujeito ao erro 131050 e à faixa
// de custo 7,8x maior. Cadência máxima: 1 vez a cada 30 dias por assinante, no máximo 3 na vida (Seção 20).
{ "name": "reativacao_v1", "language": "pt_BR", "category": "MARKETING", "components": [
{ "type": "BODY",
"text": "Olá, {{1}}. Faz {{2}} dias que você não recebe o devocional em áudio.\n\nSe quiser voltar, é só tocar abaixo. Sua conta e seu histórico continuam salvos.",
"example": { "body_text": [["Ana", "30"]] } },
{ "type": "FOOTER", "text": "Responda SAIR para não receber mais" },
{ "type": "BUTTONS", "buttons": [
{ "type": "QUICK_REPLY", "text": "Quero voltar" }, { "type": "QUICK_REPLY", "text": "Não, obrigado" } ] } ] }17.6 Submissão, aprovação e sincronização de status #
POST https://graph.facebook.com/v26.0/{WHATSAPP_BUSINESS_ACCOUNT_ID}/message_templates
Authorization: Bearer <WHATSAPP_SYSTEM_USER_TOKEN>
Content-Type: application/json{ "id": "1234567890", "status": "PENDING", "category": "UTILITY" }A submissão é feita pelo comando ops templates:sync do CLI, que lê as definições versionadas no repositório e
submete as que ainda não existem. Templates não são criados pelo painel administrativo: são artefatos de
código, revisados em pull request, porque um erro de texto aprovado leva dias para corrigir. Prazos típicos: a
maioria é avaliada em até 1 hora; casos em revisão manual levam até 24 horas, com 48 h como limite
declarado. Templates AUTHENTICATION costumam ser mais rápidos por terem corpo padronizado.
| Motivo comum de rejeição | Como o nosso desenho evita |
|---|---|
| Parâmetro no começo ou no fim do corpo, sem texto ao redor | Todos os nossos parâmetros são cercados por texto fixo |
Parâmetros adjacentes ({{1}} {{2}}) |
Nunca usamos parâmetros adjacentes |
example ausente ou incoerente |
Todo template da Seção 17.5 traz example realista |
Conteúdo promocional submetido como UTILITY |
O devocional é entrega de assinatura pedida; o único conteúdo promocional é reativacao_v1, submetido como MARKETING |
| URL encurtada ou domínio não verificado | Domínio próprio, sem encurtador |
| Erro de português, caixa alta, excesso de emoji | Revisão editorial obrigatória (Seção 19.1) |
Sincronização. Uma vez por dia, às 00:20, maintenance.cleanup varre
GET /v26.0/{WABA_ID}/message_templates?fields=name,status,category,quality_score,rejected_reason&limit=100 e
atualiza whatsapp_templates. Transições com ação: PENDING → APPROVED (template utilizável, alerta
informativo); PENDING → REJECTED (alerta alto com rejected_reason; o motor pula qualquer rota que dependa
dele); APPROVED → PAUSED/DISABLED (alerta crítico, ver 132015/132016 na Seção 17.9);
categoria efetiva diferente da submetida (alerta whatsapp.template_recategorized, dashboard recalcula).
A Meta também envia o webhook message_template_status_update, processado em tempo real; a varredura diária existe
como rede de segurança para webhook perdido — mesma lógica de reconciliação usada em pagamentos (Seção 12). Guarda
final: antes de qualquer disparo, send.plan verifica que o template da rota está APPROVED; se não estiver, o
lote não é criado com aquela rota e o alerta sai às 05:40, 20 minutos antes do envio, tempo suficiente para uma
decisão humana.
17.7 Envio de mensagens #
POST https://graph.facebook.com/v26.0/{PHONE_NUMBER_ID}/messages
Authorization: Bearer <WHATSAPP_SYSTEM_USER_TOKEN>
Content-Type: application/json{ "messaging_product": "whatsapp",
"contacts": [{ "input": "5511987654321", "wa_id": "5511987654321" }],
"messages": [{ "id": "wamid.HBgNNTUxMTk4NzY1NDMyMRUCABEYEjc2M0E5RDk2...", "message_status": "accepted" }] }messages[0].id (o wamid) é gravado em message_logs.wamid e é a chave que correlaciona o envio com os webhooks
de status posteriores. contacts[0].wa_id é gravado em subscribers.wa_id apenas quando a coluna está vazia,
no primeiro envio bem-sucedido, conforme a regra de normalização de telefone da Seção 11 — é ele que resolve a
divergência do nono dígito. A coluna é cifrada e a busca por igualdade usa subscribers.wa_id_hmac (Seção 6).
Mudança de wa_id para um telefone já cadastrado nunca é silenciosa. Se o valor devolvido pela plataforma
diferir do wa_id_hmac gravado, o sistema não sobrescreve: registra o evento, invalida todas as sessões
daquele assinante e exige nova verificação por código antes de liberar qualquer dado pessoal (Seção 8). O motivo
é concreto: operadoras brasileiras reciclam números, e entregar conta, CPF ou histórico a um aparelho apenas
porque ele respondeu no WhatsApp transforma um evento comum e fora do nosso controle em vazamento de dado sensível
de outra pessoa. Enquanto a reverificação não acontece, o envio continua pelo wa_id novo — parar de entregar
seria pior —, mas nenhuma informação de conta é revelada em mensagem.
// (a) Template com dois parâmetros de corpo e payloads de botão.
// O payload do botão NÃO faz parte da definição do template: é definido no envio. Incluir o
// devotionalId permite saber a qual dia o assinante respondeu, mesmo que toque no botão de ontem.
{ "messaging_product": "whatsapp", "recipient_type": "individual", "to": "5511987654321",
"type": "template", "template": { "name": "devocional_diario_v1", "language": { "code": "pt_BR" },
"components": [
{ "type": "body", "parameters": [
{ "type": "text", "text": "O peso da espera" },
{ "type": "text", "text": "Quando Deus demora, Ele não esqueceu. Salmos 27 mostra o que fazer no intervalo entre o pedido e a resposta." } ] },
{ "type": "button", "sub_type": "quick_reply", "index": "0",
"parameters": [{ "type": "payload", "payload": "OPEN_DEVOTIONAL:dev_01K5T8QW3M9Z2X4R7B6C1D0E" }] },
{ "type": "button", "sub_type": "quick_reply", "index": "1",
"parameters": [{ "type": "payload", "payload": "SNOOZE:dev_01K5T8QW3M9Z2X4R7B6C1D0E" }] } ] } }// (b) Template com header de vídeo (fallback do 4º dia).
{ "messaging_product": "whatsapp", "to": "5511987654321", "type": "template",
"template": { "name": "devocional_diario_video_v1", "language": { "code": "pt_BR" }, "components": [
{ "type": "header", "parameters": [{ "type": "video", "video": { "id": "1234567890123456" } }] },
{ "type": "body", "parameters": [
{ "type": "text", "text": "O peso da espera" },
{ "type": "text", "text": "Quando Deus demora, Ele não esqueceu." } ] } ] } }// (c) Texto livre, dentro da janela. Até 4.096 caracteres, com parágrafos.
// preview_url: false é obrigatório — com true, uma referência textual a um site geraria
// um cartão de pré-visualização dentro do devocional, poluindo a leitura.
{ "messaging_product": "whatsapp", "recipient_type": "individual", "to": "5511987654321", "type": "text",
"text": { "preview_url": false,
"body": "*O peso da espera*\n_Salmos 27.13-14 (Almeida 1911)_\n\n\"Pensei: Se eu não cresse que veria os bens do Senhor na terra dos viventes! Espera no Senhor, anima-te, e ele fortalecerá o teu coração; espera, pois, no Senhor.\"\n\nDavi não escreveu isso depois da resposta. Escreveu no meio da espera...\n\n*Oração*\nSenhor, ensina-me a esperar sem desistir. Amém." } }// (d) Áudio como nota de voz. Sem legenda: o tipo audio não aceita caption.
{ "messaging_product": "whatsapp", "to": "5511987654321", "type": "audio",
"audio": { "id": "1234567890123456" } }
// (e) Vídeo free-form com legenda (usado apenas em reenvio manual dentro da janela).
{ "messaging_product": "whatsapp", "to": "5511987654321", "type": "video",
"video": { "id": "1234567890123456", "caption": "Devocional de 8 de setembro — O peso da espera" } }// (f) Resposta interativa com botões (menu de ajuda, dentro da janela).
// Limites: 3 botões, 20 caracteres por título, 1.024 no corpo. Acima de 3 opções usa-se
// interactive.type: "list", com até 10 itens.
{ "messaging_product": "whatsapp", "to": "5511987654321", "type": "interactive",
"interactive": { "type": "button", "body": { "text": "Como posso ajudar?" },
"action": { "buttons": [
{ "type": "reply", "reply": { "id": "MENU_RESEND", "title": "Reenviar hoje" } },
{ "type": "reply", "reply": { "id": "MENU_BILLING", "title": "Minha assinatura" } },
{ "type": "reply", "reply": { "id": "MENU_HUMAN", "title": "Falar com alguém" } } ] } } }Token e rotação. O token é de usuário de sistema, criado no Business Manager com escopos
whatsapp_business_messaging e whatsapp_business_management, atribuído ao app e à WABA; tokens de usuário comum
expiram em 60 dias e não são aceitáveis em produção.
| Regra | Detalhe |
|---|---|
| Armazenamento | Somente em variável de ambiente injetada pelo cofre. Nunca em banco, log, mensagem de erro ou resposta de API |
| Verificação | Job diário às 03:50 chama GET /{PHONE_NUMBER_ID}?fields=id; erro 190 dispara alerta crítico |
| Rotação programada | A cada 180 dias, pelo runbook (Seção 27): gerar o novo token, atualizar o segredo, reiniciar worker e web, validar no health check, revogar o antigo |
| Rotação de emergência | Mesmo procedimento sem a espera; a revogação do antigo vem primeiro |
| Escopo | Apenas os dois acima; business_management amplo não é concedido |
A ordem do runbook (atualizar, validar, revogar) garante zero janela de indisponibilidade: mensagens em voo com o token antigo continuam válidas até a revogação.
Rotação do segredo do aplicativo, com janela dupla. META_APP_SECRET é a chave do HMAC que valida todo
webhook de entrada, e trocá-la de uma vez rejeita webhooks legítimos entre a troca no painel do provedor e o
reinício dos processos. Por isso a rotação usa duas chaves aceitas simultaneamente, META_APP_SECRET e
META_APP_SECRET_PREVIOUS: a verificação de assinatura aceita qualquer uma das duas e registra qual validou; só
depois de 100% das validações de uma hora usarem a nova é que a anterior é removida do ambiente. Sem essa janela,
toda rotação produz exatamente o cenário da Seção 17.8 — canal de entrada morto com o envio aparentemente
saudável.
Sanitização de parâmetro. Função obrigatória aplicada a todo valor de parâmetro antes do envio. Sem ela, um
título com dois-pontos seguido de quebra de linha derruba o lote inteiro com 132000.
// packages/integrations/src/whatsapp/sanitize-param.ts
const MAX_TEMPLATE_PARAM = 300;
export function sanitizeTemplateParam(raw: string, maxLength = MAX_TEMPLATE_PARAM): string {
const flat = raw
.replace(/[\r\n\t \v\f]+/g, ' ') // proibidos: quebra de linha, tabulação
.replace(/\s{2,}/g, ' ') // proibido: 4+ espaços consecutivos
.replace(/[ -]/g, '') // caracteres de controle
.trim();
if (flat.length <= maxLength) return flat;
const cut = flat.slice(0, maxLength - 1); // corta em limite de palavra, fecha com reticências
const lastSpace = cut.lastIndexOf(' ');
return `${(lastSpace > maxLength * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
}Exemplo: teaser de 340 caracteres com duas quebras de linha vira uma única linha de 300 caracteres terminada em
…, cortada na última palavra completa. O valor final é validado por Zod
(z.string().min(1).max(300).regex(/^[^\n\r\t]*$/)) e violação lança TEMPLATE_PARAM_INVALID antes da chamada
HTTP — falhar localmente é sempre melhor que gastar uma chamada para receber 132000.
17.8 Webhooks de entrada #
// apps/web/src/app/api/webhooks/whatsapp/route.ts
export async function GET(req: Request) { // verificação de assinatura do endpoint
const url = new URL(req.url);
const [mode, token, challenge] = ['hub.mode', 'hub.verify_token', 'hub.challenge']
.map((k) => url.searchParams.get(k));
// O desafio da plataforma é sempre numérico. Validar o formato antes de ecoar impede
// que o endpoint sirva de refletor caso o token de verificação vaze.
if (mode === 'subscribe' && challenge && /^[0-9]{1,32}$/.test(challenge)
&& safeEqual(token ?? '', env.WHATSAPP_VERIFY_TOKEN)) {
return new Response(challenge, { status: 200, headers: { 'content-type': 'text/plain' } });
}
return new Response('Forbidden', { status: 403 });
}
export async function POST(req: Request) {
const raw = await req.text(); // corpo CRU: o HMAC é sobre os bytes exatos
if (raw.length > 512 * 1024) { // único caminho de 4xx depois do remetente autenticado
return new Response(null, { status: 413 });
}
const signature = req.headers.get('x-hub-signature-256') ?? '';
if (!verifyMetaSignature(raw, signature)) {
logger.warn({ signaturePresent: Boolean(signature) }, 'whatsapp.webhook_invalid_signature');
return new Response('Forbidden', { status: 403 });
}
// Autenticado o remetente, NADA depois disto produz resposta não-2xx.
try {
await persistAndEnqueue(JSON.parse(raw)); // grava webhook_deliveries + enfileira whatsapp.inbound
} catch (err) {
const result = err instanceof SyntaxError ? 'PARSE_ERROR' : 'PERSIST_FAILED';
await recordWebhookFailure({ source: 'META', rawBody: raw, processingResult: result, err });
}
return new Response('EVENT_RECEIVED', { status: 200 });
}
function verifyMetaSignature(rawBody: string, header: string): boolean {
if (!header.startsWith('sha256=')) return false;
const received = Buffer.from(header.slice('sha256='.length), 'hex');
// Janela dupla de rotação do segredo do aplicativo (Seção 17.7).
for (const secret of [env.META_APP_SECRET, env.META_APP_SECRET_PREVIOUS].filter(Boolean)) {
const expected = createHmac('sha256', secret as string).update(rawBody, 'utf8').digest();
if (expected.length === received.length && timingSafeEqual(expected, received)) return true;
}
return false;
}Corpo inválido nunca produz resposta não-2xx. A validação da assinatura vem primeiro e é a única
condição que autoriza recusar o evento. Autenticado o remetente, qualquer falha posterior — corpo que não é JSON,
schema reprovado, evento desconhecido, indisponibilidade do banco ou da fila — responde 200 com corpo vazio e
persiste o ocorrido: webhook_deliveries.processing_result recebe SCHEMA_REJECTED, PARSE_ERROR ou
PERSIST_FAILED, o corpo cru é guardado para reprocessamento, e o alerta webhook_payload_rejected (severidade
alta, plantonista) dispara acima de 3 ocorrências em 15 minutos. O motivo é operacional e caro: 4xx e 5xx
levam a plataforma a retentar e, em seguida, a desativar a assinatura do webhook; perder o canal de entrada é
catastroficamente pior do que engolir um corpo malformado. O único caminho de 4xx depois do remetente
autenticado é 413, para corpo acima de 512 KB, medido antes da leitura.
A resposta do GET é o hub.challenge em texto puro, sem o envelope de API da Seção 7 — este endpoint é um
contrato externo da Meta, e a exceção está registrada aqui. Regras duras do POST: o HMAC é calculado sobre o
corpo cru, antes de qualquer parse, porque reserializar o JSON muda bytes e invalida a assinatura; a comparação
usa timingSafeEqual, nunca ===; a verificação retorna false quando os comprimentos diferem, porque
timingSafeEqual lança com buffers de tamanhos diferentes; e a resposta 200 sai em menos de 200 ms, com todo o
trabalho real na fila whatsapp.inbound (Seção 18.8) — a Meta reenvia por até 7 dias e pode desativar a assinatura
de um endpoint que falha persistentemente.
O motor de envio e o processador de entrada são caminhos independentes. O envio pode estar 100% saudável
enquanto a entrada está morta, e nesse estado todos os painéis de envio mostram sucesso enquanto nenhum
assinante pagante recebe áudio (porque a janela nunca abre) e nenhum pedido de SAIR é honrado. Três falhas
plausíveis produzem exatamente esse sintoma: assinatura de webhook desativada pela plataforma, segredo do
aplicativo dessincronizado (Seção 17.7) e recategorização de template com supressão em massa. É por isso que os
alertas whatsapp.no_inbound, whatsapp.webhook_invalid_signature e whatsapp.window_direct_collapse da
Seção 18.12 existem: sem eles, um fim de semana inteiro passa com "100% enviado" e zero entregas úteis.
// Mensagem de texto recebida
{ "object": "whatsapp_business_account", "entry": [{ "id": "<WABA_ID>", "changes": [{ "field": "messages",
"value": { "messaging_product": "whatsapp",
"metadata": { "display_phone_number": "551140028922", "phone_number_id": "<PHONE_NUMBER_ID>" },
"contacts": [{ "profile": { "name": "Ana" }, "wa_id": "5511987654321" }],
"messages": [{ "from": "5511987654321", "id": "wamid.HBgNNTUxMTk4NzY1NDMyMRUCABIYFDNBMEE...",
"timestamp": "1789020240", "type": "text", "text": { "body": "AJUDA" } }] } }] }] }// Toque em botão de template (quick reply)
"messages": [{ "from": "5511987654321", "id": "wamid.HBgN...", "timestamp": "1789020480", "type": "button",
"context": { "from": "551140028922", "id": "wamid.<id do template enviado>" },
"button": { "payload": "OPEN_DEVOTIONAL:dev_01K5T8QW3M9Z2X4R7B6C1D0E", "text": "Ler e ouvir agora" } }]
// Status de mensagem. O bloco pricing é a fonte de verdade de custo: billable e category alimentam
// message_logs.is_billable e message_logs.conversation_category, e o dashboard soma dali
// em vez de estimar (Seção 17.12).
"statuses": [{ "id": "wamid.HBgN...", "status": "delivered", "timestamp": "1789020255",
"recipient_id": "5511987654321", "conversation": { "id": "b4a...", "origin": { "type": "utility" } },
"pricing": { "billable": true, "pricing_model": "PMP", "category": "utility" } }]
// Falha de entrega
"statuses": [{ "id": "wamid.HBgN...", "status": "failed", "timestamp": "1789020260",
"recipient_id": "5511987654321", "errors": [{ "code": 131049,
"title": "Message failed to send because of an unknown error",
"message": "This message was not delivered to maintain healthy ecosystem engagement." }] }]field |
Conteúdo | Ação |
|---|---|---|
messages → messages[] |
Mensagem do assinante (texto, botão, interativa, áudio, imagem, localização, reação) | Abre a janela, grava em inbound_messages, aciona o roteador de palavras-chave (Seção 19.5) e a entrega pendente (Seção 18.6) |
messages → statuses[] |
sent, delivered, read, failed |
Atualiza message_logs |
message_template_status_update |
Aprovação, rejeição, pausa, desativação | Atualiza whatsapp_templates, alerta (Seção 17.6) |
phone_number_quality_update |
Mudança de quality_rating ou de tier |
Atualiza settings, alerta (Seção 17.10) |
account_update |
Restrição de conta, ban, mudança de tier | Alerta crítico; pausa automática de envio se event = "ACCOUNT_RESTRICTION" |
account_review_update |
Resultado de revisão da conta | Alerta informativo |
| Qualquer outro | — | Registrado em webhook_deliveries e ignorado, sem erro |
Idempotência. webhook_deliveries tem índice único em (source, dedup_key), conforme a Seção 6.26.1, onde source é
META e dedup_key é o
wamid para mensagens e wamid + ':' + status para status. Não existem colunas provider nem external_id nessa
tabela. Reentrega encontra a chave existente e é descartada
com contagem em duplicate_count — isso importa, porque a Meta reenvia com frequência real quando há lentidão.
Estados de mensagem. accepted (resposta síncrona) → sent → delivered → read, com failed possível a
partir de qualquer ponto. Cada estado tem coluna de timestamp própria em message_logs
(sent_at, delivered_at, read_at, failed_at, mais error_code e error_title). Não existe coluna
accepted_at: o instante da resposta síncrona é sent_at, gravado no momento em que a plataforma devolve o
wamid, e é esse o marco usado em toda medição de latência (Seção 18.6). Os estados
só avançam, nunca retrocedem: um webhook sent que chega depois de delivered — o que acontece — é ignorado por
comparação de ordinal, mas seu timestamp é gravado se ainda estiver nulo. A transição usa
UPDATE ... WHERE status_ordinal < :novo, o que torna a operação idempotente e imune a corrida entre workers.
read não é garantido, porque uma fração relevante dos assinantes desativa confirmação de leitura: por isso
taxa_entrega (Seção 21) conta delivered ou read, e nenhuma decisão de produto depende de read.
17.9 Códigos de erro da Meta — tabela completa de tratamento #
R = retentável. "Ação" é o que o worker faz; "Efeito" é o que muda no estado do assinante.
| Código | Significado | R | Ação do sistema | Efeito no assinante |
|---|---|---|---|---|
0/3/10 |
Erro de autenticação ou permissão do app | Não | Alerta crítico; pausa a fila de envio | Nenhum envio até resolver |
100 |
Parâmetro inválido (inclui media_id expirado ou inexistente) |
Não | Marca item como falho; se for mídia, invalida media_uploads e reenfileira media.upload |
Recebe no lote do dia seguinte |
190 |
Token expirado, inválido ou revogado | Não | Alerta crítico com plantonista; pausa send.dispatch |
Envio do dia suspenso até rotação (Seção 17.7) |
368 |
Conta temporariamente bloqueada por violação de política | Não | Alerta crítico; pausa toda a fila; abre runbook de contingência (Seção 17.10) | Sem envios até liberação |
130429 |
Rate limit da API atingido | Sim | Backoff exponencial [1s, 4s, 16s, 60s]; o limitador do BullMQ já deveria impedir |
Atraso de minutos, sem perda |
130472 |
Usuário em experimento; mensagem não entregue | Não | Registra e trata como 131049 |
Entra no lote do dia seguinte |
131000 |
Erro genérico do servidor | Sim | Retry padrão (3 tentativas) | Atraso |
131005 |
Acesso negado ao recurso | Não | Alerta alto; falha o item | Sem envio |
131008 |
Parâmetro obrigatório ausente | Não | Falha o item; é defeito de código, não de dados | Sem envio; alerta alto |
131009 |
Valor de parâmetro inválido | Não | Falha o item; registra o payload sanitizado no log | Sem envio |
131016 |
Serviço temporariamente indisponível | Sim | Retry com backoff longo [30s, 120s, 300s] |
Atraso |
131021 |
Destinatário igual ao remetente | Não | Falha o item; marca invalid_recipient |
Não recebe até correção manual |
131026 |
Mensagem não entregável (número sem WhatsApp, aparelho incompatível, número inexistente) | Não | Incrementa subscribers.undeliverable_count; em 3 dias consecutivos grava subscribers.blocked_at e blocked_reason = 'META_131026', levando status a BLOCKED (Seção 11.8), e envia e-mail se houver e-mail verificado |
Para de receber; a cobrança não é alterada; painel mostra "Não conseguimos entregar no seu WhatsApp" |
131031 |
Conta comercial bloqueada | Não | Alerta crítico; pausa geral | Sem envios |
131042 |
Problema de método de pagamento da conta comercial | Não | Alerta crítico financeiro; pausa geral | Sem envios até regularizar |
131045 |
Erro de certificado ou registro do número | Não | Alerta crítico; runbook de re-registro | Sem envios |
131047 |
Fora da janela de 24 h — free-form recusada | Não | Estado esperado, não falha de infraestrutura: reclassifica a rota para TEMPLATE_INVITE e reenfileira uma vez |
Recebe o template em vez do pacote |
131048 |
Limite de spam para o par negócio/usuário | Não | Suspende envios para esse assinante por 24 h; não conta como falha | Pula um dia |
131049 |
Meta optou por não entregar para preservar o engajamento do ecossistema | Não | Esperado e recorrente no Brasil. Registra deferred; não conta como falha, não incrementa contador de erro, não alerta; assinante entra no lote do dia seguinte |
Pula o dia, sem qualquer efeito de estado |
131050 |
Usuário desativou mensagens de marketing | Não | Marca subscribers.marketing_blocked_at; templates MARKETING deixam de ir para ele, UTILITY continua |
Continua recebendo o devocional; para de receber reativação |
131051 |
Tipo de mensagem não suportado | Não | Falha o item; defeito de código | Sem envio |
131052 |
Erro ao baixar mídia | Sim | Retry; persistindo, invalida e reenvia a mídia | Recebe só o texto naquele dia |
131053 |
Erro ao subir mídia | Sim | Retry do media.upload (Seção 16.11) |
Recebe só o texto naquele dia |
131056 |
Limite de taxa do par (negócio, usuário) | Sim | Backoff de 60 s para aquele destinatário | Atraso de minutos |
132000 |
Número de parâmetros não corresponde ao template | Não | Falha o lote inteiro para aquela rota e alerta crítico: é defeito de código que afeta todos | Ninguém recebe; operador corrige e reprocessa |
132001 |
Template não existe no nome/idioma informado | Não | Alerta crítico; verifica sincronização (Seção 17.6) | Ninguém recebe naquela rota |
132005 |
Texto hidratado do template excede o limite | Não | Falha o item; indica falha da sanitização (Seção 17.7); alerta alto | Sem envio; corrigido por reprocessamento no mesmo dia |
132007 |
Violação de política de formato do template | Não | Falha o item; alerta alto | Sem envio |
132012 |
Formato de parâmetro inválido (quebra de linha, 4+ espaços) | Não | Falha o item; alerta alto; a sanitização deveria ter impedido | Sem envio |
132015 |
Template pausado por baixa qualidade | Não | Alerta crítico; rota indisponível, troca para a alternativa quando existir (vídeo → texto). O template volta sozinho após a pausa | Recebe pela rota alternativa ou pula o dia |
132016 |
Template desativado por qualidade persistentemente baixa | Não | Alerta crítico; exige criar _v2 com novo texto e resubmeter |
Rota indisponível até novo template |
132068 |
Fluxo (Flow) bloqueado | Não | Não usamos Flows; registra e ignora | Nenhum |
133000 |
Falha ao cancelar registro do número | Não | Runbook de operação | Nenhum imediato |
133004 |
Servidor temporariamente indisponível | Sim | Retry com backoff longo | Atraso |
133005 |
PIN de verificação em duas etapas incorreto | Não | Alerta crítico: PIN errado no cofre | Bloqueia re-registro |
133006 |
Número precisa ser verificado novamente | Não | Alerta crítico; runbook de re-verificação | Envios param |
133008 |
Muitas tentativas de PIN | Não | Aguardar o bloqueio expirar; runbook | Envios param |
133009 |
Tentativas de PIN rápidas demais | Sim | Aguardar e repetir uma vez | Nenhum |
133010 |
Número não registrado | Não | Alerta crítico; executar registro | Envios param |
133015 |
Número em processo de remoção | Não | Alerta crítico | Envios param |
133016 |
Número apagado recentemente; aguardar antes de re-registrar | Não | Alerta crítico; runbook informa o tempo de espera | Envios param |
135000 |
Erro genérico do usuário | Não | Falha o item, registra payload | Entra no lote seguinte |
Regra explícita sobre 131049. É o erro mais frequente do sistema no Brasil e não é um defeito: a Meta
limita quantas mensagens de baixo engajamento um usuário recebe por dia, para preservar a experiência da
plataforma. O tratamento é registrar message_logs.status = 'failed' com error_code = 131049 e deferred = true;
não incrementar undeliverable_count; não contar em taxa_entrega como falha; não emitir alerta; e
reincluir o assinante no lote do dia seguinte. O painel de operação mostra 131049 em uma coluna separada de
"adiados", nunca junto com falhas reais. Um dia com 8% de 131049 é operação normal; alerta só acima de 25% do
lote, o que indicaria degradação de qualidade do número.
17.10 Qualidade do número e contingência #
quality_rating assume GREEN, YELLOW, RED ou UNKNOWN, com base em bloqueios, denúncias e taxa de leitura
nas últimas 24 horas. O sistema lê o valor no webhook phone_number_quality_update e, diariamente às 03:55, em
GET /{PHONE_NUMBER_ID}?fields=quality_rating,messaging_limit_tier,status.
| Estado | Resposta automática do sistema | Resposta humana |
|---|---|---|
GREEN |
Nenhuma | Nenhuma |
YELLOW |
Suspende automaticamente reativacao_v1 (o único MARKETING); mantém o devocional; alerta médio |
Revisar teasers dos últimos 7 dias e taxa de opt-out; verificar se algum texto soou promocional |
RED |
Alerta crítico; suspende todo MARKETING; reduz send.rate_per_second para 5; envia devocional apenas a quem interagiu nos últimos 14 dias |
Auditoria de conteúdo em 24 h; pausar aquisição paga; revisar o fluxo de opt-in |
UNKNOWN |
Nenhuma (volume insuficiente) | Nenhuma |
FLAGGED |
Alerta crítico; o tier não sobe enquanto durar | Runbook de qualidade |
RESTRICTED |
Alerta crítico; para de planejar lotes acima do limite | Runbook de restrição |
Plano de contingência para número banido, o pior cenário operacional do produto (procedimento detalhado no runbook da Seção 27):
- Número reserva já provisionado. Um segundo número é registrado na mesma WABA desde o primeiro mês, com os mesmos templates aprovados. Custo: apenas o número. Manter templates aprovados em dois números é o que torna a migração uma troca de variável de ambiente.
- Chaveamento.
WHATSAPP_PHONE_NUMBER_IDaponta para o reserva;workerewebreiniciam. Tempo alvo: menos de 15 minutos. - Consequência aceita e registrada: as janelas de atendimento abertas não migram, porque pertencem ao par (número do negócio, usuário). No primeiro dia após a migração, 100% da base recebe por template, e o custo daquele dia é o de um dia sem atalho de janela.
- Tier do reserva começa em 1K. Acima de 1.000 assinantes o envio é escalonado em blocos diários até o tier subir, na ordem: PAID com janela aberta, PAID sem janela, FREE.
- Comunicação por e-mail a quem tem e-mail verificado, informando o novo número, e aviso no painel. Nenhum assinante recebe mensagem do número novo sem opt-in já registrado.
- Apelação pelo Business Manager em paralelo, em até 24 h do banimento.
17.11 Rate limiting #
| Camada | Limite | Onde é aplicado |
|---|---|---|
| Plataforma | 80 mensagens/s por número | Meta |
| Nosso teto | 20 mensagens/s (send.rate_per_second em settings, padrão 20) |
Limitador do BullMQ na fila send.dispatch |
| Por destinatário | 1 mensagem a cada 500 ms | Espaçamento interno do pacote free-form |
| Tier | Clientes únicos por 24 h (Seção 17.4) | Verificado no planejamento, às 05:40 |
// O teto é uma chave de settings, não uma variável de ambiente: precisa ser ajustável
// em incidente, sem deploy e sem editar arquivo no servidor (Seção 27.6, R-03 e R-04).
const ratePerSecond = await settings.getInt('send.rate_per_second'); // padrão 20
export const sendDispatchWorker = new Worker('send.dispatch', processSendDispatch, {
connection, concurrency: 12,
limiter: { max: ratePerSecond, duration: 1000 }, // 20/s no grupo inteiro
});Mudar send.rate_per_second exige reiniciar o worker para o limitador assumir o valor novo; o comando de
operação faz as duas coisas em sequência e essa é a razão de o reinício aparecer nos runbooks logo depois do
ajuste.
O limitador do BullMQ é por grupo de workers que compartilham o Redis, não por processo: subir uma segunda
réplica não dobra a taxa, que é exatamente o comportamento desejado, porque o limite é da plataforma e não da nossa
capacidade. Por que 20/s e não 80/s: o desenho conservador deixa margem para os envios free-form disparados por
interação ao longo do dia, que competem pelo mesmo teto; 20/s entrega 3.000 mensagens em 2,5 minutos, muito dentro
da janela de 20 minutos exigida; e picos próximos do teto aumentam a incidência de 130429 sem trazer benefício.
Em RED, o valor cai para 5/s automaticamente (Seção 17.10).
17.12 Custos e o efeito econômico do desenho #
Modelo: cobrança por mensagem, por categoria; conversas iniciadas pelo usuário não são cobradas. Cenário: 3.000 PAID + 7.000 FREE, 30 dias, 40% dos PAID com janela permanentemente aberta, 10% dos PAID no caminho de vídeo, 55% de abertura de janela entre os que recebem template.
| Fluxo | Mensagens/mês | Categoria | Unitário | US$/mês |
|---|---|---|---|---|
| PAID com janela aberta (1.200 × 30) | 36.000 | Serviço | 0,0000 | 0,00 |
| PAID via template de texto (1.500 × 30) | 45.000 | UTILITY | 0,0080 | 360,00 |
| PAID via template de vídeo (300 × 30) | 9.000 | UTILITY | 0,0080 | 72,00 |
| Pacote free-form após interação (990 × 30 × 3) | 89.100 | Serviço | 0,0000 | 0,00 |
| FREE, domingo (7.000 × 4,3 semanas) | 30.100 | UTILITY | 0,0080 | 240,80 |
| Pacote free-form dos FREE que interagem (55% × 2) | 33.110 | Serviço | 0,0000 | 0,00 |
| OTP de login | 2.500 | AUTHENTICATION | 0,0315 | 78,75 |
| Cobrança e confirmações (D-3, D-1, D0, confirmações) | 4.500 | UTILITY | 0,0080 | 36,00 |
| Reativação | 300 | MARKETING | 0,0625 | 18,75 |
| Total | 249.610 | 806,30 |
Ao câmbio de referência de R$ 5,40 (Seção 16.14): R$ 4.354/mês, ou R$ 1,45 por assinante pago por mês — 7,3% da mensalidade de R$ 19,90.
O que o desenho economiza. Se tudo fosse entregue por template em vez de usar a janela, as 158.210 mensagens
hoje gratuitas custariam US$ 1.265,68 adicionais por mês como UTILITY, ou US$ 9.888 como MARKETING. O desenho
"Template + Janela" corta 61% do custo de mensageria no cenário UTILITY e 92% no cenário em que a Meta
reclassificasse o devocional como MARKETING. É por isso que a etapa 3 é o caminho preferencial e não uma
otimização opcional: cada ponto percentual de engajamento que move um assinante para a janela aberta remove
US$ 0,24 por mês do custo dele. O dashboard (Seção 21) não estima: soma message_logs.cost_micros filtrando por
message_logs.is_billable e agrupando por message_logs.conversation_category, todos preenchidos pelo bloco
pricing do webhook de status.
Unidade de custo, obrigatória. As tabelas acima estão em dólar porque é assim que a plataforma publica o
preço. O que o sistema persiste é message_logs.cost_micros, inteiro em milionésimos de BRL, convertido
uma única vez na gravação pelo câmbio de referência billing.usd_brl_reference_rate (Seção 16.14). O divisor para
exibição é 1.000.000. Os preços unitários por categoria ficam em settings sob
whatsapp.price_per_message_micros, também em milionésimos de BRL — o nome carrega a unidade justamente para que
ninguém some esse valor a um campo *_amount_cents, que é centavo de BRL e tem divisor 100. Nenhum valor
monetário do sistema é ponto flutuante.
17.13 Camada de abstração WhatsAppProvider #
// packages/integrations/src/whatsapp/types.ts
export interface SendTemplateInput {
to: string; // E.164 sem "+", conforme a API
templateName: string; languageCode: 'pt_BR';
bodyParams: string[]; // já sanitizados (Seção 17.7)
headerMedia?: { kind: 'VIDEO' | 'IMAGE' | 'DOCUMENT'; mediaId: string };
buttonPayloads?: string[]; // por índice de botão quick reply
idempotencyKey: string; // send:{subscriberId}:{devotionalDate}:{step}
}
export interface SendTextInput { to: string; body: string; previewUrl: false; idempotencyKey: string }
export interface SendMediaInput {
to: string; kind: 'AUDIO' | 'VIDEO' | 'IMAGE'; mediaId: string; caption?: string; idempotencyKey: string }
export interface SendResult { wamid: string; acceptedAt: Date; waId: string | null }
export type SubscriberEffect = 'NONE' | 'DEFER_TO_TOMORROW' | 'SUPPRESS_24H'
| 'MARK_UNDELIVERABLE' | 'MARK_MARKETING_BLOCKED' | 'HALT_ALL_SENDS';
export class WhatsAppError extends Error {
constructor(readonly code: number, readonly retryable: boolean, readonly title: string,
readonly subscriberEffect: SubscriberEffect, readonly details?: string) { super(title); }
}
export interface WhatsAppProvider {
readonly name: string; // 'META_CLOUD_API' | 'BSP_<nome>'
sendTemplate(input: SendTemplateInput): Promise<SendResult>;
sendText(input: SendTextInput): Promise<SendResult>;
sendMedia(input: SendMediaInput): Promise<SendResult>;
sendInteractive(input: SendInteractiveInput): Promise<SendResult>;
uploadMedia(buffer: Buffer, mimeType: string, filename: string): Promise<{ mediaId: string; expiresAt: Date }>;
getMediaMetadata(mediaId: string): Promise<{ sha256: string; fileSize: number } | null>;
listTemplates(): Promise<TemplateStatus[]>;
getPhoneNumberHealth(): Promise<{ qualityRating: string; messagingTier: string; status: string }>;
verifyWebhookSignature(rawBody: string, headers: Headers): boolean;
parseWebhook(rawBody: string): NormalizedWebhookEvent[];
}O ponto da abstração é que o motor de envio (Seção 18) só conhece esta interface e o enum SubscriberEffect. A
tradução de 131049 para DEFER_TO_TOMORROW, ou de 131050 para MARK_MARKETING_BLOCKED, acontece dentro do
adaptador; um BSP com códigos próprios implementa a mesma tradução e o motor não muda uma linha. parseWebhook
normaliza o payload da Meta (entry[].changes[].value.messages[]) para eventos planos
{ kind, subscriberPhone, wamid, timestamp, payload }, que é o que a fila whatsapp.inbound consome — trocar de
provedor troca o parser, não o consumidor.
17.14 Casos de borda #
| # | Situação | Comportamento definido |
|---|---|---|
| 1 | Assinante responde no exato instante em que o template está sendo enviado | O envio prossegue; a interação abre a janela e dispara a entrega completa. Ele recebe o template e, segundos depois, o pacote. Aceito |
| 2 | Assinante toca no botão de um devocional de 3 dias atrás | O payload carrega o devotionalId; o sistema entrega aquele devocional, não o de hoje, e registra message_logs.late_open_days (Seção 6.19) |
| 3 | Assinante toca duas vezes em "Ler e ouvir agora" | delivery_attempts é única em (subscriber_id, devotional_date); o segundo toque encontra delivered e responde apenas com o fechamento |
| 4 | Webhook com assinatura válida mas JSON malformado | Devolve 200 mesmo assim, grava webhook_deliveries.processing_result = 'PARSE_ERROR' com o corpo cru preservado e emite webhook_payload_rejected acima de 3 em 15 min. Responder 4xx levaria a plataforma a desativar a assinatura do endpoint (Seção 17.8) |
| 5 | Webhooks de status fora de ordem (read antes de delivered) |
Comparação de ordinal impede retrocesso; ambos os timestamps são gravados |
| 6 | wa_id diferente do phone_e164 cadastrado (nono dígito) |
Resolvido pela busca em quatro passos da Seção 11; wa_id é gravado no primeiro contato |
| 7 | Template aprovado é pausado às 05:50 | send.plan já rodou; send.dispatch recebe 132015 e para a rota inteira após 3 ocorrências consecutivas, evitando 3.000 falhas. Alerta crítico |
| 8 | Assinante bloqueia o número | 131026/131049 aumentam para ele; após 3 dias consecutivos de 131026, blocked_at e blocked_reason são preenchidos e o status passa a BLOCKED; qualquer mensagem recebida dele limpa os dois e devolve o status resolvido por resolveEntitlements (Seção 11.8) |
| 9 | Áudio enviado a tier FREE por defeito | Impossível pelo desenho: o tier é relido do banco no instante do disparo, dentro do mesmo job e imediatamente antes da chamada ao provedor (Seção 18.5). tier_at_send é registro histórico, nunca autoridade; quem perdeu o acesso pago entre o planejamento das 05:40 e o disparo recebe apenas o texto, e o passo de áudio nem é criado |
| 10 | media_id de vídeo expirado no momento do template |
Erro 100; item falha, media_uploads é invalidado, media.upload reenfileirado, e o assinante recebe o template de texto na mesma execução, como degradação |
| 11 | Assinante muda de número, ou o número é reciclado pela operadora | Toda mudança do wa_id associado a um telefone invalida as sessões do assinante e exige nova verificação por código antes de qualquer dado pessoal ser exibido ou enviado (regra da Seção 17.7). Só depois disso a Seção 20 migra o histórico. A janela do número antigo é irrelevante |
| 12 | Meta retorna 200 sem messages[0].id |
WHATSAPP_NO_WAMID, retentável uma vez: sem wamid não há como correlacionar status |
| 13 | Volume planejado excede o tier | send.plan detecta às 05:40, alerta e trunca o lote priorizando PAID; os FREE excedentes são adiados para o domingo seguinte, com registro explícito |
| 14 | Assinante envia áudio, imagem ou localização | A janela abre normalmente; o conteúdo é registrado em inbound_messages sem download da mídia, e o roteador responde com a mensagem de conteúdo não suportado (Seção 19.6) |
18. Motor de Envio Diário #
18.1 Princípios #
O motor de envio é o processo que decide quem recebe o quê, quando e por qual rota. Ele vive em apps/worker
e é governado por cinco regras:
- Planejar antes de disparar. Às 05:40 o lote inteiro é materializado em banco, com rota e mídia já resolvidas por assinante. Às 06:00 o disparo apenas executa uma lista pronta. Nenhuma decisão de negócio acontece durante o disparo.
- Idempotência por chave persistida, não por memória.
plan:{devotionalDate}esend:{subscriberId}:{devotionalDate}são chaves persistidas, garantidas por índice único emsend_batchese emdelivery_attempts. Reprocessar é sempre seguro. - Falha de um destinatário nunca afeta os outros. Cada item é uma unidade de trabalho isolada.
- Nunca enviar conteúdo incompleto. Se o devocional do dia não está pronto, usa-se a reserva; se não há reserva, não se envia (Seção 18.4).
- Fuso único. Todo horário desta seção é America/Sao_Paulo. O agendamento usa
date-fns-tze job repetível comtz: 'America/Sao_Paulo', nunca offset fixo.
18.2 Cronograma diário #
| Horário | Job | O que faz | Duração típica |
|---|---|---|---|
| 00:05 | maintenance.cleanup |
Retenção (Seção 22), remoção de temporários órfãos > 6 h, poda de job_runs e webhook_deliveries |
30 s |
| 00:05 | billing.lifecycle |
Expira períodos pagos e cortesias vencidos (Seção 13.2) | 20 s |
| 00:20 | maintenance.cleanup (etapa 2) |
Sincroniza status de templates e do número (Seções 17.6 e 17.10) | 5 s |
| 03:10 | metrics.rollup |
Consolida daily_metrics de D−1 e reprocessa D−2, D−3, D−4, D−7 e D−14 (Seção 21.5) |
20 a 90 s |
| 03:50 | maintenance.cleanup (etapa 3) |
Health check do token e da conta da Meta; GET /{PHONE_NUMBER_ID} |
2 s |
| 04:00 | billing.reconcile |
Reconciliação diária com a Asaas (Seção 12); corrige divergência por webhook perdido | 1 a 4 min |
| 04:30 | tts.generate (varredura) |
Garante áudio pronto para os próximos 3 dias; enfileira o que faltar | 1 a 3 min |
| 05:00 | send.plan modo readiness |
Verificação de prontidão do devocional do dia (Seção 18.4). Alerta imediato se não estiver pronto | 3 s |
| 05:30 | messaging.pause |
Limpa pausas encerradas e enfileira a saudação de retorno (Seção 20.6.2) | 5 s |
| 05:40 | send.plan modo plan |
Monta a coorte, resolve rota e mídia, grava send_batches + delivery_attempts |
10 a 60 s |
| 05:57 | send.plan modo verify |
Confere que o lote existe, que a mídia está válida e que o template está APPROVED; corrige rotas de vídeo sem MP4 |
5 s |
| 06:00 | send.dispatch |
Dispara o lote a 20 msg/s | 2,5 a 15 min |
| 06:05 | Alerta | Dispara se send_batches.started_at ainda é nulo |
— |
| 06:30 | Alerta | Dispara se o lote não está COMPLETED |
— |
| 06:00 → 23:59 | whatsapp.inbound + send.followup |
Processa interações, abre janelas, entrega pacotes completos (Seção 18.6) | contínuo |
| 09:00, 12:00, 18:00 | send.followup (varredura) |
Reprocessa itens RETRYING cuja janela de backoff venceu |
10 s |
| 23:55 | send.followup modo close |
Fecha entregas pendentes, incrementa consecutive_window_misses, marca o lote como CLOSED. É o último job do dia civil; nenhuma métrica do dia é consolidada antes dele |
15 s |
Os jobs marcados em negrito são os que, se falharem, degradam o produto no mesmo dia. Todos têm alerta próprio na Seção 18.12.
Por que o rollup roda às 03:10 e não no fim do dia. O fechamento das entregas acontece às 23:55; consolidar
métricas antes disso mediria o dia antes de o dia acabar, e messages_delivered, window_opens e as entregas
pendentes sairiam sistematicamente subestimados. Às 03:10 o dia alvo (D−1) já está fechado, a reconciliação de
pagamentos das 04:00 ainda não começou e o planejamento das 05:40 está longe — é a faixa de menor tráfego do dia.
A divergência de receita que a reconciliação das 04:00 corrigir entra no reprocessamento de D−2 do dia seguinte.
Não existe rollup às 23:00.
18.3 Job send.plan #
Roda em três modos, sempre para uma devotionalDate (data local). O jobId é
plan:{devotionalDate}:{mode}, o que torna o job repetível idempotente por natureza.
18.3.1 Seleção do devocional do dia #
SELECT id, title, teaser, status
FROM devotionals
WHERE scheduled_for = :devotionalDate
AND deleted_at IS NULL
AND is_evergreen = false
LIMIT 1;devotionals.scheduled_for tem índice único parcial WHERE deleted_at IS NULL AND is_evergreen = false, o que
garante no máximo um devocional programado por data. Decisão registrada: scheduled_for é nullable exatamente
para permitir o acervo de reserva — devocionais evergreen existem sem data (Seção 18.4).
Prontidão exigida para o disparo: status IN ('PUBLISHED') e, para a rota de áudio, um audio_assets corrente
(invalidated_at IS NULL, qc_failed_at IS NULL) com media_uploads válido.
18.3.2 Montagem da coorte #
SELECT s.id, s.phone_e164, s.wa_id, s.tier, s.service_window_expires_at,
s.consecutive_window_misses, s.marketing_blocked_at, s.blocked_at
FROM subscribers s
WHERE s.deleted_at IS NULL
AND s.opt_in_confirmed_at IS NOT NULL -- nunca envia sem opt-in confirmado (regra dura de opt-in, Seção 11)
AND s.opt_out_at IS NULL -- exclui quem saiu
AND (s.paused_until IS NULL OR s.paused_until <= now()) -- exclui pausa vigente
AND s.blocked_at IS NULL -- exclui bloqueados (131026 recorrente ou bloqueio administrativo)
AND (
s.tier = 'PAID' -- PAID: todos os dias
OR (s.tier = 'FREE' AND :weekday = :freeTierSendWeekday) -- FREE: só no dia configurado
)
AND NOT EXISTS ( -- exclui quem já recebeu
SELECT 1 FROM delivery_attempts da
WHERE da.subscriber_id = s.id AND da.devotional_date = :devotionalDate
)
ORDER BY s.tier DESC, s.id -- PAID primeiro: se o tier truncar, PAID é preservado
FOR UPDATE SKIP LOCKED;:freeTierSendWeekday vem de settings sob a chave send.free_tier_weekday (padrão 0 = domingo, conforme a
matriz de entitlements da Seção 7); o nome da chave segue o formato grupo.chave do catálogo da Seção 26.8.1 e
não é uma variável de ambiente. O tier de cada assinante não é recalculado aqui: a coluna é a projeção
mantida por resolveEntitlements() (Seção 7). O valor lido nesta consulta é congelado em
delivery_attempts.tier_at_send como registro histórico, e não como autoridade — a decisão que vale é a
releitura do disparo (Seção 18.5).
phone_e164 e wa_id são colunas cifradas (Seção 6): o repositório as devolve decifradas para o motor, e
qualquer busca por igualdade usa phone_hmac ou wa_id_hmac, nunca a coluna cifrada.
A leitura é paginada em blocos de 1.000 com cursor por id, para não carregar 50.000 linhas em memória.
18.3.3 Resolução de rota #
function resolveRoute(s: Subscriber, ctx: PlanContext): Route {
if (s.service_window_expires_at && s.service_window_expires_at > ctx.dispatchAt) {
return 'WINDOW_DIRECT'; // etapa 3 da Seção 17.3 — preferencial, custo zero
}
if (s.tier === 'PAID' && s.consecutive_window_misses >= ctx.windowMissThreshold && ctx.videoMediaId) {
return 'TEMPLATE_VIDEO'; // etapa 4 da Seção 17.3
}
return 'TEMPLATE_INVITE'; // etapa 1 da Seção 17.3
}ctx.windowMissThreshold vem de settings sob send.window_miss_threshold (padrão 3).
O lote é gravado em duas tabelas, na mesma transação:
| Tabela | Conteúdo |
|---|---|
send_batches |
Uma linha por (devotional_date, batch_kind): devotional_id, planned_count, contagens por rota, tier_limit_at_plan, status, planned_at, started_at, completed_at |
delivery_attempts |
Uma linha por assinante: idempotency_key = send:{subscriberId}:{devotionalDate}, subscriber_id, devotional_id, devotional_date, route, status = 'PENDING', attempt_count = 0, whatsapp_media_id resolvido |
A inserção usa INSERT ... ON CONFLICT (idempotency_key) DO NOTHING, e o número de linhas afetadas é comparado
com o tamanho da coorte. Divergência é registrada como plan.duplicates_skipped — informação, não erro: significa
que o job rodou duas vezes, e a segunda foi corretamente absorvida.
Verificação de tier. Antes de confirmar, planned_count + mensagens já enviadas nas últimas 24 h é comparado
com whatsapp.messaging_tier. Acima de 80%, alerta médio. Acima de 100%, o lote é truncado pela ordem de
ORDER BY tier DESC — PAID inteiro, FREE até o limite —, e os excedentes ficam registrados em
send_batches.truncated_count com alerta alto. FREE truncado é adiado para o próximo dia de envio FREE, nunca
enviado fora do dia configurado.
18.4 Regra de prontidão e mecanismo de reserva #
A prontidão é avaliada em três marcos, nesta ordem, e nenhum outro: 05:00 (send.plan modo readiness)
alerta e abre o runbook R-11 se não houver devocional publicado ou se o áudio estiver ausente; 05:40
(send.plan modo plan) congela a rota e a mídia de cada assinante, usando a reserva desta subseção se o áudio
ainda não existir; 05:57 (send.plan modo verify) confere que a mídia está válida e que o template está
APPROVED, e corrige rotas de vídeo sem MP4. Depois das 05:57 nada muda no lote. O que continua mudando
depois das 05:57 é o estado do assinante, e é por isso que existe a revalidação no disparo da Seção 18.5: a
prontidão do conteúdo congela, a elegibilidade da pessoa não.
Às 05:00, send.plan em modo readiness avalia o devocional do dia:
devocional de hoje existe e status = PUBLISHED?
| |
não sim
| |
| áudio corrente + media_id válido?
| | |
| sim não (só para PAID)
| | |
| v v
| TUDO PRONTO alerta tts.audio_not_ready_by_deadline
| PAID recebe SÓ TEXTO neste dia
| (degradação, não bloqueio)
v
alerta content.not_ready_by_deadline (CRÍTICA, plantonista, 05:00)
|
v
existe reserva evergreen elegível?
| |
sim não
| |
v v
usa a reserva NÃO ENVIA NADA
marca fallback_used alerta content.no_evergreen_available (CRÍTICA)
send_batches.status = 'SKIPPED_NO_CONTENT'
painel e e-mail ao operador às 05:01Decisão sobre a degradação parcial: se o texto está pronto e o áudio não, o envio acontece com texto apenas
para todos, inclusive PAID. Segurar o devocional inteiro por causa do áudio prejudicaria toda a base para
proteger um benefício de parte dela. O assinante PAID recebe, no fechamento do pacote, a mensagem
devotional.audio_unavailable (Seção 19), e o áudio fica disponível no acervo do painel assim que ficar pronto.
18.4.1 Mecanismo de reserva (evergreen) #
Especificação completa, porque não pode haver ambiguidade num caminho que só é exercitado em emergência.
| Aspecto | Decisão |
|---|---|
| Marcação | devotionals.is_evergreen = true e scheduled_for IS NULL. São devocionais atemporais: sem menção a data, estação, feriado ou notícia |
| Estado exigido | status = 'PUBLISHED' com áudio corrente e media_uploads válido, exatamente como um devocional normal. Reserva sem áudio não é reserva |
| Estoque mínimo | 5 evergreen prontos. O job das 04:30 verifica e alerta content.evergreen_stock_low quando cai abaixo de 5; abaixo de 2, severidade alta |
| Seleção | O evergreen com last_used_as_fallback_at mais antigo (ou nulo), desempate por id. Rodízio simples, sem aleatoriedade — reprodutível em investigação |
| Reuso | Um evergreen não pode ser usado duas vezes em 180 dias. Se todos os 5 estiverem dentro da janela de 180 dias, o mais antigo é usado assim mesmo e o alerta é elevado a crítico |
| Registro | send_batches.fallback_used = true, send_batches.fallback_reason, devotionals.last_used_as_fallback_at atualizado. O painel mostra o dia marcado como "conteúdo de reserva" |
| Texto ao assinante | Nenhuma diferença. O assinante não é avisado de que recebeu conteúdo de reserva. A abertura do roteiro de narração não menciona a data para os evergreen (Seção 16.3.1 usa a data do envio, e para evergreen o bloco 1 é substituído por "Palavra Diária. Devocional de hoje.") |
| Recuperação | O devocional que deveria ter saído continua em scheduled_for original e o painel oferece "reagendar para a próxima data livre" em um clique |
Nunca enviar conteúdo incompleto é regra absoluta: se não há devocional pronto nem reserva elegível, o lote é
marcado SKIPPED_NO_CONTENT e nenhuma mensagem sai. Um dia sem devocional é um problema; um devocional pela
metade é uma quebra de confiança.
18.5 Job send.dispatch #
Às 06:00, um job por lote enfileira um job filho por item de delivery_attempts com status = 'PENDING'. O
jobId do filho é a própria idempotency_key, o que impede duplicação mesmo se o pai for reexecutado.
async function processDispatchItem(job: Job<DispatchItemData>) {
const attempt = await claimAttempt(job.data.idempotencyKey); // UPDATE ... WHERE status IN ('PENDING','RETRYING')
if (!attempt) return { skipped: 'already_processed' }; // idempotência: outro worker pegou
// REVALIDAÇÃO NO DISPARO. Leitura por chave primária, no mesmo job, imediatamente
// antes da chamada ao provedor. tier_at_send é histórico, nunca autoridade.
const sub = await loadSubscriberForUpdate(attempt.subscriber_id); // SELECT ... WHERE id = $1
if (sub.deleted_at || sub.blocked_at) {
return finalize(attempt, 'SKIPPED_INELIGIBLE', 'deleted_or_blocked'); // sem chamada ao provedor
}
if (sub.opt_out_at) {
return finalize(attempt, 'SKIPPED_OPTED_OUT', 'opt_out_after_plan');
}
if (sub.paused_until && sub.paused_until > new Date()) {
return finalize(attempt, 'SKIPPED_PAUSED', 'paused_after_plan');
}
const ent = resolveEntitlements(sub); // fonte única (Seção 7)
if (attempt.tier_at_send === 'PAID' && ent.tier === 'FREE') { // rebaixamento na hora
await downgradeAttempt(attempt, { tierAtSendEffective: 'FREE', downgradedAt: new Date() });
}
switch (attempt.route) {
case 'WINDOW_DIRECT': return deliverFullPackage(sub, ent, attempt);
case 'TEMPLATE_VIDEO': return ent.audioEnabled
? sendTemplate(sub, attempt, 'devocional_diario_video_v1')
: sendTemplate(sub, attempt, 'devocional_diario_v1');
case 'TEMPLATE_INVITE': return sendTemplate(sub, attempt, 'devocional_diario_v1');
}
}deliverFullPackage envia, com 500 ms entre mensagens: (1) texto completo; (2) áudio, somente se
ent.audioEnabled; (3) fechamento. Cada mensagem grava sua própria linha em message_logs com o step, e o
progresso é persistido em delivery_attempts.steps_completed — se o worker morrer entre a mensagem 2 e a 3, a
retomada envia apenas a 3.
18.5.1 Revalidação no disparo #
delivery_attempts.tier_at_send é registro histórico, nunca autoridade. Imediatamente antes de chamar o
provedor, e dentro do mesmo job, o motor relê subscribers.tier, subscribers.opt_out_at,
subscribers.deleted_at, subscribers.blocked_at e subscribers.paused_until por chave primária e aplica,
nesta ordem:
| Condição relida | Desfecho do item | Chamada ao provedor |
|---|---|---|
deleted_at ou blocked_at preenchido |
SKIPPED_INELIGIBLE |
Nenhuma |
opt_out_at preenchido |
SKIPPED_OPTED_OUT |
Nenhuma |
paused_until vigente |
SKIPPED_PAUSED |
Nenhuma |
tier = 'FREE' em item planejado como PAID |
Rebaixa o pacote na hora: entrega apenas o texto do plano gratuito, grava delivery_attempts.downgraded_at = now() e tier_at_send_effective = 'FREE' |
Sim, sem o passo de áudio |
| Nenhuma das anteriores | Segue a rota planejada | Sim |
As colunas delivery_attempts.tier_at_send, tier_at_send_effective e downgraded_at são declaradas na
Seção 6.20; esta subseção define apenas quando cada uma é escrita.
A leitura extra é uma consulta por chave primária, abaixo de 1 ms, sobre um lote de no máximo 12.400 itens: no
pior caso do domingo, 12 segundos de CPU de banco distribuídos por 10 minutos de disparo. Essa revalidação é o
que torna verdadeira a regra de carência zero também dentro dos 15 minutos de disparo e das varreduras de
send.followup ao longo do dia. Sem ela, o congelamento das 05:40 concederia na prática um dia inteiro de
carência a todo inadimplente: um PAYMENT_OVERDUE processado às 05:52 grava tier = FREE corretamente (Seção
13.3), e às 06:00 o disparo entregaria mesmo assim o texto completo e o áudio narrado, todos os dias, para
todo mundo que perdeu o acesso naquela madrugada. A revogação é imediata por decisão de produto, e imediata
significa também "no meio do lote".
A mesma revalidação vale para send.followup: um pacote disparado por interação às 19:00 relê o estado no
instante do envio, e não o que foi congelado às 05:40.
O caso simétrico também é real: um assinante que envia SAIR às 05:58 está no lote planejado às 05:40 e, sem a
revalidação, receberia a mensagem depois de pedir para sair — o que é, ao mesmo tempo, descumprimento do direito
de revogação e a causa direta da denúncia que derruba a reputação do número.
18.6 Processamento de mensagens de entrada #
Assinante envia mensagem ou toca em botão
|
v
POST /api/webhooks/whatsapp -- valida HMAC, grava webhook_deliveries, 200 em < 200 ms
|
v
fila whatsapp.inbound (concurrency 8)
|
+-- resolve o assinante pela busca em 4 passos da Seção 11
+-- grava inbound_messages
+-- UPDATE subscribers SET service_window_expires_at = msg_ts + 24h,
| consecutive_window_misses = 0, last_inbound_at = msg_ts
+-- roteador de palavras-chave (Seção 19.5) -- palavras de SAÍDA primeiro, sempre
| |
| +-- é palavra-chave? executa a ação e responde
| |
| +-- não é? existe entrega pendente hoje?
| | |
| sim não
| | |
| v v
| enfileira send.followup resposta de conteúdo
| (prioridade 1) não reconhecido (Seção 19.6)
v
send.followup entrega o pacote completo (texto, áudio se PAID, fechamento)Precedência absoluta das palavras-chave de saída. O reconhecimento das sete palavras de saída (Seção 19.5) é a primeira operação executada sobre toda mensagem de entrada, antes da proteção anti-loop, antes do escalonamento humano, antes da entrega pendente e independentemente do estado do assinante. Nenhum caminho deste diagrama pode consumir uma mensagem antes desse teste.
Latência alvo: da chegada do webhook à primeira mensagem do pacote, p50 < 2 s, p95 < 5 s, p99 < 12 s. É
medida com inbound_messages.received_at como marco zero e message_logs.sent_at do passo 1 como marco final —
sent_at é o instante da resposta síncrona da plataforma (Seção 17.8) e não existe coluna accepted_at —,
exposta como histograma followup_latency_seconds (Seção 23). Alerta médio se o p95 de uma hora ultrapassar 8 s.
Três decisões que sustentam a latência: send.followup tem priority: 1 (a fila do BullMQ atende antes dos
jobs de lote); o worker de inbound não faz nenhuma chamada HTTP externa antes de enfileirar; e o pacote não
espera confirmação de delivered entre os passos, apenas o accepted síncrono.
Reação a botões. OPEN_DEVOTIONAL:{devotionalId} entrega aquele devocional específico. SNOOZE:{devotionalId}
abre a janela (toda interação abre) mas não entrega: responde com a mensagem devotional.snoozed e mantém a
entrega pendente até 23:59. CONFIRM_OPTIN conclui o opt-in (Seção 11) e dispara onboarding.optin_confirmed.
18.7 Entrega pendente e contador de dias sem interação #
Um assinante que recebeu o template e não interagiu tem uma entrega pendente:
delivery_attempts.status = 'AWAITING_INTERACTION'.
| Regra | Valor |
|---|---|
| Duração da pendência | Até 23:59 do mesmo dia local. Uma interação a qualquer momento nesse intervalo dispara a entrega completa daquele devocional |
| Após 23:59 | status = 'EXPIRED_NO_INTERACTION'. O devocional daquele dia não é mais entregue por essa via — o de amanhã já está a caminho, e empilhar dois devocionais confunde |
| Acesso posterior | O devocional continua disponível no acervo do painel (respeitando o entitlement da Seção 7) e pode ser pedido pela palavra-chave HOJE no mesmo dia |
| Contador | subscribers.consecutive_window_misses incrementa em 1 às 23:55, apenas para quem teve naquele dia ao menos uma mensagem em estado delivered ou read e nenhuma mensagem de entrada. Zera no instante em que qualquer mensagem de entrada é processada, inclusive SAIR |
| Dias que não contam | Dia cuja única saída foi adiada (131049), suprimida (131048) ou falha não incrementa: o assinante não teve oportunidade de interagir. Esses dias alimentam subscribers.undelivered_days, contador separado que aciona a regra de entrega mínima do plano pago (Seção 13) |
| Efeito do contador | >= send.window_miss_threshold (padrão 3) e tier PAID → rota TEMPLATE_VIDEO no dia seguinte, ou seja, no quarto dia consecutivo sem interação (Seção 17.3, etapa 4) |
| Teto do caminho de vídeo | 7 dias consecutivos. Depois disso, volta a TEMPLATE_INVITE e o assinante entra na régua de reengajamento (Seção 20) |
O nome do contador é consecutive_window_misses em todo o documento; no_interaction_days não existe e não pode
ser reintroduzido.
Exemplo: assinante PAID recebe template segunda, terça e quarta, com os três delivered e sem interagir. Quinta
às 05:40, consecutive_window_misses = 3 e a rota vira TEMPLATE_VIDEO. Ele recebe o MP4 com o áudio embutido
sem precisar tocar em nada. Se interagir na quinta, o contador zera e sexta volta a TEMPLATE_INVITE. Se, em vez
disso, os três dias tivessem sido 131049, o contador continuaria em zero e ele não entraria no caminho de
vídeo — porque a plataforma já estava suprimindo a entrega, e o MP4 seria suprimido do mesmo jeito.
18.8 Catálogo de filas BullMQ #
Esta subseção é a dona da lista de filas. São treze filas, com estes nomes exatos, no formato dominio.acao.
Nenhuma outra seção do documento cria fila, renomeia fila ou usa apelido: os nomes abaixo aparecem sem variação em
todo o documento, e um nome com hífen (whatsapp-send, email-send, media-upload) ou um sufixo .dlq é sempre
erro de escrita, não uma fila diferente.
| Fila | Payload | Conc. | Rate limit | Tentativas | Backoff | Timeout | O que o worker faz |
|---|---|---|---|---|---|---|---|
send.plan |
{ devotionalDate, mode } |
1 | 6/h | 3 | fixo 60 s | 300 s | Monta a coorte, resolve rotas, grava lote (Seção 18.3) |
send.dispatch |
{ idempotencyKey, subscriberId, route } |
12 | 20/s | 4 | exp. 2 s, fator 3 | 60 s | Envia template ou pacote completo (Seção 18.5) |
send.followup |
{ subscriberId, devotionalId, trigger } |
8 | 20/s | 3 | exp. 15 s | 60 s | Entrega o pacote após interação; varreduras de retry e fechamento |
whatsapp.inbound |
{ webhookDeliveryId } |
8 | — | 5 | exp. 5 s | 30 s | Resolve assinante, abre janela, roteia palavra-chave, dispara followup |
tts.generate |
{ devotionalId, mode, force } |
2 | 10/min | 3 | exp. 30 s | 300 s | Pipeline de áudio (Seção 16.13) |
media.upload |
{ audioAssetId, kind } |
3 | 30/min | 5 | exp. 10 s | 120 s | Upload à Media API, grava media_uploads (Seção 16.11) |
billing.webhook |
{ paymentEventId } |
4 | — | 8 | exp. 10 s | 30 s | Processa evento da Asaas de forma idempotente (Seção 12) |
billing.reconcile |
{ date } |
1 | 4/h | 3 | fixo 300 s | 900 s | Varre assinaturas e pagamentos contra a Asaas (Seção 12) |
billing.lifecycle |
{ date } |
1 | 4/h | 3 | fixo 120 s | 300 s | Expira períodos pagos vencidos e cortesias vencidas (Seção 13.2, transição T14) |
metrics.rollup |
{ date } |
1 | 4/h | 3 | fixo 120 s | 300 s | Consolida daily_metrics de D−1 no formato longo (Seção 21.5) |
maintenance.cleanup |
{ stage } |
1 | 8/h | 2 | fixo 300 s | 600 s | Retenção, temporários órfãos, sync de templates e do número |
email.send |
{ template, subscriberId, params } |
4 | 60/min | 3 | exp. 20 s | 30 s | E-mails transacionais da Seção 19.3 |
messaging.pause |
{ date } |
1 | 4/h | 2 | fixo 60 s | 120 s | Limpa pausas encerradas e enfileira a saudação de retorno (Seção 20.6.2) |
A coluna "Backoff" dá o atraso da primeira repetição e o tipo de progressão; a fórmula completa, com jitter total, é a da Seção 27.2.1, e o valor de "Tentativas" desta tabela é o que vale para cada fila — a Seção 27.2 descreve a curva do atraso, não o número de tentativas.
Configuração comum a todas: removeOnComplete: { age: 604800, count: 5000 }, removeOnFail conforme a retenção
por fila da Seção 27.5, lockRenewTime = metade do timeout, e um listener de failed que grava job_runs com
status = 'DEAD' quando as tentativas esgotam.
Não existe fila de dead-letter. O BullMQ não tem DLQ nativa e nós não criamos uma: trabalho morto é um job no
estado failed da própria fila mais uma linha em job_runs com status = 'DEAD', job_name, queue,
bull_job_id, attempt, error_code e payload redigido. job_runs é a dead-letter do sistema, e o painel
de operação lista os jobs mortos com um botão de reprocessar. Nenhuma seção pode citar send.dispatch.dlq,
email.send.dlq ou qualquer outro nome com esse sufixo — eles não existem. O procedimento de inspeção e
reprocessamento está na Seção 27.5.
Jobs repetíveis são registrados na subida do worker com tz: 'America/Sao_Paulo' e jobId estável, de modo que
reiniciar o processo não duplica o agendamento. O nome do agendador do lote diário é plan-daily-batch, e é
esse o nome esperado pelo runbook R-01 (Seção 27.6) e pela verificação de integração da Seção 24.5:
await queue.upsertJobScheduler('plan-daily-batch', { pattern: '40 5 * * *', tz: 'America/Sao_Paulo' },
{ name: 'send.plan', data: { mode: 'plan' }, opts: { jobId: 'plan-daily-batch' } });O fuso é parte da asserção, não um detalhe: sem tz, o padrão 40 5 * * * dispara às 05:40 UTC, ou seja, 02:40
em Brasília.
18.9 Idempotência de ponta a ponta #
| Nível | Chave | Onde é garantida |
|---|---|---|
| Planejamento do dia | plan:{devotionalDate} |
jobId do BullMQ + única em send_batches (devotional_date, batch_kind) |
| Envio por assinante | send:{subscriberId}:{devotionalDate} |
Única em delivery_attempts.idempotency_key |
| Passo dentro do pacote | send:{subscriberId}:{devotionalDate}:{step} |
delivery_attempts.steps_completed (bitmask) atualizado após cada accepted |
| Geração de áudio | tts:{devotionalId}:{scriptHash} |
jobId + única em audio_assets (devotional_id, narration_script_hash, voice_id) |
| Upload de mídia | media:{devotionalId}:{kind}:{sha256} |
jobId + única parcial em media_uploads (Seção 16.11) |
| Webhook da Meta | wamid / wamid:status |
Única em webhook_deliveries (source, dedup_key) (Seção 6.26.1) |
| Webhook da Asaas | event.id |
Única em payment_events (Seção 12) |
A garantia de que nenhum assinante recebe o mesmo devocional duas vezes repousa em três camadas, nesta ordem:
(1) delivery_attempts.idempotency_key é única, então só existe uma tentativa por assinante por dia; (2)
claimAttempt faz UPDATE ... SET status='SENDING', locked_by=:worker WHERE idempotency_key=:k AND status IN ('PENDING','RETRYING') RETURNING *, e a linha só é retornada para um worker; (3) steps_completed impede repetir
um passo já aceito na retomada.
Prova por cenário: o worker envia o texto, grava steps_completed = 0b001, e é morto antes do áudio. O lock
expira em 60 s, o job volta à fila, claimAttempt encontra status = 'SENDING' com locked_at expirado, retoma,
lê steps_completed e envia apenas áudio e fechamento. O assinante recebe o texto uma única vez.
18.10 Tratamento de falha por destinatário #
Toda falha passa pela tradução do adaptador (Seção 17.13), que devolve um SubscriberEffect. O motor conhece
apenas o efeito.
SubscriberEffect |
Origem típica | Ação em delivery_attempts |
Ação em subscribers |
|---|---|---|---|
NONE |
5xx, 131000, 131016, timeout |
status='RETRYING', attempt_count++, next_retry_at |
— |
DEFER_TO_TOMORROW |
131049, 130472 |
status='DEFERRED', deferred=true |
Nenhuma. Não conta como falha (Seção 17.9) |
SUPPRESS_24H |
131048 |
status='SUPPRESSED' |
suppressed_until = now + 24 h |
MARK_UNDELIVERABLE |
131026 por 3 dias seguidos |
status='FAILED' |
blocked_at = now, blocked_reason = 'META_131026', status = 'BLOCKED'; e-mail se houver e-mail verificado |
MARK_MARKETING_BLOCKED |
131050 |
status='FAILED' |
marketing_blocked_at = now; continua recebendo UTILITY |
HALT_ALL_SENDS |
190, 368, 131031, 131042 |
Lote pausado | Nenhuma; alerta crítico e pausa da fila |
Retry. Máximo de 4 tentativas por item, com backoff exponencial de base 2 s e fator 3 e jitter total
(Seção 27.2.1): os atrasos máximos são 2 s, 6 s, 18 s e 54 s, cada um sorteado em [0, máximo]. O tempo esperado
até a última tentativa fica em torno de 40 segundos, o que cabe folgadamente na janela de 20 minutos do lote.
Esgotadas as tentativas, status = 'FAILED' com error_code, e o item entra no relatório do lote. A
varredura de send.followup às 09:00, 12:00 e 18:00 recolhe itens RETRYING cuja janela venceu — isso cobre o
caso em que o backoff ultrapassa o fim do disparo.
Entrada no lote seguinte. Nada precisa ser feito: a coorte do dia seguinte (Seção 18.3.2) não exclui quem
falhou hoje, porque a cláusula NOT EXISTS filtra por devotional_date, não por sucesso. Um assinante que falhou
segunda participa normalmente do lote de terça. A única exclusão persistente é blocked_at.
Contenção automática. Se a taxa de falha ultrapassar 20% nas primeiras 200 tentativas de um lote, o motor
pausa o lote sozinho: grava send_batches.status = 'HALTED_BY_GUARD' com o error_code dominante, para de
consumir a fila daquele lote preservando os itens PENDING e RETRYING, e emite alerta crítico. A retomada é
sempre humana, pelo runbook R-03 (Seção 27.6). O motivo é aritmético: às 06:03 pode não haver ninguém acordado,
e continuar disparando contra um erro sistêmico — um token expirado, um template pausado — multiplica o dano à
reputação do número sem entregar uma única mensagem a mais. Com 4 tentativas por item, um lote de 8.000 contra um
erro sistêmico queima 32.000 chamadas antes de qualquer alerta de horário disparar.
18.11 Reenvio manual #
Dois caminhos, ambos sujeitos ao limite diário da matriz de entitlements (Seção 7): 1 reenvio/dia para FREE, 3/dia para PAID.
| Caminho | Como | Contabilização |
|---|---|---|
| Painel do assinante | Botão "Reenviar no WhatsApp" no devocional de hoje (Seção 14) | delivery_attempts.manual_resend_count do dia |
Palavra-chave HOJE (Seção 19.5) |
Mesmo contador | |
| Painel administrativo | Ação "Reenviar para este assinante", papéis ADMIN/OWNER |
Não incrementa manual_resend_count e não tem limite; fica registrado apenas em admin_audit_log, com operador e motivo |
O reenvio respeita a rota corrente: com janela aberta, envia o pacote free-form completo (custo zero); com janela
fechada, envia o template de convite (custo de uma mensagem UTILITY). Não existe reenvio de áudio isolado fora
da janela — é impossível pela restrição da restrição (a) da Seção 17.2.
Excedido o limite, a resposta é a mensagem devotional.resend_limit (Seção 19), que informa o limite e a hora em
que ele reinicia (00:00 local). Reenvio manual nunca cria uma nova delivery_attempts: incrementa o contador
da existente e grava novas linhas em message_logs. Isso preserva a garantia de unicidade da Seção 18.9.
18.12 Observabilidade do lote e alertas #
send_batches expõe, ao vivo, o progresso do dia. O painel administrativo consulta a cada 5 segundos durante a
janela de disparo:
SELECT status, route, COUNT(*) AS total
FROM delivery_attempts
WHERE devotional_date = CURRENT_DATE
GROUP BY status, route;| Métrica exibida | Fonte |
|---|---|
| Planejados / enviados / entregues / lidos / falhos / adiados | Contagem por status |
| Progresso percentual | (SENT + FAILED + DEFERRED) / planned_count |
| Taxa de envio instantânea | message_logs da última janela de 60 s |
| Tempo decorrido e projeção de término | started_at + taxa corrente |
| Distribuição por rota | WINDOW_DIRECT / TEMPLATE_INVITE / TEMPLATE_VIDEO |
| Custo estimado do lote | Contagem por categoria × preço de settings (Seção 17.12) |
| Alerta | Condição | Severidade | Destino |
|---|---|---|---|
send.batch_not_started |
06:05 e started_at IS NULL |
Crítica | Plantonista |
send.batch_not_completed |
06:30 e status != 'COMPLETED' |
Crítica | Plantonista |
send.failure_rate_high |
falhas reais / planejados > 5%, avaliado a cada 60 s após 10% de progresso | Alta | Canal de operação |
send.batch_failure_rate |
taxa de falha acima de 10% avaliada durante o disparo, a partir de 300 tentativas registradas | Crítica | Plantonista |
send.batch_error_dominant |
um único error_code responde por mais de 60% das falhas, a partir de 100 falhas |
Crítica | Plantonista — indica erro sistêmico, não falhas individuais |
send.batch_halted_by_guard |
send_batches.status = 'HALTED_BY_GUARD' (contenção automática de 18.10) |
Crítica | Plantonista |
whatsapp.no_inbound |
nenhum webhook de entrada válido processado nos últimos 60 min entre 06:00 e 22:00 | Crítica | Plantonista — único detector de canal de entrada morto (Seção 17.8) |
whatsapp.webhook_invalid_signature |
mais de 10 rejeições de assinatura em 5 min | Crítica | Plantonista — segredo do aplicativo dessincronizado (Seção 17.7) |
whatsapp.window_direct_collapse |
participação da rota WINDOW_DIRECT no lote abaixo de 50% da média dos 7 dias anteriores |
Crítica | Plantonista |
webhook_payload_rejected |
mais de 3 corpos rejeitados em 15 min (Seção 17.8) | Alta | Plantonista |
send.deferred_rate_high |
131049 > 25% do lote |
Média | Canal de operação (Seção 17.10) |
send.tier_threshold |
planejado > 80% do tier | Média | Canal de operação |
send.tier_exceeded |
lote truncado | Alta | Canal + e-mail |
content.not_ready_by_deadline |
05:00 sem devocional PUBLISHED |
Crítica | Plantonista |
content.no_evergreen_available |
reserva vazia no momento da necessidade | Crítica | Plantonista |
content.evergreen_stock_low |
estoque < 5 | Média (< 2: Alta) | Canal de operação |
followup.latency_high |
p95 de 1 h > 8 s | Média | Canal de operação |
queue.depth_high |
qualquer fila com > 5.000 aguardando por mais de 10 min | Alta | Canal de operação |
queue.dead_jobs |
qualquer linha nova em job_runs com status='DEAD' |
Alta | Canal de operação |
A taxa de falha exclui explicitamente DEFERRED: 131049 é operação normal no Brasil (Seção 17.9) e incluí-lo
faria o alerta de 5% disparar quase todo dia, o que treinaria o operador a ignorá-lo.
Falha concentrada nas mesmas pessoas. Uma taxa agregada saudável esconde o pior padrão de churn: assinantes
que falham sempre. Todo assinante com falha de entrega em 3 dias consecutivos entra na lista entrega_cronica
do painel de envios, revisada semanalmente. Três por cento de falha distribuída é ruído; três por cento
concentrada nas mesmas 300 pessoas por seis semanas é a base cancelando em silêncio, com o painel mostrando 97%
de entrega.
18.13 Backfill e reprocessamento pelo CLI de operação #
apps/ops expõe comandos idempotentes. Todos aceitam --dry-run, imprimem o plano e exigem --confirm para
executar; todos registram em admin_audit_log com o operador identificado.
# Replaneja um dia. Não recria itens existentes (ON CONFLICT DO NOTHING).
pnpm ops send:plan --date 2026-09-08 --dry-run
# Dispara o que ficou pendente. Só toca em PENDING e RETRYING.
pnpm ops send:dispatch --date 2026-09-08 --confirm
# Reprocessa apenas os itens falhos de um dia, com limite de segurança.
pnpm ops send:retry-failed --date 2026-09-08 --max 500 --confirm
# Reenvia para um único assinante (suporte). Ignora o limite diário do assinante.
pnpm ops send:one --subscriber sub_01K5T8QW3M --date 2026-09-08 --confirm
# Backfill de áudio para um intervalo (após troca de voz, por exemplo).
pnpm ops tts:backfill --from 2026-09-01 --to 2026-09-30 --force --confirm
# Revalida e reenvia mídias com media_id perto de expirar.
pnpm ops media:refresh --within-days 3 --confirm
# Reprocessa jobs mortos de uma fila (estado failed + linhas DEAD em job_runs, Seção 27.5).
pnpm ops queue:dead:replay --queue send.dispatch --since 2026-09-08 --confirm
# Pausa e retoma o lote do dia durante um incidente (Seção 27.6, R-03).
pnpm ops send:halt --date 2026-09-08 --reason "190" --confirm
pnpm ops send:resume --date 2026-09-08 --confirm
# Recalcula métricas de um intervalo. Alvo padrão do job diário é D-1, às 03:10.
pnpm ops metrics:rollup --from 2026-09-01 --to 2026-09-08 --confirmTrês guardas obrigatórias em todo backfill: (1) nunca reenvia para quem já tem status='SENT' naquela data,
o que é garantido pelo próprio filtro de status e pela unicidade de delivery_attempts; (2) --max limita o
alcance de um comando, com padrão de 1.000, para que um erro de digitação em --date não produza um disparo em
massa; (3) backfill de datas anteriores a 7 dias exige --allow-old, porque reenviar o devocional da semana
passada quase nunca é a intenção.
18.14 Capacidade #
Premissas: 20 msg/s de teto (Seção 17.11), 12 workers concorrentes, latência média de 180 ms por chamada à Meta,
40% dos PAID em WINDOW_DIRECT (que custam 3 mensagens cada, contra 1 do template).
| Base | Composição do dia útil | Mensagens no disparo | Tempo a 20/s | Tempo com folga de retry (+30%) | Dentro da janela de 20 min? |
|---|---|---|---|---|---|
| 3.000 assinantes (900 PAID) | 360 WINDOW_DIRECT × 3 + 540 template |
1.620 | 1 min 21 s | 1 min 46 s | Sim |
| 10.000 (3.000 PAID) | 1.200 × 3 + 1.800 | 5.400 | 4 min 30 s | 5 min 51 s | Sim |
| 50.000 (15.000 PAID) | 6.000 × 3 + 9.000 | 27.000 | 22 min 30 s | 29 min 15 s | Não |
| Base | Domingo (pico: PAID + FREE) | Mensagens | Tempo a 20/s | A 60/s |
|---|---|---|---|---|
| 3.000 (2.100 FREE) | 1.620 + 2.100 | 3.720 | 3 min 6 s | 1 min 2 s |
| 10.000 (7.000 FREE) | 5.400 + 7.000 | 12.400 | 10 min 20 s | 3 min 27 s |
| 50.000 (35.000 FREE) | 27.000 + 35.000 | 62.000 | 51 min 40 s | 17 min 13 s |
Conclusões operacionais registradas:
- Até 10.000 assinantes, a configuração padrão de 20 msg/s cumpre a janela de 20 minutos com folga, inclusive no pico de domingo.
- A partir de cerca de 25.000 assinantes,
send.rate_per_secondprecisa subir para 60. É uma mudança de chave de configuração, não de arquitetura, e exige tier ilimitado equality_ratingGREEN. - Em 50.000, mesmo a 60 msg/s o domingo leva 17 minutos. A mitigação registrada é escalonar o envio FREE em
duas ondas (06:00 e 06:30) por faixa de
id, mantendo o PAID inteiramente na primeira onda. O camposend_batches.batch_kindé criado para isso (Seção 6.22). - O gargalo nunca é o nosso processo: 12 workers a 180 ms sustentam 66 msg/s. O limite é sempre a plataforma.
- Os envios free-form disparados por interação ao longo do dia (89.100/mês no cenário de 10.000, Seção 17.12) chegam a menos de 4 msg/s no pico das 06:05 às 06:20 e cabem no mesmo teto sem contenção perceptível.
18.15 Casos de borda #
| # | Situação | Comportamento definido |
|---|---|---|
| 1 | send.plan roda duas vezes às 05:40 |
jobId determinístico + ON CONFLICT DO NOTHING; a segunda execução registra plan.duplicates_skipped e não cria nada |
| 2 | Worker cai às 06:07 com o lote pela metade | Locks expiram em 60 s; os jobs voltam à fila; claimAttempt e steps_completed impedem duplicidade (Seção 18.9) |
| 3 | Redis reinicia durante o disparo | Jobs em voo são perdidos, mas delivery_attempts continua em SENDING com locked_at velho; a varredura das 09:00 os recolhe. Nenhum assinante recebe duas vezes |
| 4 | Assinante vira PAID às 05:50 | O lote de hoje já o classificou como FREE. Se hoje não é domingo, ele não está no lote: o painel oferece "Receber o devocional de hoje agora", que cria a tentativa sob demanda. A partir de amanhã, fluxo normal |
| 5 | Assinante vira FREE às 05:52 (falha de pagamento) | A revalidação da Seção 18.5.1 relê o tier no disparo: o item é rebaixado na hora, com downgraded_at e tier_at_send_effective = 'FREE'; ele recebe o texto e não recebe o áudio. Sem carência, conforme a Seção 13.3 |
| 6 | Devocional publicado às 05:59 | O lote das 05:40 usou a reserva. O painel avisa e oferece reagendar. Não há disparo duplo |
| 7 | Horário de verão | O Brasil não tem horário de verão desde 2019, mas o job usa tz: 'America/Sao_Paulo' e date-fns-tz, então uma reintrodução não exigiria mudança de código |
| 8 | Dois workers processam o mesmo item | claimAttempt é um UPDATE ... RETURNING condicional; apenas um recebe a linha, o outro retorna already_processed |
| 9 | Assinante interage às 23:58 | A entrega dispara; a janela abre; o pacote sai. O fechamento das 23:55 já rodou, então consecutive_window_misses foi incrementado indevidamente — corrigido pelo próprio processamento do inbound, que zera o contador |
| 10 | Lote com 0 destinatários (sábado sem PAID) | send_batches.status = 'COMPLETED' com planned_count = 0. Não é erro e não alerta |
| 11 | Tier cai de 100K para 10K por restrição | send.plan lê o tier antes de confirmar e trunca priorizando PAID; alerta alto (Seção 18.3.3) |
| 12 | Reenvio manual pedido enquanto o lote roda | Permitido. O contador é independente e a delivery_attempts do dia já existe; o reenvio só acrescenta message_logs |
| 13 | Assinante pausado com paused_until no passado |
A cláusula do planejamento aceita paused_until <= now(), então ele já volta ao lote sem depender de job nenhum. O messaging.pause das 05:30 é cosmético: limpa a coluna e enfileira a saudação de retorno; se falhar, o assinante recebe o devocional normalmente e apenas não recebe a saudação |
| 14 | Relógio do container com deriva | Os jobs repetíveis usam o relógio do Redis para agendamento e now() do banco para as janelas, o que elimina divergência entre réplicas |
| 15 | Assinante envia SAIR às 06:07, com o lote em execução e o item dele ainda não processado |
A revalidação do disparo (Seção 18.5.1) encontra opt_out_at preenchido e encerra o item como SKIPPED_OPTED_OUT, sem nenhuma chamada ao provedor. Ele não recebe nada daquele lote |
| 16 | Evento de inadimplência processado às 05:52, depois do planejamento | O item é rebaixado no disparo: texto do plano gratuito, sem áudio, com downgraded_at preenchido. É a regra de revogação imediata alcançando o lote já planejado |
| 17 | Pedido de eliminação concluído entre 05:40 e 06:00 | deleted_at preenchido; a revalidação encerra como SKIPPED_INELIGIBLE e nenhuma chamada ao provedor acontece com o telefone daquele titular |
| 18 | Lote atinge 20% de falha nas primeiras 200 tentativas | Contenção automática (Seção 18.10): status = 'HALTED_BY_GUARD', alerta crítico, itens pendentes preservados. A retomada é humana, pelo runbook R-03 |
19. Catálogo de Mensagens e Fluxos Conversacionais #
19.1 Princípios e tom de voz #
Todo texto que o sistema envia por WhatsApp ou e-mail está catalogado aqui. Nenhuma string voltada ao assinante é
escrita fora deste catálogo: as mensagens vivem em packages/core/src/messages/ como funções tipadas que recebem
variáveis e devolvem texto pronto, e o worker nunca concatena texto à mão.
Tom de voz. Direto, respeitoso, adulto. O assinante é uma pessoa que acorda cedo e escolheu receber isso.
| Princípio | O que significa |
|---|---|
| Frases curtas | Uma ideia por frase. WhatsApp é lido em tela pequena, em movimento |
| Sem venda dentro do conteúdo | O devocional nunca menciona plano, preço ou upgrade. Isso vive em mensagens próprias |
| Sem urgência artificial | Nada de "última chance", "só hoje", "não perca" |
| Sem emoji | Zero. Reduz risco de reclassificação de template e combina com o conteúdo |
| Formatação com moderação | *negrito* só no título e no rótulo "Oração". _itálico_ só na referência bíblica. Nunca em blocos inteiros |
| Segunda pessoa | "você", nunca "o assinante" ou "o usuário" |
| Erro sem culpa | "Não conseguimos entregar" e não "você não recebeu" |
| Saída sempre visível | Toda mensagem iniciada pelo sistema deixa claro como sair |
| Ruim | Por quê | Bom |
|---|---|---|
| "[emoji] BOM DIA, GUERREIRO! [emoji] Seu devocional CHEGOU!" | Caixa alta, emoji, tom de campanha | "Bom dia. O devocional de hoje já está pronto para você." |
| "Ops! Algo deu errado :( tenta de novo mais tarde" | Infantil, sem informação, sem próximo passo | "Não conseguimos processar agora. Tente de novo em alguns minutos ou responda AJUDA." |
| "Você não pagou e perdeu o acesso." | Acusatório | "Não conseguimos confirmar seu pagamento, e o acesso ao áudio foi encerrado hoje." |
| "Aproveite! Assine já e receba 30 devocionais + BÔNUS!" | Venda dentro do conteúdo | "Você recebe o devocional em texto aos domingos. No plano pago, todos os dias com áudio." |
| "Clique aqui: bit.ly/pd2026" | Encurtador reprova template e reduz confiança | "Acesse app.palavradiaria.com.br/conta" |
19.2 Convenções do catálogo #
- Chave:
familia.eventoemsnake_case, estável, usada em logs e emmessage_logs.message_key. - Tipo:
TEMPLATE(fora da janela, exige aprovação — Seção 17.5) ouFREE_FORM(dentro da janela) ouEMAIL. - Variáveis: em templates são posicionais (
{{1}},{{2}}) e passam obrigatoriamente porsanitizeTemplateParam(Seção 17.7). Em free-form são nomeadas ({{nome}}) e admitem quebra de linha. - Limites: template body 1.024; parâmetro de template sem quebra de linha, sem tabulação e sem 4+ espaços; texto livre 4.096; teaser 300 em uma única linha (Seção 17.2).
- Falha: toda mensagem declara o que acontece se não for entregue. O padrão é retry pelo motor (Seção 18.10); quando há caminho alternativo, ele está explícito.
- Fila de saída: mensagens de WhatsApp saem por
send.dispatchousend.followup; mensagens de e-mail saem exclusivamente pela filaemail.send. As treze filas do sistema estão na Seção 18.8 e este catálogo não cria nenhuma outra.
19.3 Índice do catálogo #
| Chave | Gatilho | Canal | Tipo | Variáveis | Se falhar |
|---|---|---|---|---|---|
otp.code |
Pedido de login | TEMPLATE codigo_acesso_v1 |
código | Após 30 s sem delivered, oferece magic link por e-mail |
|
otp.rate_limited |
4º pedido de OTP na hora | Web | — | minutos | Exibido na tela; sem envio |
auth.magic_link_email |
Fallback de login | link, validade | Exibe erro na tela e sugere tentar o WhatsApp | ||
onboarding.welcome_optin |
Cadastro concluído | TEMPLATE boas_vindas_v1 |
nome | Reenvia 1× em 6 h; depois e-mail | |
onboarding.optin_confirmed |
Toque em "Sim, quero receber" ou SIM |
FREE_FORM | nome, primeiroEnvio | Retry padrão | |
onboarding.optin_declined |
Toque em "Agora não" | FREE_FORM | — | Retry padrão | |
onboarding.optin_pending_reminder |
48 h sem confirmar | TEMPLATE boas_vindas_v1 |
nome | Uma única vez; depois só e-mail | |
devotional.invite |
Disparo 06:00, janela fechada | TEMPLATE devocional_diario_v1 |
título, teaser | Seção 18.10 | |
devotional.invite_video |
4º dia sem interação (PAID) | TEMPLATE devocional_diario_video_v1 |
título, teaser, mídia | Cai para devotional.invite |
|
devotional.full_text |
Janela aberta | FREE_FORM | conteúdo completo | Retry 3×; depois entra no lote de amanhã | |
devotional.closing |
Após texto e áudio | FREE_FORM | próximoEnvio | Silencioso: não repete | |
devotional.free_weekly |
Domingo, tier FREE | FREE_FORM | conteúdo completo | Retry padrão | |
devotional.audio_unavailable |
PAID, áudio não pronto | FREE_FORM | — | Silencioso | |
devotional.snoozed |
Toque em "Depois" | FREE_FORM | — | Silencioso | |
devotional.resend_today |
Palavra HOJE ou botão do painel |
FREE_FORM ou TEMPLATE | conteúdo | Retry padrão | |
devotional.resend_limit |
Limite diário atingido | FREE_FORM | limite, tier | Silencioso | |
devotional.late_open |
Botão de devocional antigo | FREE_FORM | data | Retry padrão | |
devotional.none_today |
Pedido de HOJE sem lote |
FREE_FORM | próximoEnvio | Silencioso | |
billing.subscription_confirmed |
PAYMENT_CONFIRMED |
TEMPLATE pagamento_confirmado_v1 |
nome, valor, próximaCobrança | Fallback e-mail email.receipt |
|
billing.pix_charge_created |
Cobrança PIX gerada | FREE_FORM (ou template se fora da janela) | valor, vencimento, link | Fallback e-mail | |
billing.reminder_d3 |
3 dias antes do vencimento | TEMPLATE lembrete_pagamento_v1 |
nome, data, valor, link | Fallback e-mail | |
billing.reminder_d1 |
1 dia antes | TEMPLATE lembrete_pagamento_v1 |
idem | Fallback e-mail | |
billing.reminder_d0 |
Dia do vencimento, 09:00 | TEMPLATE lembrete_pagamento_v1 |
idem | Fallback e-mail | |
billing.card_expiring |
Cartão vence em 15 dias | TEMPLATE lembrete_pagamento_v1 |
nome, mês/ano | Fallback e-mail | |
billing.access_revoked |
PAYMENT_OVERDUE / DELETED |
TEMPLATE acesso_encerrado_v1 |
nome | Fallback email.access_revoked |
|
billing.refund_processed |
PAYMENT_REFUNDED |
TEMPLATE acesso_encerrado_v1 (variante) |
nome, valor, prazo | Fallback e-mail | |
billing.chargeback_opened |
PAYMENT_CHARGEBACK_REQUESTED |
valor | Registro interno; sem WhatsApp | ||
billing.cancellation_requested |
Cancelamento no painel | FREE_FORM | fimDoCiclo | Fallback e-mail | |
billing.cancellation_effective |
Fim do ciclo pago | TEMPLATE acesso_encerrado_v1 |
nome | Fallback e-mail | |
optout.confirmed |
Qualquer uma das sete palavras de saída da Seção 19.5, ou o payload OPT_OUT |
FREE_FORM | — | Retry 2×; depois registra e para. O opt_out_at já está gravado: a falha do aviso nunca desfaz a saída |
|
optout.already |
SAIR de quem já saiu |
FREE_FORM | — | Silencioso | |
optout.paid_warning |
SAIR com assinatura ativa |
FREE_FORM | fimDoCiclo, link | Retry padrão | |
reactivation.campaign |
30 dias sem receber áudio | TEMPLATE reativacao_v1 |
nome, dias | Sem retry: é MARKETING | |
reactivation.confirmed |
VOLTAR ou botão |
FREE_FORM | próximoEnvio | Retry padrão | |
pause.started |
PAUSAR ou painel |
FREE_FORM | retornoEm | Retry padrão | |
pause.ended |
Fim da pausa | FREE_FORM | — | Silencioso | |
phone.change_started |
Troca de número no painel | WhatsApp (número novo) | TEMPLATE codigo_acesso_v1 |
código | Bloqueia a troca |
phone.change_completed |
Troca confirmada | WhatsApp (ambos) | FREE_FORM | númeroNovo | Retry padrão |
help.menu |
AJUDA/MENU |
FREE_FORM (interativa) | tier | Retry 1×; depois versão em texto | |
help.unrecognized |
Texto não reconhecido | FREE_FORM | — | Silencioso; limite anti-loop | |
help.unsupported_content |
Áudio, imagem, vídeo, localização | FREE_FORM | — | Silencioso | |
support.human_business_hours |
FALAR COM ALGUÉM, 9h–18h úteis |
FREE_FORM | protocolo | Retry padrão | |
support.human_after_hours |
Fora do horário | FREE_FORM | protocolo, retorno | Retry padrão | |
system.generic_error |
Exceção não tratada no roteador | FREE_FORM | — | Silencioso | |
system.maintenance_notice |
Manutenção programada | TEMPLATE boas_vindas_v1 (variante) |
janela | Sem retry | |
survey.monthly |
Dia 15, PAID há 30+ dias, dentro da janela | FREE_FORM (interativa) | — | Sem retry; tenta no mês seguinte | |
survey.thanks |
Resposta da pesquisa | FREE_FORM | nota | Silencioso | |
email.welcome |
Cadastro com e-mail | nome, link | Registra bounce | ||
email.receipt |
Pagamento confirmado | valor, período, link | Registra bounce | ||
email.access_revoked |
Revogação de acesso | nome, link | Registra bounce | ||
email.data_export_ready |
Export LGPD pronto | link, validade | Reemite sob demanda |
19.4 Textos exatos #
19.4.1 Autenticação e onboarding #
[otp.code] TEMPLATE codigo_acesso_v1 (corpo padronizado pela Meta, traduzido para pt_BR)
{{1}} é seu código de verificação. Por segurança, não compartilhe este código.
Rodapé: Este código expira em 10 minutos.
Botão: Copiar código
[otp.rate_limited] tela web
Você pediu vários códigos seguidos. Aguarde {{minutos}} minutos e tente de novo. Se o
código anterior chegou, ele ainda é válido.
[auth.magic_link_email] e-mail — assunto: Seu acesso à Palavra Diária
Olá. Use o link abaixo para entrar na sua conta. Ele vale por 15 minutos e só pode ser
usado uma vez.
{{link}}
Se não foi você quem pediu, ignore este e-mail. Nada muda na sua conta.
[onboarding.welcome_optin] TEMPLATE boas_vindas_v1
Cabeçalho: Bem-vindo à Palavra Diária
Olá, {{1}}. Seu cadastro foi criado.
Para começar a receber o devocional, confirme abaixo. Só depois da sua confirmação
enviaremos qualquer conteúdo.
Rodapé: Você pode cancelar quando quiser
Botões: Sim, quero receber | Agora não
[onboarding.optin_confirmed] FREE_FORM
Confirmado, {{nome}}. Você começa a receber o devocional {{primeiroEnvio}}, às 6h.
Guarde este número nos seus contatos para não perder as mensagens.
A qualquer momento: responda AJUDA para ver as opções, ou SAIR para cancelar.
[onboarding.optin_declined] FREE_FORM
Tudo bem. Não vamos enviar nada.
Se mudar de ideia, responda VOLTAR e começamos no dia seguinte.
[onboarding.optin_pending_reminder] TEMPLATE boas_vindas_v1
Olá, {{1}}. Seu cadastro está pronto, mas ainda falta confirmar para começarmos a
enviar o devocional. É só tocar no botão abaixo.19.4.2 Devocional #
[devotional.invite] TEMPLATE devocional_diario_v1
Cabeçalho: Devocional de hoje
Bom dia. O devocional de hoje já está pronto para você.
*{{1}}*
{{2}}
Toque no botão abaixo para receber o texto completo e o áudio.
Rodapé: Responda SAIR para cancelar
Botões: Ler e ouvir agora | Depois
{{1}} = título, até 120 caracteres, uma linha.
{{2}} = teaser, até 300 caracteres, uma linha, sanitizado (Seção 17.7).
Exemplo de teaser: "Quando Deus demora, Ele não esqueceu. Salmos 27 mostra o que
fazer no intervalo entre o pedido e a resposta." (114 caracteres)
[devotional.invite_video] TEMPLATE devocional_diario_video_v1
Cabeçalho: vídeo MP4 com a capa do dia e o áudio completo
Bom dia. O devocional de hoje está no vídeo acima, com o áudio completo.
*{{1}}*
{{2}}
Para receber o texto completo, toque no botão.
Rodapé: Responda SAIR para cancelar
Botões: Quero o texto | Depois
[devotional.full_text] FREE_FORM — até 4.096 caracteres
*{{titulo}}*
_{{referencia}} ({{versao}})_
{{textoBiblico}}
{{reflexao}}
*Oração*
{{oracao}}
[devotional.closing] FREE_FORM
O próximo devocional chega {{proximoEnvio}}, às 6h.
Responda AJUDA para ver as opções.
proximoEnvio para PAID: "amanhã". Para FREE: "no próximo domingo".
[devotional.free_weekly] FREE_FORM — enviado aos domingos ao tier FREE
*{{titulo}}*
_{{referencia}} ({{versao}})_
{{textoBiblico}}
{{reflexao}}
*Oração*
{{oracao}}
Você recebe o devocional em texto todo domingo. No plano pago, ele chega todos os dias,
com áudio narrado: app.palavradiaria.com.br/assinar
[devotional.audio_unavailable] FREE_FORM
O áudio de hoje ainda está sendo preparado. Assim que ficar pronto, ele aparece no seu
acervo em app.palavradiaria.com.br/acervo. O texto acima está completo.
[devotional.snoozed] FREE_FORM
Sem problema. O devocional de hoje fica guardado até as 23h59.
Quando quiser, responda HOJE e eu envio na hora.
[devotional.resend_today] FREE_FORM
Aqui está o devocional de hoje.
(seguido de devotional.full_text e, para PAID, do áudio)
[devotional.resend_limit] FREE_FORM
Você já pediu o devocional de hoje {{limite}} vezes, que é o limite do seu plano. O
contador reinicia à meia-noite.
Ele continua disponível em app.palavradiaria.com.br/acervo.
[devotional.late_open] FREE_FORM
Este é o devocional de {{data}}. O de hoje já foi enviado — se quiser, responda HOJE.
[devotional.none_today] FREE_FORM
Ainda não há devocional para hoje. O próximo chega {{proximoEnvio}}, às 6h.19.4.3 Cobrança e assinatura #
[billing.subscription_confirmed] TEMPLATE pagamento_confirmado_v1
Cabeçalho: Pagamento confirmado
Pronto, {{1}}. Recebemos seu pagamento de {{2}}.
A partir de amanhã, às 6h, você recebe o devocional em texto e áudio todos os dias.
Sua próxima cobrança é em {{3}}.
Rodapé: Obrigado por assinar
Botão: Ver minha conta
[billing.pix_charge_created] FREE_FORM
Sua cobrança PIX de {{valor}} está pronta e vence em {{vencimento}}.
Pague pelo link: {{link}}
Assim que o pagamento cair, o áudio diário é liberado automaticamente.
[billing.reminder_d3 | billing.reminder_d1 | billing.reminder_d0] TEMPLATE lembrete_pagamento_v1
Cabeçalho: Sua assinatura
Olá, {{1}}. Sua assinatura da Palavra Diária vence em {{2}}.
Valor: {{3}}
Pague pelo link abaixo para manter o áudio diário ativo.
Rodapé: Pagamento processado pela Asaas
Botão: Pagar agora
D-3: {{2}} = "11/09/2026". D-1: "amanhã, 11/09". D0: "hoje, 11/09".
[billing.card_expiring] TEMPLATE lembrete_pagamento_v1 (variante)
Olá, {{1}}. O cartão da sua assinatura vence em {{2}}. Atualize antes da próxima
cobrança para não perder o áudio diário.
[billing.access_revoked] TEMPLATE acesso_encerrado_v1
Cabeçalho: Sua assinatura foi encerrada
Olá, {{1}}. Não conseguimos confirmar o pagamento da sua assinatura, e o acesso ao
áudio diário foi encerrado hoje.
Você continua recebendo o devocional em texto aos domingos, sem custo. Para voltar a
receber todos os dias com áudio, reative abaixo.
Rodapé: Responda SAIR para não receber mais nada
Botão: Reativar assinatura
[billing.refund_processed] TEMPLATE acesso_encerrado_v1 (variante)
Olá, {{1}}. Seu reembolso de {{2}} foi processado e chega em até {{3}} dias úteis,
conforme o prazo do seu banco.
O acesso ao áudio diário foi encerrado. Você continua recebendo o devocional em texto
aos domingos.
[billing.chargeback_opened] e-mail — assunto: Contestação recebida
Recebemos uma contestação de {{valor}} referente à sua assinatura. O acesso ao áudio
diário foi suspenso enquanto o caso é analisado pelo seu banco. Se foi um engano,
responda a este e-mail.
[billing.cancellation_requested] FREE_FORM
Cancelamento registrado. Você continua com acesso ao áudio diário até {{fimDoCiclo}},
que é o período que você já pagou. Depois disso, volta a receber o devocional em texto
aos domingos.
Mudou de ideia? Responda VOLTAR antes de {{fimDoCiclo}}.
[billing.cancellation_effective] TEMPLATE acesso_encerrado_v1 (variante)
Olá, {{1}}. Seu período pago terminou hoje. A partir de agora você recebe o devocional
em texto aos domingos.
Para voltar a receber todos os dias com áudio, reative abaixo.A distinção entre billing.access_revoked (falha de pagamento, efeito imediato) e
billing.cancellation_effective (cancelamento pedido pelo assinante, efeito no fim do ciclo já pago) é
obrigatória e está refletida nos dois textos. Não há carência em nenhum dos casos — no segundo, o que existe é o
período já pago, conforme a Seção 13.
19.4.4 Saída, retorno, pausa e troca de número #
[optout.confirmed] FREE_FORM
Pronto. Você não vai mais receber o devocional.
Se mudar de ideia, responda VOLTAR a qualquer momento.
[optout.already] FREE_FORM
Você já está fora da lista e não recebe mais o devocional.
Para voltar, responda VOLTAR.
[optout.paid_warning] FREE_FORM
Você não vai mais receber mensagens.
Sua próxima cobrança foi suspensa. O período que você já pagou continua valendo até
{{fimDoCiclo}} e, sem reativação em 30 dias, a assinatura é encerrada. Para ver os
detalhes ou voltar antes disso, acesse {{link}} ou responda VOLTAR.
[reactivation.campaign] TEMPLATE reativacao_v1
Olá, {{1}}. Faz {{2}} dias que você não recebe o devocional em áudio.
Se quiser voltar, é só tocar abaixo. Sua conta e seu histórico continuam salvos.
Rodapé: Responda SAIR para não receber mais
Botões: Quero voltar | Não, obrigado
[reactivation.confirmed] FREE_FORM
Que bom ter você de volta. O próximo devocional chega {{proximoEnvio}}, às 6h.
[pause.started] FREE_FORM
Pausado. Você não recebe nada até {{ultimoDiaPausado}}, e volta a receber em {{retornoEm}}, às 6h.
Para voltar antes, responda VOLTAR.
ultimoDiaPausado é o último dia SEM envio; retornoEm é o primeiro dia COM envio.
Dizer só uma das duas datas é o que faz o assinante achar que perdeu um dia
(Seção 20.6.1).
[pause.ended] FREE_FORM
Sua pausa terminou. Amanhã, às 6h, o devocional volta.
[phone.change_started] TEMPLATE codigo_acesso_v1
(enviado ao NÚMERO NOVO; o código confirma a posse antes da troca)
[phone.change_completed] FREE_FORM
(ao número novo)
Número atualizado. A partir de agora o devocional chega aqui.
(ao número antigo)
O número da sua conta foi alterado para {{numeroNovo}}. Se não foi você, responda
AJUDA agora.Canal da confirmação de saída. optout.confirmed, optout.already e optout.paid_warning só saem pelo
WhatsApp quando o opt-out foi pedido pelo WhatsApp, e nesse caso é uma única mensagem de confirmação.
Quando a origem é o painel do assinante ou o link de descadastro, a confirmação aparece na tela e por e-mail,
quando houver e-mail verificado, e nenhuma mensagem de WhatsApp é enviada: quem pediu para parar de receber
mensagens por um canal que não é o WhatsApp não deve receber mais uma mensagem no WhatsApp (Seção 20.4.1).
19.4.5 Ajuda, suporte, sistema e pesquisa #
[help.menu] FREE_FORM interativa (3 botões) + texto de apoio
Como posso ajudar?
Botões: Reenviar hoje | Minha assinatura | Falar com alguém
Você também pode responder:
HOJE - receber o devocional de hoje
PAUSAR - parar por 7 dias
SAIR - cancelar o envio
VOLTAR - voltar a receber
ASSINAR - conhecer o plano com áudio diário
[help.unrecognized] FREE_FORM
Não entendi. Responda AJUDA para ver o que eu consigo fazer.
[help.unsupported_content] FREE_FORM
Recebi sua mensagem, mas não consigo abrir áudios, imagens ou arquivos por aqui.
Se precisa de ajuda, responda AJUDA. Para falar com uma pessoa, responda FALAR COM ALGUÉM.
[support.human_business_hours] FREE_FORM
Certo. Uma pessoa da equipe assume esta conversa em instantes.
Seu protocolo é {{protocolo}}. Pode escrever sua dúvida aqui.
[support.human_after_hours] FREE_FORM
Nosso atendimento funciona de segunda a sexta, das 9h às 18h.
Registrei seu pedido com o protocolo {{protocolo}}. Alguém responde {{retorno}}.
Se for sobre cobrança, você também pode resolver agora em
app.palavradiaria.com.br/conta.
[system.generic_error] FREE_FORM
Não consegui processar isso agora. Tente de novo em alguns minutos.
Se continuar, responda FALAR COM ALGUÉM.
[system.maintenance_notice] TEMPLATE
Manutenção programada: {{1}}. Durante esse período o painel pode ficar indisponível.
O devocional das 6h não será afetado.
[survey.monthly] FREE_FORM interativa
De 0 a 10, qual a chance de você indicar a Palavra Diária para alguém?
Responda com um número. Leva 5 segundos e ajuda muito.
[survey.thanks] FREE_FORM
Obrigado pela nota {{nota}}. Se quiser contar o motivo, é só escrever aqui.19.4.6 E-mails #
[email.welcome] assunto: Sua conta na Palavra Diária está pronta
Olá, {{nome}}. Sua conta foi criada com o número {{telefone}}.
Acesse o painel: {{link}}
O devocional chega pelo WhatsApp, todos os dias às 6h (ou aos domingos, no plano gratuito).
[email.receipt] assunto: Recibo — Palavra Diária
Pagamento de {{valor}} confirmado.
Período: {{inicio}} a {{fim}}. Método: {{metodo}}.
Segunda via e histórico: {{link}}
[email.access_revoked] assunto: Seu acesso ao áudio diário foi encerrado
Olá, {{nome}}. Não conseguimos confirmar o pagamento da sua assinatura, e o acesso ao
áudio diário foi encerrado hoje. Você continua recebendo o devocional em texto aos
domingos. Para reativar: {{link}}
[email.data_export_ready] assunto: Seus dados estão prontos para download
O arquivo com todos os seus dados está disponível em {{link}}. O link vale por
{{validade}} e depois expira por segurança.19.5 Palavras-chave de entrada #
Precedência absoluta das palavras-chave de saída. O reconhecimento de SAIR, PARAR, PARE, CANCELAR,
STOP, DESCADASTRAR e REMOVER é a primeira operação executada sobre toda mensagem de entrada: antes da
proteção anti-loop (Seção 19.6), antes do escalonamento humano (Seção 19.7), antes da entrega pendente (Seção
18.6), antes de qualquer classificação de intenção, e independentemente do estado do assinante — inclusive
BLOCKED, pausado, em atendimento humano, em modo silencioso ou já com deleted_at preenchido. Nenhum caminho de
código pode consumir uma mensagem de entrada antes desse teste. Um teste de integração envia cada uma das sete
palavras em cada estado possível do assinante e falha se opt_out_at não for gravado em qualquer um deles.
O motivo: uma palavra de saída não honrada é, ao mesmo tempo, descumprimento do Art. 8º, § 5º da LGPD e a causa
direta da denúncia dentro do aplicativo que derruba a reputação do número (Seção 17.10).
Normalização antes de qualquer comparação: trim, maiúsculas, remoção de acentos (NFD + remoção de
diacríticos), colapso de espaços internos, remoção de pontuação final. " Sair!! " e "SAIR" produzem a mesma
chave. A implementação canônica é normalizeKeyword() da Seção 20.3.2, que termina em .toUpperCase(); nenhuma
seção reimplementa a normalização, e as tabelas de palavras-chave deste documento estão em maiúsculas porque é
essa a forma normalizada. O reconhecimento exige correspondência exata da mensagem inteira ou de uma das
variantes listadas — nunca includes, porque "não quero sair do plano" não é um pedido de opt-out.
| Palavra-chave (e variantes) | Ação | Resposta |
|---|---|---|
AJUDA, MENU, OPCOES, ? |
Abre o menu | help.menu |
HOJE, DEVOCIONAL, DEVOCIONAL DE HOJE |
Reenvia o devocional do dia, respeitando o limite (Seção 18.11) | devotional.resend_today, devotional.resend_limit ou devotional.none_today |
SAIR, PARAR, PARE, CANCELAR, STOP, DESCADASTRAR, REMOVER |
opt_out_at = now; envios cessam imediatamente; consent_events recebe registro com type = 'OPT_OUT' |
optout.confirmed, optout.already ou optout.paid_warning |
Payload OPT_OUT |
Mesmo efeito de SAIR, sem passar pela normalização — é payload de botão, não texto |
optout.confirmed |
VOLTAR, RETORNAR, QUERO VOLTAR, REATIVAR |
Limpa opt_out_at e paused_until; registra consent_events com type = 'RE_OPT_IN' |
reactivation.confirmed |
PAUSAR, PAUSA, FERIAS, FÉRIAS |
Pausa de 7 dias, o padrão do canal. Um número de 1 a 30 na mesma mensagem (ex.: PAUSAR 15) define a duração |
pause.started |
ASSINAR, PLANO, PRECO, AUDIO |
Envia o link de checkout | Texto curto com app.palavradiaria.com.br/assinar |
FALAR COM ALGUEM, ATENDENTE, HUMANO, SUPORTE, AJUDA HUMANA |
Abre atendimento (Seção 19.7) | support.human_business_hours ou support.human_after_hours |
SIM, CONFIRMO, QUERO RECEBER |
Confirma opt-in, se pendente | onboarding.optin_confirmed |
CANCELAR ASSINATURA |
Inicia cancelamento da cobrança (Seção 20) | billing.cancellation_requested |
| Número de 0 a 10, com pesquisa aberta | Registra a nota | survey.thanks |
Payload OPEN_DEVOTIONAL:{id} |
Entrega o devocional daquele id | Pacote completo |
Payload SNOOZE:{id} |
Mantém pendente até 23:59 | devotional.snoozed |
Payload CONFIRM_OPTIN |
Confirma opt-in | onboarding.optin_confirmed |
Payload MENU_RESEND / MENU_BILLING / MENU_HUMAN |
Atalhos do menu | Conforme a ação |
As sete palavras de saída são exatamente estas, em todo o documento e na chave send.optout_keywords de
settings: SAIR, PARAR, PARE, CANCELAR, STOP, DESCADASTRAR, REMOVER. Nenhuma lista mais curta é
válida.
Regra de desempate entre CANCELAR e CANCELAR ASSINATURA. CANCELAR sozinho é opt-out de mensagens.
CANCELAR ASSINATURA é pedido de cancelamento da cobrança. Como o reconhecimento exige correspondência exata da
mensagem inteira, a mensagem mais longa nunca cai na regra mais curta — não existe ambiguidade a resolver por
heurística. Quando o assinante enviar CANCELAR e tiver assinatura paga vigente, a resposta é
optout.paid_warning, que confirma a parada dos envios e informa o efeito financeiro: a próxima cobrança é
suspensa, o período já pago continua valendo e, em 30 dias sem reativação, a assinatura é encerrada
(Seção 13.4.6).
Precedência. SAIR e suas variantes têm prioridade máxima e são avaliadas antes de qualquer outra regra,
inclusive antes da entrega pendente, do modo silencioso e do atendimento humano. Um assinante que responde SAIR
ao template das 06:00 não recebe o pacote completo: recebe apenas a confirmação de saída. Isso é
conformidade, não preferência.
19.6 Mensagem não reconhecida e proteção anti-loop #
| Regra | Valor |
|---|---|
| Resposta por janela | Uma única help.unrecognized por janela de atendimento de 24 h |
| Contador | subscribers.unrecognized_replies_in_window, zerado ao abrir nova janela |
| Após a primeira | Mensagens não reconhecidas seguintes são registradas em inbound_messages e não recebem resposta |
| Exceção | Palavras-chave sempre respondem, quantas vezes forem enviadas |
| Teto absoluto | Máximo de 5 mensagens automáticas enviadas ao mesmo assinante por hora, contando tudo |
| Conteúdo não textual | help.unsupported_content, sujeito ao mesmo limite de uma por janela |
Nenhuma regra desta subseção alcança as palavras-chave de saída: elas são avaliadas antes de tudo o que está aqui (Seção 19.5). O modo silencioso silencia a resposta automática, nunca o opt-out.
Nunca responder a mensagem automática. Três guardas, aplicadas em ordem: (1) se o telefone de origem —
inbound_messages.phone_e164, decifrado na aplicação (Seção 22.5) e normalizado pela Seção 11.4 — consta da lista
whatsapp.no_reply_numbers em settings, ignora; (2) se a mensagem tem context.forwarded = true ou
context.frequently_forwarded = true, registra e não responde — encaminhamento em massa não é conversa; (3) se o
mesmo texto exato chegou 3 vezes em 60 segundos, ativa subscribers.autoreply_suppressed_until = now + 1 h e
emite inbound.loop_suspected.
Sem essas guardas, dois sistemas automáticos conversando produzem milhares de mensagens em minutos, com custo real e dano à qualidade do número (Seção 17.10).
19.7 Escalonamento para atendimento humano #
| Aspecto | Decisão |
|---|---|
| Gatilho | Palavra-chave de suporte, botão MENU_HUMAN, ou 3 help.unrecognized na mesma janela |
| Horário | Segunda a sexta, 9h às 18h (America/Sao_Paulo), exceto feriados nacionais |
| Efeito imediato | subscribers.human_handoff_until = now + 2 h; todas as respostas automáticas ficam suspensas nesse período, exceto o devocional das 06:00 |
| O que o atendimento humano não suspende | As palavras-chave de saída. Uma mensagem que seja exatamente uma das sete palavras da Seção 19.5 é processada como opt-out mesmo com atendimento humano aberto, e a confirmação optout.confirmed é enviada. Segurar um SAIR porque a conversa está com uma pessoa é o modo mais fácil de perder o número por denúncia |
| Protocolo | ULID curto com prefixo PD-, exibido ao assinante e gravado em inbound_messages.support_protocol |
| Ferramenta | O painel administrativo tem a caixa "Atendimento", com as conversas abertas ordenadas por tempo de espera, e permite responder free-form dentro da janela |
| SLA | 1 dia útil para PAID (matriz da Seção 7); melhor esforço para FREE |
| Fora do horário | support.human_after_hours, com o próximo horário útil calculado e informado ("na segunda, a partir das 9h") |
| Janela expirada | Se a janela de 24 h fechar antes da resposta humana, o atendente vê o aviso no painel e o contato passa a e-mail, quando houver e-mail verificado |
| Encerramento | O atendente marca a conversa como resolvida, o que limpa human_handoff_until e devolve o controle ao roteador automático |
19.8 Fluxos conversacionais #
19.8.1 Cadastro e opt-in #
WEB SISTEMA WHATSAPP
| | |
|-- telefone + CPF + checkbox ---------->| |
| (consentimento versionado) |-- consent_events (append) --> |
| |-- otp.code (TEMPLATE) ------->|
| | |
|<-- tela "digite o código" -------------| [Ana digita 4 8 2 9 1 7]
|-- código ----------------------------->| |
| | válido? TTL 10 min, 5 tentativas
| | |
|<-- conta criada, tier FREE ------------|-- onboarding.welcome_optin -->|
| | [Sim, quero receber] [Agora não]
| | |
| |<-- CONFIRM_OPTIN ------------|
| | opt_in_confirmed_at = now
| | service_window = +24 h
| |-- onboarding.optin_confirmed >|
| | |
| NENHUM DEVOCIONAL É ENVIADO ANTES DESTE PONTO
| |
| 48 h sem confirmar -> onboarding.optin_pending_reminder (1x)
| 7 dias sem confirmar -> cadastro fica inativo, sem envio19.8.2 Entrega diária #
05:40 send.plan resolve a rota de cada assinante (Seção 18.3.3)
06:00 |
+-- rota WINDOW_DIRECT (janela aberta) ------------------------------+
| -> devotional.full_text |
| -> áudio (só PAID) |
| -> devotional.closing custo US$ 0,00 |
| |
+-- rota TEMPLATE_INVITE --------------------------------------------+
| -> devotional.invite custo UTILITY |
| | |
| +-- [Ler e ouvir agora] --> janela abre |
| | -> full_text -> áudio (PAID) -> closing |
| | latência p95 < 5 s custo US$ 0,00 |
| | |
| +-- [Depois] --> devotional.snoozed |
| | pendente até 23:59; HOJE entrega na hora |
| | |
| +-- sem interação até 23:59 |
| -> EXPIRED_NO_INTERACTION |
| -> consecutive_window_misses++ (só se entregue) |
| |
+-- rota TEMPLATE_VIDEO (PAID, 3 dias sem interação) ----------------+
-> devotional.invite_video (MP4 com áudio no header)
-> assinante recebe o áudio SEM precisar interagir
-> interagiu? contador zera, volta ao fluxo normal19.8.3 Saída, retorno e ajuda #
ASSINANTE escreve ROTEADOR (Seção 19.5)
| |
| normaliza com normalizeKeyword(): trim, MAIÚSCULAS, sem acento, sem pontuação final
| |
+-- "SAIR"/"PARAR"/"PARE"/"CANCELAR"/-->| PRECEDÊNCIA MÁXIMA, antes de tudo
| "STOP"/"DESCADASTRAR"/"REMOVER" | (inclusive de anti-loop e atendimento humano)
| | opt_out_at = now
| | envios cessam IMEDIATAMENTE
| | consent_events registra a revogação
| |
| tem assinatura ativa?
| | |
| não sim
| | |
|<-- optout.confirmed -----------+ |
|<-- optout.paid_warning ---------------------+
| (a assinatura não é cancelada de imediato, mas a cobrança do ciclo
| seguinte é suspensa — Seção 13.4.6)
|
+-- "VOLTAR" -------------------------->| opt_out_at = NULL, paused_until = NULL
|<-- reactivation.confirmed ------------|
|
+-- "PAUSAR" / "PAUSAR N" ------------->| pausa de 7 dias (padrão) ou de N dias,
| | com N de 1 a 30 (Seção 20.6.1)
|<-- pause.started ---------------------|
|
+-- "AJUDA" / "MENU" ------------------>| monta menu conforme o tier
|<-- help.menu (3 botões + texto) ------|
|
+-- "HOJE" ---------------------------->| verifica limite do tier (Seção 18.11)
|<-- resend_today | resend_limit | none_today
|
+-- "FALAR COM ALGUEM" ---------------->| human_handoff_until = now + 2 h
| | automações suspensas
|<-- human_business_hours | after_hours -|
|
+-- texto qualquer -------------------->| primeira da janela?
| | sim -> help.unrecognized
| | não -> registra e SILENCIA
|
+-- áudio/imagem/arquivo -------------->| help.unsupported_content (1x por janela)19.9 Casos de borda #
| # | Situação | Comportamento definido |
|---|---|---|
| 1 | SAIR respondido ao template das 06:00 |
Precedência máxima: recebe só optout.confirmed; o pacote completo é cancelado. Se o lote ainda estiver disparando, a revalidação do disparo (Seção 18.5.1) encerra o item como SKIPPED_OPTED_OUT sem chamar o provedor |
| 2 | SAIR de quem tem assinatura ativa |
optout.paid_warning. O opt-out não cancela a assinatura de imediato, mas suspende a cobrança do ciclo seguinte na mesma transação; a regra completa está na Seção 13.4.6 |
| 3 | VOLTAR de quem nunca saiu |
Tratado como confirmação de opt-in se pendente; senão, help.unrecognized |
| 4 | Texto com "sair" no meio da frase | Não aciona opt-out: a correspondência é da mensagem inteira |
| 5 | HOJE enviado a um FREE numa terça |
devotional.none_today, informando o próximo domingo |
| 6 | Teaser gerado com 340 caracteres | Truncado para 300 na última palavra, com … (Seção 17.7) |
| 7 | Devocional cujo texto passa de 4.096 caracteres | Enviado em duas mensagens, quebradas em limite de parágrafo, com o áudio depois da segunda |
| 8 | Assinante responde com emoji apenas | Abre a janela; conta como interação; entrega o pacote pendente; sem help.unrecognized |
| 9 | Pesquisa respondida com "10!!!" | Normalização remove pontuação; nota 10 registrada |
| 10 | Assinante em human_handoff_until recebe o devocional das 06:00 |
Entregue normalmente. Só as respostas automáticas do roteador ficam suspensas |
| 11 | Duas palavras-chave na mesma mensagem ("AJUDA SAIR") | Não há correspondência exata; responde help.unrecognized — nunca adivinha intenção em pedido ambíguo de saída |
| 12 | Mensagem encaminhada em massa | Não recebe resposta automática (Seção 19.6), mas abre a janela normalmente |
| 13 | SAIR enviado por quem está em atendimento humano ou em modo silencioso |
Processado normalmente: as sete palavras de saída passam antes da proteção anti-loop e do handoff (Seção 19.5). opt_out_at é gravado e a confirmação sai |
| 14 | SAIR enviado por quem já tem blocked_at preenchido |
Também é processado: opt_out_at é gravado. O estado BLOCKED diz que não conseguimos entregar, não que deixamos de ouvir |
| 15 | PAUSAR 45 |
Fora da faixa de 1 a 30: aplica o padrão de 7 dias e a resposta informa a faixa aceita, sem tratar como erro |
20. Cancelamento, Opt-out e Retenção #
20.1 Os dois caminhos, que nunca se confundem #
Existem duas saídas diferentes, com efeitos diferentes, e o sistema jamais trata uma como se fosse a outra:
(1) Opt-out de mensagens. O assinante quer parar de receber no WhatsApp. Ele diz "SAIR" no WhatsApp ou desliga o recebimento no painel. Efeito: os envios cessam imediatamente. A assinatura não é cancelada de imediato, mas a cobrança do ciclo seguinte é suspensa (Seção 13.4.6): o período já pago continua valendo, nenhuma cobrança nova é gerada, e se em 30 dias não houver reativação a assinatura é encerrada ao fim desse período. O acervo continua acessível no painel.
A razão de não cancelar de imediato é proteger contra o SAIR acidental — a palavra chega por
engano com frequência. A razão de suspender a cobrança é que a base legal da entrega é o
consentimento do titular: revogado o consentimento, o serviço não pode ser prestado, e cobrar
por serviço que não se pode prestar é infração. As duas coisas convivem, e é por isso que este
caminho tem duas consequências e não uma.
(2) Cancelamento da assinatura. O assinante quer parar de pagar. Ele cancela pelo painel. Efeito: não haverá nova cobrança, e o acesso pago vai até o fim do ciclo já pago (Seção 13.4). A conta não é apagada, as mensagens não param — ele continua recebendo o devocional, no tier que tiver: pago até o fim do período, gratuito depois.
O erro que essa separação evita, dos dois lados:
- Cancelar a cobrança de quem só pediu silêncio: perda de receita e de um assinante que talvez quisesse apenas uma pausa.
- Continuar enviando para quem cancelou a cobrança: nenhum, na verdade — quem cancela a cobrança continua sendo assinante gratuito, e continuar enviando é o comportamento correto. O erro simétrico seria parar de enviar para quem só cancelou a cobrança, o que transformaria um downgrade em desaparecimento.
Por isso, sempre que o sistema detectar um dos dois pedidos vindo de alguém em quem o outro também se aplica, ele avisa e oferece, mas nunca executa os dois por conta própria. Especificamente:
- Assinante pagante que faz opt-out recebe, na confirmação, o aviso explícito de que a próxima cobrança foi suspensa, de até quando o acesso já pago vale, e um link para cancelar de vez ou para voltar a receber (20.3.5).
- Assinante que cancela a assinatura pelo painel vê, na tela de confirmação, a informação de que continuará recebendo o devocional gratuito aos domingos, com um link para desligar o recebimento se for isso que ele quer (20.7.5).
20.2 Matriz cruzada dos dois caminhos #
Quatro combinações possíveis. Assinante de exemplo: plano mensal, ciclo pago de 10/08/2026 a 10/09/2026, ação tomada em 22/08/2026.
| Cobrança ativa | Cobrança suspensa ou cancelada | |
|---|---|---|
| Recebendo mensagens | Estado normal do pagante. Envios: diários, com áudio. Cobrança: renova em 10/09. Acervo: completo. Dados: intactos. | Cancelou a assinatura, continua recebendo. Envios: diários com áudio até 10/09, depois só domingo sem áudio. Cobrança: nenhuma nova. Acervo: completo até 10/09, depois últimos 7 dias. Dados: intactos. |
| Opt-out ativo | Combinação impossível por construção: o opt-out suspende a cobrança na mesma transação em que grava opt_out_at (Seção 13.4.6). Nenhum assinante fica em silêncio pagando. |
Pediu silêncio. Envios: nenhum, desde 22/08. Cobrança: suspensa; nenhuma cobrança em 10/09. Acesso pago segue até 10/09 e depois cai para FREE. Acervo: acessível pelo painel, conforme o tier. Dados: intactos até pedir exclusão. Em 30 dias sem reativação, a assinatura é encerrada. |
A célula superior direita e a inferior direita descrevem o mesmo estado financeiro por caminhos diferentes: um pelo pedido de cancelamento, outro pelo pedido de silêncio. A diferença entre elas é só se as mensagens continuam.
Detalhamento por dimensão:
| Dimensão | Só opt-out | Só cancelamento | Ambos | Exclusão de conta (20.9) |
|---|---|---|---|---|
| Envio no WhatsApp | Para imediatamente | Continua, no tier vigente | Para imediatamente | Para imediatamente |
| Cobrança recorrente | Suspensa no ato; encerrada em 30 dias sem reativação | Para na virada do ciclo | Para na virada do ciclo | Cancelada no ato |
| Acesso pago | Mantido até o fim do ciclo | Mantido até o fim do ciclo | Mantido até o fim do ciclo | Encerrado no ato |
| Login no painel | Funciona | Funciona | Funciona | Bloqueado |
| Acervo no painel | Acessível conforme o tier | Acessível conforme o tier | Acessível conforme o tier | Inacessível |
| Dados pessoais | Mantidos | Mantidos | Mantidos | Anonimizados (20.9.3) |
| Histórico de pagamentos | Mantido | Mantido | Mantido | Retido por obrigação fiscal |
| Reversível pelo assinante | Sim, VOLTAR ou painel |
Sim, nova assinatura | Sim, os dois | Não, após a anonimização |
20.3 Opt-out pelo WhatsApp #
20.3.1 Palavras-chave reconhecidas #
São sete, e esta é a lista completa em todo o documento:
SAIR, PARAR, PARE, CANCELAR, STOP, DESCADASTRAR, REMOVER.
A lista é lida da chave de configuração send.optout_keywords (JSON, formato grupo.chave
do catálogo da Seção 26.8.1), semeada com exatamente esses sete valores. Nenhuma seção lista
seis, oito ou uma variação.
Além do texto, o payload de botão OPT_OUT (enviado quando o assinante toca no botão de
descadastro de uma mensagem interativa) tem o mesmo efeito, sem passar pela normalização.
Precedência absoluta das palavras-chave de saída. O reconhecimento dessas sete palavras é a
primeira operação executada sobre toda mensagem de entrada — antes da proteção anti-laço,
antes do escalonamento para atendimento humano, antes de qualquer classificação de intenção, e
independentemente do estado do assinante, inclusive BLOCKED, excluído, pausado, em
atendimento humano ou em modo silencioso. Nenhum caminho de código pode consumir uma mensagem
de entrada antes desse teste.
Motivo, registrado porque a consequência é dupla: uma palavra de saída não honrada é ao mesmo
tempo descumprimento do direito de revogar o consentimento e a causa direta da denúncia dentro
do aplicativo, que é o que derruba a reputação do número (Seção 17). Um assinante que escreve
SAIR, recebe de novo no dia seguinte e escreve SAIR outra vez não reclama: ele denuncia.
Um teste de integração envia cada uma das sete palavras em cada estado possível do
assinante e falha se opt_out_at não for gravado em qualquer um deles.
Regra de desempate entre saída de mensagens e cancelamento de cobrança: CANCELAR sozinho é
opt-out. CANCELAR ASSINATURA é pedido de cancelamento de cobrança. Como o reconhecimento
exige correspondência exata da mensagem inteira (20.3.3), a mensagem mais longa nunca cai na
regra mais curta. Quando o assinante enviar CANCELAR e tiver assinatura paga vigente, a
resposta é a variante de pagante de 20.3.5, que confirma a parada dos envios, informa a
suspensão da cobrança e pergunta, com botão, se ele também quer encerrar a assinatura de vez.
20.3.2 Normalização #
Toda mensagem recebida passa por normalizeKeyword() antes da comparação:
// packages/core/src/keywords.ts
export function normalizeKeyword(raw: string): string {
return raw
.normalize('NFD') // separa acentos dos caracteres base
.replace(/[̀-ͯ]/g, '') // remove os diacríticos
.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}]/gu, '') // remove emoji e VS16
.replace(/[^\p{L}\p{N}\s]/gu, ' ') // pontuação vira espaço
.replace(/\s+/g, ' ') // colapsa espaços, tabs e quebras de linha
.trim()
.toUpperCase();
}
// Valores padrão. Em execução, as duas listas vêm de `send.optout_keywords` e
// `send.optin_keywords` em `settings`; estas constantes são o seed e o fallback de boot.
export const OPT_OUT_KEYWORDS = new Set([
'SAIR', 'PARAR', 'PARE', 'CANCELAR', 'STOP', 'DESCADASTRAR', 'REMOVER',
]);
export const OPT_IN_KEYWORDS = new Set(['VOLTAR', 'RETORNAR', 'QUERO VOLTAR', 'REATIVAR']);SIM não é palavra de reativação, deliberadamente. É a resposta afirmativa mais comum do
português e chegaria como resposta a qualquer pergunta que o produto fizesse — inclusive à
própria confirmação de saída de 20.3.3, o que reverteria um opt-out recém-pedido. Reativar
exige uma palavra que só faça sentido reativando.
Esta é a implementação canônica da normalização de mensagem de entrada em todo o documento. Nenhuma outra seção reimplementa a regra, e a saída é sempre em maiúsculas — qualquer texto que descreva a normalização como "minúsculas" está errado e contradiz este bloco de código.
Exemplos de normalização:
| Recebido | Normalizado | Casa? |
|---|---|---|
sair |
SAIR |
sim |
Sair. |
SAIR |
sim |
SAIR!!! |
SAIR |
sim |
Sair seguido de um emoji (\u{1F64F}) |
SAIR |
sim |
cancelar |
CANCELAR |
sim |
Descadastrar |
DESCADASTRAR |
sim |
REMOVER-ME |
REMOVER ME |
não (duas palavras) |
quero sair |
QUERO SAIR |
não |
não quero cancelar |
NAO QUERO CANCELAR |
não |
Posso cancelar depois? |
POSSO CANCELAR DEPOIS |
não |
20.3.3 Decisão: correspondência exata, não contida #
Decisão registrada: a correspondência é EXATA sobre a mensagem inteira normalizada. Uma mensagem só dispara opt-out se, depois de normalizada, for idêntica a uma das palavras-chave.
Justificativa, com os casos reais que a decisão evita:
- "não quero cancelar" contém
CANCELAR. Correspondência contida descadastraria alguém que disse exatamente o oposto. - "preciso cancelar meu cartão no banco, mas quero continuar recebendo" contém
CANCELAR. - "vou parar de reclamar e começar a orar" contém
PARAR. - "quero remover só o áudio" contém
REMOVER. - "que mensagem linda, não pare nunca" — normaliza para
QUE MENSAGEM LINDA NAO PARE NUNCA, que contém a palavra-chavePARE. Com correspondência contida, o elogio mais entusiasmado da base descadastraria quem o escreveu. É o exemplo mais eloquente de por que a correspondência é exata sobre a mensagem inteira.
O custo da decisão é o inverso: alguém que escreve "quero sair" não é atendido automaticamente. Esse custo é coberto pelo caminho de confirmação, que é obrigatório:
Quando a mensagem contém uma palavra-chave mas não é igual a ela, e tem no máximo 120 caracteres, o sistema responde com uma confirmação interativa em vez de ignorar:
"Você quer parar de receber o Palavra Diária no WhatsApp? Responda SAIR para confirmar. Se foi engano, é só ignorar esta mensagem."
Essa resposta é enviada no máximo uma vez a cada 24 horas por assinante, controlada pela
chave optout-hint:{subscriberId}:{YYYY-MM-DD} em delivery_attempts. Mensagens acima de 120
caracteres não recebem a confirmação, porque textos longos com CANCELAR no meio quase nunca
são pedido de saída — são conversa.
Resultado: precisão alta no gatilho automático, sem deixar ninguém preso. Quem quer sair, sai em no máximo duas mensagens.
20.3.4 Efeito imediato #
Ao reconhecer a correspondência exata, dentro de uma única transação:
subscribers.opt_out_at = now(),opt_out_reason = 'WHATSAPP',opt_out_keyword = <palavra normalizada>.subscribers.paused_until = NULL— opt-out sobrepõe pausa; não faz sentido manter as duas.- Grava
consent_eventscomtype = 'OPT_OUT',granted = false, o canalWHATSAPP, eevidencecom o texto recebido e owa_id. Registro imutável (Seção 22.7.3). - Cancela todos os jobs de envio pendentes do assinante nas filas
send.plan,send.dispatchesend.followup, porjobIdprefixado comsend:{subscriberId}:. - Suspende a cobrança do ciclo seguinte, quando houver assinatura vigente:
cancel_at_period_end = true,billing_suspended_at = now()e, após o commit,DELETE /v3/subscriptions/{id}na Asaas. O período já pago não é tocado e o tier seguePAIDatécurrent_period_end. A regra completa, com o prazo de 30 dias para reativar e o encerramento automático, é da Seção 13.4.6.
Nenhuma das duas funções de acesso da Seção 13.3.1 é chamada aqui: opt-out não retira acesso pago, apenas silencia o canal e para a cobrança futura.
O motor de envio (Seção 18.5) relê opt_out_at por chave primária imediatamente antes de cada
chamada à API do WhatsApp, então mesmo um job que escapou do cancelamento termina como
SKIPPED_OPTED_OUT, sem tocar no provedor. É essa releitura, e não o cancelamento de jobs, que
torna o efeito verdadeiramente imediato: o cancelamento de fila é otimização, a releitura é a
garantia.
Latência real: o webhook de entrada da Meta chega em menos de 1 segundo, o processamento é
enfileirado e executa em menos de 2 segundos. Um assinante que envia SAIR às 06:00:03,
enquanto o lote diário está rodando, não recebe a mensagem das 06:00:05.
20.3.5 Resposta de confirmação, uma única vez #
A confirmação é enviada apenas na transição de opt_out_at IS NULL para preenchido. A
guarda é a própria transação: se a linha já tinha opt_out_at, a atualização não muda nada e
nenhuma mensagem é enfileirada. O instante do envio fica em
subscribers.opt_out_confirmation_sent_at, verificado dentro da mesma transação que grava
opt_out_at, para que uma reentrega de webhook não produza duas mensagens de despedida.
Quando a origem é o WhatsApp, esta mensagem única é toda a confirmação: não há e-mail de confirmação, não há segunda mensagem, não há aviso posterior. A regra de canal completa, com as três origens possíveis, está em 20.4.1.
Texto para assinante gratuito (redação final na Seção 19):
"Pronto. Você não vai mais receber o Palavra Diária no WhatsApp. Se mudar de ideia, é só responder VOLTAR."
Texto para assinante pagante — obrigatoriamente diferente, com o efeito sobre a cobrança:
"Pronto. Você não vai mais receber o Palavra Diária no WhatsApp. Sua próxima cobrança está suspensa: nada será cobrado em 10/09/2026. Seu acesso ao plano pago, que você já pagou, vale até 10/09/2026. Se quiser voltar, é só responder VOLTAR. Se quiser encerrar de vez, acesse app.palavradiaria.com.br/assinatura."
O valor e a data vêm de subscriptions.amount_cents, formatado por formatBRL a partir de
centavos inteiros, e de current_period_end. O aviso é obrigatório e não é configurável:
silenciar alguém sem dizer o que acontece com o dinheiro dele é prática abusiva, e continuar
cobrando por um serviço que não pode mais ser entregue é infração (Seção 13.4.6).
Repetições de SAIR depois do opt-out: nenhuma mensagem é enviada. O sistema registra a
mensagem em inbound_messages e não responde. Motivo: responder criaria um laço com quem
mandou a palavra por engano várias vezes, e o assinante já foi informado.
20.3.6 Casos de borda do opt-out por WhatsApp #
| Caso | Comportamento |
|---|---|
| Mensagem de número não cadastrado | Registra em inbound_messages com subscriber_id = NULL; responde uma única vez com "Não encontramos um cadastro para este número." e ignora depois |
Mensagem de assinante com deleted_at |
Ignora silenciosamente |
SAIR durante uma pausa vigente |
Aplica opt-out e limpa paused_until |
SAIR em mensagem com mídia e legenda SAIR |
A legenda é tratada como texto; casa normalmente |
SAIR em áudio de voz |
Não há transcrição no MVP; o sistema responde uma única vez pedindo que envie por texto |
SAIR e VOLTAR em sequência rápida |
Cada um é aplicado na ordem de chegada; a última mensagem vence. inbound_messages preserva a sequência |
Mensagem duplicada pela Meta (mesmo wamid) |
Índice único em inbound_messages.wamid descarta a repetição antes do processamento |
wa_id diferente do phone_e164 cadastrado |
Resolução por wa_id_hmac → phone_hmac → variantes com e sem nono dígito, conforme a regra de normalização de telefone da Seção 11.4. A comparação é sempre pelo índice cego, nunca pela coluna cifrada |
Mudança do wa_id associado a um telefone |
Tratada como troca de aparelho ou chip reciclado, e não como detalhe técnico: todas as sessões daquele assinante são invalidadas e é exigida nova verificação por código antes de liberar qualquer dado pessoal. Ver o parágrafo abaixo |
| Opt-out durante o envio do pacote da janela aberta | O texto já enviado permanece; áudio e fechamento são cancelados |
Chip reciclado e troca de número. Operadoras brasileiras reemitem números desativados, e a
posse do número é o único fator de autenticação do assinante. Por isso, qualquer mudança do
wa_id associado a um phone_hmac já cadastrado invalida todas as sessões daquele
assinante e exige nova verificação por código antes de qualquer leitura de dado pessoal.
O que a mudança de wa_id não faz: não entrega a conta, o CPF, o histórico de pagamentos
nem o acervo a quem respondeu no WhatsApp. Uma palavra-chave de saída continua sendo honrada
sem verificação nenhuma — parar de enviar para um número nunca expõe dado de ninguém, e é
justamente o que o novo dono do número quer. Mas ler, exportar ou alterar dados exige a
verificação. A regra completa de sessão restrita por dormência é da Seção 8.15.1.1.
Motivo registrado: entregar sessão plena por posse de número transforma a reciclagem de chip — evento comum e inteiramente fora do nosso controle — em vazamento de CPF e de histórico financeiro de outra pessoa.
20.4 Opt-out pelo painel e pelo link de descadastro #
20.4.1 No painel #
app.palavradiaria.com.br/preferencias tem um controle "Receber o devocional no WhatsApp",
ligado por padrão. Desligar abre um diálogo de confirmação que mostra, quando aplicável, o
aviso de que a próxima cobrança será suspensa, o período já pago continua valendo e, sem
reativação em 30 dias, a assinatura é encerrada (Seção 13.4.6), com valor e data. Confirmado,
chama POST /api/me/opt-out com source = 'DASHBOARD'.
Canal da confirmação de opt-out — regra única do produto, sem exceção. O canal da confirmação é determinado exclusivamente pela origem do pedido:
| Origem do opt-out | Confirmação no WhatsApp | Confirmação na tela | Confirmação por e-mail |
|---|---|---|---|
| WhatsApp (palavra-chave) | Sim, exatamente uma mensagem, e nada além dela | Não se aplica | Não |
| Painel do assinante | Não | Sim | Sim, quando houver e-mail verificado |
| Link de descadastro (20.4.2) | Não | Sim | Sim, quando houver e-mail verificado |
O motivo é simples e vale como justificativa escrita da regra: quem pediu para parar de receber mensagens no WhatsApp por um canal que não é o WhatsApp não deve receber mais uma mensagem no WhatsApp. O assinante já viu a confirmação na tela, e mandar uma mensagem para quem acabou de pedir silêncio é contraditório. Pelo caminho inverso, quem pediu pelo próprio WhatsApp precisa de uma resposta ali, senão fica sem saber se foi atendido — por isso a mensagem única de 20.3.5, e apenas ela.
20.4.2 Link de descadastro #
Toda mensagem de e-mail transacional traz um link de descadastro no rodapé. As mensagens de WhatsApp trazem o rodapé de template "Responda SAIR para cancelar", que é a via nativa do canal.
Formato do link: https://app.palavradiaria.com.br/descadastrar/{token}.
O token é opaco, com 32 bytes aleatórios codificados em base64url. Ele não é derivado do telefone nem do id do assinante, para que não seja adivinhável nem enumerável.
Persistência: a tabela unsubscribe_tokens, definida pela Seção 6 — que é a dona do modelo de
dados e a única seção que cria tabela. Esta seção não a define e não introduz tabela
nova: apenas a consome, pelas colunas que a Seção 6 declara (subscriber_id, token_hash,
created_at, expires_at, used_at). Só o hash é gravado; o
token em claro existe apenas dentro do link enviado. Validade de 180 dias, rotação a cada uso
bem sucedido. Os tipos, os índices, a chave estrangeira, o ON DELETE e a retenção estão na
Seção 6.
O token não vive em settings. As chaves de settings seguem o formato grupo.chave, são
configuração do produto e precisam existir no seed; uma chave por assinante não é configuração,
não caberia no seed, e transformaria a tabela de configuração em armazenamento de credencial.
A página do link:
- Não executa o opt-out no
GET. Prefetchers de e-mail e antivírus corporativos abrem links automaticamente; umGETdestrutivo descadastraria gente sem intenção nenhuma. - Mostra o número mascarado (
+55 11 9****-8829), a confirmação e um botão. OPOSTdo formulário executa o opt-out. - Funciona sem login. É um link autenticado pelo próprio token.
- Após a execução, mostra o resultado, o aviso de que a próxima cobrança será suspensa quando aplicável, e um botão "Voltar a receber" que desfaz na hora. A confirmação fica na tela e segue por e-mail quando houver e-mail verificado; não se envia WhatsApp, pela regra de canal de 20.4.1.
- Token inválido, expirado ou já rotacionado: página neutra "Este link não é mais válido" com link para o login. Não revela se o assinante existe.
20.5 Reativação do recebimento #
20.5.1 Pelo WhatsApp #
Palavras-chave: VOLTAR, RETORNAR, SIM. Mesma normalização e mesma regra de
correspondência exata de 20.3.2 e 20.3.3.
Efeito, em uma transação: opt_out_at = NULL, opt_out_reason = NULL,
opt_out_keyword = NULL, paused_until = NULL, e consent_events com type = 'RE_OPT_IN',
channel = 'WHATSAPP' e granted = true.
opt_in_confirmed_at não é reescrito: ele marca a confirmação original do opt-in em duas
etapas (Seção 11) e é prova de consentimento inicial. A reativação é um evento novo em
consent_events, não uma reescrita do passado.
Resposta enviada:
"Que bom ter você de volta. Você vai receber o Palavra Diária [todos os dias / aos domingos] às 6h."
O trecho entre colchetes vem de sendFrequency resolvido pela função da Seção 13.6.2 — nunca
de um if local.
20.5.2 O que é restaurado #
| Item | Restaurado? |
|---|---|
| Envios futuros | Sim, a partir do próximo horário programado |
| Devocionais do período em silêncio | Não. Não há reenvio retroativo pelo WhatsApp |
| Acesso ao acervo | Nunca foi perdido; segue conforme o tier |
| Tier e assinatura | Nunca foram alterados pelo opt-out |
| Contadores de reenvio manual | Zerados na virada do dia, como sempre |
| Pausa vigente | Cancelada; reativar encerra a pausa |
Decisão sobre não reenviar o histórico: despejar 40 devocionais atrasados no WhatsApp de quem
volta é a maneira mais rápida de provocar um bloqueio por spam e derrubar o quality_rating
do número (Seção 17). O acervo web existe exatamente para isso, e a mensagem de boas-vindas
traz o link.
20.5.3 Pelo painel #
O controle de /preferencias volta a ser ligado, chamando POST /api/me/opt-in. O efeito é
idêntico. Confirmação exibida na tela; nenhuma mensagem de WhatsApp é enviada, porque o
próximo devocional já é a confirmação prática.
20.6 Pausa temporária #
20.6.1 Para que serve e como funciona #
Viagem, retiro, luto, semana difícil. O assinante quer silêncio por alguns dias, sem sair e sem cancelar. A pausa é a alternativa suave ao opt-out, e é também a oferta de retenção de 20.7.4.
Parâmetro: 1 a 30 dias. subscribers.paused_until = now() + N dias, com hora ajustada para
03:00 de America/Sao_Paulo do dia seguinte ao último dia pausado, de modo que o retorno caia
antes do envio das 06:00.
Pelo painel, a duração é sempre escolhida em um seletor. Pelo WhatsApp, a duração padrão do
canal é de 7 dias: PAUSAR, PAUSA, FERIAS ou FÉRIAS sozinhos pausam por 7 dias, e o
assinante pode dizer PAUSAR N, com N de 1 a 30, para escolher — por exemplo PAUSAR 15. Um
número fora da faixa recebe a resposta explicando os limites, sem pausar nada.
Durante a pausa:
canReceiveMessageséfalsecomblockedReason = 'PAUSED'(Seção 13.6.2).- Nenhum devocional, nenhum lembrete de cobrança pelo WhatsApp, nenhuma mensagem de marketing.
- Exceção única: mensagens transacionais críticas de cobrança continuam — confirmação de pagamento, falha de pagamento e perda de acesso. Elas informam sobre dinheiro e sobre o contrato, e omiti-las prejudicaria o assinante. Essa exceção não vale para o opt-out, que bloqueia tudo (20.10).
- A cobrança continua normalmente. Pausa não é congelamento de assinatura.
- O acesso ao acervo continua, integral, conforme o tier.
- O painel exibe o estado com a data de retorno e um botão "Voltar agora".
20.6.2 Como termina #
Naturalmente: a função de entitlements para de bloquear quando paused_until <= now. Nenhum
job é necessário para o efeito.
O job messaging.pause, na fila messaging.pause, roda às 05:30 e faz duas coisas cosméticas,
mas úteis: limpa paused_until das pausas encerradas e enfileira a mensagem de retorno. Se o
job falhar, o assinante recebe o devocional normalmente e apenas não recebe a saudação de
volta — a pausa termina sozinha porque a função de entitlements para de bloquear quando
paused_until <= now, e nada depende do job para isso.
Antecipadamente: VOLTAR no WhatsApp ou o botão no painel encerram a pausa na hora
(paused_until = NULL).
Mensagem de retorno, enviada às 05:55 do primeiro dia ativo, antes do devocional:
"Sua pausa terminou. A partir de hoje você volta a receber o Palavra Diária. Que essa retomada seja boa."
Se o assinante encerrou a pausa manualmente, a mensagem de retorno não é enviada — a ação dele já foi a confirmação.
20.6.3 Regras e limites #
| Regra | Valor |
|---|---|
| Duração mínima | 1 dia |
| Duração máxima | 30 dias |
| Pausas por assinante | Máximo 3 por período de 90 dias |
| Pausa sobre pausa | Substitui a anterior; não soma. A rota aceita e devolve 200 com a nova data |
| Pausa com opt-out ativo | Rejeitada com 409 SUBSCRIBER_OPTED_OUT: quem já está em silêncio não precisa de pausa |
| Efeito na cobrança | Nenhum |
| Efeito no tier | Nenhum |
| Registro do início | consent_events com type = 'PAUSE_STARTED', channel conforme a origem, granted = false e evidence = {"days": N, "pausedUntil": "..."} |
| Registro do fim | consent_events com type = 'PAUSE_ENDED' e granted = true |
Os dois tipos são valores do enum ConsentType declarado na Seção 6, e a coluna de metadados
chama-se evidence — não metadata. Não existe o valor 'PAUSE' isolado.
Exemplo: assinante pausa 10 dias em 25/08/2026 às 22:10. paused_until fica
2026-09-05T06:00:00Z (03:00 de 05/09 em Brasília). Ele não recebe nada de 26/08 a 04/09. Em
05/09 às 05:55 recebe a mensagem de retorno e às 06:00 o devocional. A cobrança de 10/09 sai
normalmente.
20.7 Cancelamento da assinatura pelo painel #
20.7.1 Fluxo em duas etapas #
O cancelamento nunca acontece em um clique. Duas etapas, com uma tela entre elas:
Painel /assinatura
│
│ clique em "Cancelar assinatura"
▼
┌──────────────────────────────────────────────────────────────┐
│ ETAPA 1 — Pesquisa de motivo │
│ GET /api/me/subscription/cancellation-preview │
│ │
│ Mostra: data exata de término do acesso, valor economizado, │
│ o que ele perde, o que continua recebendo. │
│ Pede: motivo (lista fechada) + comentário livre opcional. │
└───────────────────────────┬──────────────────────────────────┘
│ "Continuar"
▼
┌──────────────────────────────────────────────────────────────┐
│ ETAPA 2 — Oferta de retenção (uma única vez por assinatura) │
│ │
│ "Que tal pausar por 30 dias em vez de cancelar?" │
│ [ Pausar 30 dias ] [ Cancelar mesmo assim ] │
│ │
│ Se já houve oferta antes: esta etapa é PULADA. │
└──────┬──────────────────────────────────┬────────────────────┘
│ Pausar │ Cancelar mesmo assim
▼ ▼
POST /api/me/pause POST /api/me/subscription/cancel
{ days: 30 } { reason, comment, confirm: true }
│ │
▼ ▼
Assinatura intacta DELETE /v3/subscriptions/{id} na Asaas
Envios pausados 30 dias status = CANCELED, cancel_at_period_end = true
Acesso PAID até current_period_end
Mensagem com a data exata20.7.2 Etapa 1 — o que a tela mostra #
Dados concretos, calculados, nunca genéricos:
- "Seu acesso ao plano pago vai até 10/09/2026." A data vem de
current_period_endconvertida para America/Sao_Paulo. - "Até lá, nada muda: você continua recebendo o devocional todos os dias, com áudio."
- "Depois dessa data, você continua conosco no plano gratuito: devocional em texto todos os domingos, sem áudio, com os últimos 7 dias no acervo."
- "Não haverá nova cobrança."
- Para plano anual, acrescenta os meses restantes: "Você tem mais 7 meses de acesso já pagos."
20.7.3 Pesquisa de motivo #
Lista fechada, seleção única, obrigatória:
| Valor | Rótulo exibido |
|---|---|
TOO_EXPENSIVE |
Está caro para mim agora |
NOT_USING |
Não estou lendo/ouvindo |
TOO_MANY_MESSAGES |
Mensagens demais |
CONTENT_NOT_RELEVANT |
O conteúdo não me atende |
AUDIO_QUALITY |
Não gostei do áudio |
TECHNICAL_PROBLEMS |
Tive problemas técnicos |
FOUND_ALTERNATIVE |
Encontrei outra opção |
TEMPORARY_FINANCIAL |
Aperto financeiro temporário |
OTHER |
Outro motivo |
Campo livre opcional, de 0 a 500 caracteres, com contador. Quando o motivo é OTHER, o campo
livre passa a ser obrigatório, com mínimo de 5 caracteres — "Outro" sem explicação não
informa nada.
Armazenamento: subscription_events com type = 'CANCELLATION_SURVEY' e metadata
{ reason, comment }. O comentário é texto livre do titular e entra no escopo de anonimização
da Seção 22 quando a conta é excluída.
A oferta de retenção é adaptada ao motivo:
| Motivo | Oferta apresentada |
|---|---|
TOO_MANY_MESSAGES |
Pausa de 30 dias, com destaque para "você escolhe quando voltar" |
TEMPORARY_FINANCIAL |
Pausa de 30 dias, com o texto "sua assinatura continua, mas você pode cancelar depois se precisar" |
| Todos os demais | Pausa de 30 dias, texto padrão |
A oferta é sempre a mesma coisa — pausa de 30 dias. Só o texto muda. Decisão registrada: uma única oferta de retenção, sem desconto, sem plano alternativo, sem "espere, temos uma proposta". Motivo: desconto de retenção corrói o preço, exige tabela de cupons que o produto não tem (Seção 12.16), e sequências de ofertas empilhadas são hostis. Uma oferta, honesta, e o botão de cancelar sempre visível e com o mesmo peso visual.
20.7.4 Etapa 2 — a oferta, uma única vez #
A oferta é exibida no máximo uma vez por assinatura. O controle é
subscription_events com type = 'RETENTION_OFFER_SHOWN': se já existir um registro para
aquela subscription_id, a etapa 2 é pulada e o assinante vai direto para a confirmação.
Isso é verificado no servidor, em
GET /api/me/subscription/cancellation-preview, que devolve
retentionOffer: { available: boolean, type: 'PAUSE_30_DAYS' }. O cliente não decide isso.
Os dois botões têm o mesmo tamanho e o mesmo contraste. O botão de cancelar não é escondido, não é cinza-claro e não exige rolagem. Requisito de interface, não sugestão.
20.7.5 Confirmação e efeito #
Ao confirmar, dentro de uma transação:
assertTransition('ACTIVE', 'CANCELED')(Seção 13.2.3).subscriptions.status = 'CANCELED',cancel_requested_at = now(),cancel_at_period_end = true,end_reason = 'USER_REQUEST'.- Remove
pending_plan_idse houver troca de plano agendada. subscription_eventscomtype = 'CANCELED',source = 'SUBSCRIBER'.- Após o commit:
DELETE /v3/subscriptions/{asaas_subscription_id}(Seção 12.8.1).404é sucesso. Falha persistente enfileira retry e, esgotado, cria tarefa administrativa com alertabilling_cancel_not_propagated— porque uma assinatura não removida na Asaas voltaria a cobrar. - Envia a confirmação.
O tier não muda. subscribers.tier continua PAID, e a função de entitlements continua
devolvendo PAID porque CANCELED com currentPeriodEnd no futuro concede acesso
(Seção 13.6.2). Em 11/09 às 00:05 o job billing.lifecycle faz a transição T14.
Tela de confirmação e mensagem enviada, ambas com a data exata:
"Sua assinatura foi cancelada. Você continua com o plano pago — devocional diário e áudio — até 10/09/2026. Depois dessa data, você continua recebendo o devocional em texto aos domingos, no plano gratuito. Não haverá nova cobrança. Se quiser parar de receber as mensagens também, acesse app.palavradiaria.com.br/preferencias."
O último parágrafo é o cruzamento obrigatório entre os dois caminhos (20.1).
20.7.6 Cancelamento pelo administrador #
Um ADMIN ou OWNER cancela pelo painel administrativo, com justificativa de 10 a 500
caracteres. O efeito é idêntico ao do assinante (T12), com end_reason = 'ADMIN_REQUEST' e
registro em admin_audit_log. A mensagem enviada é a mesma, sem menção ao admin.
EDITOR não pode cancelar assinatura: recebe 403 FORBIDDEN_ROLE.
20.8 Cancelamento por inadimplência #
Não é cancelamento; é expiração. A diferença importa e aparece em tudo.
| Aspecto | Cancelamento voluntário | Inadimplência |
|---|---|---|
| Estado final | CANCELED → EXPIRED na virada |
EXPIRED direto |
end_reason |
USER_REQUEST |
NON_PAYMENT |
| Quando o acesso acaba | current_period_end |
Imediatamente (Seção 13.3) |
| Quem inicia | Assinante ou admin | Ninguém; é a ausência de pagamento |
| Pesquisa de motivo | Sim | Não |
| Oferta de retenção | Sim, uma vez | Não |
| Mensagem | Confirmação com data futura | Aviso de perda de acesso, no passado imediato |
| Remoção na Asaas | Sim, no ato | Sim, após o PAYMENT_OVERDUE |
Aparece em churn_voluntario |
Sim | Não — entra em churn_involuntario |
O que o assinante recebe, no momento do PAYMENT_OVERDUE (redação final na Seção 19):
"Não conseguimos confirmar o pagamento da sua assinatura, que vencia em 10/09. Seu acesso ao plano pago foi encerrado hoje. Você continua com a gente: vai receber o devocional em texto aos domingos, no plano gratuito. Para voltar ao plano completo, com áudio todos os dias, acesse app.palavradiaria.com.br/assinar."
Regras da mensagem:
- Enviada uma única vez por cobrança vencida, com chave de idempotência
overdue-notice:{paymentId}. - Tom informativo, sem cobrança emocional, sem "você foi bloqueado", sem urgência artificial.
- Não há segunda mensagem, terceira mensagem, nem sequência de recuperação pelo WhatsApp. Um assinante inadimplente que não reagiu ao primeiro aviso não recebe insistência — ele passa a receber o conteúdo gratuito de domingo, que é a melhor recuperação possível.
- Se o assinante estiver em opt-out, a mensagem não é enviada pelo WhatsApp. Vai por e-mail, se houver e-mail verificado. Se não houver, o painel mostra o estado no próximo login e nada mais é feito.
Não há remarketing agressivo, não há "última chance", não há contagem regressiva.
20.9 Exclusão de conta (LGPD) #
20.9.1 Fluxo #
O titular tem direito à eliminação dos dados pessoais (LGPD, Art. 18, VI). O fluxo é autosserviço, com confirmação forte.
Painel /preferencias → "Excluir minha conta"
│
▼
┌────────────────────────────────────────────────────────────────┐
│ TELA 1 — Consequências, em texto claro │
│ • Você para de receber o devocional imediatamente. │
│ • Sua assinatura paga é cancelada agora, sem reembolso do │
│ período restante. (mostra o período que será perdido) │
│ • Você perde acesso ao acervo e ao painel. │
│ • Seus dados pessoais são anonimizados em até 30 dias. │
│ • Guardamos registros de pagamento e de consentimento por │
│ obrigação legal (veja abaixo). │
│ • Esta ação não pode ser desfeita depois da anonimização. │
│ [ Cancelar ] [ Quero excluir minha conta ] │
└───────────────────────────┬────────────────────────────────────┘
▼
┌────────────────────────────────────────────────────────────────┐
│ TELA 2 — Confirmação por OTP │
│ POST /api/me/deletion-request → envia OTP de 6 dígitos no │
│ WhatsApp (template codigo_acesso_v1) │
│ Campo de 6 dígitos + [ Confirmar exclusão ] │
│ POST /api/me/deletion-request/confirm { code } │
└───────────────────────────┬────────────────────────────────────┘
▼
EFEITO IMEDIATO (mesma transação)
deleted_at = now() · opt_out_at = now() · sessões revogadas
assinatura cancelada e removida na Asaas · envios cancelados
│
▼
JANELA DE ARREPENDIMENTO — 7 dias
Login bloqueado; contato com o suporte pode reverter.
│
▼
ANONIMIZAÇÃO — job privacy.anonymize, fila maintenance.cleanup,
diário às 03:20
(processa pedidos com deletion_requested_at <= now() - 7 dias)20.9.2 Confirmação por OTP #
O OTP é o mesmo mecanismo de login da Seção 8: 6 dígitos, TTL de 10 minutos, 5 tentativas, 3
envios por hora por número. Um OTP de exclusão é marcado com purpose = 'ACCOUNT_DELETION' e
não serve para login, nem o de login serve para exclusão.
Se o assinante estiver em opt-out, o OTP ainda é enviado pelo WhatsApp: é uma mensagem transacional de autenticação pedida por ele naquele instante, dentro da janela aberta pela própria interação. Se não houver janela aberta, o template de autenticação é usado. Essa é a única mensagem que atravessa o opt-out, e ela existe porque sem ela o titular não consegue exercer o direito.
20.9.3 O que é anonimizado #
Regra de alcance, e ela é a parte que costuma faltar. A operação percorre todas as
tabelas que contenham telefone, wa_id, e-mail, CPF ou conteúdo de mensagem do titular, e não
apenas subscribers. A lista é derivada da coluna, não da tabela: qualquer coluna cujo
nome esteja em ('phone_e164','wa_id','email','cpf','payload_excerpt','text_body') e cuja
linha se relacione ao assinante é zerada. Deixar o telefone em claro em message_logs por 18
meses depois de dizer ao titular que a conta foi eliminada é, em uma perícia, indefensável — e
message_logs é a maior tabela do sistema.
O job privacy.anonymize, na fila maintenance.cleanup, atua em transação única por
assinante:
| Tabela | Campo | Ação |
|---|---|---|
subscribers |
phone_e164 |
Recebe o envelope cifrado da constante '[removido]', o que satisfaz o CHECK de formato de envelope sem guardar dado nenhum |
subscribers |
phone_hmac |
Recebe 64 caracteres hexadecimais de crypto.randomBytes(32). Preserva o NOT NULL e a unicidade, sem qualquer relação com o número original, e sem exigir uma chave de pseudonimização nova |
subscribers |
wa_id, wa_id_hmac |
NULL |
subscribers |
email, email_hmac |
NULL |
subscribers |
display_name |
'Assinante removido' |
subscribers |
anonymized_at |
now() |
subscriber_profiles |
full_name |
'Assinante removido' |
subscriber_profiles |
cpf, cpf_hmac, cpf_last4 |
Retidos até o fim da retenção fiscal (ver 20.9.4 e o estágio 2 abaixo) |
sessions |
todas as linhas | Removidas |
otp_codes |
todas as linhas | Removidas |
message_logs |
phone_e164, wa_id |
NULL. O roteamento histórico não precisa do número; a coluna deixa de ser NOT NULL para permitir isso (Seção 6.19) |
message_logs |
payload_excerpt |
NULL. Pode conter o nome do titular em parâmetro de template e texto digitado por ele |
message_logs |
demais colunas | Metadados de entrega mantidos: status, sent_at, error_code, conversation_category, cost_micros |
inbound_messages |
phone_e164, wa_id |
NULL |
inbound_messages |
text_body |
'[removido]'; wamid mantido |
delivery_attempts |
colunas de destino, quando houver | NULL |
subscription_events |
cancel_comment |
NULL — é texto livre escrito pelo titular |
consent_events |
ip, user_agent |
NULL; tipo, policy_version, consent_text_hash, granted e timestamp mantidos |
daily_metrics |
— | Agregado sem dado pessoal; intocada |
Trecho normativo da transação, para que o alcance não dependa de memória de quem implementa:
await tx.$executeRaw`UPDATE message_logs
SET phone_e164 = NULL, wa_id = NULL, payload_excerpt = NULL
WHERE subscriber_id = ${subscriberId}`;
await tx.$executeRaw`UPDATE inbound_messages
SET phone_e164 = NULL, wa_id = NULL, text_body = '[removido]'
WHERE subscriber_id = ${subscriberId}`;Um teste de integração cria um assinante, gera tráfego em todas as tabelas, executa a
anonimização e falha se uma consulta pelo telefone original, pelo wa_id ou pelo e-mail
original devolver qualquer linha em qualquer tabela.
Escrita em consent_events sob gatilho append-only. A tabela é protegida por um gatilho que
recusa UPDATE e DELETE. Zerar ip e user_agent é exatamente um UPDATE, e um controle de
integridade não pode impedir o cumprimento de um direito do titular. A saída não é
desabilitar o gatilho: é um caminho privilegiado explícito e auditado. A atualização é executada
pela conexão do papel privacy_operator, cuja exceção está codificada dentro do próprio gatilho
e verifica, coluna a coluna, que só ip e user_agent mudaram e que type, granted,
policy_version, consent_text_hash e created_at continuam idênticos (Seção 22.7.3). Nenhum gatilho é
desabilitado em momento algum — desabilitar exigiria ser dono da tabela e abriria uma janela em
que qualquer escrita passa.
O resultado é irreversível na prática e mantém a integridade referencial: nenhuma linha filha vira órfã, nenhum relatório histórico quebra.
Os dois estágios, e a distinção não é semântica. O que 20.9.3 descreve é o estágio 1 —
pseudonimização, executado em até 30 dias do pedido. Enquanto cpf, cpf_hmac e
asaas_customer_id existirem, a linha continua sendo dado pessoal: o identificador do
processador recupera um cadastro completo com nome, CPF, e-mail e telefone em um clique. Nesse
estágio a linha permanece integralmente no escopo da LGPD, conta na apuração de qualquer
incidente, e está sujeita ao mesmo controle de acesso das linhas ativas.
O estágio 2 — anonimização acontece quando a retenção fiscal vence, 5 anos após o último
pagamento: cpf, cpf_hmac, cpf_last4, asaas_customer_id, asaas_subscription_id e
asaas_payment_id são zerados, e é enviada à Asaas a solicitação de exclusão do cadastro
correspondente. O job maintenance.enforce_retention, na fila maintenance.cleanup, executa o
estágio 2 diariamente sobre as linhas cuja retenção venceu, e registra a transição em
admin_audit_log com action = 'SUBSCRIBER_FULLY_ANONYMIZED'.
Chamar o estágio 1 de anonimização levaria a equipe a excluir essas pessoas da contagem de titulares em uma comunicação de incidente — que é precisamente o erro que a autoridade procura.
20.9.4 O que é retido, e por quê #
| Dado | Retenção | Base |
|---|---|---|
payments e payment_events completos |
5 anos | Obrigação fiscal e contábil; prova em disputa financeira e chargeback |
subscriber_profiles.cpf / cpf_hmac / cpf_last4 (cifrado) |
5 anos após o último pagamento | Vinculado ao registro fiscal da cobrança; sem ele o registro de pagamento perde valor probatório. Zerado no estágio 2 |
consent_events (tipo, policy_version, consent_text_hash, timestamp, canal) |
5 anos | Prova de consentimento e de opt-out, exigível pela autoridade e em disputa |
message_logs (metadados de entrega, sem telefone, sem wa_id e sem conteúdo) |
18 meses | Prova de entrega em disputa e apuração de qualidade do canal. O que a lei permite reter é o metadado, não o identificador pessoal |
admin_audit_log |
5 anos | Trilha de auditoria de ações administrativas |
| Backups | 35 dias | Ciclo de retenção de backup; a anonimização se propaga naturalmente ao expirarem |
Fundamento da retenção: a LGPD, no Art. 16, permite a conservação de dados para cumprimento de
obrigação legal ou regulatória e para exercício regular de direitos. A eliminação a pedido do
titular não anula essas hipóteses. O que a lei obriga a reter é retido de forma
pseudonimizada, ligado apenas ao identificador interno do assinante — nunca ao telefone, ao
wa_id, ao e-mail ou ao CPF em claro. A política completa, com prazos e responsáveis, é da
Seção 22; esta subseção descreve apenas o comportamento do fluxo de exclusão.
A tela de exclusão informa isso ao titular, em português direto, antes da confirmação:
"Mesmo depois da exclusão, guardamos por 5 anos os registros de pagamento e de consentimento, porque a lei exige. Eles ficam desvinculados do seu perfil e não são usados para nenhuma comunicação."
20.9.5 O que acontece com a assinatura ativa #
Decisão registrada: a exclusão de conta cancela a assinatura imediatamente e não devolve o
período restante. O acesso acaba no ato, não em current_period_end.
Justificativa: manter o acesso exigiria manter a conta funcionando, o que contraria o próprio pedido. E a exclusão é ato deliberado do titular, com aviso explícito na tela. O valor do período perdido é mostrado antes da confirmação:
"Você tem acesso pago até 10/09/2026. Se excluir a conta agora, perde esses 16 dias e não há reembolso."
Se o titular quiser o reembolso, ele deve pedir pelo suporte antes de excluir, dentro da política de 12.14.1. Depois da exclusão, a conta não existe para receber a devolução do serviço, embora o reembolso financeiro ainda seja tecnicamente possível pela Asaas usando o registro de pagamento retido.
A assinatura é removida na Asaas com DELETE /v3/subscriptions/{id} na mesma execução. O
customer da Asaas não é removido, porque ele carrega o histórico fiscal das cobranças;
ele é atualizado com notificationDisabled: true, já o padrão nosso, e nada mais.
20.9.6 Janela de arrependimento e casos de borda #
| Caso | Comportamento |
|---|---|
| Titular se arrepende em 3 dias | Suporte reverte: deleted_at = NULL, opt_out_at = NULL, sessões continuam revogadas. A assinatura não volta; ele precisa contratar de novo |
| Titular se arrepende no dia 9 | Anonimização já ocorreu. Não há reversão. Ele pode cadastrar-se de novo, como assinante novo, com histórico zerado |
| Webhook de pagamento chega após a exclusão | Processado normalmente para o registro financeiro; nenhuma mensagem é enviada (Seção 12.12.5) |
Exclusão pedida durante PENDING_PAYMENT |
A assinatura pendente é removida na Asaas e marcada EXPIRED com end_reason = 'ABANDONED' |
| Exclusão pedida com chargeback em aberto | Permitida. Os registros financeiros são retidos e a disputa continua com base neles |
| Segundo pedido de exclusão | 409 DELETION_ALREADY_REQUESTED, com a data prevista da anonimização |
| Cadastro novo com o mesmo telefone após a anonimização | Permitido: o phone_e164 antigo foi pseudonimizado e o número real está livre. O assinante novo não vê nada do antigo |
| Cadastro novo com o mesmo telefone dentro dos 7 dias | Bloqueado com 409 PHONE_PENDING_DELETION, orientando a contatar o suporte para reverter |
20.10 Retenção e win-back #
20.10.1 A regra dura #
Quem fez opt-out não recebe absolutamente nada pelo WhatsApp. Nem devocional, nem lembrete, nem promoção, nem "sentimos sua falta", nem pesquisa, nem aviso de novidade.
A única exceção é o OTP de autenticação solicitado pelo próprio titular naquele instante (20.9.2 e Seção 8), porque ele é resposta a uma ação em curso, não iniciativa nossa.
Isso não é preferência de tom: é a condição para manter o número de WhatsApp saudável.
Mensagem de marketing para quem pediu para sair gera bloqueio e denúncia, o quality_rating
cai, e a capacidade de entrega do produto inteiro é comprometida (Seção 17).
Implementação: o guarda canReceiveMessages da função de entitlements (Seção 13.6.2) é
verificado imediatamente antes de toda chamada à API do WhatsApp, sem exceção de caminho.
Não há flag para pular a verificação, não há rota administrativa que force envio, e o
enfileiramento em massa também filtra por ele.
// packages/core/src/messaging/guard.ts
export function assertCanSendWhatsApp(ent: Entitlements, kind: MessageKind): void {
if (kind === 'AUTH_OTP') return; // única exceção, iniciada pelo titular
if (!ent.canReceiveMessages) {
throw new BlockedRecipientError(ent.blockedReason ?? 'UNKNOWN');
}
}20.10.2 E-mail #
E-mail é canal separado, com consentimento separado e ativo. Regras:
- O opt-out do WhatsApp não derruba o consentimento de e-mail, e o opt-out de e-mail não
derruba o do WhatsApp. São dois registros distintos em
consent_events, com canais distintos. - E-mail de marketing e win-back só vai para quem tem
email_verified_atpreenchido eemail_marketing_consent_atpreenchido e sem opt-out de e-mail. - O consentimento de e-mail marketing não é coletado por checkbox pré-marcado, nunca é implícito no cadastro, e tem texto próprio.
- Todo e-mail traz link de descadastro de um clique no rodapé, conforme 20.4.2.
- E-mail transacional (recibo, falha de pagamento, OTP de fallback, confirmação de exclusão) não depende de consentimento de marketing e é enviado a qualquer assinante com e-mail verificado, inclusive em opt-out de WhatsApp.
20.10.3 Campanhas permitidas e proibidas #
| Público | ||
|---|---|---|
| Ativo, recebendo, gratuito | Devocional de domingo; convite de upgrade no máximo 1 vez a cada 30 dias, dentro da janela aberta | Permitido com consentimento |
| Ativo, recebendo, pagante | Devocional diário; transacional de cobrança | Permitido com consentimento |
| Em pausa | Só transacional crítico de cobrança (20.6.1) | Permitido com consentimento |
| Cancelou a assinatura, ainda recebendo | Devocional gratuito; 1 convite de retorno, 7 dias após o rebaixamento, e nada mais | Permitido com consentimento |
| Inadimplente, ainda recebendo | Devocional gratuito; 1 aviso de perda de acesso (20.8) | Permitido com consentimento |
| Opt-out | Nada. Nenhuma mensagem, nunca | Só com consentimento de e-mail ativo e separado |
| Conta excluída | Nada | Nada |
| Chargeback em aberto | Só a mensagem neutra de encerramento | Nada de marketing |
Proibições explícitas, que valem como requisito:
- Nenhuma campanha de win-back pelo WhatsApp para quem fez opt-out — em nenhum prazo, com nenhum texto, sob nenhuma justificativa de "só desta vez".
- Nenhuma reintrodução automática de quem fez opt-out em qualquer lista de envio, nem por
importação, nem por seed, nem por script de operação. O comando
opsde importação recusa registros comopt_out_atpreenchido e falha com erro claro. - Nenhum convite de upgrade dentro da própria mensagem do devocional gratuito de domingo. O convite, quando existe, é mensagem separada e obedece ao limite de 1 a cada 30 dias.
- Nenhuma pesquisa de satisfação por WhatsApp para quem saiu.
- Nenhum "estamos com saudade" 90 dias depois. Se ele quiser voltar,
VOLTARfunciona para sempre e o painel também.
20.10.4 Win-back permitido, e como é feito #
Para quem continua recebendo e apenas deixou de pagar, existe um único convite de retorno, enviado 7 dias após o rebaixamento, dentro da janela de atendimento aberta ou como parte da mensagem de fechamento do devocional de domingo. Texto:
"Sentimos falta do áudio na sua rotina? O plano completo tem devocional todos os dias, com narração. app.palavradiaria.com.br/assinar"
Limite: uma vez por rebaixamento. Um assinante que assina, cai, volta e cai de novo recebe o convite duas vezes — uma por queda —, nunca em intervalo menor que 30 dias.
O canal principal de win-back é o próprio produto gratuito: quem recebe um bom devocional todo domingo tem motivo para voltar sem que ninguém peça.
20.11 Métricas de cancelamento #
As definições canônicas de métrica são da Seção 21. Esta subseção especifica os recortes que o dashboard exibe sobre cancelamento e opt-out, e de onde vêm os dados.
| Indicador | Fonte | Recorte |
|---|---|---|
| Cancelamentos voluntários no período | subscription_events type = 'CANCELED', source IN ('SUBSCRIBER','ADMIN') |
Dia, semana, mês |
| Expirações por inadimplência | subscriptions end_reason = 'NON_PAYMENT' |
Dia, semana, mês |
| Churn voluntário vs involuntário | Os dois acima sobre a base ativa no início do período | Mensal |
| Opt-outs | consent_events type = 'OPT_OUT' |
Por origem: WHATSAPP, DASHBOARD, UNSUBSCRIBE_LINK |
| Reativações de recebimento | consent_events type = 'RE_OPT_IN' |
Mensal |
| Pausas iniciadas e concluídas | consent_events type IN ('PAUSE_STARTED','PAUSE_ENDED') |
Mensal, com duração média |
| Taxa de aceite da oferta de retenção | RETENTION_OFFER_SHOWN vs pausas criadas em até 10 minutos |
Mensal |
| Motivos agregados | subscription_events type = 'CANCELLATION_SURVEY' |
Barras horizontais, ordenadas |
| Sobrevida média da assinatura | current_period_end − primeira ativação das encerradas |
Mensal, em dias |
| Exclusões de conta | subscribers.deleted_at |
Mensal |
Painel "Cancelamento" no dashboard administrativo (Seção 21 é a dona do layout):
- Card com cancelamentos voluntários, expirações por inadimplência e a razão entre eles.
- Gráfico de barras dos motivos, com contagem e percentual, período selecionável.
- Lista dos comentários livres mais recentes, com telefone mascarado, paginada por cursor,
visível para
ADMINeOWNER.EDITORvê os motivos agregados, não os comentários — o comentário é dado pessoal. - Taxa de aceite da oferta de retenção, com o número absoluto de assinaturas salvas.
- Série temporal de opt-outs por origem, para detectar picos ligados a uma mensagem específica. Um pico de opt-out no dia seguinte a um devocional é sinal de conteúdo mal recebido, e essa correlação é o principal uso da métrica.
Alertas: os dois alertas de negócio deste fluxo são definidos no catálogo da Seção 21.12, com
esses nomes exatos — biz_optout_spike (opt-outs de D−1 acima de 3× a média de 30 dias, com
pelo menos 15 opt-outs absolutos) e biz_cancellation_spike (cancelamentos voluntários de
D−1 acima de 3× a média de 30 dias, com pelo menos 10 absolutos). Ambos são derivados de
daily_metrics pelo job de alertas de negócio e entregues pelo canal descrito na Seção 23.8.
Esta subseção não redefine limiar nenhum: o catálogo da Seção 21 é o dono.
20.12 Endpoints #
Todos usam o envelope e o catálogo de erros da Seção 7. Todos os erros comuns de sessão
(UNAUTHENTICATED 401, SESSION_EXPIRED 401, SUBSCRIBER_DELETED 410) valem para as rotas
de assinante e não são repetidos linha a linha.
Convenção de caminho seguida aqui: ações sobre a própria conta do assinante ficam sob
/api/me/, com nome de ação no singular (/api/me/opt-out, /api/me/pause,
/api/me/deletion-request, /api/me/subscription/cancel); coleções administrativas são
substantivos no plural em kebab-case (/api/admin/cancellations).
Trava de impersonação. Todas as rotas de escrita desta subseção — opt-out, opt-in, pausa, cancelamento, registro da oferta de retenção e pedido de eliminação — são recusadas em sessão de impersonação administrativa, sem exceção. A impersonação é integralmente somente leitura, e a recusa acontece no invólucro de API antes de chegar ao handler, pelo critério da guarda de autenticação e não pelo prefixo do caminho (Seção 8.13). O caso que essa regra evita é concreto: um operador de suporte dentro da conta de uma assinante clica em "Cancelar assinatura" para entender a reclamação, a assinatura é cancelada de verdade na Asaas, a assinante recebe uma confirmação que nunca pediu, e o registro de auditoria mostra a ação como se fosse dela.
20.12.1 POST /api/me/opt-out #
- Autenticação: sessão de assinante. Papel:
SUBSCRIBER. - Request:
export const optOutSchema = z.object({
source: z.enum(['DASHBOARD', 'UNSUBSCRIBE_LINK']).default('DASHBOARD'),
acknowledgeBillingChange: z.boolean().optional(),
});O campo source é o da requisição e é gravado na coluna subscribers.opt_out_reason
(Seção 6.3). Não existe coluna opt_out_source.
acknowledgeBillingChange é obrigatório e igual a true quando o assinante tem
assinatura vigente. Sem isso, a rota devolve 422 BILLING_ACKNOWLEDGEMENT_REQUIRED. O campo
confirma que o assinante leu o efeito financeiro: a próxima cobrança é suspensa, o período já
pago continua valendo e, em 30 dias sem reativação, a assinatura é encerrada (Seção 13.4.6). É
a garantia, no servidor, de que ninguém é silenciado sem entender o efeito financeiro.
- Resposta
200:
{
"data": {
"optOutAt": "2026-08-25T14:31:09.220Z",
"canReceiveMessages": false,
"billingSuspended": true,
"nextChargeCanceled": true,
"paidAccessUntil": "2026-09-10T13:37:00.000Z",
"subscriptionEndsOn": "2026-09-24",
"reactivateBeforeToKeepSubscription": "2026-09-24",
"cancelSubscriptionUrl": "https://app.palavradiaria.com.br/assinatura"
},
"meta": { "requestId": "req_01K3QG7T9V1X3Z5B7D9F1H3K5M", "timestamp": "2026-08-25T14:31:09.220Z" }
}subscriptionEndsOn é o menor entre current_period_end e a data do pedido mais 30 dias; para
assinante sem assinatura vigente, os cinco campos de cobrança vêm null e billingSuspended é
false.
- Erros:
BILLING_ACKNOWLEDGEMENT_REQUIRED(422),ALREADY_OPTED_OUT(409, comoptOutAtemdetails),RATE_LIMITED(429, 10 por hora). - Efeitos colaterais: grava
opt_out_at,opt_out_reason; limpapaused_until; gravaconsent_eventscomtype = 'OPT_OUT'egranted = false; suspende a cobrança do ciclo seguinte quando houver assinatura vigente (Seção 13.4.6); cancela jobs de envio pendentes; mostra a confirmação na tela e envia confirmação por e-mail se houver e-mail verificado. Não envia WhatsApp, qualquer que seja osource, pela regra de canal de 20.4.1. - Idempotência: a segunda chamada devolve
409 ALREADY_OPTED_OUTsem efeito e sem nova mensagem.
20.12.2 POST /api/me/opt-in #
- Autenticação: sessão de assinante. Papel:
SUBSCRIBER. - Request: corpo vazio.
- Resposta
200:
{
"data": {
"optOutAt": null,
"canReceiveMessages": true,
"sendFrequency": "WEEKLY_SUNDAY",
"nextSendAt": "2026-08-30T09:00:00.000Z"
},
"meta": { "requestId": "req_01K3QH9V1X3Z5B7D9F1H3K5M7P", "timestamp": "2026-08-25T14:40:02.017Z" }
}- Erros:
NOT_OPTED_OUT(409),SUBSCRIBER_NOT_OPTED_IN(409, quando nunca houve confirmação de opt-in em duas etapas e é preciso refazer o onboarding da Seção 11),RATE_LIMITED(429). - Efeitos colaterais: limpa
opt_out_atepaused_until; gravaconsent_eventscomtype = 'RE_OPT_IN'egranted = true. - Idempotência: segunda chamada devolve
409 NOT_OPTED_OUT, tratado como sucesso pelo cliente.
20.12.3 POST /api/me/pause #
- Autenticação: sessão de assinante. Papel:
SUBSCRIBER. - Request:
export const pauseSchema = z.object({
days: z.number().int().min(1).max(30),
});- Resposta
200:
{
"data": { "pausedUntil": "2026-09-05T06:00:00.000Z", "days": 10,
"resumesOn": "2026-09-05", "billingUnaffected": true },
"meta": { "requestId": "req_01K3QJ1X3Z5B7D9F1H3K5M7P9R", "timestamp": "2026-08-25T22:10:44.900Z" }
}- Erros:
PAUSE_DAYS_OUT_OF_RANGE(422),SUBSCRIBER_OPTED_OUT(409),PAUSE_LIMIT_EXCEEDED(429, mais de 3 pausas em 90 dias),RATE_LIMITED(429). - Efeitos colaterais: grava
paused_until; gravaconsent_eventscomtype = 'PAUSE_STARTED'eevidence = {"days": N, "pausedUntil": "..."}; cancela envios do período. Envia confirmação pelo WhatsApp antes de a pausa valer, uma única vez. - Idempotência: não é idempotente — cada chamada redefine a data a partir de agora. Uma pausa vigente é substituída, não somada, e a resposta traz a data nova.
20.12.4 DELETE /api/me/pause #
- Autenticação: sessão de assinante. Papel:
SUBSCRIBER. - Resposta
200:{ "data": { "pausedUntil": null, "canReceiveMessages": true } }. - Erros:
PAUSE_NOT_ACTIVE(404). - Efeitos: limpa
paused_until; gravaconsent_eventscomtype = 'PAUSE_ENDED'egranted = true. Nenhuma mensagem de retorno é enviada, porque a ação foi do próprio assinante. - Idempotência: segunda chamada devolve
404, tratado como sucesso.
20.12.5 GET /api/me/subscription/cancellation-preview #
- Autenticação: sessão de assinante. Papel:
SUBSCRIBER. - Resposta
200:
{
"data": {
"subscriptionId": "sub_01K3QB2D4F6H8K0M2P4R6T8V0X",
"planName": "Plano mensal",
"amountCents": 1990,
"paidAccessUntil": "2026-09-10T13:37:00.000Z",
"remainingDays": 19,
"afterCancellation": {
"sendFrequency": "WEEKLY_SUNDAY",
"audioEnabled": false,
"archiveWindowDays": 7,
"manualResendsPerDay": 1
},
"retentionOffer": { "available": true, "type": "PAUSE_30_DAYS" },
"reasons": [
{ "value": "TOO_EXPENSIVE", "label": "Está caro para mim agora" },
{ "value": "NOT_USING", "label": "Não estou lendo/ouvindo" },
{ "value": "TOO_MANY_MESSAGES", "label": "Mensagens demais" },
{ "value": "CONTENT_NOT_RELEVANT", "label": "O conteúdo não me atende" },
{ "value": "AUDIO_QUALITY", "label": "Não gostei do áudio" },
{ "value": "TECHNICAL_PROBLEMS", "label": "Tive problemas técnicos" },
{ "value": "FOUND_ALTERNATIVE", "label": "Encontrei outra opção" },
{ "value": "TEMPORARY_FINANCIAL", "label": "Aperto financeiro temporário" },
{ "value": "OTHER", "label": "Outro motivo" }
]
},
"meta": { "requestId": "req_01K3QK3Z5B7D9F1H3K5M7P9R1T", "timestamp": "2026-08-22T21:12:55.640Z" }
}- Erros:
SUBSCRIPTION_NOT_FOUND(404),SUBSCRIPTION_NOT_ACTIVE(409). - Efeitos colaterais: nenhum. A exibição da oferta só é registrada quando o cliente confirma que a mostrou, no passo seguinte. Idempotência: leitura pura.
20.12.6 POST /api/me/subscription/retention-offer-shown #
- Autenticação: sessão de assinante. Papel:
SUBSCRIBER. - Request: corpo vazio.
- Resposta
200:{ "data": { "recorded": true } }. - Erros:
SUBSCRIPTION_NOT_FOUND(404),RETENTION_OFFER_ALREADY_SHOWN(409). - Efeitos: grava
subscription_eventsRETENTION_OFFER_SHOWN. É o que faz a oferta ser única (20.7.4). - Idempotência: a segunda chamada devolve
409e não grava segundo evento.
20.12.7 POST /api/me/subscription/cancel #
- Autenticação: sessão de assinante. Papel:
SUBSCRIBER. - Request:
export const cancelSubscriptionSchema = z.object({
reason: z.enum(['TOO_EXPENSIVE', 'NOT_USING', 'TOO_MANY_MESSAGES', 'CONTENT_NOT_RELEVANT',
'AUDIO_QUALITY', 'TECHNICAL_PROBLEMS', 'FOUND_ALTERNATIVE',
'TEMPORARY_FINANCIAL', 'OTHER']),
comment: z.string().trim().max(500).optional(),
confirm: z.literal(true),
}).refine((v) => v.reason !== 'OTHER' || (v.comment?.length ?? 0) >= 5, {
path: ['comment'], message: 'Conte rapidamente o motivo.',
});- Resposta
200:
{
"data": {
"subscriptionId": "sub_01K3QB2D4F6H8K0M2P4R6T8V0X",
"status": "CANCELED",
"canceledAt": "2026-08-22T21:14:03.101Z",
"paidAccessUntil": "2026-09-10T13:37:00.000Z",
"downgradesToFreeOn": "2026-09-11",
"nextChargeCanceled": true,
"stillReceivesMessages": true,
"message": "Sua assinatura foi cancelada. Você continua com o plano pago até 10/09/2026."
},
"meta": { "requestId": "req_01K3QM5B7D9F1H3K5M7P9R1T3V", "timestamp": "2026-08-22T21:14:03.101Z" }
}- Erros:
SUBSCRIPTION_NOT_FOUND(404),SUBSCRIPTION_NOT_ACTIVE(409, quando já estáCANCELED,EXPIREDouREFUNDED, com o status atual emdetails),CONFIRMATION_REQUIRED(422, quandoconfirmnão étrue),CANCELLATION_REASON_REQUIRED(422),CANCELLATION_COMMENT_REQUIRED(422),IDEMPOTENCY_KEY_REQUIRED(400),IDEMPOTENCY_KEY_REUSED(422),PAYMENT_PROVIDER_UNAVAILABLE(503, quando oDELETEna Asaas falha em todas as tentativas — nesse caso o cancelamento é aplicado localmente e a propagação vira tarefa com retry; a resposta é200comnextChargeCanceled: falsee a mensagem "Estamos concluindo o cancelamento junto ao provedor de pagamento"),RATE_LIMITED(429). - Efeitos colaterais: os seis passos de 20.7.5.
- Idempotência: o header
Idempotency-Keyé obrigatório (Seção 7.7.1), porque cancelar propaga uma chamada de escrita para a Asaas. Chamada sem a chave devolve400 IDEMPOTENCY_KEY_REQUIRED. Replay da mesma chave devolve a resposta gravada, comIdempotent-Replay: true, sem chamar a Asaas de novo. A mesma chave com corpo diferente devolve422 IDEMPOTENCY_KEY_REUSED. Repetição com chave nova depois do sucesso devolve409 SUBSCRIPTION_NOT_ACTIVEcomstatus: 'CANCELED'e a mesmapaidAccessUntil, o que o cliente trata como sucesso; a Asaas também não é chamada nesse caminho.
20.12.8 POST /api/me/deletion-request #
- Autenticação: sessão de assinante. Papel:
SUBSCRIBER. - Request: corpo vazio.
- Resposta
200:
{
"data": {
"otpSent": true, "channel": "WHATSAPP", "expiresInSeconds": 600,
"paidAccessLostUntil": "2026-09-10T13:37:00.000Z", "paidDaysLost": 16
},
"meta": { "requestId": "req_01K3QN7D9F1H3K5M7P9R1T3V5X", "timestamp": "2026-08-25T15:02:11.443Z" }
}- Erros:
DELETION_ALREADY_REQUESTED(409, comanonymizationScheduledFor),OTP_RATE_LIMITED(429, 3 envios por hora por número),WHATSAPP_UNAVAILABLE(503). - Efeitos: cria
otp_codescompurpose = 'ACCOUNT_DELETION'; envia o OTP. Nada é excluído nesta etapa. - Idempotência: dentro do TTL, uma nova chamada reenvia o mesmo código em vez de gerar outro, respeitando o limite de envios.
20.12.9 POST /api/me/deletion-request/confirm #
- Autenticação: sessão de assinante. Papel:
SUBSCRIBER. - Request:
export const confirmDeletionSchema = z.object({
code: z.string().regex(/^[0-9]{6}$/),
});- Resposta
200:
{
"data": {
"deletedAt": "2026-08-25T15:04:38.702Z",
"anonymizationScheduledFor": "2026-09-01T06:20:00.000Z",
"subscriptionCanceled": true,
"retainedData": ["payments", "payment_events", "consent_events", "cpf"],
"recoveryUntil": "2026-09-01T06:20:00.000Z"
},
"meta": { "requestId": "req_01K3QP9F1H3K5M7P9R1T3V5X7Z", "timestamp": "2026-08-25T15:04:38.702Z" }
}- Erros:
OTP_INVALID(401),OTP_EXPIRED(410, e nunca 401 —410 Gonedistingue código expirado, que existiu e não existe mais, de código inválido, o que permite ao cliente habilitar o botão de reenvio sem ambiguidade),OTP_ATTEMPTS_EXCEEDED(429, após 5 tentativas o código é invalidado e é preciso pedir outro),DELETION_NOT_REQUESTED(409),RATE_LIMITED(429). - Efeitos colaterais, em uma transação:
deleted_at,opt_out_at,deletion_requested_at = now(); revoga todas assessions; cancela jobs de envio; gravaconsent_eventscomtype = 'DATA_DELETION_REQUESTED'egranted = false. Após o commit:DELETE /v3/subscriptions/{id}e agendamento doprivacy.anonymize. - Idempotência: a segunda chamada devolve
409 DELETION_NOT_REQUESTED, porque o OTP foi consumido e o pedido já foi executado.
20.12.10 POST /api/public/unsubscribe #
Rota pública do link de descadastro (20.4.2). Sem sessão.
- Autenticação: o próprio token opaco. Papel: nenhum.
- Request:
export const publicUnsubscribeSchema = z.object({
token: z.string().min(32).max(64),
acknowledgeBillingChange: z.boolean().optional(),
});acknowledgeBillingChange tem aqui o mesmo significado de 20.12.1: confirma que o assinante
leu o efeito financeiro, e não que a cobrança continua.
- Resposta
200: igual à de 20.12.1, maismaskedPhone. - Erros:
UNSUBSCRIBE_TOKEN_INVALID(404, mesma resposta para token inexistente, expirado ou já rotacionado — não revela existência),BILLING_ACKNOWLEDGEMENT_REQUIRED(422),RATE_LIMITED(429, 20 por hora por IP; e 10 por hora por endereço na guarda de borda da Seção 7.12.2, por ser rota pública que aceita token). - Efeitos: mesmos de 20.12.1; rotaciona o token em
unsubscribe_tokens; gravaconsent_eventscomsource = 'UNSUBSCRIBE_LINK'e o IP. A confirmação aparece na tela e segue por e-mail quando houver e-mail verificado; não se envia WhatsApp (20.4.1). - Idempotência: o token é de uso único; a repetição cai em
UNSUBSCRIBE_TOKEN_INVALID, e a página exibe "Você já está descadastrado".
20.12.11 GET /api/admin/cancellations #
- Autenticação: sessão de admin. Papel:
ADMINouOWNER.EDITORrecebe403. - Query:
?limit=<1..100>&cursor=<opaque>&from=<YYYY-MM-DD>&to=<YYYY-MM-DD>&reason=<enum>. - Resposta
200: lista paginada por cursor comsubscriptionId,maskedPhone,planCode,canceledAt,paidAccessUntil,reason,comment,retentionOfferShown,retentionOfferAccepted,lifetimeDays,totalPaidCents. - Erros:
FORBIDDEN_ROLE(403),INVALID_CURSOR(400),INVALID_DATE_RANGE(422, quandofrom > toou o intervalo passa de 366 dias),LIMIT_OUT_OF_RANGE(422). - Efeitos colaterais: grava
admin_audit_logcomaction = 'CANCELLATION_REPORT_VIEW', porque a resposta expõe comentários que são dado pessoal. - Idempotência: leitura pura.
20.12.12 GET /api/admin/cancellations/reasons #
- Autenticação: sessão de admin. Papel:
EDITOR,ADMINouOWNER— agregado não expõe dado pessoal. - Query:
?from=<YYYY-MM-DD>&to=<YYYY-MM-DD>. - Resposta
200:
{
"data": {
"from": "2026-08-01", "to": "2026-08-31", "total": 47,
"byReason": [
{ "reason": "TOO_EXPENSIVE", "count": 14, "percent": 29.8 },
{ "reason": "NOT_USING", "count": 11, "percent": 23.4 },
{ "reason": "TOO_MANY_MESSAGES", "count": 8, "percent": 17.0 },
{ "reason": "TEMPORARY_FINANCIAL", "count": 6, "percent": 12.8 },
{ "reason": "CONTENT_NOT_RELEVANT", "count": 4, "percent": 8.5 },
{ "reason": "OTHER", "count": 4, "percent": 8.5 }
],
"retention": { "offersShown": 47, "offersAccepted": 9, "acceptRate": 19.1 },
"involuntary": { "nonPayment": 23 },
"optOuts": { "whatsapp": 31, "dashboard": 12, "unsubscribeLink": 3 }
},
"meta": { "requestId": "req_01K3QR1H3K5M7P9R1T3V5X7Z9B", "timestamp": "2026-09-01T09:00:00.000Z" }
}- Erros:
FORBIDDEN_ROLE(403),INVALID_DATE_RANGE(422). - Efeitos colaterais: nenhum. Idempotência: leitura pura. Resposta cacheada por 5 minutos em
Redis, chave
admin:cancel-reasons:{from}:{to}.
21. Dashboard de Métricas e Analytics #
Esta seção é a dona das definições de métrica do produto. Toda métrica citada em qualquer outra seção — alertas de negócio, critérios de aceitação, relatórios executivos, painéis de monitoramento — usa a definição registrada aqui, com a fórmula exata registrada aqui. Nenhuma outra seção redefine uma métrica, e nenhuma outra seção inventa uma variante da mesma métrica com denominador diferente.
Os nomes das métricas são identificadores técnicos e, por isso, ficam em inglês: eles viram
nomes de coluna, valores da coluna metric_key da tabela daily_metrics, nomes de série no
Recharts e nomes de métrica no Prometheus (Seção 23). A prosa que descreve cada métrica é em
português do Brasil.
21.1 Princípios de medição #
Sete princípios regem tudo o que está nesta seção. Eles existem porque a maioria dos erros de métrica em produtos de assinatura não é erro de SQL, é erro de definição.
- Uma métrica, uma fórmula, um dono. Se dois painéis mostram "conversão", eles mostram o mesmo número, calculado pelo mesmo código, a partir da mesma tabela.
- Numerador e denominador são armazenados separadamente. Toda taxa é persistida como três
valores:
numerator,denominatorevalue. Isso permite reagregar corretamente (ver Seção 21.13.4). A média de sete taxas diárias não é a taxa da semana. - O dia é o dia civil de America/Sao_Paulo. Nunca UTC. A conversão é explícita em toda query de rollup (ver Seção 21.13.2).
- Custo é gravado no momento do fato, nunca recalculado. O preço por mensagem da Meta e o preço por caractere do provedor de TTS mudam ao longo do tempo. Recalcular o custo de janeiro com a tabela de preços de agosto produz um número falso. Ver Seção 21.2.6.
- O dashboard nunca lê a tabela transacional. Ele lê
daily_metrics. A justificativa está na Seção 21.7. - Dado que chega atrasado é normal e previsto. Confirmação de leitura no WhatsApp e webhook de pagamento podem chegar horas depois. O rollup reprocessa os três dias anteriores todo dia (Seção 21.5.3).
- Todo número exibido carrega a data de cálculo. O rodapé de cada tela mostra
Calculado em <timestamp> · Dados até <metric_date>. Sem isso, um painel travado parece um painel saudável.
21.2 Catálogo canônico de métricas #
Tabela mestre. Colunas: chave técnica, definição em uma frase, fórmula exata, fonte (tabela e colunas), granularidade e periodicidade de cálculo.
Convenções da coluna "Fórmula": count(X) conta linhas distintas de X; D é o dia civil de
America/Sao_Paulo em cálculo; M é o mês civil; [a,b) é intervalo semiaberto.
Convenção obrigatória de unidade monetária, válida para todas as fórmulas desta seção. O banco guarda dinheiro em duas unidades inteiras distintas, e confundi-las erra o resultado por um fator de dez mil:
| Unidade | Sufixo da coluna | O que é | Divisor para BRL | Onde aparece |
|---|---|---|---|---|
| Centavo de real | *_amount_cents |
Inteiro, centésimos de BRL | 100 | subscriptions.amount_cents, payments.amount_cents, payments.net_amount_cents, payments.fee_cents, plans.amount_cents |
| Micro de real | *_cost_micros |
Inteiro, milionésimos de BRL | 1.000.000 | message_logs.cost_micros, audio_assets.cost_micros |
Custos unitários usam micros porque uma mensagem de WhatsApp custa frações de centavo e uma narração custa frações de centavo por caractere: arredondar para centavo no momento do fato zeraria o custo de quase toda linha. Regras que não admitem exceção:
- Toda fórmula que produz um valor com sufixo
_brldeclara explicitamente o divisor. - Nenhuma fórmula soma centavos com micros sem conversão explícita para BRL antes da soma.
- Nenhum valor monetário é representado em ponto flutuante em nenhum ponto do caminho de
gravação. A conversão para
NUMERICacontece só no rollup, ao produzir a linha dedaily_metrics. - A Seção 6 é a dona dos nomes e dos tipos dessas colunas; esta seção apenas as consome.
21.2.1 Base de assinantes #
| Chave | Definição | Fórmula | Fonte | Granularidade | Periodicidade |
|---|---|---|---|---|---|
active_subscribers |
Assinantes que ainda recebem mensagens e não foram excluídos. | count(subscribers WHERE opt_out_at IS NULL AND deleted_at IS NULL AND opt_in_confirmed_at IS NOT NULL) no fim de D |
subscribers.opt_out_at, .deleted_at, .opt_in_confirmed_at |
Diária (snapshot) | Diária, 03:10 |
active_subscribers_free |
Assinantes ativos no tier FREE. | active_subscribers AND tier = 'FREE' |
subscribers.tier |
Diária (snapshot) | Diária, 03:10 |
active_subscribers_paid |
Assinantes ativos no tier PAID. | active_subscribers AND tier = 'PAID' |
subscribers.tier |
Diária (snapshot) | Diária, 03:10 |
paid_share |
Fração de assinantes ativos no tier pago. | active_subscribers_paid / active_subscribers |
derivada | Diária | Diária, 03:10 |
new_subscribers |
Cadastros que confirmaram o opt-in no dia. | count(subscribers WHERE opt_in_confirmed_at ∈ D) |
subscribers.opt_in_confirmed_at |
Diária | Diária, 03:10 |
new_registrations |
Cadastros iniciados no dia, tenham confirmado ou não. | count(subscribers WHERE created_at ∈ D) |
subscribers.created_at |
Diária | Diária, 03:10 |
paused_subscribers |
Assinantes com pausa de retenção vigente, que voltam a receber sozinhos ao fim do prazo. | count(subscribers WHERE paused_until > fim de D AND deleted_at IS NULL) |
subscribers.paused_until, .deleted_at |
Diária (snapshot) | Diária, 03:10 |
opted_out_subscribers |
Assinantes que pediram para parar de receber mensagens mas mantêm cadastro. | count(subscribers WHERE opt_out_at IS NOT NULL AND deleted_at IS NULL) no fim de D |
subscribers.opt_out_at, .deleted_at |
Diária (snapshot) | Diária, 03:10 |
deleted_subscribers |
Assinantes que pediram exclusão no dia. | count(subscribers WHERE deleted_at ∈ D) |
subscribers.deleted_at |
Diária | Diária, 03:10 |
reactivated_subscribers |
Assinantes que voltaram após opt-out no dia. | count(consent_events WHERE type = 'RE_OPT_IN' AND created_at ∈ D) |
consent_events.type, .created_at |
Diária | Diária, 03:10 |
Exemplo concreto. Em 2026-11-08 o banco tem 9.412 linhas em subscribers com
opt_in_confirmed_at preenchido; 318 têm opt_out_at preenchido; 44 têm deleted_at
preenchido (das quais 12 também têm opt_out_at). Então
active_subscribers = 9412 − 318 − 44 + 12 = 9062, opted_out_subscribers = 318 − 12 = 306.
A subtração dupla é o erro clássico aqui; a query real usa predicados combinados, não
subtrações encadeadas, exatamente para evitá-lo.
Decisão explícita sobre a pausa de retenção: um assinante em pausa continua contando em
active_subscribers. Ele não pediu para sair, não foi excluído e volta a receber sozinho ao
fim do prazo. Tratá-lo como perdido faria a base parecer encolher a cada oferta de retenção
aceita, invertendo o sinal do indicador. Quem precisa do recorte de quem não está recebendo hoje
usa paused_subscribers somado a opted_out_subscribers.
21.2.2 Opt-in, aquisição e conversão #
| Chave | Definição | Fórmula | Fonte | Granularidade | Periodicidade |
|---|---|---|---|---|---|
optin_confirmation_rate |
Fração dos cadastros web que chegou a confirmar o opt-in no WhatsApp. | count(subscribers WHERE created_at ∈ D AND opt_in_confirmed_at IS NOT NULL AND opt_in_confirmed_at < created_at + 72h) / count(subscribers WHERE created_at ∈ D) |
subscribers.created_at, .opt_in_confirmed_at |
Diária (coorte de cadastro) | Diária, 03:10, e reprocessada por 3 dias |
optin_median_minutes |
Mediana do tempo entre o cadastro web e a confirmação no WhatsApp. | percentile_cont(0.5) WITHIN GROUP (ORDER BY opt_in_confirmed_at − created_at) para a coorte de D |
subscribers |
Diária | Diária, 03:10 |
free_to_paid_conversion_rate |
Fração dos assinantes FREE existentes no início do mês que virou PAID durante o mês. | count(subscription_events WHERE type = 'TIER_UPGRADED' AND created_at ∈ M AND subscriber estava FREE em M−1 fim) / active_subscribers_free no último dia de M−1 |
subscription_events, daily_metrics |
Mensal | Diária (acumulado no mês corrente), fechada no dia 1 |
free_to_paid_conversion_rate_d30 |
Fração de uma coorte de cadastro que converteu em até 30 dias. | count(coorte com primeiro TIER_UPGRADED ≤ opt_in_confirmed_at + 30d) / count(coorte) |
subscribers, subscription_events |
Coorte diária | Diária, 03:10, coortes fechadas com 30 dias |
time_to_conversion_avg_days |
Tempo médio entre confirmação do opt-in e a primeira conversão para PAID. | avg(first_upgrade_at − opt_in_confirmed_at) sobre as conversões ocorridas em D, em dias com 2 casas |
subscribers, subscription_events |
Diária | Diária, 03:10 |
time_to_conversion_median_days |
Mediana do mesmo intervalo. | percentile_cont(0.5) da mesma população |
idem | Diária | Diária, 03:10 |
checkout_started |
Sessões de checkout iniciadas no dia. | count(subscriptions WHERE created_at ∈ D) (toda tentativa cria linha em PENDING_PAYMENT) |
subscriptions.created_at |
Diária | Diária, 03:10 |
checkout_completion_rate |
Fração dos checkouts iniciados que virou pagamento confirmado em até 72 h. | count(subscriptions criadas em D que atingiram ACTIVE em ≤72h) / checkout_started |
subscriptions, subscription_events |
Diária (coorte) | Diária, reprocessada por 4 dias |
Nota sobre a janela de 72 h em optin_confirmation_rate e checkout_completion_rate: sem
janela, a taxa de um dia recente sempre parece pior do que a de um dia antigo, porque o dia
antigo teve mais tempo para acumular confirmações. A janela fixa torna os dias comparáveis. É
por isso que essas duas métricas só ficam finais depois de 3 e 4 dias, respectivamente
(ver Seção 21.5.3).
Exemplo concreto de free_to_paid_conversion_rate. Em 31/10 havia 6.800 assinantes FREE
ativos. Durante novembro, 214 deles registraram TIER_UPGRADED. Um assinante que se cadastrou
em 12/11 e converteu em 20/11 não entra no numerador desta métrica, porque não estava na
base de 31/10; ele aparece em free_to_paid_conversion_rate_d30 da coorte de 12/11. Logo,
free_to_paid_conversion_rate de novembro = 214 / 6800 = 3,15%.
21.2.3 Retenção e churn #
| Chave | Definição | Fórmula | Fonte | Granularidade | Periodicidade |
|---|---|---|---|---|---|
active_subscriptions |
Assinaturas pagas em vigor no fim do dia. | count(subscriptions WHERE status = 'ACTIVE') |
subscriptions.status |
Diária (snapshot) | Diária, 03:10 |
monthly_churn_rate |
Fração das assinaturas ativas no dia 1 do mês que deixou de estar ACTIVE até o fim do mês. | count(subscriptions que estavam ACTIVE em M dia 1 e não estão ACTIVE em M último dia) / count(ACTIVE em M dia 1) |
subscriptions, subscription_events |
Mensal | Diária (parcial), fechada no dia 1 do mês seguinte |
churn_by_reason |
Mesma contagem de churn, quebrada pelo motivo da saída. | numerador de monthly_churn_rate agrupado por reason |
subscription_events.reason |
Mensal, por dimensão | Diária |
voluntary_churn_rate |
Churn causado por cancelamento pedido pelo assinante. | churn_by_reason['USER_CANCELED'] / ACTIVE em M dia 1 |
idem | Mensal | Diária |
involuntary_churn_rate |
Churn causado por falha de pagamento, estorno ou chargeback. | (churn_by_reason['PAYMENT_OVERDUE'] + ['PAYMENT_REFUNDED'] + ['PAYMENT_CHARGEBACK'] + ['PAYMENT_DELETED']) / ACTIVE em M dia 1 |
idem | Mensal | Diária |
retention_curve_mN |
Fração de uma coorte mensal de assinantes pagos ainda ACTIVE N meses depois. | count(coorte ainda ACTIVE em M0+N) / count(coorte em M0) para N ∈ {1..12} |
subscriptions |
Coorte mensal × N | Mensal, dia 1 |
Valores possíveis de subscription_events.reason usados no eixo do gráfico de churn_by_reason,
com o rótulo exato exibido no painel:
reason |
Rótulo no painel | Origem |
|---|---|---|
USER_CANCELED |
Cancelou no painel | Ação do assinante (Seção 20) |
PAYMENT_OVERDUE |
Falha de pagamento | Webhook PAYMENT_OVERDUE (Seção 12) |
PAYMENT_REFUNDED |
Estorno | Webhook PAYMENT_REFUNDED |
PAYMENT_CHARGEBACK |
Chargeback | Webhook PAYMENT_CHARGEBACK_REQUESTED |
PAYMENT_DELETED |
Cobrança removida | Webhook PAYMENT_DELETED |
CARD_EXPIRED |
Cartão expirado | Falha de cobrança com código de cartão vencido |
ADMIN_ACTION |
Ação administrativa | Cancelamento manual por ADMIN/OWNER |
SUBSCRIBER_DELETED |
Exclusão de conta | Pedido de eliminação (Seção 22.9) |
Como não há período de carência (regra registrada na Seção 13), a distinção entre churn
voluntário e involuntário é operacionalmente relevante: o involuntário é atacável com a régua
de cobrança e com payment_recovery_rate (Seção 21.2.7), o voluntário não.
21.2.4 Receita #
| Chave | Definição | Fórmula | Fonte | Granularidade | Periodicidade |
|---|---|---|---|---|---|
mrr_brl |
Receita recorrente normalizada por mês, em reais. | sum(CASE plans.interval WHEN 'MONTHLY' THEN subscriptions.amount_cents WHEN 'YEARLY' THEN subscriptions.amount_cents/12.0 END)/100 sobre subscriptions.status = 'ACTIVE' no fim de D |
subscriptions.amount_cents, plans.interval |
Diária (snapshot) | Diária, 03:10 |
arr_brl |
Receita recorrente anualizada. | mrr_brl × 12 |
derivada | Diária | Diária, 03:10 |
new_mrr_brl |
MRR adicionado por assinaturas que entraram em ACTIVE no dia. | sum(valor normalizado das assinaturas que entraram em ACTIVE em D) |
subscription_events, subscriptions |
Diária | Diária, 03:10 |
churned_mrr_brl |
MRR perdido por assinaturas que saíram de ACTIVE no dia. | sum(valor normalizado das assinaturas que saíram de ACTIVE em D) |
idem | Diária | Diária, 03:10 |
net_mrr_change_brl |
Variação líquida do MRR no dia. | new_mrr_brl − churned_mrr_brl |
derivada | Diária | Diária, 03:10 |
arpu_brl |
Receita recorrente média por assinante pagante. | mrr_brl / active_subscribers_paid |
derivada | Diária | Diária, 03:10 |
arpu_blended_brl |
Receita recorrente média por assinante ativo, pagante ou não. | mrr_brl / active_subscribers |
derivada | Diária | Diária, 03:10 |
estimated_ltv_brl |
Valor estimado que um assinante pagante gera antes de sair. | arpu_brl / monthly_churn_rate do mês fechado mais recente, com teto de 36 meses de vida |
derivada | Mensal | Mensal, dia 1 |
ltv_cac_ratio |
Razão entre LTV estimado e custo de aquisição informado. | estimated_ltv_brl / cac_brl, com cac_brl = (ops.marketing_spend_monthly_cents / 100) / new_paid_subscriptions do mês |
settings, derivada |
Mensal | Mensal, dia 1 |
gross_revenue_brl |
Dinheiro efetivamente recebido no dia. | sum(payments.amount_cents WHERE status IN ('CONFIRMED','RECEIVED') AND confirmed_at ∈ D)/100 |
payments.amount_cents, .confirmed_at |
Diária | Diária, 03:10 |
refunded_brl |
Dinheiro devolvido no dia. | sum(payments.amount_cents WHERE status = 'REFUNDED' AND refunded_at ∈ D)/100 |
payments.amount_cents, .refunded_at |
Diária | Diária, 03:10 |
net_revenue_brl |
Receita líquida de estornos. | gross_revenue_brl − refunded_brl |
derivada | Diária | Diária, 03:10 |
Decisões explícitas de contabilidade registradas aqui:
- Só existe MRR de assinatura paga vigente. O plano gratuito não é uma linha da tabela
planse não tem assinatura: ser gratuito é a ausência de assinatura vigente (regra da Seção 13.1). Onde a interface exibe um "plano gratuito", ele é um item sintético montado pelo handler, sem correspondência no banco. Por isso nenhuma fórmula de receita precisa excluir linhas de valor zero: elas não existem. ops.marketing_spend_monthly_centsé a chave de gasto de marketing. É lançada manualmente pelo papelOWNERno mesmo formulário de lançamento de custo da tela de Custos, no formatogrupo.chavedo catálogo de configuração (Seção 26.8.1), com tipo inteiro em centavos e valor padrão0. Com valor zero,ltv_cac_ratioé gravada como nula, nunca como infinito.- MRR usa o valor contratado, não o valor recebido. Um plano anual de R$ 199,00 contribui
com R$ 16,583333 por mês para o MRR desde o dia em que fica ACTIVE, mesmo que o dinheiro
inteiro tenha entrado no primeiro dia.
gross_revenue_brlmede caixa;mrr_brlmede contrato. Os dois números divergem por construção e a tela de receita explica isso em nota fixa. - Assinatura em
PENDING_PAYMENTnão conta no MRR. SóACTIVEconta. - Taxa da Asaas não é descontada do MRR. O MRR é bruto. O custo de adquirência entra em
total_cost_per_subscriber_brl(Seção 21.2.6) como custo, não como redução de receita. estimated_ltv_brltem teto de 36 meses. Com churn mensal de 2%, a fórmula ingênuaARPU/churndaria 50 meses de vida, o que é fantasia para um produto novo. O teto é aplicado comomin(1/monthly_churn_rate, 36) × arpu_brl. Semonthly_churn_ratefor zero no mês (base pequena), a métrica é gravada como nula, não como infinito.
Exemplo numérico de mrr_brl, com as unidades explícitas. Base com 2.400 assinaturas mensais
de amount_cents = 1990 e 600 anuais de amount_cents = 19900. A soma normalizada em centavos
é 2400 × 1990 + 600 × (19900/12) = 4.776.000 + 995.000 = 5.771.000 centavos. Dividindo por
100: mrr_brl = 57.710,00. arr_brl = 692.520,00. arpu_brl = 57.710,00 / 3.000 = 19,24.
Repare que a divisão por 12 acontece em centavos e em numeric, antes da divisão por 100;
fazê-la em inteiro truncaria R$ 0,0033 por assinatura anual por mês, o que a 3.000 assinantes
seria uma diferença visível no fechamento do ano.
21.2.5 Entrega e engajamento #
| Chave | Definição | Fórmula | Fonte | Granularidade | Periodicidade |
|---|---|---|---|---|---|
messages_sent |
Mensagens que a API da Meta aceitou no dia. | count(message_logs WHERE status <> 'REJECTED' AND sent_at ∈ D) |
message_logs.sent_at, .status |
Diária | Diária, 03:10 |
delivery_rate |
Fração das mensagens enviadas que chegou ao aparelho. | count(message_logs WHERE status IN ('delivered','read') AND sent_at ∈ D) / count(message_logs WHERE sent_at ∈ D AND status <> 'REJECTED') |
message_logs.status |
Diária | Diária, 03:10, reprocessada por 3 dias |
read_rate |
Fração das mensagens entregues que foi lida. | count(message_logs WHERE status = 'read' AND sent_at ∈ D) / count(message_logs WHERE status IN ('delivered','read') AND sent_at ∈ D) |
idem | Diária | Diária, reprocessada por 3 dias |
failure_rate |
Fração das mensagens enviadas que terminou em falha. | count(message_logs WHERE status = 'failed' AND sent_at ∈ D) / messages_sent |
idem | Diária | Diária, reprocessada por 3 dias |
failure_rate_by_error_code |
Mesma taxa, quebrada por código de erro da Meta. | numerador de failure_rate agrupado por message_logs.error_code |
message_logs.error_code |
Diária, por dimensão | Diária |
window_open_rate |
Fração dos assinantes que receberam o convite por template e abriram a janela de 24 h no mesmo dia. | count(distinct subscriber_id em inbound_messages com created_at ∈ D e created_at > o template daquele dia) / count(distinct subscriber_id que recebeu template em D) |
inbound_messages, message_logs |
Diária | Diária, 03:10 |
time_to_window_open_median_minutes |
Mediana do intervalo entre o envio do template e a primeira resposta do assinante. | percentile_cont(0.5) WITHIN GROUP (ORDER BY first_inbound_at − template_sent_at) em minutos |
message_logs, inbound_messages |
Diária | Diária, 03:10 |
time_to_window_open_avg_minutes |
Média do mesmo intervalo, truncada em 24 h. | avg(least(first_inbound_at − template_sent_at, 24h)) |
idem | Diária | Diária, 03:10 |
template_skip_rate |
Fração dos envios diários que dispensou o template porque a janela já estava aberta. | count(delivery_attempts WHERE step = 'FREEFORM_DIRECT' AND devotional_date = D) / count(delivery_attempts WHERE devotional_date = D) |
delivery_attempts.step |
Diária | Diária, 03:10 |
messages_by_template_category |
Volume de mensagens por categoria de conversa cobrada pela Meta. | count(message_logs WHERE sent_at ∈ D) agrupado por conversation_category |
message_logs.conversation_category |
Diária, por dimensão | Diária |
audio_delivery_rate |
Fração dos assinantes PAID elegíveis do dia que recebeu o áudio entregue. | count(message_logs WHERE message_type = 'audio' AND status IN ('delivered','read') AND sent_at ∈ D) / count(assinantes PAID elegíveis em D) |
message_logs, delivery_attempts |
Diária | Diária, 03:10 |
video_fallback_rate |
Fração dos envios PAID que precisou do template de vídeo por falta de janela aberta. | count(delivery_attempts WHERE step = 'TEMPLATE_VIDEO' AND devotional_date = D) / count(delivery_attempts PAID em D) |
delivery_attempts.step |
Diária | Diária, 03:10 |
opt_out_rate |
Fração dos assinantes ativos que pediu para sair no dia. | count(subscribers WHERE opt_out_at ∈ D) / active_subscribers no início de D |
subscribers.opt_out_at |
Diária | Diária, 03:10 |
opt_out_rate_30d |
Mesma taxa acumulada em 30 dias corridos, para suavizar ruído. | sum(numerator dos 30 dias) / avg(denominator dos 30 dias) |
daily_metrics |
Diária (janela móvel) | Diária, 03:10 |
panel_wau |
Assinantes distintos que abriram o painel web nos últimos 7 dias. | count(distinct subscriber_id em sessions WHERE last_seen_at ∈ [D−6, D]) |
sessions |
Diária (janela) | Diária, 03:10 |
manual_resend_count |
Reenvios manuais pedidos pelo painel no dia. | count(delivery_attempts WHERE origin = 'MANUAL_RESEND' AND created_at ∈ D) |
delivery_attempts.origin |
Diária | Diária, 03:10 |
downgraded_at_send_count |
Assinantes rebaixados de PAID para FREE entre o planejamento e o disparo do lote. | count(delivery_attempts WHERE downgraded_at IS NOT NULL AND devotional_date = D) |
delivery_attempts.downgraded_at |
Diária | Diária, 03:10 |
Duas armadilhas de denominador ficam registradas aqui, porque erram com frequência:
read_ratetemdelivered+readno denominador, nãosent. Uma mensagem que nunca foi entregue não podia ser lida; incluí-la no denominador mistura problema de entrega com problema de interesse. Quem quiser a taxa de leitura sobre o enviado usadelivery_rate × read_rate.window_open_ratetem no denominador apenas quem recebeu template, não a base toda. Assinantes atendidos pelo atalho de janela já aberta (estratégiaFREEFORM_DIRECT) não recebem template e, portanto, não podem "abrir" nada — incluí-los deprimiria a taxa artificialmente à medida que o produto melhora. O par correto de leitura éwindow_open_ratejunto comtemplate_skip_rate.audio_delivery_ratetem no denominador quem era PAID no instante do disparo, e não quem era PAID no instante do planejamento das 05:40. O tier é reavaliado no momento de cada envio (regra da Seção 18.5), de modo que um assinante rebaixado entre o planejamento e o disparo recebe apenas o texto do plano gratuito e é excluído do denominador desta métrica. Na prática, o coletor lêdelivery_attempts.tier_at_send_effective, e nãodelivery_attempts.tier_at_send: o primeiro é o que de fato foi entregue, o segundo é o registro histórico do que se pretendia entregar. Usartier_at_sendfaria a métrica reprovar o motor todo dia em que houvesse rebaixamento, quando o motor está justamente fazendo a coisa certa.- Uma métrica de acompanhamento acompanha esse caso:
downgraded_at_send_count=count(delivery_attempts WHERE downgraded_at IS NOT NULL AND devotional_date = D), diária, consolidada às 03:10. Ela mede quantos assinantes perderam o acesso pago entre o planejamento e o disparo, e é o número que prova, dia a dia, que a revogação imediata está funcionando.
Exemplo concreto. Em um domingo, 3.000 PAID e 7.000 FREE são elegíveis. 1.850 PAID já tinham
janela aberta e foram atendidos direto (template_skip_rate do recorte PAID = 61,7%). Os
1.150 PAID restantes e os 7.000 FREE receberam template: 8.150 templates. 2.720 assinantes
responderam no mesmo dia. window_open_rate = 2720 / 8150 = 33,4%.
21.2.6 Custos #
Decisão de arquitetura de custo registrada aqui e obrigatória para as demais seções: o custo
é gravado no momento do fato. message_logs grava conversation_category, is_billable e
cost_micros no instante em que a Meta confirma a mensagem; audio_assets grava provider,
char_count e cost_micros no instante em que o áudio é gerado (pipeline na Seção 16). O
rollup soma colunas já gravadas; ele nunca multiplica volume por uma tabela de preços atual.
Motivo: os preços por mensagem da Meta e por caractere do provedor de TTS mudam, e recalcular o
passado com o preço de hoje produz um histórico falso que não bate com a fatura. As faixas de
preço vigentes e a origem do valor unitário estão na Seção 17 (WhatsApp) e na Seção 16 (TTS); a
chave de configuração com os preços correntes é lida de settings no momento da gravação.
Unidade dos custos unitários: micros de real, divisor 1.000.000. Tanto
message_logs.cost_micros quanto audio_assets.cost_micros são inteiros em milionésimos de
BRL, conforme a convenção declarada na abertura da Seção 21.2. Uma mensagem utilitária de
R$ 0,04 grava cost_micros = 40000; um caractere narrado a R$ 0,000165 grava 165 micros. Ler
essas colunas como se fossem centavos — isto é, dividir por 100 em vez de 1.000.000 —
superestima o custo em dez mil vezes e é o erro que esta subseção existe para impedir. Já
payments é dinheiro de verdade e usa centavos, divisor 100. As duas unidades nunca são
somadas antes de virarem BRL.
| Chave | Definição | Fórmula | Fonte | Granularidade | Periodicidade |
|---|---|---|---|---|---|
whatsapp_cost_brl |
Custo de mensagens do WhatsApp no dia. | sum(message_logs.cost_micros WHERE sent_at ∈ D)/1000000 |
message_logs.cost_micros |
Diária | Diária, 03:10 |
whatsapp_cost_by_category_brl |
Mesmo custo, quebrado por categoria de conversa. | idem, agrupado por conversation_category |
message_logs.conversation_category, .cost_micros |
Diária, por dimensão | Diária, 03:10 |
whatsapp_cost_per_subscriber_brl |
Custo médio de WhatsApp por assinante ativo no dia. | whatsapp_cost_brl / active_subscribers |
derivada | Diária | Diária, 03:10 |
whatsapp_billable_messages |
Mensagens que geraram cobrança no dia. | count(message_logs WHERE is_billable = TRUE AND sent_at ∈ D) |
message_logs.is_billable |
Diária | Diária, 03:10 |
whatsapp_free_messages |
Mensagens enviadas dentro da janela de atendimento, sem custo. | count(message_logs WHERE is_billable = FALSE AND sent_at ∈ D) |
idem | Diária | Diária, 03:10 |
tts_cost_brl |
Custo de geração de áudio no dia. | sum(audio_assets.cost_micros WHERE created_at ∈ D)/1000000 |
audio_assets.cost_micros |
Diária | Diária, 03:10 |
tts_cost_monthly_brl |
Custo de TTS no mês. | sum(tts_cost_brl dos dias de M) |
daily_metrics |
Mensal | Diária (acumulado) |
tts_chars_generated |
Caracteres narrados no dia. | sum(audio_assets.char_count WHERE created_at ∈ D) |
audio_assets.char_count |
Diária | Diária, 03:10 |
tts_cost_per_devotional_brl |
Custo médio de áudio por devocional produzido. | tts_cost_brl / count(distinct devotional_id em audio_assets criados em D) |
audio_assets |
Diária | Diária, 03:10 |
payment_fee_brl |
Taxas de adquirência retidas pela Asaas no dia. | sum(payments.amount_cents − payments.net_amount_cents WHERE confirmed_at ∈ D)/100 |
payments.amount_cents, .net_amount_cents, .confirmed_at |
Diária | Diária, 03:10 |
infra_cost_brl |
Custo diário de infraestrutura, rateado. | (ops.infra_cost_monthly_cents / 100) / número de dias do mês |
settings |
Diária | Diária, 03:10 |
total_cost_brl |
Custo total do dia. | whatsapp_cost_brl + tts_cost_brl + payment_fee_brl + infra_cost_brl, todos já convertidos para BRL pelos divisores próprios antes da soma |
derivada | Diária | Diária, 03:10 |
total_cost_per_subscriber_brl |
Custo total por assinante ativo no dia. | total_cost_brl / active_subscribers |
derivada | Diária | Diária, 03:10 |
total_cost_per_subscriber_month_brl |
Custo total por assinante ativo no mês. | sum(total_cost_brl em M) / avg(active_subscribers em M) |
daily_metrics |
Mensal | Diária (acumulado) |
gross_margin |
Margem bruta recorrente. | (mrr_brl/30 − total_cost_brl) / (mrr_brl/30) no dia |
derivada | Diária | Diária, 03:10 |
infra_cost_brl é lançado manualmente. A chave ops.infra_cost_monthly_cents guarda o valor
mensal do VPS, storage, domínio e serviços auxiliares, em centavos inteiros, no formato
grupo.chave do catálogo de configuração (Seção 26.8.1). O painel de custos mostra a data do
último lançamento e alerta quando o valor tem mais de 60 dias, para que ninguém confie em um
rateio obsoleto.
Exemplo numérico de um dia de operação com 9.000 assinantes ativos (3.000 PAID, 6.000 FREE, dia útil), com cada unidade escrita por extenso para que a conversão fique verificável: 1.150 templates de convite para PAID sem janela aberta, 0 templates FREE (não é domingo), 1.850 pacotes free-form sem custo, 40 conversas de suporte.
| Componente | Dado gravado | Conversão | BRL |
|---|---|---|---|
whatsapp_cost_brl |
1.150 linhas com cost_micros = 40000 → sum = 46.000.000 micros |
/1.000.000 |
46,00 |
whatsapp_free_messages |
~5.600 linhas com is_billable = FALSE |
— | — |
tts_cost_brl |
1 devocional de 4.200 caracteres a 165 micros/char → cost_micros = 693000 |
/1.000.000 |
0,69 |
payment_fee_brl |
sum(amount_cents − net_amount_cents) = 7.800 centavos |
/100 |
78,00 |
infra_cost_brl |
ops.infra_cost_monthly_cents = 90000 → 900,00/30 |
/100, depois rateio |
30,00 |
total_cost_brl = 46,00 + 0,69 + 78,00 + 30,00 = 154,69 e
total_cost_per_subscriber_brl = 154,69 / 9000 ≈ 0,0172.
Contraprova do erro que a convenção de unidade impede: lidos como centavos, os mesmos
46.000.000 micros virariam R$ 460.000,00 de custo de WhatsApp em um único dia — dez mil vezes
o valor real, e um número que faria gross_margin ficar negativa e disparar todos os alertas
de custo da Seção 21.12 no primeiro dia de operação.
21.2.7 Cobrança e inadimplência #
| Chave | Definição | Fórmula | Fonte | Granularidade | Periodicidade |
|---|---|---|---|---|---|
delinquency_rate |
Fração das cobranças com vencimento no dia que não foi paga até o fim do dia. | count(payments WHERE due_date = D AND status NOT IN ('CONFIRMED','RECEIVED')) / count(payments WHERE due_date = D) |
payments.due_date, .status |
Diária | Diária, 03:10, reprocessada por 7 dias |
overdue_subscriptions |
Assinaturas revogadas por falta de pagamento no dia. | count(subscription_events WHERE type='STATUS_CHANGED' AND to_status='EXPIRED' AND reason='PAYMENT_OVERDUE' AND created_at ∈ D) |
subscription_events |
Diária | Diária, 03:10 |
payment_recovery_rate |
Fração das cobranças vencidas de um dia que acabou paga em até 14 dias. | count(payments com due_date = D e status final CONFIRMED/RECEIVED com confirmed_at ≤ due_date+14d e que estiveram vencidas) / count(payments com due_date = D que ficaram vencidas) |
payments.due_date, .confirmed_at, payment_events |
Diária (coorte) | Diária, fechada em 14 dias |
reactivation_after_overdue_rate |
Fração dos assinantes rebaixados por inadimplência que voltou a PAID em até 30 dias. | count(coorte com novo TIER_UPGRADED ≤ 30d) / count(coorte rebaixada em D) |
subscription_events |
Coorte diária | Diária, fechada em 30 dias |
pix_vs_card_share |
Distribuição das assinaturas ativas por forma de pagamento. | count(subscriptions ACTIVE agrupado por billing_type) / active_subscriptions |
subscriptions.billing_type |
Diária, por dimensão | Diária, 03:10 |
card_decline_rate |
Fração das tentativas de cobrança em cartão que foi recusada no dia. | count(payment_events WHERE event = 'PAYMENT_OVERDUE' e billing_type='CREDIT_CARD' ∈ D) / count(tentativas de cobrança em cartão em D) |
payment_events, payments |
Diária | Diária, 03:10 |
chargeback_rate_30d |
Contestações abertas nos últimos 30 dias sobre as cobranças confirmadas no mesmo período. | count(payments WHERE status = 'CHARGEBACK' AND updated_at ∈ [D−29, D]) / count(payments WHERE status IN ('CONFIRMED','RECEIVED') AND confirmed_at ∈ [D−29, D]) |
payments.status, .confirmed_at |
Diária (janela móvel) | Diária, 03:10 |
dunning_reminder_sent |
Lembretes de vencimento enviados no dia. | count(message_logs WHERE template_name = 'lembrete_pagamento_v1' AND sent_at ∈ D) |
message_logs.template_name |
Diária | Diária, 03:10 |
dunning_reminder_sent_by_stage |
Mesmo total, quebrado pelo momento da régua. | idem, agrupado por message_logs.message_key (dunning_d3, dunning_d1, dunning_d0) |
message_logs.message_key |
Diária, por dimensão | Diária, 03:10 |
Nota sobre os três estágios da régua: existe um único template de lembrete de pagamento,
lembrete_pagamento_v1, que é um dos oito templates aprovados do produto (Seção 17.5). Os três
momentos da régua — três dias antes, um dia antes e no dia do vencimento — são o mesmo template
com parâmetros diferentes, distinguidos pela coluna message_logs.message_key. Não existem, e
não podem ser criados, templates separados por estágio: cada template novo exige aprovação da
Meta e amplia a superfície de recategorização.
payment_recovery_rate só existe porque PIX gera uma cobrança nova a cada ciclo e o assinante
pode pagar com atraso. Como não há carência (Seção 13), o assinante perde o acesso no
vencimento e o recupera quando paga. A métrica mede quanto dessa perda é revertida. Exemplo:
das 120 cobranças PIX com vencimento em 05/11, 31 não foram pagas no dia; 19 foram pagas até
19/11. delinquency_rate de 05/11 = 31/120 = 25,8%; payment_recovery_rate de 05/11 =
19/31 = 61,3%.
21.2.8 Conteúdo editorial #
| Chave | Definição | Fórmula | Fonte | Granularidade | Periodicidade |
|---|---|---|---|---|---|
devotionals_scheduled_ahead |
Dias de calendário à frente com devocional já em READY ou posterior. |
count(devotionals WHERE scheduled_for > D AND status IN ('READY','AUDIO_PENDING','AUDIO_READY','PUBLISHED')) |
devotionals.status, .scheduled_for |
Diária (snapshot) | Diária, 03:10 |
content_pipeline_days |
Quantos dias consecutivos a partir de amanhã têm conteúdo pronto, sem buraco. | maior n tal que todos os dias [D+1, D+n] têm devocional com status >= READY |
devotionals |
Diária | Diária, 03:10 e às 18:00 |
audio_generation_success_rate |
Fração das gerações de áudio do dia que terminou em AUDIO_READY sem fallback. |
count(audio_assets WHERE provider='elevenlabs' AND created_at ∈ D) / count(audio_assets WHERE created_at ∈ D) |
audio_assets.provider |
Diária | Diária, 03:10 |
audio_generation_duration_p95_s |
Percentil 95 do tempo de geração de áudio. | percentile_cont(0.95) de job_runs.duration_ms para job_name='tts.generate' em D |
job_runs |
Diária | Diária, 03:10 |
devotional_revisions |
Revisões editoriais registradas no dia. | count(devotional_revisions WHERE created_at ∈ D) |
devotional_revisions |
Diária | Diária, 03:10 |
21.3 Métricas derivadas e regras de arredondamento #
| Regra | Decisão |
|---|---|
| Taxas | Armazenadas como fração NUMERIC(18,6) no intervalo [0,1]. A conversão para porcentagem acontece só na apresentação, com 1 casa decimal. |
| Dinheiro | Nas tabelas transacionais: inteiro em centavos (*_amount_cents, divisor 100) para valores monetários e inteiro em micros (*_cost_micros, divisor 1.000.000) para custos unitários. Em daily_metrics, sempre em reais como NUMERIC(18,6), já convertido. Nunca ponto flutuante em nenhuma das etapas. A apresentação usa Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }). |
| Conversão de unidade | Acontece uma única vez, dentro do coletor da métrica, ao produzir a linha de daily_metrics. Nenhuma camada acima do rollup — endpoint, gráfico, exportação — divide ou multiplica valor monetário. Um teste de rollup afirma, para cada métrica com sufixo _brl, que o valor gravado bate com o cálculo feito com o divisor declarado no catálogo. |
| Denominador zero | A métrica é gravada com value = NULL, numerator e denominator preenchidos. O gráfico mostra descontinuidade, não zero. Zero é um valor de negócio; nulo é ausência de base. |
| Divisão de inteiros | Sempre com cast explícito para numeric antes da divisão, para evitar truncamento silencioso do Postgres. |
| Percentis | percentile_cont, nunca percentile_disc, para que a mediana de uma amostra par seja interpolada. |
| Fuso na apresentação | Todos os eixos de data são rotulados em America/Sao_Paulo e o painel exibe o fuso no rodapé. |
21.4 Tabela daily_metrics #
A Seção 6.25 é a dona da definição física desta tabela. O que fica registrado aqui é a
justificativa do formato e a regra de uso da coluna dimension. A reprodução do DDL abaixo
existe só para leitura corrida desta seção; se algum dia divergir da Seção 6.25, vale a Seção
6.25.
Decisão: formato longo (uma linha por métrica por dia por dimensão). O formato largo — uma
coluna nomeada por métrica — é abolido em todo o documento. Justificativa: várias métricas do
catálogo são quebradas por dimensão de cardinalidade aberta — código de erro da Meta, categoria
de conversa, motivo de churn, forma de pagamento, estágio da régua de cobrança. Um formato
largo exigiria uma migração de schema a cada novo código de erro que a Meta introduzir, e cada
migração dessas em uma tabela de histórico é risco puro sem contrapartida. O formato longo
absorve dimensões novas sem migração e mantém a leitura eficiente com um índice composto
adequado. Consequência obrigatória para as demais seções: nenhuma seção pode citar uma coluna
como daily_metrics.mrr_cents ou daily_metrics.delivery_rate_bp; toda leitura é por
metric_key e dimension.
CREATE TABLE daily_metrics (
id CHAR(26) PRIMARY KEY,
metric_date DATE NOT NULL,
metric_key TEXT NOT NULL,
dimension TEXT NOT NULL DEFAULT '',
value NUMERIC(18,6) NULL,
numerator NUMERIC(18,6) NULL,
denominator NUMERIC(18,6) NULL,
rollup_version SMALLINT NOT NULL DEFAULT 1,
is_final BOOLEAN NOT NULL DEFAULT FALSE,
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT daily_metrics_unique UNIQUE (metric_date, metric_key, dimension),
CONSTRAINT chk_daily_metrics_date CHECK (metric_date >= DATE '2026-01-01'),
CONSTRAINT chk_daily_metrics_denominator CHECK (denominator IS NULL OR denominator >= 0)
);
CREATE INDEX daily_metrics_key_date_idx ON daily_metrics (metric_key, metric_date DESC);
CREATE INDEX daily_metrics_date_idx ON daily_metrics (metric_date DESC);
CREATE INDEX daily_metrics_not_final_idx ON daily_metrics (metric_date) WHERE is_final = FALSE;| Coluna | Tipo | Nulo | Default | Descrição |
|---|---|---|---|---|
id |
CHAR(26) |
não | — | ULID gerado na aplicação. Chave primária. |
metric_date |
DATE |
não | — | Dia civil em America/Sao_Paulo ao qual o valor se refere. |
metric_key |
TEXT |
não | — | Chave do catálogo da Seção 21.2. Validada contra um enum TypeScript no código; não há constraint de enum no banco, para permitir métricas novas sem migração. |
dimension |
TEXT |
não | '' |
Valor da dimensão quando a métrica é quebrada (por exemplo 131047, UTILITY, USER_CANCELED). String vazia para métricas escalares — string vazia, e não NULL, para que a constraint UNIQUE funcione (em Postgres, NULL não colide com NULL). |
value |
NUMERIC(18,6) |
sim | — | Valor final da métrica. Nulo quando o denominador é zero. |
numerator |
NUMERIC(18,6) |
sim | — | Numerador, para métricas de taxa. Nulo para contagens simples. |
denominator |
NUMERIC(18,6) |
sim | — | Denominador, para métricas de taxa. Nulo para contagens simples. |
rollup_version |
SMALLINT |
não | 1 |
Versão do algoritmo que produziu a linha. Incrementada quando uma fórmula muda. |
is_final |
BOOLEAN |
não | FALSE |
TRUE quando a janela de dados atrasados já fechou (Seção 21.5.3). |
computed_at |
TIMESTAMPTZ |
não | now() |
Quando a linha foi calculada pela última vez. |
created_at |
TIMESTAMPTZ |
não | now() |
Quando a linha nasceu. Nunca muda em reprocessamento. |
updated_at |
TIMESTAMPTZ |
não | now() |
Atualizado a cada sobrescrita, junto com computed_at. |
Não há chave estrangeira nesta tabela: ela é um agregado desacoplado, e é justamente esse
desacoplamento que permite manter histórico depois de o dado transacional ser eliminado por
política de retenção (Seção 22.8). Como não há FK, não existe comportamento ON DELETE
aplicável a esta tabela. A tabela nunca é apagada por
cascade e não participa de soft delete.
Volume. Cerca de 70 chaves escalares mais aproximadamente 40 linhas de dimensão por dia resultam em ~110 linhas/dia, ~40 mil linhas/ano. Cinco anos de histórico cabem em menos de 30 MB. Essa é a razão de a tabela poder ser retida indefinidamente.
Por que dimension usa string vazia em vez de NULL: no Postgres, uma constraint UNIQUE
não impede duas linhas com NULL na coluna, porque NULL <> NULL. Com NULL, um rollup
executado duas vezes criaria duas linhas para active_subscribers do mesmo dia, e o
ON CONFLICT do upsert não dispararia. A string vazia elimina a classe inteira de bug.
21.5 Job de rollup #
21.5.1 Identificação e agendamento #
| Item | Valor |
|---|---|
| Nome do job | metrics.rollup |
| Fila | metrics.rollup (uma das treze filas do catálogo da Seção 18.8; o nome do job é igual ao nome da fila) |
| Agendamento | Repetível, cron: '10 3 * * *', tz: 'America/Sao_Paulo' |
| Alvo padrão | D−1 já fechado, mais reprocessamento de D−2, D−3, D−4, D−7 e D−14 |
| Concorrência | 1. jobId fixo metrics:rollup:{targetDate} garante que duas execuções para o mesmo dia não coexistam. |
| Duração esperada | Menos de 90 s com 50.000 assinantes |
| Timeout | 10 min; além disso o job falha e dispara alerta (Seção 23.8) |
| Registro | Uma linha em job_runs por execução, com job_name, queue, started_at, finished_at, status, duration_ms e metadata contendo os dias processados |
Existe um único rollup, às 03:10, e o alvo é sempre o dia D−1 já encerrado. Não há, e não
pode haver, consolidação às 23:00 nem em nenhum outro horário dentro do próprio dia medido: um
rollup que rodasse antes do fim do dia civil mediria um dia incompleto e subestimaria
sistematicamente messages_sent, delivery_rate, window_open_rate e todas as métricas de
custo, porque o fechamento das entregas pendentes ocorre às 23:55 (Seção 18.2) e é o último
evento do dia civil.
O horário 03:10 foi escolhido por quatro razões, nesta ordem: fica depois do fechamento das
entregas das 23:55 do dia alvo, o que garante que o dia medido está completo; fica antes da
reconciliação de pagamentos das 04:00; fica bem antes do planejamento do envio diário das 05:40
(Seção 18); e fica em uma faixa de baixíssimo tráfego, quando as consultas pesadas não competem
com requisições de assinantes. A divergência de receita que a reconciliação das 04:00 vier a
corrigir entra no reprocessamento de D−2 executado no dia seguinte — é exatamente para isso
que a lista de reprocessamento existe.
21.5.2 Algoritmo #
// apps/worker/src/jobs/metrics-rollup.ts
import { z } from 'zod'
export const rollupDailyInput = z.object({
targetDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
backfillDays: z.array(z.number().int().min(0).max(400)).default([1, 2, 3, 4, 7, 14]),
force: z.boolean().default(false),
onlyKeys: z.array(z.string()).optional(),
})
export async function runRollupDaily(input: z.infer<typeof rollupDailyInput>) {
const anchor = input.targetDate ?? todayInSaoPaulo()
const dates = input.backfillDays.map((n) => subDaysCivil(anchor, n))
for (const date of dates) {
if (!input.force && (await isFinal(date))) {
logger.info({ date }, 'metrics.rollup.skip_final')
continue
}
const written = await db.$transaction(async (tx) => {
const rows: MetricRow[] = []
for (const collector of COLLECTORS) {
if (input.onlyKeys && !input.onlyKeys.includes(collector.key)) continue
rows.push(...(await collector.collect(tx, date)))
}
await upsertMetrics(tx, date, rows)
await markFinalIfDue(tx, date)
return rows.length
}, { timeout: 120_000, isolationLevel: 'RepeatableRead' })
logger.info({ date, count: written }, 'metrics.rollup.done')
}
}A contagem de linhas é devolvida pela transação, e não lida de uma variável declarada dentro
dela. Escrever rows.length fora do bloco não compila, e é o tipo de detalhe que passa
despercebido em revisão e trava o build no primeiro pnpm build.
O upsert é sempre por conflito na chave natural, o que torna a reexecução segura:
INSERT INTO daily_metrics (id, metric_date, metric_key, dimension, value, numerator,
denominator, rollup_version, is_final, computed_at,
created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, FALSE, now(), now(), now())
ON CONFLICT (metric_date, metric_key, dimension) DO UPDATE
SET value = EXCLUDED.value,
numerator = EXCLUDED.numerator,
denominator = EXCLUDED.denominator,
rollup_version = EXCLUDED.rollup_version,
computed_at = now(),
updated_at = now()
WHERE daily_metrics.is_final = FALSE OR EXCLUDED.rollup_version > daily_metrics.rollup_version;A cláusula WHERE no DO UPDATE é o mecanismo de proteção do histórico: uma linha já marcada
como final só é sobrescrita se o algoritmo tiver mudado de versão. Um rollup rodado por engano
sobre um mês antigo não corrompe nada.
Uma coleta escalar típica, por exemplo active_subscribers, tem esta forma. Note a conversão
de fuso explícita, que é a regra da Seção 21.13.2:
SELECT count(*)::numeric AS value
FROM subscribers
WHERE opt_in_confirmed_at IS NOT NULL
AND opt_in_confirmed_at < ($1::date + INTERVAL '1 day') AT TIME ZONE 'America/Sao_Paulo'
AND (opt_out_at IS NULL OR opt_out_at >= ($1::date + INTERVAL '1 day') AT TIME ZONE 'America/Sao_Paulo')
AND (deleted_at IS NULL OR deleted_at >= ($1::date + INTERVAL '1 day') AT TIME ZONE 'America/Sao_Paulo');Reparar no detalhe: o snapshot de "fim do dia D" é reconstruído a partir dos timestamps de
transição, e não do estado atual da linha. Sem isso, reprocessar 60 dias atrás retornaria o
estado de hoje aplicado a todos os dias, achatando a série inteira. Essa reconstrução só é
possível porque subscribers guarda opt_out_at e deleted_at como timestamps, e porque
subscription_events é append-only.
Uma coleta com dimensão, por exemplo failure_rate_by_error_code:
SELECT COALESCE(error_code, 'UNKNOWN') AS dimension,
count(*)::numeric AS numerator,
(SELECT count(*)::numeric FROM message_logs m2
WHERE m2.sent_at >= $1::timestamptz AND m2.sent_at < $2::timestamptz
AND m2.status <> 'REJECTED') AS denominator
FROM message_logs m1
WHERE m1.sent_at >= $1::timestamptz AND m1.sent_at < $2::timestamptz
AND m1.status = 'failed'
GROUP BY 1;21.5.3 Janela de dados atrasados e marcação de final #
Nem toda métrica fica correta às 03:10 do dia seguinte. Confirmações de leitura do WhatsApp
chegam por webhook e podem demorar; pagamentos PIX são conciliados no dia seguinte; o webhook
da Asaas pode ficar pausado por horas. Por isso cada métrica tem uma janela própria de
maturação, e só depois dela is_final vira TRUE.
| Métrica | Janela até is_final |
Motivo |
|---|---|---|
active_subscribers e demais snapshots de base |
1 dia | Estado derivado de timestamps já gravados. |
delivery_rate, read_rate, failure_rate* |
3 dias | Status de mensagem chega por webhook assíncrono. |
optin_confirmation_rate |
4 dias | Janela de atribuição de 72 h mais folga. |
checkout_completion_rate |
4 dias | Janela de 72 h mais folga. |
delinquency_rate |
7 dias | Conciliação diária pode corrigir status de pagamento. |
payment_recovery_rate |
15 dias | Janela de recuperação de 14 dias. |
free_to_paid_conversion_rate_d30, reactivation_after_overdue_rate |
31 dias | Janela de coorte de 30 dias. |
retention_curve_mN |
Nunca marcada final antes de N+1 meses | Coorte por definição aberta. |
| Métricas de custo | 3 dias | Custo é gravado no fato, mas mensagens do fim do dia podem ter status pendente. |
markFinalIfDue aplica a tabela acima. Em produção, a série de delivery_rate de um dia
tipicamente sobe de 91% na primeira apuração para 97% na terceira; o painel sinaliza pontos
não finais com marcador aberto no gráfico e legenda "parcial".
21.5.4 Falha do rollup #
| Cenário | Comportamento |
|---|---|
| Erro de SQL em um coletor | A transação daquele dia é revertida por inteiro. Nenhuma linha parcial é gravada. O job registra status='FAILED' em job_runs e re-tenta com backoff exponencial (3 tentativas: 1 min, 5 min, 25 min). |
| Timeout de 10 min | Mesmo tratamento. Após a terceira falha, dispara o alerta metrics_rollup_failed (Seção 23.8). |
| Banco indisponível | O job falha e a fila re-tenta. Se D−1 não for calculado até 08:00, o alerta metrics_stale dispara. |
| Rollup pulado por queda prolongada | O job do dia seguinte reprocessa D−1..D−4, D−7 e D−14, o que cobre automaticamente até quatro dias consecutivos de indisponibilidade. Além disso, reprocessamento manual (Seção 21.6). |
| Dois workers pegando o mesmo dia | Impossível: jobId determinístico metrics:rollup:{date} faz o BullMQ descartar o duplicado, e a constraint UNIQUE da tabela protege o resto. |
21.6 Reprocessamento histórico #
Reprocessar é uma operação normal, não uma emergência. Três situações a exigem: correção de bug em uma fórmula, chegada de dado atrasado fora da janela, e mudança deliberada de definição.
Comando de operação:
# Reprocessa um intervalo fechado, respeitando is_final
pnpm ops metrics:rollup --from 2026-01-01 --to 2026-01-31
# Reprocessa forçando sobrescrita de linhas já finais
pnpm ops metrics:rollup --from 2026-01-01 --to 2026-01-31 --force
# Reprocessa apenas duas métricas, útil após corrigir uma fórmula
pnpm ops metrics:rollup --from 2026-01-01 --to 2026-08-24 --force \
--only delivery_rate,read_rate
# Mostra o que mudaria, sem gravar
pnpm ops metrics:rollup --from 2026-08-01 --to 2026-08-24 --dry-runRegras obrigatórias do reprocessamento:
- Mudança de fórmula exige incremento de
rollup_version. Sem isso, oON CONFLICTnão sobrescreve linhas finais e o histórico fica inconsistente com o presente. A versão vive empackages/core/src/metrics/version.tse é anotada por métrica. - Todo reprocessamento com
--forceregistra uma linha emadmin_audit_logcomadmin_user_iddo operador,actor_type,actor_role,action = 'METRICS_ROLLUP_FORCED'emetadatacontendo o intervalo, as chaves afetadas e a justificativa passada em--reason— nos nomes de coluna da Seção 6.9. O parâmetro--reasoné obrigatório quando--forceestá presente; sem ele o comando aborta. - Reprocessamento nunca roda entre 05:30 e 06:30, para não competir com a janela de envio
diário. O comando recusa a execução nesse intervalo e sugere
--allow-send-windowpara o caso excepcional. - Reprocessar não recria dado transacional que já foi eliminado. Se
message_logsde 2024 já foi purgado pela política de retenção (Seção 22.8), o rollup daquele período produziria zeros. Por isso o comando bloqueia intervalos anteriores ao horizonte de retenção da tabela envolvida e exibe a mensagem de erro correspondente. O histórico antigo emdaily_metricspermanece intocado, que é exatamente o motivo de a tabela existir. --dry-runimprime um diff por métrica, no formatometric_key dimension date: 0.912 -> 0.968 (+6,1%), limitado às 200 maiores divergências.
21.7 Por que o dashboard não consulta a tabela transacional #
Quatro razões, todas verificáveis.
- Volume. Com a meta de 10.000 assinantes do primeiro ano, o produto gera algo em torno de
12.000 linhas por dia em
message_logs(assinantes PAID recebendo três mensagens diárias, assinantes FREE recebendo três aos domingos, mais status e mensagens de suporte). São ~4,3 milhões de linhas por ano. No cenário dimensionado de 50.000 assinantes, passam de 21 milhões por ano. Um gráfico de "taxa de entrega nos últimos 12 meses" varreria dezenas de milhões de linhas a cada carregamento de tela. - Latência e previsibilidade. O alvo de p95 das rotas de API é o registrado na Seção 9.12.
Uma agregação anual sobre
message_logsnão cabe nesse orçamento em nenhuma máquina razoável, e pior: o tempo varia com o crescimento da base, de modo que a tela fica progressivamente mais lenta sem nenhuma mudança de código. - Contenção. A janela de envio diário é o momento de maior escrita em
message_logs. Uma varredura analítica nesse instante compete por I/O exatamente com o processo mais crítico do produto. Lerdaily_metricscusta poucos milissegundos e não toca nas tabelas quentes. - Retenção destrói o histórico.
message_logsé retido por 18 meses einbound_messagespor prazo semelhante (Seção 22.8). Um gráfico "desde o início" alimentado pela tabela transacional passaria a mentir no dia em que a primeira purga rodasse: a série simplesmente começaria a cair para zero no passado.daily_metricsguarda o agregado para sempre, com custo de armazenamento desprezível, e é imune à purga.
Exceção única e explícita: as telas de detalhe do painel administrativo — por exemplo, a
lista de falhas de envio de hoje ou o histórico de mensagens de um assinante específico — leem
a tabela transacional diretamente, sempre filtradas por subscriber_id ou por um intervalo de
no máximo 7 dias, com paginação por cursor (padrão registrado na Seção 7). Detalhe operacional
é consulta pontual e indexada; painel analítico é agregação. São coisas diferentes e usam
caminhos diferentes.
21.8 Telas do dashboard administrativo #
Acesso: papéis ADMIN e OWNER veem todas as telas; EDITOR vê apenas Conteúdo e Entrega,
sem números de receita nem de custo. A matriz completa de permissões é a da Seção 3.8, que é a
dona do assunto.
Os caminhos das seis telas são em português, como toda a interface do produto, inclusive o
painel administrativo. As seis constam do mapa de rotas do painel na Seção 15.1, com papel
mínimo EDITOR para Conteúdo e Entrega e ADMIN para Visão Geral, Crescimento, Receita e
Custos. Nenhuma tela administrativa existe fora daquele mapa.
Componentes comuns a todas as telas:
- Seletor de período no topo, com atalhos
Hoje,7 dias,30 dias,90 dias,Mês atual,Mês anterior,12 meses,Personalizado. O padrão ao abrir é30 dias. O período selecionado é persistido em query string (?from=2026-07-01&to=2026-07-31), de modo que a URL é compartilhável. - Comparação com período anterior, ligada por padrão. O período de comparação tem o mesmo
número de dias e termina no dia anterior ao início do período atual. Cada cartão numérico
mostra a variação percentual com seta e cor: verde para variação favorável, vermelho para
desfavorável — e "favorável" é definido por métrica, porque queda de churn é boa e queda de
MRR é ruim. A definição fica no catálogo, no campo
direction: 'up_is_good' | 'down_is_good'. - Estado de carregamento: esqueleto com a mesma altura do conteúdo final, para não deslocar o layout. Cartões numéricos mostram barra cinza pulsante; gráficos mostram retângulo com eixos desenhados e área vazia. Nunca spinner de página inteira.
- Estado vazio: quando não há linhas em
daily_metricspara o período, o gráfico é substituído por bloco com o texto "Sem dados para o período selecionado." e, se o produto tem menos de 30 dias de operação, o subtítulo "As séries aparecem conforme os dias são consolidados." Não se desenha um gráfico de zeros — zero e ausência não são a mesma coisa. - Estado de erro: bloco com "Não foi possível carregar os indicadores." e botão
Tentar de novo, que refaz a consulta via TanStack Query. - Rodapé de frescor:
Dados consolidados até <metric_date> · Última apuração <timestamp> · Fuso America/Sao_Paulo. Semetric_datefor anterior aontem, o rodapé fica âmbar com o texto "Consolidação atrasada". - Marcação de dados parciais: pontos com
is_final = falserecebem marcador vazado e a legenda ganha o item "parcial". O tooltip do ponto informa "valor ainda pode mudar".
Biblioteca de gráficos: Recharts (linha de versão na Seção 4). Paleta de no máximo 6 cores por gráfico, com rótulos diretos quando houver 3 séries ou menos.
21.8.1 Tela Visão Geral — /admin/metricas #
Objetivo: responder em dez segundos se o produto está saudável hoje.
Cartões numéricos (linha superior, seis cartões):
| Cartão | Métrica | Formato | Comparação |
|---|---|---|---|
| Assinantes ativos | active_subscribers (último dia do período) |
inteiro | vs. mesmo indicador no fim do período anterior |
| Assinantes pagos | active_subscribers_paid |
inteiro + paid_share como subtítulo |
idem |
| MRR | mrr_brl |
moeda BRL | idem |
| Taxa de entrega | delivery_rate (média ponderada do período) |
percentual, 1 casa | idem |
| Novos assinantes | new_subscribers (soma do período) |
inteiro | soma do período anterior |
| Churn mensal | monthly_churn_rate do mês fechado mais recente |
percentual, 1 casa | mês anterior |
Gráficos:
- Assinantes ativos ao longo do tempo. Área empilhada. Eixo X:
metric_date(dia). Eixo Y: contagem, começando em zero. Séries:active_subscribers_freeeactive_subscribers_paid. Tooltip mostra as duas séries e o total. - Envios e entregas do dia. Barras agrupadas. Eixo X: dia. Eixo Y: contagem. Séries:
messages_sent, entregues (delivery_rate × messages_sent) e falhas. Linha secundária no eixo Y direito:delivery_rateem percentual. - Saúde da operação. Painel de sete indicadores em formato de semáforo, sem gráfico:
conteúdo pronto para os próximos dias (
content_pipeline_days, verde ≥ 7, âmbar 3–6, vermelho ≤ 2), lote do dia concluído (sim/não, a partir desend_batches), fila de envio vazia, webhook da Asaas ativo nas últimas 24 h, qualidade do número do WhatsApp, último rollup, e último backup. Cada item leva ao runbook correspondente na Seção 27.
21.8.2 Tela Crescimento — /admin/metricas/crescimento #
Cartões: new_subscribers (soma), optin_confirmation_rate (ponderada),
free_to_paid_conversion_rate (mês corrente, parcial, com selo "parcial"),
time_to_conversion_median_days, opt_out_rate_30d, reactivated_subscribers (soma).
Gráficos:
- Funil do período. Barras horizontais em cascata, com valor absoluto e taxa de passagem
entre etapas:
new_registrations→new_subscribers(opt-in confirmado) →checkout_started→ assinaturas ACTIVE. Cada barra mostra a taxa em relação à barra anterior. - Novos assinantes por dia. Barras. Eixo X: dia. Eixo Y: contagem. Duas séries empilhadas: novos FREE e novos PAID diretos. Linha de média móvel de 7 dias sobreposta.
- Curva de conversão por coorte. Linhas múltiplas. Eixo X: dias desde a confirmação do
opt-in (0 a 60). Eixo Y:
free_to_paid_conversion_rate_d30acumulada. Uma linha por coorte mensal, com as coortes mais antigas em cinza claro e a mais recente em destaque. - Retenção por coorte. Mapa de calor. Linhas: coorte mensal de entrada em PAID. Colunas:
meses desde a entrada (M0 a M12). Célula:
retention_curve_mNem percentual, com escala de cor sequencial. Células de coortes ainda imaturas ficam hachuradas. - Opt-out. Linha simples. Eixo X: dia. Eixo Y:
opt_out_rateem percentual, com banda deopt_out_rate_30dao fundo.
Filtros adicionais desta tela: tier (Todos, FREE, PAID) e origem de cadastro
(utm_source registrado no cadastro), aplicados como dimension quando disponível.
21.8.3 Tela Receita — /admin/metricas/receita #
Cartões: mrr_brl, arr_brl, arpu_brl, net_mrr_change_brl (soma do período),
estimated_ltv_brl, gross_revenue_brl (soma do período).
Gráficos:
- Evolução do MRR. Linha. Eixo X: dia. Eixo Y: reais. Série única
mrr_brl. Anotações verticais nos dias em que houve mudança de preço registrada emsettings. - Ponte de MRR. Barras em cascata por mês. Componentes: MRR inicial,
new_mrr_brl(positivo, verde),churned_mrr_brl(negativo, vermelho), MRR final. Torna visível se o crescimento vem de aquisição ou de redução de perda. - Caixa versus contrato. Duas linhas no mesmo eixo. Série 1:
gross_revenue_brldiário. Série 2:mrr_brl / 30. Nota fixa abaixo do gráfico explicando que planos anuais empurram o caixa para frente do contrato. - Mix de planos. Barras 100% empilhadas por mês. Séries: mensal e anual, a partir da
leitura de
plans.intervalsobre as assinaturas ativas. Não há série de plano gratuito neste gráfico: o plano gratuito não é uma linha deplanse não gera assinatura (Seção 13.1). - Inadimplência e recuperação. Combinação. Barras: cobranças vencidas por dia
(
delinquency_rate × denominador). Linha no eixo direito:payment_recovery_rateda coorte daquele dia, exibida somente quando a coorte fechou (15 dias).
Nota fixa obrigatória no rodapé desta tela: "MRR é receita contratada e normalizada por mês; receita bruta é dinheiro recebido no dia. Planos anuais fazem os dois números divergirem por construção."
21.8.4 Tela Entrega — /admin/metricas/entrega #
Cartões: delivery_rate, read_rate, window_open_rate, template_skip_rate,
time_to_window_open_median_minutes, failure_rate.
Gráficos:
- Taxas de entrega e leitura. Duas linhas. Eixo X: dia. Eixo Y: percentual de 0 a 100.
Séries:
delivery_rateeread_rate. Linha de referência tracejada em 95% para entrega. - Falhas por código de erro. Barras empilhadas. Eixo X: dia. Eixo Y: contagem de falhas.
Uma série por
dimensiondefailure_rate_by_error_code, limitada aos 6 códigos mais frequentes do período; o restante entra como "Outros". A legenda traduz o código para o rótulo operacional (por exemplo,131047→ "Fora da janela de 24 h"), e o clique no segmento abre a lista transacional filtrada daquele dia e daquele código. - Estratégia de envio. Barras 100% empilhadas. Séries, a partir de
delivery_attempts.step:FREEFORM_DIRECT(janela aberta),TEMPLATE_INVITE(convite),TEMPLATE_VIDEO(fallback de vídeo). Mostra visualmente se o produto está migrando para o caminho preferencial e barato. - Tempo até abrir a janela. Histograma. Eixo X: faixas de minutos (0–5, 5–15, 15–30, 30–60, 1–3 h, 3–6 h, 6–12 h, 12–24 h, não abriu). Eixo Y: contagem de assinantes. Período agregado.
- Duração do lote diário. Linha. Eixo X: dia. Eixo Y: minutos entre o primeiro e o último
envio do lote, lido de
send_batches. Linha de referência em 20 minutos, que é o alvo de capacidade do motor de envio registrado na Seção 18.14.
Filtro adicional: tier e tipo de mensagem (template, text, audio, video).
21.8.5 Tela Conteúdo — /admin/metricas/conteudo #
Cartões: content_pipeline_days, devotionals_scheduled_ahead,
audio_generation_success_rate, audio_generation_duration_p95_s, devotional_revisions
(soma), audio_delivery_rate.
Gráficos:
- Calendário de prontidão. Grade de calendário dos próximos 60 dias. Cada dia colorido por
devotionals.status: cinza (sem devocional), âmbar (DRAFT), azul (READY/AUDIO_PENDING), verde (AUDIO_READY/PUBLISHED), verde escuro (SENT). Clique abre o devocional no editor (Seção 15). - Engajamento por devocional. Tabela ordenável, não gráfico, com uma linha por devocional dos últimos 90 dias: data, título, enviados, taxa de entrega, taxa de leitura, taxa de abertura de janela, opt-outs no dia. Permite ver qual conteúdo prendeu e qual afastou. Ordenação padrão: data decrescente.
- Geração de áudio. Barras empilhadas por dia: gerações pelo provedor primário e pelo
provedor de fallback, a partir de
audio_assets.provider. Linha secundária:audio_generation_duration_p95_s.
21.8.6 Tela Custos — /admin/metricas/custos #
Cartões: total_cost_brl (soma do período), total_cost_per_subscriber_brl (média
ponderada), whatsapp_cost_brl (soma), tts_cost_monthly_brl (mês corrente),
payment_fee_brl (soma), gross_margin (média ponderada).
Gráficos:
- Composição do custo. Área empilhada. Eixo X: dia. Eixo Y: reais. Séries:
whatsapp_cost_brl,tts_cost_brl,payment_fee_brl,infra_cost_brl. - Custo por assinante. Linha. Eixo X: dia. Eixo Y: reais com 4 casas.
total_cost_per_subscriber_brl, comwhatsapp_cost_per_subscriber_brlcomo segunda linha. Linha de referência no valor deops.cost_per_subscriber_target_cents / 100(padrão R$ 0,90/mês, equivalente a R$ 0,03/dia). - Mensagens cobradas versus gratuitas. Barras 100% empilhadas por dia:
whatsapp_billable_messagesewhatsapp_free_messages. É o gráfico que prova o valor econômico do desenho de janela aberta; quanto maior a fatia gratuita, melhor. - Custo do WhatsApp por categoria. Barras empilhadas por dia, séries de
whatsapp_cost_by_category_brl, cujadimensioné o valor demessage_logs.conversation_category(UTILITY,MARKETING,AUTHENTICATION,SERVICE). Se a Meta reclassificar o template diário, a mudança fica visível imediatamente neste gráfico — que é o ponto de detecção previsto na Seção 17. - Margem bruta. Linha com faixa. Eixo Y: percentual.
gross_margindiária com banda de média móvel de 7 dias.
Bloco fixo abaixo dos gráficos: "Rateio de infraestrutura lançado em <data>: R$ <valor>/mês."
Com mais de 60 dias desde o lançamento, o bloco fica âmbar e exibe "Rateio desatualizado —
atualize em Configurações".
21.9 API de métricas e exportação #
21.9.1 GET /api/admin/metrics/series #
| Item | Valor |
|---|---|
| Método e caminho | GET /api/admin/metrics/series |
| Autenticação | Cookie de sessão (__Host-session), conforme Seção 8 |
| Papel exigido | ADMIN ou OWNER; EDITOR apenas para chaves do grupo Conteúdo e Entrega |
| Idempotência | Total. Somente leitura, sem efeitos colaterais. |
export const metricsSeriesQuery = z.object({
keys: z.string().min(1).transform((s) => s.split(',')).pipe(z.array(z.string()).min(1).max(12)),
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
granularity: z.enum(['day', 'week', 'month']).default('day'),
dimension: z.string().max(64).optional(),
compare: z.boolean().default(false),
}).refine((v) => v.from <= v.to, { message: 'Intervalo inválido.', path: ['from'] })
.refine((v) => diffDays(v.from, v.to) <= 730, { message: 'Máximo de 730 dias.', path: ['to'] })Resposta 200:
{
"data": {
"granularity": "day",
"series": [
{
"key": "delivery_rate",
"dimension": null,
"direction": "up_is_good",
"unit": "ratio",
"points": [
{ "date": "2026-08-22", "value": 0.968, "numerator": 11623, "denominator": 12007, "isFinal": true },
{ "date": "2026-08-23", "value": 0.971, "numerator": 11701, "denominator": 12051, "isFinal": true },
{ "date": "2026-08-24", "value": 0.934, "numerator": 11209, "denominator": 12001, "isFinal": false }
],
"previousPeriod": null
}
],
"consolidatedThrough": "2026-08-24",
"computedAt": "2026-08-25T06:10:44.120Z"
},
"meta": { "requestId": "req_01J9K2M4R7T8V0X1Y2Z3A4B5C6", "timestamp": "2026-08-25T09:00:00.000Z" }
}Erros possíveis:
| HTTP | code |
Quando |
|---|---|---|
| 401 | UNAUTHENTICATED |
Sem cookie de sessão válido. |
| 403 | FORBIDDEN_ROLE |
Papel sem acesso ao grupo da métrica pedida. |
| 422 | VALIDATION_ERROR |
Falha do schema Zod; details traz field e issue. |
| 422 | UNKNOWN_METRIC_KEY |
Chave fora do catálogo da Seção 21.2; details[].field = 'keys'. |
| 422 | RANGE_TOO_LARGE |
Mais de 730 dias entre from e to. |
| 429 | RATE_LIMITED |
Mais de 120 requisições por minuto por sessão administrativa. |
| 500 | INTERNAL_ERROR |
Falha inesperada; requestId presente no log. |
Regra de agregação por granularidade, implementada no servidor e não no cliente:
-- Semana e mês: reagregar numerador e denominador, nunca a média de value.
SELECT date_trunc($1, metric_date)::date AS bucket,
CASE WHEN sum(denominator) > 0 THEN sum(numerator) / sum(denominator) END AS value,
sum(numerator) AS numerator,
sum(denominator) AS denominator,
bool_and(is_final) AS is_final
FROM daily_metrics
WHERE metric_key = $2 AND dimension = $3 AND metric_date BETWEEN $4 AND $5
GROUP BY 1 ORDER BY 1;Para métricas de contagem (numerator IS NULL), a agregação usa sum(value); para snapshots
(active_subscribers, mrr_brl), usa o último valor do bucket, porque somar snapshots diários
não tem significado. O catálogo declara o aggregation de cada métrica
(rate | sum | last | avg) e a query escolhe o caminho a partir dele. Somar
mrr_brl de 30 dias e apresentar como MRR do mês é o erro mais comum em painéis de assinatura;
aqui ele é impossível por construção.
21.9.2 GET /api/admin/metrics/export #
| Item | Valor |
|---|---|
| Método e caminho | GET /api/admin/metrics/export |
| Autenticação | Cookie de sessão |
| Papel exigido | ADMIN ou OWNER |
| Idempotência | Total; somente leitura. |
| Efeitos colaterais | Uma linha em admin_audit_log com admin_user_id, actor_type = 'ADMIN', actor_role, action = 'METRICS_EXPORTED', entity_type = 'metrics_export' e metadata com o relatório, o intervalo e a contagem de linhas. Os nomes das colunas são os da Seção 6.9, dona do esquema. |
export const metricsExportQuery = z.object({
report: z.enum([
'daily_metrics', // série longa crua, todas as chaves
'growth_summary', // um registro por dia com as métricas de crescimento
'revenue_summary', // um registro por dia com receita e MRR
'delivery_summary', // um registro por dia com entrega e engajamento
'cost_summary', // um registro por dia com custos
'churn_by_reason', // um registro por mês e motivo
'cohort_retention', // matriz de coorte
]),
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
format: z.enum(['csv', 'json']).default('csv'),
})Limites e comportamento, todos decididos aqui:
| Limite | Valor | Comportamento ao exceder |
|---|---|---|
| Intervalo máximo | 400 dias | 422 RANGE_TOO_LARGE |
| Linhas em resposta síncrona | 50.000 | Acima disso, 413 EXPORT_TOO_LARGE com instrução de reduzir o intervalo |
| Exportações por hora por administrador | 10 | 429 RATE_LIMITED |
| Tamanho máximo do arquivo | 25 MB | Coberto pelo limite de linhas |
| Tempo máximo | 30 s | 504 EXPORT_TIMEOUT |
A resposta é transmitida em streaming (Transfer-Encoding: chunked) para não materializar o
arquivo em memória. Cabeçalhos: Content-Type: text/csv; charset=utf-8 e
Content-Disposition: attachment; filename="delivery_summary_2026-07-01_2026-07-31.csv".
Formato CSV — decisões explícitas, porque exportação brasileira mal formatada é um problema real:
- Separador vírgula, codificação UTF-8 com BOM (
no início). O BOM faz o Excel em português reconhecer o acento sem passar pelo assistente de importação. - Decimal com ponto, não vírgula. Motivo: o arquivo é consumido por planilha e por script;
o ponto é inequívoco. O cabeçalho da coluna indica a unidade (
delivery_rate_ratio,mrr_brl). - Datas em
YYYY-MM-DD. Timestamps em ISO 8601 comZ. - Valores nulos ficam vazios, nunca
0, nuncanullliteral. - Primeira linha é cabeçalho com nomes de coluna em
snake_caseinglês. - Uma linha final de comentário não é incluída: arquivo limpo, sem rodapé.
Exemplo de saída de delivery_summary:
metric_date,messages_sent,delivered,read,failed,delivery_rate_ratio,read_rate_ratio,window_open_rate_ratio,is_final
2026-07-01,12007,11623,7412,384,0.968000,0.637700,0.334100,true
2026-07-02,12043,11702,7501,341,0.971700,0.641000,0.341200,trueEm format=json, a resposta é um array JSON puro em streaming (Content-Type: application/json), com uma propriedade por coluna em camelCase, seguindo a convenção de
campos JSON registrada na Seção 7. O envelope padrão { data, meta } não é usado nas
exportações, porque o objetivo é um arquivo consumível diretamente; essa exceção fica registrada
aqui e na Seção 7.
Erros possíveis: 401 UNAUTHENTICATED, 403 FORBIDDEN_ROLE, 422 VALIDATION_ERROR,
422 RANGE_TOO_LARGE, 413 EXPORT_TOO_LARGE, 429 RATE_LIMITED, 504 EXPORT_TIMEOUT,
500 INTERNAL_ERROR.
21.9.3 GET /api/admin/metrics/overview #
Endpoint de conveniência que devolve, em uma única chamada, os cartões da tela de Visão Geral
já calculados com comparação de período. Existe para evitar seis requisições paralelas no
carregamento inicial. Mesma autenticação, mesmos papéis, mesmos erros de series, mais
422 VALIDATION_ERROR. Resposta 200:
{
"data": {
"period": { "from": "2026-07-26", "to": "2026-08-24" },
"previousPeriod": { "from": "2026-06-26", "to": "2026-07-25" },
"cards": [
{ "key": "active_subscribers", "value": 9062, "previousValue": 8611, "changeRatio": 0.0524, "direction": "up_is_good", "unit": "count" },
{ "key": "mrr_brl", "value": 57710.0, "previousValue": 54120.5, "changeRatio": 0.0663, "direction": "up_is_good", "unit": "brl" },
{ "key": "delivery_rate", "value": 0.9681, "previousValue": 0.9702, "changeRatio": -0.0022, "direction": "up_is_good", "unit": "ratio" }
],
"health": {
"contentPipelineDays": 12,
"lastBatchCompletedAt": "2026-08-25T09:04:11.000Z",
"queueDepth": 0,
"asaasWebhookHealthy": true,
"whatsappQualityRating": "GREEN",
"lastRollupAt": "2026-08-25T06:10:44.120Z",
"lastBackupAt": "2026-08-25T05:00:12.000Z"
},
"consolidatedThrough": "2026-08-24"
},
"meta": { "requestId": "req_01J9K2M4R7T8V0X1Y2Z3A4B5C6", "timestamp": "2026-08-25T09:00:00.000Z" }
}21.10 Analytics de produto no site #
21.10.1 Decisão de ferramenta #
Decisão adotada: Umami, auto-hospedado, executando como contêiner adicional no mesmo stack
de Docker Compose descrito na Seção 25, atrás do Caddy, no subdomínio
analytics.palavradiaria.com.br, usando um banco próprio (umami) na mesma instância
PostgreSQL.
Justificativa, em ordem de peso:
- Não há transferência internacional de dados de navegação. Todo o processamento ocorre no mesmo VPS brasileiro. Isso elimina uma entrada inteira do inventário de transferência internacional da Seção 22.7.6 e simplifica a Política de Privacidade.
- Não usa cookie nem identificador persistente por padrão. O identificador de visitante é derivado de um hash com sal rotacionado diariamente, o que impede rastreamento entre dias e sustenta a classificação de tratamento anonimizado adotada na Seção 21.11.
- Custo marginal zero. O contêiner consome poucas centenas de MB de RAM e reaproveita o Postgres existente. Ferramentas hospedadas cobram por evento e ficariam entre R$ 100 e R$ 400 por mês na escala do primeiro ano.
- Script leve. Cerca de 2 KB, sem impacto relevante no LCP alvo da landing registrado na Seção 9.12.
- Licença permissiva e exportação livre. Os dados ficam em tabelas do nosso banco, e um
pg_dumpbasta para migrar.
Alternativa aceita, sem necessidade de nova decisão de arquitetura: Plausible Community
Edition, igualmente auto-hospedado. É intercambiável porque a instrumentação do produto passa
por um adaptador único (packages/core/src/analytics/track.ts) que expõe
track(event, props); trocar o provedor significa reescrever esse adaptador e o snippet de
carregamento, nada além disso. A troca seria justificada se for necessário funil nativo e
retenção de eventos por mais de 24 meses.
Alternativas rejeitadas, com motivo: Google Analytics 4, rejeitado por transferência internacional, uso de identificadores persistentes e necessidade de consentimento prévio para qualquer coleta, o que degradaria a amostra; Meta Pixel como ferramenta de analytics, rejeitado pelo mesmo motivo e por conflitar com a postura de privacidade do produto — ele só é carregado se houver campanha paga ativa e consentimento de marketing (Seção 21.11).
21.10.2 Catálogo de eventos de front-end #
Todo evento tem nome em snake_case inglês e propriedades em camelCase. Nenhuma propriedade
transporta dado pessoal: proibido enviar telefone, e-mail, CPF, nome, subscriberId ou
qualquer identificador que ligue o evento a uma pessoa. Essa proibição é verificada em teste
automatizado que falha o build se um payload de track() contiver chave da lista negra
(Seção 24).
| Evento | Quando dispara | Propriedades |
|---|---|---|
page_view |
Automático, a cada navegação | path, referrer, utmSource, utmMedium, utmCampaign |
landing_hero_cta_click |
Clique no botão principal do topo | variant (primary), plan (monthly|annual|none) |
landing_pricing_view |
Bloco de preços entra 50% na viewport por 1 s | plan |
landing_faq_open |
Abertura de um item da FAQ | questionId |
signup_form_start |
Primeiro foco em campo do formulário | source (hero|pricing|footer) |
signup_form_error |
Erro de validação exibido | field, issue (código, nunca o valor digitado) |
signup_submitted |
Envio aceito pelo servidor | plan, consentMarketing (booleano) |
otp_requested |
Pedido de código | channel (whatsapp|email) |
otp_verified |
Código aceito | attempts (1..5) |
otp_failed |
Código recusado | reason (expired|invalid|locked) |
optin_whatsapp_pending |
Tela de "confirme no WhatsApp" exibida | — |
checkout_started |
Abertura da etapa de pagamento | plan, billingType |
checkout_payment_method_selected |
Escolha de cartão ou PIX | billingType |
checkout_submitted |
Envio do pagamento | plan, billingType |
checkout_error |
Erro retornado no checkout | code (o code do envelope de erro da Seção 7) |
checkout_success |
Página de sucesso exibida | plan, billingType |
panel_login_started |
Início de login no painel | channel |
panel_devotional_opened |
Abertura de um devocional no acervo | daysAgo (inteiro) |
panel_audio_play |
Play no player web | daysAgo |
panel_audio_complete |
Áudio ouvido até 95% | daysAgo |
panel_resend_requested |
Pedido de reenvio manual | tier |
panel_cancel_started |
Abertura do fluxo de cancelamento | tier |
panel_cancel_confirmed |
Cancelamento confirmado | reason (motivo escolhido na lista) |
panel_cancel_abandoned |
Saída do fluxo sem confirmar | step |
cookie_consent_shown |
Banner exibido | — |
cookie_consent_decision |
Escolha do usuário | analytics (booleano), marketing (booleano) |
Propriedades globais anexadas automaticamente a todos os eventos pelo adaptador: env
(production|staging), appVersion (o mesmo valor exposto nos logs, Seção 23.1), device
(mobile|tablet|desktop, derivado da largura da viewport, não do user-agent completo) e
locale.
Relação entre analytics de front-end e daily_metrics: são fontes independentes e servem a
perguntas diferentes. Analytics responde "onde o visitante desiste na landing"; daily_metrics
responde "quantos assinantes ativos existem". Números de negócio nunca vêm do analytics de
front-end, porque bloqueadores de anúncio suprimem parte dos eventos e o número ficaria
subestimado. Se os dois discordarem, daily_metrics vence, sempre. Esta regra está escrita na
tela de crescimento como nota fixa.
21.11 Consentimento de cookies #
Classificação adotada, em três categorias:
| Categoria | Conteúdo | Base | Comportamento |
|---|---|---|---|
| Essenciais | Cookie de sessão __Host-session, cookie de proteção CSRF __Host-csrf, cookie de preferência de consentimento pd_consent |
Necessários para prestar o serviço pedido; não dependem de consentimento | Sempre ativos. O banner informa, não pergunta. |
| Analytics anônimo | Instrumentação Umami, sem cookie, sem identificador persistente, sem armazenamento do IP | Legítimo interesse, com opt-out disponível | Ativo por padrão, desligável no banner e na página de preferências |
| Marketing | Pixel de campanha paga, quando houver campanha ativa | Consentimento específico, opt-in | Desativado por padrão. Só carrega após aceite explícito. |
O que é coletado sem consentimento, de forma exaustiva:
- Cookies essenciais, listados acima, com finalidade e prazo declarados na Política de Privacidade.
- Logs de servidor (Seção 23.1): endereço IP, user-agent, método, caminho, status, latência
e
requestId. Base legal: legítimo interesse em segurança e prevenção a fraude. Retenção registrada na Seção 22.8. O IP é dado pessoal e por isso o log de acesso tem retenção curta. - Analytics anônimo: caminho da página, referenciador, parâmetros UTM, categoria de dispositivo, e um identificador de visitante derivado por hash com sal rotacionado a cada 24 h, que impede reidentificação e correlação entre dias. O IP não é armazenado; ele entra apenas no cálculo do hash e é descartado na mesma requisição.
O que não é coletado sem consentimento: pixel de rede de anúncios, mapa de calor, gravação de sessão, fingerprint de dispositivo, dados de formulário antes do envio. Nenhum desses recursos existe no produto, e a página de preferências afirma isso explicitamente.
Banner: aparece na primeira visita, ancorado no rodapé, não bloqueia a leitura da página, com
três ações de mesmo peso visual — Aceitar tudo, Somente essenciais e Preferências. Não há
padrão escuro: recusar leva o mesmo número de cliques que aceitar. A decisão é gravada no cookie
pd_consent (SameSite=Lax, Secure, 12 meses, sem HttpOnly, porque o script precisa lê-lo)
com o formato v1|analytics=1|marketing=0|ts=1787635200. Se o assinante estiver autenticado no
momento da decisão, ela também é registrada em consent_events com type = 'COOKIE_CONSENT',
que é o registro imutável descrito na Seção 22.7.3.
Renovação: o banner reaparece quando a versão da política de cookies muda (prefixo v1 do
cookie) ou 12 meses depois da última decisão. Revogação: link permanente Preferências de cookies no rodapé de todas as páginas, que reabre o painel de escolha.
21.12 Alertas de negócio #
Alertas de negócio são derivados de daily_metrics pelo job metrics.check_business_alerts,
que roda na fila metrics.rollup logo após o rollup, às 03:25, e usa o mesmo pipeline de
entrega dos alertas técnicos descrito na Seção 23.8. A separação é de origem, não de mecanismo: alerta técnico nasce de série
Prometheus, alerta de negócio nasce de daily_metrics.
| Alerta | Condição exata | Severidade | Canal | Destinatário | Ação esperada |
|---|---|---|---|---|---|
biz_signups_collapsed |
new_subscribers de D−1 < 40% da média dos 14 dias anteriores, com média ≥ 10 |
P2 | Slack #alertas-negocio + e-mail |
Produto e Marketing | Verificar landing, checkout e campanhas ativas no mesmo dia útil |
biz_optin_rate_drop |
optin_confirmation_rate (janela de 7 dias) < 60% ou queda > 15 pontos vs. 7 dias anteriores |
P2 | Slack #alertas-negocio |
Produto | Investigar template de boas-vindas e qualidade do número (Seção 27) |
biz_conversion_drop |
free_to_paid_conversion_rate_d30 da coorte fechada mais recente < 60% da mediana das 4 coortes anteriores |
P3 | Slack #alertas-negocio |
Produto | Revisar página de vendas e preço |
biz_churn_spike |
monthly_churn_rate parcial do mês corrente projetado > 6% ao mês |
P2 | Slack + e-mail | Produto e Owner | Abrir análise de churn_by_reason |
biz_involuntary_churn_spike |
involuntary_churn_rate do mês > 3% ou > 2× o voluntário |
P2 | Slack + e-mail | Financeiro | Revisar régua de cobrança e taxa de recusa de cartão |
biz_mrr_declining |
net_mrr_change_brl acumulado dos últimos 7 dias < 0 |
P2 | Slack #alertas-negocio |
Owner | Diagnóstico de aquisição versus retenção |
biz_optout_spike |
opt_out_rate de D−1 > 3× a média de 30 dias, com pelo menos 15 opt-outs absolutos |
P1 | Slack #alertas-p1 + e-mail |
Produto e Conteúdo | Ler o devocional enviado e a régua do dia antes do próximo envio |
biz_cancellation_spike |
Cancelamentos voluntários de D−1 > 3× a média de 30 dias, com pelo menos 10 absolutos |
P2 | Slack #alertas-negocio + e-mail |
Produto | Ler os motivos e comentários do dia (Seção 20.11) |
biz_delivery_rate_low |
delivery_rate de D−1 < 90% |
P1 | Slack #alertas-p1 |
Operação | Runbook de falha de entrega (Seção 27) |
biz_window_open_rate_drop |
window_open_rate (7 dias) < 20% ou queda > 10 pontos |
P3 | Slack #alertas-negocio |
Conteúdo | Revisar teaser e botões do template |
biz_cost_per_subscriber_high |
total_cost_per_subscriber_month_brl projetado > (ops.cost_per_subscriber_target_cents / 100) × 1,3 |
P2 | Slack + e-mail | Owner | Verificar mistura de estratégia de envio e categoria de conversa |
biz_video_fallback_high |
video_fallback_rate de D−1 > 15% |
P3 | Slack #alertas-negocio |
Produto | Fallback de vídeo é caro; revisar engajamento |
biz_delinquency_high |
delinquency_rate de D−1 > 20% com pelo menos 20 cobranças no dia |
P2 | Slack + e-mail | Financeiro | Conferir integração de cobrança e lembretes |
biz_recovery_low |
payment_recovery_rate da coorte fechada < 40% |
P3 | Slack #alertas-negocio |
Financeiro | Revisar mensagens de lembrete |
biz_chargeback_rate_high |
chargeback_rate_30d > 0,5% |
P2 | Slack + e-mail | Financeiro e Owner | As bandeiras costumam agir em torno de 1%; investigar antes de chegar lá (Seção 12.14.2) |
biz_chargeback_rate_critical |
chargeback_rate_30d > 0,9% |
P1 | Slack #alertas-p1 + e-mail |
Owner | Risco à conta de recebimento inteira; acionar a Asaas no mesmo dia |
biz_content_pipeline_low |
content_pipeline_days < 3 |
P1 | Slack #alertas-p1 + e-mail |
Editorial e Owner | Produzir conteúdo antes do próximo envio |
biz_metrics_stale |
Nenhuma linha de daily_metrics para D−1 até as 08:00 |
P2 | Slack #alertas-p2 |
Técnico | Runbook de rollup (Seção 27) |
Regras de supressão, para que o canal continue confiável:
- Cada alerta dispara no máximo uma vez por dia. Um alerta que persista por mais de 3 dias
consecutivos é reenviado com o prefixo
[PERSISTENTE, 4º dia]e sobe uma severidade. - Alertas com denominador pequeno são suprimidos: se o denominador da métrica do dia for menor
que 20, o alerta não dispara e o job registra
suppressed_low_volumeno log. Isso evita o ruído dos primeiros meses, quando 2 opt-outs em 30 assinantes viram "600% acima da média". - Todo alerta de negócio traz no corpo: valor atual, valor de referência, período comparado, link direto para a tela do dashboard com o período já filtrado, e o link do runbook.
21.13 Precisão e integridade dos números #
21.13.1 Como o sistema evita contagem dupla #
Cinco mecanismos, em camadas:
- Chave de idempotência no envio. Cada envio grava
delivery_attemptscom a chave únicasend:{subscriberId}:{devotionalDate}(regra registrada na Seção 18). Um replay do job não cria uma segunda tentativa, logo não inflamessages_sent. - Identificador da Meta como chave natural de mensagem.
message_logsguarda owamidretornado pela API com índice único. Um webhook de status reentregue atualiza a linha existente em vez de criar outra. A Meta reentrega webhooks com frequência; sem esse índice, a taxa de entrega passaria de 100%. - Idempotência de evento de pagamento.
payment_eventstem índice único noevent.idda Asaas (regra registrada na Seção 12). O mesmo evento processado duas vezes não gera duas transições emsubscription_events, logo não conta dois churns. - Contagem por entidade distinta, não por linha, onde faz sentido.
window_open_ratecontadistinct subscriber_id, e não linhas deinbound_messages— um assinante que manda cinco mensagens abriu uma janela, não cinco. - Upsert por chave natural no rollup. A constraint
UNIQUE (metric_date, metric_key, dimension)torna o rollup idempotente por construção. Rodar o job duas vezes para o mesmo dia produz exatamente o mesmo resultado.
Cenário concreto que os cinco mecanismos resolvem juntos: o worker envia 3.000 mensagens, cai
antes de confirmar o job, o BullMQ re-executa. Sem idempotência, messages_sent marcaria 6.000
e whatsapp_cost_brl dobraria — e a divergência com a fatura da Meta só apareceria no fim do
mês. Com a chave send:{subscriberId}:{devotionalDate}, a segunda execução encontra as
tentativas já gravadas, não reenvia e não registra nada novo.
21.13.2 Fuso horário na agregação diária #
O banco guarda timestamptz em UTC (convenção registrada na Seção 6). O dia de negócio é o dia
civil de America/Sao_Paulo. A conversão é feita uma vez, na borda da query de rollup, com
uma função utilitária compartilhada, e nunca com offset fixo.
// packages/core/src/metrics/day-window.ts
import { fromZonedTime } from 'date-fns-tz'
const TZ = 'America/Sao_Paulo'
/** Retorna [início, fim) em UTC para o dia civil informado em America/Sao_Paulo. */
export function civilDayWindowUtc(civilDate: string): { start: Date; end: Date } {
const start = fromZonedTime(`${civilDate}T00:00:00`, TZ)
const end = fromZonedTime(`${addDays(civilDate, 1)}T00:00:00`, TZ)
return { start, end }
}Toda query de rollup recebe start e end como parâmetros e usa comparação semiaberta
>= start AND < end. Consequências obrigatórias:
- Nunca
BETWEENcom timestamps.BETWEENé fechado nos dois lados e inclui exatamente a meia-noite do dia seguinte, duplicando eventos na fronteira. - Nunca
date_trunc('day', sent_at)semAT TIME ZONE. Isso truncaria em UTC e jogaria todos os eventos entre 21:00 e 23:59 de São Paulo para o dia seguinte. No caso deste produto, isso é catastrófico: um envio das 06:00 é 09:00 UTC e ficaria correto, mas um opt-out às 22:30 apareceria no dia errado, deslocando a correlação entre devocional e opt-out. - O horário de verão brasileiro está extinto desde 2019, mas o código usa a base de fusos do
sistema por meio de
date-fns-tze nunca-03:00embutido. Se a regra voltar, os dias de 23 h e 25 h serão tratados corretamente sem mudança de código, e a comparação semiaberta continuará correta. O teste de rollup inclui um caso com data de transição de fuso histórica para garantir isso (Seção 24). - A coluna
metric_dateé o dia civil de São Paulo, sempre. O front-end nunca reconverte: ele exibemetric_datecomo veio.
Exemplo concreto do erro evitado: no dia 2026-08-24, o envio ocorreu às 06:03 (09:03 UTC) e um assinante deu opt-out às 21:47 (2026-08-25T00:47 UTC). Agregando em UTC, o opt-out apareceria em 25/08 e a análise "o devocional de 24/08 causou opt-outs?" mostraria zero. Agregando com o fuso correto, ele cai em 24/08, junto do devocional que o motivou.
21.13.3 Como um dia recalculado é corrigido #
Procedimento fechado, em cinco passos:
- Diagnóstico com
--dry-run. O operador roda o comando com--dry-runpara o intervalo suspeito e lê o diff por métrica. - Decisão sobre versão. Se a divergência vem de dado atrasado,
rollup_versionpermanece. Se vem de mudança de fórmula, a versão da métrica é incrementada no código e o deploy acontece antes do reprocessamento. - Execução com justificativa.
pnpm ops metrics:rollup --from … --to … --force --reason "…". O--reasoné obrigatório e vai paraadmin_audit_log. - Registro visível. Toda linha reescrita atualiza
computed_at. A tela afetada passa a exibir, no rodapé, "Série recalculada em<data>" durante 7 dias, para que ninguém compare um relatório impresso antigo com a tela nova sem perceber a mudança. - Comunicação. Se a correção mudar uma métrica publicada externamente (por exemplo, MRR
informado a um sócio), a mudança é anunciada no canal
#alertas-negociocom o valor antigo, o novo e a causa. Correção silenciosa de número financeiro é proibida.
Regra de imutabilidade prática: daily_metrics não é append-only — ela é sobrescrita sob
controle. O registro de auditoria de quem sobrescreveu, quando e por quê é o que fornece a
rastreabilidade. A tabela append-only correspondente é admin_audit_log (Seção 23.11).
21.13.4 Reagregação correta de taxas #
Regra única e obrigatória, repetida aqui porque é a fonte mais comum de números errados em painéis: para obter uma taxa de um período de vários dias, some numeradores e denominadores e divida uma vez. Nunca calcule a média das taxas diárias.
Demonstração com números reais do catálogo. Dois dias de delivery_rate:
| Dia | Numerador (entregues) | Denominador (enviadas) | Taxa do dia |
|---|---|---|---|
| 2026-08-23 | 11.701 | 12.051 | 97,10% |
| 2026-08-24 | 45 | 90 | 50,00% |
Média das taxas diárias: (97,10% + 50,00%) / 2 = 73,55% — número sem significado, porque trata um dia de 90 mensagens como igual a um dia de 12.051. Reagregação correta: (11.701 + 45) / (12.051 + 90) = 11.746 / 12.141 = 96,75%.
É por isso que numerator e denominator são colunas de primeira classe em daily_metrics
(Seção 21.4) e por isso o endpoint de série faz a reagregação no servidor (Seção 21.9.1). O
cliente nunca calcula taxa a partir de valores já divididos.
Para métricas de snapshot (active_subscribers, mrr_brl, active_subscriptions), a
agregação de período é o último valor do período, não a soma nem a média. Somar
active_subscribers de 30 dias produziria 270 mil assinantes, o que é um número que já apareceu
em relatório de produto real e nunca deve aparecer neste.
21.13.5 Verificação cruzada e testes de sanidade #
O job de rollup termina executando um conjunto de invariantes. Qualquer violação grava log em
warn, registra a métrica metrics_sanity_violation_total (Seção 23.4) e, quando a violação é
grave, falha o job.
| Invariante | Verificação | Gravidade |
|---|---|---|
| Soma dos tiers | active_subscribers_free + active_subscribers_paid = active_subscribers |
Grave: falha o job |
| Taxas no intervalo | Toda métrica com unit = 'ratio' tem 0 ≤ value ≤ 1 |
Grave: falha o job |
| Denominador consistente | numerator ≤ denominator para toda taxa |
Grave: falha o job |
| Custo não negativo | total_cost_brl ≥ 0 e cada componente ≥ 0 |
Grave: falha o job |
| Continuidade da base | abs(active_subscribers[D] − active_subscribers[D−1]) ≤ max(50, 5% da base) |
Aviso: registra e alerta biz_metrics_anomaly |
| MRR versus assinaturas | mrr_brl entre active_subscriptions × 16,58 e active_subscriptions × 19,90 (com folga de 5% para mudanças de preço) |
Aviso |
| Cobertura de chaves | Todas as chaves escalares do catálogo têm linha no dia | Aviso: indica coletor quebrado |
| Conciliação de receita | gross_revenue_brl do mês difere em menos de 1% da soma de payments conciliados com a Asaas |
Aviso mensal, cruzado com a reconciliação da Seção 12 |
22. Segurança, Privacidade e LGPD #
O produto trata dados que a Lei nº 13.709/2018 classifica como pessoais sensíveis. A assinatura de um devocional cristão revela convicção religiosa do titular (Art. 5º, II). Essa constatação, registrada aqui e não negociável, muda o regime jurídico de todo o tratamento: a base legal precisa ser consentimento específico e destacado (Art. 11, I), o relatório de impacto deixa de ser opcional, e o padrão de segurança técnica exigido é mais alto do que o de um produto de assinatura comum. Toda decisão desta seção parte daí.
22.1 Modelo de ameaças #
22.1.1 Ativos #
| Ativo | Por que vale | Impacto se comprometido |
|---|---|---|
| Base de assinantes (telefone, nome, CPF, e-mail) associada a convicção religiosa | Dado sensível, monetizável, com valor para spam e para discriminação | Crítico: sanção da ANPD, ação civil, dano reputacional irreversível |
| Chaves de criptografia de coluna e de índice cego | Destravam telefone e CPF em massa | Crítico |
Token da WhatsApp Cloud API e META_APP_SECRET |
Permitem enviar mensagens em nome da marca | Crítico: banimento do número e perda do canal |
| Chave de API da Asaas | Permite criar, estornar e consultar cobranças | Crítico: fraude financeira |
Reputação do número do WhatsApp (quality_rating) |
Não é dado, é ativo operacional: sem número, não há produto | Crítico: produto para de funcionar |
| Sessões administrativas | Acesso a base inteira e a exportações | Alto |
| Conteúdo editorial e áudios | Propriedade intelectual, custo de produção | Médio |
Registros de consentimento (consent_events) |
São a prova de conformidade | Alto: sem eles, o opt-in é indefensável |
22.1.2 Atores #
| Ator | Motivação | Capacidade típica |
|---|---|---|
| Bot de cadastro em massa | Esgotar OTP, gerar custo, poluir base | Automação simples, IPs residenciais rotativos |
| Raspador de base | Enumerar assinantes por telefone | Requisições em volume contra endpoints públicos |
| Atacante oportunista | Exploração de CVE em dependência ou serviço exposto | Ferramentas automatizadas de varredura |
| Fraudador de pagamento | Assinar com cartão de terceiro, gerar chargeback | Cartões testados, dados de CPF válidos |
| Ex-administrador | Acesso residual após desligamento | Credenciais válidas até revogação |
| Insider com acesso legítimo | Exportar base por curiosidade ou venda | Painel administrativo e exportação CSV |
| Comprometimento de terceiro (Meta, Asaas, TTS, storage) | Fora do nosso controle | Vazamento indireto |
22.1.3 Superfícies de ataque #
Landing e formulário de cadastro; endpoint de solicitação e verificação de OTP; checkout com
CPF e cartão; webhooks públicos de Asaas e Meta; painel do assinante; painel administrativo;
exportações CSV/JSON; URLs assinadas do storage de mídia; endpoint /metrics; acesso SSH e
Docker no VPS; cadeia de dependências npm; imagem de contêiner; backups.
22.1.4 Riscos priorizados #
Escala: probabilidade × impacto, ambos de 1 a 5.
| # | Risco | Prob. | Imp. | Prior. | Controle principal |
|---|---|---|---|---|---|
| R1 | Vazamento da base com telefone, CPF e inferência religiosa | 2 | 5 | 10 | Criptografia de coluna (22.5), menor privilégio (22.12), retenção curta (22.8) |
| R2 | Comprometimento do token da Meta e envio em nome da marca | 2 | 5 | 10 | Segredos fora do repositório e rotação (22.4) |
| R3 | Enumeração de assinantes por telefone em endpoint público | 4 | 3 | 12 | Respostas indistinguíveis e rate limit (22.6.2) |
| R4 | Cadastro em massa por bot esgotando OTP e gerando custo | 4 | 3 | 12 | Rate limit em camadas, anti-bot, bloqueio de descartáveis (22.6) |
| R5 | Sequestro de sessão administrativa | 2 | 5 | 10 | Cookie __Host-, TTL de 12 h, TOTP obrigatório (Seção 8) |
| R6 | Webhook forjado alterando estado de assinatura | 3 | 4 | 12 | HMAC e token comparados em tempo constante (22.6.5) |
| R7 | Injeção de conteúdo malicioso pelo editor (XSS armazenado) | 2 | 4 | 8 | Sanitização de markdown na renderização (22.2.4) |
| R8 | SSRF por URL controlada externamente | 2 | 4 | 8 | Lista fechada de destinos em safeFetch (22.2.6) |
| R9 | Dependência comprometida na cadeia de suprimentos | 3 | 4 | 12 | Lockfile, auditoria em CI, build reprodutível (22.12.4) |
| R10 | Perda de dados por falha de backup | 2 | 5 | 10 | Backup cifrado, teste de restauração mensal (Seção 25) |
| R11 | Exportação indevida da base por insider | 2 | 4 | 8 | Auditoria de exportação, limites, revisão trimestral (22.12.3) |
| R12 | Chargeback em massa | 3 | 3 | 9 | Revogação imediata, CPF obrigatório, limites de cadastro |
22.2 Controles de segurança da aplicação #
22.2.1 Validação de entrada #
Toda entrada externa passa por um schema Zod (linha de versão na Seção 4) na borda, antes
de qualquer lógica. "Borda" significa: route handler HTTP, consumidor de fila, parser de webhook
e argumento de comando da CLI de operação. Nenhuma função de domínio aceita any, unknown ou
objeto não validado.
Regras obrigatórias:
- Schemas usam
.strict()por padrão. Campo desconhecido no corpo gera422 VALIDATION_ERROR, não é ignorado silenciosamente. Motivo: um campo extra aceito é a porta de entrada de poluição de protótipo e de atribuição em massa. - Todo
stringtem.max(). Sem limite explícito, uma requisição com 50 MB de texto chega ao banco. O limite global de corpo é 1 MB, exceto no upload administrativo (22.2.7). - Números monetários chegam como inteiro em centavos (
*_amount_cents), nunca como float. Custos unitários muito pequenos chegam em milionésimos de real (*_cost_micros). As duas unidades nunca se misturam na mesma fórmula sem conversão explícita (Seção 6). - Datas chegam como
YYYY-MM-DDou ISO 8601 com fuso, validadas por regex e depois por parser. - Telefone é validado aqui e em nenhum outro lugar. O valor chega como texto livre, é normalizado e validado em E.164 pela regra da Seção 11.4, na borda, antes de ser cifrado. Essa é a única validação de formato que existe: a coluna correspondente guarda um envelope cifrado e o banco não pode inspecioná-la (22.5.3).
- Identificadores chegam como ULID de 26 caracteres validados por
z.string().length(26).regex(/^[0-9A-HJKMNP-TV-Z]{26}$/). - O erro devolvido segue o envelope da Seção 7:
code,messageem português edetailscomfieldeissue.detailsnunca ecoa o valor recebido, apenas o código do problema — ecoar o valor cria refletor de conteúdo controlado pelo atacante.
export const subscriberSignupSchema = z.object({
name: z.string().trim().min(2).max(80),
phone: z.string().trim().min(10).max(20),
email: z.string().trim().toLowerCase().email().max(160).optional(),
consentService: z.literal(true), // aceite obrigatório
consentReligiousData: z.literal(true), // aceite específico e destacado (22.7.2)
consentMarketing: z.boolean().default(false),
consentTextVersion: z.string().regex(/^v\d+$/),
turnstileToken: z.string().min(10).max(4096),
}).strict()22.2.2 Injeção de SQL #
Todo acesso a dados passa pelo Prisma (linha de versão na Seção 4), que parametriza as consultas. As três regras que fecham o restante da superfície:
$queryRawUnsafee$executeRawUnsafesão proibidos. A proibição é imposta por regra de ESLint (no-restricted-properties) que falha o build.$queryRawé permitido apenas com template tag e interpolação de parâmetro (Prisma.sql), o que gera consulta parametrizada. As queries de rollup da Seção 21.5 são o caso legítimo.- Identificadores dinâmicos são resolvidos por lista fechada. Ordenação por coluna vinda da
query string mapeia uma string do usuário para um valor de um
Record<string, Prisma.Sql>definido em código. Nunca há concatenação de nome de coluna.
O usuário de banco da aplicação não tem CREATE, DROP nem ALTER; migrações rodam com um
usuário separado (22.12.2). Assim, mesmo uma injeção bem-sucedida não altera o schema.
22.2.3 Escapamento de saída e XSS refletido #
React escapa por padrão toda interpolação em JSX. As regras que sustentam isso:
dangerouslySetInnerHTMLé proibido, com uma única exceção controlada: a renderização dereflection_md, tratada em 22.2.4. A proibição é imposta por regra de ESLint com exceção nomeada em um único arquivo.- URLs vindas de dados nunca vão direto para
hrefousrc. Passam porassertSafeUrl(value, ['https:']), que rejeitajavascript:,data:evbscript:. - Nenhum dado do usuário entra em contexto de
<script>,<style>, atributo de evento oueval. Não há uso deeval,new FunctionnemsetTimeoutcom string em nenhum ponto. - Respostas de API são sempre
Content-Type: application/json; charset=utf-8, comX-Content-Type-Options: nosniff(22.3), o que impede que um JSON com conteúdo controlado seja interpretado como HTML.
22.2.4 XSS armazenado no conteúdo editorial #
reflection_md é markdown escrito por um editor humano autenticado. É o único conteúdo do
sistema que vira HTML. Pipeline obrigatório, tanto na renderização web quanto na conversão para
texto de WhatsApp:
// packages/core/src/content/render-markdown.ts
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import rehypeStringify from 'rehype-stringify'
const schema = {
...defaultSchema,
tagNames: ['p', 'br', 'strong', 'em', 'blockquote', 'ul', 'ol', 'li', 'h3', 'h4', 'a'],
attributes: { a: ['href', 'title'] },
protocols: { href: ['https', 'mailto'] },
}
export function renderReflection(md: string): string {
return unified()
.use(remarkParse)
.use(remarkRehype) // sem allowDangerousHtml: HTML cru no markdown é descartado
.use(rehypeSanitize, schema)
.use(rehypeStringify)
.processSync(md)
.toString()
}Decisões: a sanitização acontece na renderização, não na gravação — assim, um endurecimento
futuro do schema protege retroativamente todo o acervo, e o texto original permanece editável.
<img>, <iframe>, <script>, <style> e atributos on* não constam da lista de permitidos,
portanto são removidos. Links recebem rel="noopener noreferrer" e target="_blank" injetados
na renderização, nunca vindos do markdown.
22.2.5 CSRF #
Modelo de cookie adotado (definido na Seção 8): __Host-session, HttpOnly, Secure,
SameSite=Lax, Path=/, sem Domain. Regra de plataforma que vale para todos os cookies
do produto e não admite exceção: o prefixo __Host- só é aceito pelo navegador com Secure,
Path=/ e sem Domain; um Set-Cookie com prefixo __Host- e qualquer outro Path é
descartado silenciosamente. Por isso __Host-session, __Host-refresh e __Host-csrf são
todos emitidos com Path=/. Sobre essa base, a estratégia é defesa em três camadas, todas
obrigatórias:
SameSite=Laxjá bloqueia o envio do cookie em requisiçõesPOST,PATCH,PUTeDELETEoriginadas de outro site. Isso cobre a maior parte dos ataques clássicos.- Verificação de origem em todo método que muda estado. O middleware compara o header
Origincom a lista de origens próprias. SeOriginestiver ausente, usaSec-Fetch-Site; se este também faltar, a requisição é recusada com403 CSRF_ORIGIN_MISMATCH. RequisiçõesGETeHEADsão isentas porque não mudam estado — e nenhuma rotaGETdo produto muda estado, o que é verificado em teste. - Token de dupla submissão para rotas autenticadas por cookie. No login, o servidor emite
__Host-csrf(Secure,SameSite=Lax,Path=/, semHttpOnly, 32 bytes aleatórios em base64url). O cliente lê o valor e o envia no headerX-CSRF-Token. O servidor compara em tempo constante. Divergência gera403 CSRF_TOKEN_INVALID.__Host-csrfé o único nome deste cookie em todo o documento; nenhum outro nome, em nenhuma seção, designa o token de dupla submissão. Ele é intencionalmente legível por script e não é credencial: sozinho, não autentica nada.
// apps/web/src/lib/security/csrf.ts
const ALLOWED_ORIGINS = [
'https://palavradiaria.com.br',
'https://www.palavradiaria.com.br',
'https://app.palavradiaria.com.br',
]
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
export function assertCsrf(req: Request): void {
if (SAFE_METHODS.has(req.method)) return
// O cookie de CSRF tem um único nome em todo o documento: `__Host-csrf`.
const cookieToken = readCookie(req, '__Host-csrf')
const origin = req.headers.get('origin')
const fetchSite = req.headers.get('sec-fetch-site')
const originOk = origin
? ALLOWED_ORIGINS.includes(origin)
: fetchSite === 'same-origin' || fetchSite === 'none'
if (!originOk) throw new AppError('CSRF_ORIGIN_MISMATCH', 403)
const header = req.headers.get('x-csrf-token')
if (!cookieToken || !header || !timingSafeEqualStr(cookieToken, header)) {
throw new AppError('CSRF_TOKEN_INVALID', 403)
}
}Isenções explícitas e justificadas: POST /api/webhooks/asaas e POST /api/webhooks/whatsapp
não usam cookie de sessão e são autenticados por segredo compartilhado e HMAC (22.6.5); aplicar
CSRF a eles seria incoerente. Toda rota isenta é declarada em uma lista literal no middleware, e
um teste garante que nenhuma outra rota autenticada por cookie escape da verificação.
22.2.6 SSRF #
Regra estrutural: nenhuma URL fornecida por usuário, assinante, administrador ou webhook é usada como destino de requisição de saída. Não existe funcionalidade de "informe a URL da sua imagem" em nenhum ponto do produto — a ausência dessa funcionalidade é uma decisão de segurança, não um esquecimento.
Todas as chamadas externas passam por safeFetch, que aplica lista fechada de destinos:
// packages/integrations/src/http/safe-fetch.ts
const EGRESS_ALLOWED_HOSTS = new Set([
'graph.facebook.com',
'api.asaas.com',
'api-sandbox.asaas.com',
'api.elevenlabs.io',
'texttospeech.googleapis.com',
'api.resend.com',
'challenges.cloudflare.com', // verificação anti-bot (22.6.4)
new URL(env.S3_ENDPOINT).hostname, // storage de mídia
])
export async function safeFetch(url: string, init: RequestInit = {}): Promise<Response> {
const u = new URL(url)
if (u.protocol !== 'https:') throw new AppError('EGRESS_PROTOCOL_BLOCKED', 500)
if (!EGRESS_ALLOWED_HOSTS.has(u.hostname)) throw new AppError('EGRESS_HOST_BLOCKED', 500)
const addresses = await dns.lookup(u.hostname, { all: true })
const pinned = addresses.find((a) => !isPrivateAddress(a.address))
if (!pinned || addresses.some((a) => isPrivateAddress(a.address))) {
throw new AppError('EGRESS_PRIVATE_ADDRESS', 500)
}
return fetch(u, {
...init,
redirect: 'manual',
signal: AbortSignal.timeout(15_000),
// @ts-expect-error agente com lookup fixado no endereço já validado
dispatcher: agentPinnedTo(pinned.address, u.hostname),
})
}A fixação do endereço é o que efetivamente impede a religação de DNS. Resolver o nome,
conferir o resultado e depois entregar o nome ao fetch deixa o cliente resolver de novo:
entre as duas resoluções o registro pode mudar, e a verificação vira decoração. Por isso o
endereço validado é fixado no agente da requisição. Com a lista fechada de destinos o risco
prático é baixo, mas a proteção precisa corresponder ao que o código afirma fazer.
EGRESS_ALLOWED_HOSTS é a lista completa de destinos de saída do produto. Um teste percorre
as Seções 12, 16, 17, 22 e 25 procurando nomes de host citados como destino de chamada externa e
falha se algum não constar da lista — foi assim que a ausência do verificador anti-bot passaria
despercebida até o dia do lançamento. O nome da constante é deliberadamente distinto de
ALLOWED_ORIGINS (Seção 26.3), que é a lista de origens de entrada aceitas em CORS: são
coisas diferentes e nomes quase iguais produzem o erro de configurar uma pensando na outra.
redirect: 'manual' é deliberado: um redirecionamento aceito automaticamente contorna a lista
de destinos. Quando um provedor legítimo responde 3xx — o caso real é o download de mídia da
Meta, que devolve uma URL de CDN —, o código trata o redirecionamento explicitamente e revalida
o novo destino contra um sufixo de domínio permitido (*.fbcdn.net, *.cdninstagram.com) antes
de seguir. isPrivateAddress rejeita 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16,
127.0.0.0/8, 169.254.0.0/16, ::1 e fc00::/7.
O contêiner do worker roda com egressos restritos no nível do Docker: apenas 443/TCP para fora, sem acesso à rede de gerenciamento do host, o que fecha o caminho para metadados de nuvem.
22.2.7 Upload de arquivo #
Upload existe em um único lugar: painel administrativo, para arte de capa do devocional e para substituição manual de áudio. Assinantes não fazem upload em nenhuma circunstância.
| Regra | Valor |
|---|---|
| Papel exigido | EDITOR, ADMIN ou OWNER |
| Tipos aceitos, imagem | image/jpeg, image/png, image/webp |
| Tipos aceitos, áudio | audio/mpeg, audio/ogg |
| Tamanho máximo, imagem | 5 MB |
| Tamanho máximo, áudio | 16 MB |
| Dimensão máxima, imagem | 4000 × 4000 px |
| Duração máxima, áudio | 480 s (8 minutos) |
| Verificação de conteúdo | Assinatura binária real com file-type, ignorando Content-Type e extensão declarados |
| Reprocessamento | Imagem re-codificada com sharp para WebP, removendo EXIF e qualquer payload embutido; áudio validado e re-encodado com ffprobe/ffmpeg |
| Nome no storage | media/{YYYY}/{MM}/{ulid}.{ext} — o nome original é descartado, nunca reutilizado |
| Bucket | Privado. Nenhum objeto é público. Entrega por URL assinada com TTL de 15 minutos. |
| SVG | Rejeitado sempre. SVG é um documento executável. |
| Antivírus | Não há varredura antivírus. Justificativa: o conteúdo é sempre re-codificado e nunca executado nem servido do mesmo domínio da aplicação. |
Os dois limites de áudio — 16 MB e 480 segundos — são os da plataforma de mensageria, definidos na seção dona do pipeline de áudio (Seção 16.8), e não valores próprios desta subseção. Áudio substituído manualmente segue exatamente as mesmas regras do áudio gerado: um arquivo que a plataforma recusaria não pode entrar pelo painel. Aceitar 20 MB aqui produziria um arquivo impossível de entregar, descoberto só no envio das 06:00.
Erros: 413 FILE_TOO_LARGE, 415 UNSUPPORTED_MEDIA_TYPE (tipo declarado fora da lista),
422 FILE_CONTENT_MISMATCH (assinatura binária não corresponde ao declarado),
422 FILE_DIMENSIONS_EXCEEDED, 422 AUDIO_TOO_LONG, 500 STORAGE_UNAVAILABLE. O upload é
idempotente por Idempotency-Key opcional; sem a chave, cada envio cria um objeto novo, e o
objeto anterior é removido do storage quando o devocional passa a apontar para o novo.
22.3 Cabeçalhos HTTP de segurança #
Aplicados pelo middleware do Next.js em toda resposta, e reforçados no Caddy para o caso de a aplicação estar fora do ar e o proxy servir página de erro.
| Cabeçalho | Valor exato |
|---|---|
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=() |
Cross-Origin-Opener-Policy |
same-origin |
Cross-Origin-Resource-Policy |
same-site |
X-DNS-Prefetch-Control |
off |
Cache-Control (rotas /api/* e páginas autenticadas) |
no-store, no-cache, must-revalidate, private |
Content-Security-Policy |
ver abaixo |
Cross-Origin-Embedder-Policy não é enviado. Justificativa registrada: require-corp
quebraria o carregamento do áudio a partir do subdomínio de mídia sem trazer benefício, porque o
produto não usa SharedArrayBuffer nem medição de alta resolução.
22.3.1 Content-Security-Policy #
Origens realmente usadas: a própria aplicação, o subdomínio de mídia
(https://media.palavradiaria.com.br, que serve áudio e imagem por URL assinada) e o subdomínio
de analytics (https://analytics.palavradiaria.com.br). Não há CDN de terceiros, não há fonte
externa — as fontes são auto-hospedadas justamente para manter a política fechada.
Política de produção, com nonce por requisição:
Content-Security-Policy:
default-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
object-src 'none';
script-src 'self' 'nonce-{NONCE}' 'strict-dynamic';
style-src 'self' 'nonce-{NONCE}';
img-src 'self' data: blob: https://media.palavradiaria.com.br;
media-src 'self' blob: https://media.palavradiaria.com.br;
font-src 'self';
connect-src 'self' https://analytics.palavradiaria.com.br https://media.palavradiaria.com.br;
manifest-src 'self';
worker-src 'self' blob:;
upgrade-insecure-requests;
report-uri /api/csp-report;
report-to csp-endpointNotas de implementação, todas necessárias para que a política funcione de fato:
- O nonce é gerado por requisição no middleware (
crypto.randomUUID()codificado em base64url), injetado no header e propagado ao Next.js. Páginas com nonce não podem ser cacheadas estaticamente; as páginas públicas de marketing, que não têm script inline, usam uma variante da política sem nonce e comscript-src 'self', o que preserva o cache estático e o alvo de LCP da Seção 20. 'strict-dynamic'é necessário porque o Next.js carrega os chunks do bundle a partir do script inicial. Com ele, os navegadores modernos ignoram a lista de hosts emscript-srce confiam na cadeia de carregamento originada do script com nonce. Por isso nenhum host é listado ali, e o script de analytics é servido do próprio domínio e carregado por uma tag que recebe o nonce da requisição. Listar um host ao lado de'strict-dynamic'é inócuo e induz ao erro de acreditar que ele está liberado.connect-srcinclui o domínio de mídia porque o player fazfetchda URL assinada antes de criar o blob.media-srcgoverna o elemento<audio>, não a requisição — sem o domínio emconnect-src, o áudio do plano pago não toca em navegador nenhum.style-srcusa nonce e não'unsafe-inline'. O Tailwind (linha de versão na Seção 4) gera folha de estilo estática, e o<style>crítico emitido pelo Next.js recebe o nonce. Onde um componente de terceiro exigir estilo inline por atributo, o componente é substituído — não se relaxa a política.img-srcincluidata:por causa de imagens SVG inline geradas em build eblob:por causa de pré-visualização de upload no painel administrativo.media-srcincluiblob:porque o player web pode receber o áudio como blob após download autenticado.frame-ancestors 'none'é o controle real de clickjacking;X-Frame-Options: DENYfica como redundância para clientes antigos.- O checkout não usa iframe de terceiro. A tokenização do cartão ocorre pelo cliente
tipado da Asaas em chamada server-side (Seção 12), o que evita ter de abrir
frame-src. report-uriaponta para uma rota interna que grava as violações como log estruturado em nívelwarn(Seção 23.1) e incrementacsp_violation_total(Seção 23.4), com rate limit de 100 relatórios por minuto por IP para não virar vetor de inundação de log.
Terceira variante — páginas com formulário (/cadastro, /contato e a tela de pedido de
código). Idêntica à política com nonce, com três acréscimos obrigatórios, e usada
exclusivamente nessas rotas:
script-src 'self' 'nonce-{NONCE}' 'strict-dynamic' https://challenges.cloudflare.com;
frame-src 'self' https://challenges.cloudflare.com;
connect-src 'self' https://analytics.palavradiaria.com.br
https://media.palavradiaria.com.br
https://challenges.cloudflare.com;Três observações que fazem essa variante funcionar de fato:
- Sem a diretiva
frame-src,default-src 'none'bloqueia o widget do verificador anti-bot, que é renderizado dentro de um frame. A diretiva precisa ser declarada, não herdada. - Como
'strict-dynamic'faz os navegadores modernos ignorarem a lista de hosts descript-src, o script do widget é carregado por uma tag que recebe o nonce da requisição, e nunca por injeção dinâmica. O host aparece emscript-srcapenas para navegadores que não entendem'strict-dynamic'. connect-srcrecebe o mesmo host porque o widget faz chamadas próprias durante o desafio.
Sem essa variante, o cenário é o pior possível: o widget não renderiza, turnstileToken fica
vazio, o cadastro devolve 422 VALIDATION_ERROR e a taxa de conversão do lançamento é zero, com
a causa visível apenas no console do navegador. Por isso um teste de ponta a ponta abre cada rota
com formulário, coleta as violações de CSP do console e falha se houver qualquer uma — e o item
correspondente do checklist de 22.13 cita nominalmente as páginas com formulário, não apenas as
"páginas principais".
Em staging, a política é idêntica, mas enviada também como
Content-Security-Policy-Report-Only com as diretivas candidatas ao próximo endurecimento, o
que permite testar mudanças sem quebrar o ambiente.
22.4 Segredos #
22.4.1 Onde vivem e como são injetados #
| Camada | Regra |
|---|---|
| Repositório | Nenhum segredo, nunca. .env.example traz apenas nomes e placeholders. .gitignore cobre .env* exceto .env.example. |
| Servidor | Arquivos em /etc/palavra-diaria/secrets/, um arquivo por segredo, modo 0400, dono root. |
| Contêineres | Injetados como Docker secrets, montados em /run/secrets/<nome>. A aplicação lê VAR_FILE quando presente e cai para VAR apenas em desenvolvimento local. |
| CI | GitHub Actions Secrets, escopados por ambiente, sem acesso a partir de pull request de fork. |
| Desenvolvimento local | .env.local com credenciais de sandbox exclusivamente. Nenhum segredo de produção em máquina de desenvolvedor. |
A leitura de configuração acontece uma única vez, na inicialização, por um schema Zod que valida presença e formato de todos os segredos. Se faltar um segredo obrigatório, o processo não sobe — falha alta e imediata, em vez de erro obscuro na primeira chamada. O registro completo das variáveis está na Seção 26.
Proteções contra vazamento acidental: os valores nunca são logados (a configuração de redação
está em 23.1); um toJSON() sobrescrito no objeto de configuração devolve [REDACTED] para
chaves sensíveis; o rastreamento de pilha em produção não inclui variáveis de ambiente; e um
hook de pré-commit roda gitleaks para barrar segredo colado por engano.
22.4.2 Inventário e rotação #
A cadência de rotação de cada segredo é a da Seção 26.7.3, que é a dona única do registro de configuração e, portanto, do prazo. Esta subseção não repete prazos: ela declara para que serve cada segredo e como rotacioná-lo sem derrubar o produto. As três âncoras de cadência fixadas em 26.7.3 são 90 dias para a chave de assinatura de sessão, 180 dias para as senhas de banco e de Redis, e 365 dias para as chaves de cifra.
| Segredo | Uso | Procedimento de rotação |
|---|---|---|
DATABASE_URL (senha) |
Acesso ao Postgres | Criar segunda senha com ALTER ROLE, atualizar segredo, reiniciar web e worker em sequência, confirmar conexões, revogar a antiga |
REDIS_PASSWORD |
Fila e cache | Redis aceita requirepass novo com reinício; janela de manutenção de 2 minutos fora do horário de envio |
SESSION_JWT_PRIVATE_KEY / SESSION_JWT_PUBLIC_KEY (EdDSA) |
Assinatura de sessão | Publicar novo par com kid novo, manter o anterior na lista de verificação por 30 dias, remover depois. Sessões vigentes continuam válidas. |
ENCRYPTION_KEY + ENCRYPTION_KEY_PREVIOUS |
Criptografia de coluna | A chave corrente vira a anterior, uma chave nova entra como corrente, o job de re-criptografia converte o histórico, a anterior é removida ao fim (22.5.5 e Seção 26.7.4) |
PHONE_INDEX_KEY + PHONE_INDEX_KEY_PREVIOUS |
HMAC do índice cego | Rotação na mesma janela de ENCRYPTION_KEY, com chave anterior aceita em leitura e backfill de reescrita (22.5.5) |
WHATSAPP_SYSTEM_USER_TOKEN |
Envio pela Cloud API | Gerar novo token no painel da Meta, atualizar segredo, validar com envio de teste ao número interno, revogar o antigo |
META_APP_SECRET |
HMAC do webhook da Meta | Rotacionar no painel da Meta; aceitar as duas assinaturas por 24 h com META_APP_SECRET_PREVIOUS |
WHATSAPP_VERIFY_TOKEN |
Verificação inicial do webhook | Atualizar segredo e reconfigurar a assinatura na Meta |
ASAAS_API_KEY |
Cobrança | Gerar nova chave no painel da Asaas, atualizar, validar com GET /v3/finance/balance, revogar a antiga |
ASAAS_WEBHOOK_TOKEN |
Autenticação do webhook | Aceitar token novo e anterior por 24 h via ASAAS_WEBHOOK_TOKEN_PREVIOUS, atualizar no painel da Asaas, remover o anterior |
ELEVENLABS_API_KEY |
TTS primário | Rotação simples; falha só afeta geração de áudio, com fallback ativo |
GOOGLE_TTS_CREDENTIALS_JSON |
TTS de fallback | Nova conta de serviço, chave antiga desativada após validação |
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY |
Storage de mídia | Criar par novo, atualizar, validar leitura e escrita, apagar o antigo |
RESEND_API_KEY |
E-mail transacional | Rotação simples |
METRICS_TOKEN |
Proteção de /api/internal/metrics, /api/internal/ready e /api/internal/version |
Atualizar segredo e a configuração do coletor de métricas e do monitor externo na mesma janela |
BACKUP_ENCRYPTION_KEY |
Cifra dos backups | Nunca descartar chave antiga enquanto existir backup cifrado com ela; guardar em cofre offline |
TURNSTILE_SECRET_KEY |
Anti-bot | Rotação no painel do provedor |
Rotação fora de cadência é sempre permitida e sempre obrigatória sob suspeita de vazamento: o prazo de 26.7.3 é o teto, nunca o piso.
Os nomes acima são os nomes canônicos definidos pela Seção 26, que é a dona do registro de variáveis de ambiente. Nenhum apelido é aceito em código, em arquivo de composição ou em script de inicialização; a regra e a lista de apelidos proibidos estão em 26.1.1, e um teste de pipeline reprova qualquer nome fora do registro de 26.3.
Toda rotação é registrada em admin_audit_log com action = 'SECRET_ROTATED', o nome do
segredo (nunca o valor), o operador e a data. Um job mensal verifica a data da última rotação de
cada segredo em settings, sob a chave ops.secret_rotated_at (Seção 26.8.1), e dispara o
alerta secret_rotation_due (Seção 23.8) quando o prazo vence.
22.4.3 Resposta a vazamento de segredo #
Prazos contados a partir da suspeita, não da confirmação.
| Momento | Ação |
|---|---|
| 0–15 min | Revogar o segredo no provedor de origem. Revogação vem antes de investigação: um segredo revogado não faz mais dano. |
| 15–30 min | Emitir substituto, atualizar o arquivo de segredo, reiniciar os serviços afetados, confirmar operação normal. |
| 30–60 min | Levantar o escopo: quando o segredo foi criado, onde esteve, quem teve acesso, quais requisições usaram ele. Consultar admin_audit_log e os logs de acesso. |
| 1–4 h | Avaliar impacto sobre dados pessoais. Se houve ou pode ter havido acesso a dados de titulares, o procedimento de incidente da Seção 22.7.8 é acionado em paralelo. |
| 4–24 h | Para SESSION_JWT_PRIVATE_KEY: invalidar todas as sessões (DELETE FROM sessions) e forçar novo login. Para ENCRYPTION_KEY ou PHONE_INDEX_KEY: iniciar re-criptografia e reindexação imediatas (22.5.5). Para WHATSAPP_SYSTEM_USER_TOKEN: auditar mensagens enviadas no período e verificar quality_rating. |
| 24–72 h | Relatório pós-incidente conforme Seção 23.10, incluindo a causa raiz da exposição e a correção que impede a repetição. |
Casos especiais: se o segredo vazou em um commit, revogar e reescrever o histórico não é suficiente — assume-se comprometido de forma permanente, porque cópias e espelhos podem existir. Se vazou em log, o expurgo do log é feito, mas a revogação continua obrigatória.
22.5 Criptografia #
22.5.1 Em trânsito #
TLS terminado no Caddy (linha de versão na Seção 4), com certificados Let's Encrypt renovados
automaticamente. Versão mínima TLS 1.2, com TLS 1.3 preferido e negociado por padrão;
TLS 1.0 e 1.1 desabilitados. Conjuntos de cifras limitados aos AEAD com sigilo encaminhado
(TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256 e os
equivalentes ECDHE em 1.2). HSTS com pré-carregamento (22.3). O tráfego entre contêineres corre
em rede Docker interna, não exposta ao host; a conexão com o Postgres usa sslmode=require
mesmo dentro da rede interna, para que um erro futuro de exposição de porta não resulte em
tráfego em texto claro.
Manter TLS 1.2 é uma decisão consciente: parte dos aparelhos Android antigos usados pelo público do produto não negocia 1.3, e recusá-los excluiria assinantes reais sem ganho proporcional de segurança.
22.5.2 Em repouso #
| Camada | Controle |
|---|---|
| Volume do banco | Disco do VPS cifrado com LUKS (AES-256-XTS). A chave é fornecida na inicialização e não fica no próprio disco. |
| PostgreSQL | Sem cifra nativa por tablespace; a proteção é o disco cifrado mais a criptografia de coluna (22.5.3). |
| Storage de mídia | Criptografia do lado do servidor habilitada no bucket. Bucket privado, sem acesso anônimo, sem listagem. |
| Backups | pg_dump comprimido e cifrado com age usando BACKUP_ENCRYPTION_KEY antes de sair do host. Backup nunca trafega nem repousa em texto claro. |
| Logs | Não recebem dado pessoal por construção (23.1). O volume de log é cifrado pelo mesmo LUKS. |
22.5.3 Criptografia adicional em nível de aplicação #
Decisão: telefone e CPF são cifrados na aplicação, antes de chegar ao banco. Justificativa: são os dois identificadores que, combinados à simples existência da linha, revelam convicção religiosa — o dado sensível do produto. Disco cifrado protege contra roubo físico da máquina; não protege contra um dump obtido por credencial de banco vazada, que é o cenário R1 do modelo de ameaças. A criptografia de coluna fecha exatamente essa lacuna.
Esta subseção não declara coluna nenhuma. As colunas cifradas e as colunas de índice cego
correspondentes — subscribers.phone_e164 e subscribers.phone_hmac, subscribers.wa_id e
subscribers.wa_id_hmac, subscribers.email e subscribers.email_hmac,
subscriber_profiles.cpf e subscriber_profiles.cpf_hmac — são declaradas na Seção 6
(6.3 e 6.4), existem desde a primeira migration e constam do schema.prisma e do DDL daquela
seção. Criptografia de campo e índice cego são decisões de schema, não de endurecimento
posterior: adiá-las obrigaria a reescrever toda consulta por telefone escrita nos marcos
intermediários. Quando houver dúvida sobre tipo, nulidade ou índice de qualquer uma dessas
colunas, a Seção 6 é a fonte, e esta seção não a contradiz.
O que é desta seção: o algoritmo do envelope, a gestão e a rotação das chaves, a função de índice cego (22.5.4) e o procedimento de rotação com dados já gravados (22.5.5).
Não são cifrados em nível de aplicação: subscribers.display_name (necessário em ordenação e
busca por prefixo no painel, e isoladamente de baixo risco), id, timestamps e demais colunas
operacionais.
Formato do valor armazenado e onde o formato é validado. Uma coluna cifrada guarda o
envelope v<versão>:<iv>:<ciphertext>:<tag>, e nada além disso. Consequência direta e
obrigatória: nenhum CHECK de banco pode validar o conteúdo de uma coluna cifrada — o banco
não vê o telefone, vê o envelope, e um CHECK de formato E.164 sobre essa coluna faria todo
INSERT de assinante falhar. A validação do formato E.164 é exclusivamente da camada de
aplicação, pelo schema Zod da borda (22.2.1), antes de cifrar. O único CHECK admissível sobre
essas colunas é o de forma do próprio envelope, e ele é declarado na Seção 6.
Algoritmo e formato do envelope:
// packages/core/src/crypto/field-encryption.ts
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
/**
* Duas chaves nomeadas, nunca uma família de variáveis versionadas:
* ENCRYPTION_KEY — corrente, usada em toda escrita nova;
* ENCRYPTION_KEY_PREVIOUS — opcional, aceita apenas em leitura durante a rotação.
* Os dois nomes são os canônicos do registro da Seção 26.3 e do procedimento de 26.7.4.
* O prefixo `v<n>` do envelope permanece apenas como marcador de FORMATO — ele diz qual
* é o layout do envelope, não qual chave o produziu.
*/
const ENVELOPE_FORMAT = 1
/** Formato: v<formato>:<iv b64url>:<ciphertext b64url>:<tag b64url> */
export function encryptField(plain: string, aad: string): string {
const key = currentKey() // ENCRYPTION_KEY, 32 bytes
const iv = randomBytes(12) // 96 bits, único por operação
const cipher = createCipheriv('aes-256-gcm', key, iv)
cipher.setAAD(Buffer.from(aad, 'utf8'))
const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()])
return `v${ENVELOPE_FORMAT}:${b64u(iv)}:${b64u(ct)}:${b64u(cipher.getAuthTag())}`
}
export function decryptField(envelope: string, aad: string): string {
const [v, iv, ct, tag] = envelope.split(':')
if (v !== `v${ENVELOPE_FORMAT}`) throw new AppError('INTERNAL_ERROR', 500)
// Corrente primeiro; a anterior só existe durante a janela de rotação, e fora
// dela decryptionKeys() devolve um único elemento.
for (const key of decryptionKeys()) {
try {
const decipher = createDecipheriv('aes-256-gcm', key, b64uDec(iv))
decipher.setAAD(Buffer.from(aad, 'utf8'))
decipher.setAuthTag(b64uDec(tag))
return Buffer.concat([decipher.update(b64uDec(ct)), decipher.final()]).toString('utf8')
} catch {
// Tag inválida com esta chave: tenta a próxima. Nunca vaza qual falhou.
}
}
throw new AppError('INTERNAL_ERROR', 500)
}
/** AAD amarra o texto cifrado à linha e à coluna de origem. */
export const aadFor = (table: string, column: string, id: string) => `${table}:${column}:${id}`Decisões embutidas no código acima:
- AES-256-GCM, que é cifra autenticada: adulteração do texto cifrado falha na verificação da tag em vez de produzir texto plausível.
- IV aleatório de 96 bits por operação, nunca reutilizado. Reuso de IV em GCM destrói a
segurança do modo; por isso o IV vem de
randomBytesa cada escrita, e nunca é derivado do identificador da linha. - AAD amarrando tabela, coluna e
id. Sem AAD, um atacante com acesso de escrita ao banco poderia copiar o telefone cifrado do assinante A para a linha do assinante B e o sistema decifraria sem reclamar. Com AAD, a operação falha. Isso exige que oidseja conhecido antes da cifra — o que é verdade, porque o ULID é gerado na aplicação (convenção da Seção 6). - Prefixo de formato, que permite trocar o layout do envelope no futuro sem ambiguidade na
leitura. Ele não identifica a chave: a rotação de chave é resolvida pela leitura com duas
chaves nomeadas (22.5.5 e Seção 26.7.4), não por uma família
ENCRYPTION_KEY_V<n>, que produziria duas convenções de rotação incompatíveis no mesmo produto. - As chaves ficam em
ENCRYPTION_KEYeENCRYPTION_KEY_PREVIOUS(32 bytes em base64), carregadas na inicialização, mantidas em memória e nunca gravadas no banco. Guardar a chave ao lado do dado cifrado anularia o controle inteiro.
22.5.4 Impacto em índices e busca, e o índice cego #
O problema: phone_e164 cifrado com IV aleatório produz um texto cifrado diferente a cada
escrita. Consequências diretas — o índice único do telefone deixa de funcionar, e a busca
WHERE phone_e164 = $1 deixa de existir. Como a identidade do assinante é o telefone
(regra da Seção 8.1, normalizado pela regra da Seção 11.4) e a busca por telefone acontece em
todo webhook de entrada, isso precisa ser resolvido por completo, e não contornado.
Solução adotada: índice cego determinístico com HMAC-SHA-256, em coluna separada.
As colunas de índice cego são declaradas na Seção 6, em 6.3 e 6.4, junto das colunas
cifradas que acompanham: phone_hmac, wa_id_hmac, email_hmac e cpf_hmac. Esta subseção
define como o valor é calculado e como ele é usado; a Seção 6 define tipo, nulidade e
índice de cada coluna. A restrição de unicidade do telefone, exigida pela regra de identidade da
Seção 8.1, passa a ser materializada em phone_hmac: funcionalmente é equivalente, porque HMAC é
determinístico e injetivo na prática, logo dois telefones iguais colidem e dois diferentes não.
O mesmo vale para wa_id_hmac e email_hmac, que recebem índice único parcial.
cpf_hmac é a exceção e não é único. É um índice comum, e a decisão é deliberada. Duas
razões concretas, ambas de negócio: um titular pseudonimizado mantém cpf_hmac por até 5 anos
por obrigação fiscal (22.9), e uma restrição única o impediria de voltar a assinar meses depois,
com um erro de banco que o suporte não conseguiria interpretar; e é corriqueiro no Brasil que uma
pessoa contrate para um familiar usando o próprio CPF. O papel de cpf_hmac é detectar, não
impedir: o mesmo CPF em mais de duas contas ativas soma 25 pontos na pontuação de risco de
22.6.3 e aparece como sinal na tela de assinantes, que é o comportamento desejado. Ele serve
ainda para consultar a lista de bloqueio por fraude.
// packages/core/src/crypto/blind-index.ts
import { createHmac } from 'node:crypto'
const NS = { phone: 'phone_e164', waId: 'wa_id', cpf: 'cpf', email: 'email' } as const
/** Determinístico e com namespace, para que o mesmo valor em colunas diferentes não colida. */
export function blindIndex(value: string, ns: keyof typeof NS, key: Buffer): string {
return createHmac('sha256', key).update(`${NS[ns]}|${value}`).digest('hex')
}
export const phoneIndex = (e164: string) =>
blindIndex(e164, 'phone', currentIndexKey())Por que HMAC e não hash simples: o espaço de telefones brasileiros tem cerca de um bilhão de
combinações plausíveis. Um SHA-256 sem chave seria quebrado por força bruta em minutos, e o
índice não protegeria nada. O HMAC exige a chave secreta PHONE_INDEX_KEY, que não fica no
banco — fica no arquivo de segredos do host (22.4.1) e está declarada no registro de variáveis
da Seção 26.3.9, junto de PHONE_INDEX_KEY_PREVIOUS. Um dump do banco, isolado, não permite nem
decifrar (falta ENCRYPTION_KEY) nem enumerar (falta PHONE_INDEX_KEY).
Por que uma chave separada da chave de cifra: comprometer a chave de índice permite enumerar, mas não decifrar; comprometer a chave de cifra permite decifrar as linhas obtidas, mas não localizar uma pessoa específica sem varredura completa. Chaves separadas mantêm os dois riscos separados.
O que o índice cego vaza, declarado de forma honesta: igualdade. É possível saber que duas linhas têm o mesmo telefone, e é possível, com a chave, confirmar se um telefone específico está na base. Isso é aceito porque é exatamente a capacidade de que o produto precisa; nenhuma funcionalidade requer comparação de ordem ou busca por prefixo sobre telefone.
Busca por telefone com as variantes do nono dígito, integrando a regra de normalização da Seção 11:
// packages/core/src/subscribers/lookup.ts
export async function findSubscriberByInbound(waId: string, from: string) {
const candidates = [
{ col: 'wa_id_hmac', hash: blindIndex(waId, 'waId', currentIndexKey()) },
...phoneVariants(from).map((p) => ({ col: 'phone_hmac', hash: phoneIndex(p) })),
]
// phoneVariants devolve, em ordem: E.164 exato, variante sem o 9º dígito, variante com o 9º.
for (const c of candidates) {
const row = await db.subscriber.findFirst({ where: { [c.col]: c.hash, deletedAt: null } })
if (row) return row
}
return null
}A ordem de tentativa é a mesma da regra de normalização da Seção 11 — wa_id, depois telefone
exato, depois as variantes —, e cada tentativa é uma consulta indexada de custo constante. Em
vez de uma consulta, são no máximo quatro; com índice único, isso é irrelevante em desempenho.
Busca no painel administrativo: o administrador digita o telefone completo, o servidor normaliza, calcula o HMAC e busca por igualdade. Busca parcial por telefone não é suportada, e essa é uma decisão explícita, não uma limitação a contornar depois. Busca parcial exigiria índice por prefixo sobre valor determinístico curto, o que reintroduziria a enumerabilidade que o HMAC elimina. A busca operacional cotidiana é feita por nome, por identificador do assinante ou pelo número exato — que é o que o administrador tem em mãos quando um assinante escreve pedindo suporte. A tela de busca explica isso ao administrador em texto de ajuda.
Exibição: o telefone é decifrado apenas quando a tela precisa dele e é mascarado por padrão
como +55 11 *****-4321. Revelar o número completo exige clique em Mostrar, que registra
admin_audit_log com action = 'PII_REVEALED', o alvo e o motivo selecionado. O CPF nunca é
exibido inteiro: apenas ***.***.789-01, e só nas telas de cobrança.
22.5.5 Rotação de chaves com dados já gravados #
Chave de cifra (ENCRYPTION_KEY e ENCRYPTION_KEY_PREVIOUS). A rotação usa duas chaves
nomeadas e uma janela de leitura dupla, exatamente como descrito no procedimento operacional da
Seção 26.7.4: a chave corrente passa a ser ENCRYPTION_KEY_PREVIOUS, uma chave nova entra como
ENCRYPTION_KEY, e a partir do reinício toda escrita usa a nova enquanto a leitura tenta a nova
e cai para a anterior (código de decryptField em 22.5.3). O job security.reencrypt_fields
percorre as tabelas em lotes de 500 linhas, decifra e recifra com a corrente, dentro de uma
transação por lote. O job é retomável — guarda o último id processado em settings sob
ops.reencrypt_cursor — e roda fora da janela de envio.
A chave anterior só é removida da configuração quando o comando pnpm ops crypto:verify
reportar zero linhas que só decifram com ela. Como o prefixo do envelope marca formato e não
chave, a verificação não pode ser feita por LIKE sobre o valor: ela tenta decifrar uma amostra
completa com a chave corrente e conta as falhas. Remover a chave anterior antes disso torna os
dados correspondentes irrecuperáveis, e por isso a checagem de boot descrita em 26.7.4
recusa iniciar nessa combinação.
Chave de índice (PHONE_INDEX_KEY). Aqui não há prefixo possível, porque o valor precisa
ser comparável por igualdade direta no índice. O procedimento tem três fases:
- Fase de leitura dupla.
PHONE_INDEX_KEYrecebe a chave nova ePHONE_INDEX_KEY_PREVIOUSa antiga. Toda busca calcula os dois HMACs e consulta comIN (novo, antigo). Toda escrita usa apenas a chave nova. - Backfill. O job
security.reindex_blindpercorre as linhas em lotes de 500, recalculaphone_hmac,wa_id_hmac,cpf_hmaceemail_hmaccom a chave nova e grava. Como a decifra é necessária para recalcular, o job depende da chave de cifra vigente. Com 50.000 assinantes e lotes de 500, são 100 lotes — poucos minutos. - Encerramento. Verificado que nenhuma linha usa a chave antiga (o job registra o total
convertido e compara com a contagem da tabela),
PHONE_INDEX_KEY_PREVIOUSé removida e a busca volta a um único HMAC.
Durante a fase 1, uma colisão de unicidade é impossível, porque o índice único continua sobre a mesma coluna e cada linha tem um único valor por vez. Se o backfill for interrompido, o sistema permanece funcional na fase 1 indefinidamente; a única perda é o custo de uma comparação a mais por busca.
22.5.6 Dados de cartão e escopo PCI-DSS #
Nenhum dado de cartão é persistido. Não existe coluna de número, CVV ou validade em tabela alguma, e a proibição é verificada por teste que inspeciona o schema. O que o sistema guarda é o token devolvido pelo processador de pagamento e os metadados não sensíveis necessários para a interface e para a régua de cobrança: bandeira, últimos quatro dígitos e mês e ano de validade. Esses metadados não permitem transação e não são dados de conta de pagamento no sentido do padrão.
Escopo declarado: SAQ D-Merchant. O checkout não usa iframe nem redirecionamento hospedado pelo processador — os dados do cartão são recebidos pelo nosso servidor e encaminhados para tokenização em chamada server-side (desenho registrado na Seção 12). Isso coloca a aplicação no caminho dos dados de titular de cartão e afasta os questionários simplificados. Registrar o escopo correto é decisão consciente: alegar SAQ A-EP com este desenho seria falso e inutilizaria a atestação em caso de incidente.
Controles obrigatórios decorrentes desse escopo, todos já cobertos nesta seção e aqui consolidados: TLS em toda a cadeia (22.5.1); dados de cartão apenas em memória, nunca em disco, nunca em log, nunca em rastro (23.1.4 e 23.5); campos de cartão na lista de redação do logger; proibição de gravar o payload de tokenização em qualquer tabela; acesso ao código do checkout restrito e revisado; registro de auditoria de toda ação de cobrança (23.11.1); segregação de papéis de banco (22.12.2); e varredura de vulnerabilidade e revisão de dependências (22.12.4).
Se, no futuro, o checkout migrar para captura hospedada pelo processador, o escopo cai para SAQ A quando a captura for por redirecionamento completo para página hospedada — que é a rota decidida, registrada na Seção 12.9.3 —, ou para SAQ A-EP quando for por componente embutido que a nossa página carrega. A diferença não é semântica: no redirecionamento a nossa página sai inteiramente do fluxo dos dados de cartão, que é exatamente o caso do SAQ A. A migração é desejável e fica registrada como opção, não como pendência: o desenho atual é válido e conforme.
Enquanto o escopo for SAQ D-Merchant, as obrigações abaixo — delegadas a esta seção pela Seção 12.9.3 — são exigíveis e têm responsável nomeado:
- Varredura trimestral por fornecedor autorizado (ASV) do domínio de checkout, com relatório arquivado.
- Revisão anual do questionário SAQ D, assinada pelo
OWNER. - Política de segurança da informação escrita, revisada anualmente e comunicada à equipe.
- Correção de vulnerabilidades de severidade
criticalehighem até 30 dias a partir da divulgação, medida pela varredura de 22.12.4. - Revisão trimestral de acessos, na forma de 22.12.3.
22.6 Proteção contra abuso #
22.6.1 Rate limit #
Implementação com Redis (linha de versão na Seção 4), algoritmo de janela deslizante, chave
composta por dimensão. Toda resposta bloqueada é 429 RATE_LIMITED com header Retry-After em
segundos, conforme o envelope da Seção 7.
| Endpoint | Dimensão | Limite | Janela | Bloqueio ao exceder |
|---|---|---|---|---|
POST /api/signups (cadastro) |
IP | 5 | 1 h | 1 h |
POST /api/signups |
Sub-rede /24 | 30 | 1 h | 1 h |
POST /api/auth/otp/request |
Telefone | 3 | 1 h | 1 h |
POST /api/auth/otp/request |
Telefone | 8 | 24 h | 24 h |
POST /api/auth/otp/request |
IP | 10 | 1 h | 1 h |
POST /api/auth/otp/verify |
Telefone | 5 tentativas por código | TTL do código (10 min) | Código invalidado |
POST /api/auth/otp/verify |
IP | 30 | 1 h | 1 h |
POST /api/auth/admin/login |
5 | 15 min | 15 min | |
POST /api/auth/admin/login |
IP | 20 | 15 min | 1 h |
POST /api/me/subscription (checkout) |
Assinante | 5 | 1 h | 1 h |
POST /api/public/unsubscribe |
IP | 10 | 1 h | 1 h |
POST /api/devotionals/{devotionalId}/resends |
Assinante | conforme entitlement (Seção 13) | dia civil | Recusa com RESEND_LIMIT_REACHED |
GET /api/admin/metrics/export |
Administrador | 10 | 1 h | 1 h |
Rotas /api/* autenticadas, geral |
Sessão | 300 | 1 min | 1 min |
| Rotas públicas, geral | IP | 600 | 1 min | 1 min |
| Cota global de pedidos de código | Sistema | 500, com reserva de 20% para números já cadastrados | 1 h | 503 SERVICE_BUSY |
Esta tabela é réplica informativa. A dona dos limites de código de acesso é a Seção 8.2.1; aqui eles reaparecem sob a ótica de controle de abuso, com exatamente os mesmos valores — 3 pedidos por hora por número, 8 por 24 h por número, 10 pedidos por IP por hora, 30 verificações por IP por hora, TTL de 10 minutos e 5 tentativas por código. Divergir de 8.2.1 em qualquer número é defeito, não decisão local.
O rate limit de código de acesso é aplicado antes de qualquer verificação de existência do telefone, e os contadores contam pedidos, nunca envios efetivos. Se contassem envios, o quarto pedido responderia de forma diferente para número cadastrado e não cadastrado, e essa diferença sozinha seria um oráculo completo de enumeração — anulando as demais defesas de 22.6.2. A cota global existe para limitar custo, e a reserva de 20% existe porque uma cota global única é, por si, um vetor de negação de serviço: esgotada de propósito, ela impediria o login de toda a base.
22.6.2 Prevenção de enumeração #
POST /api/auth/otp/requestresponde200com o mesmo corpo, no mesmo tempo, exista ou não o telefone. O corpo é{ "data": { "sent": true, "expiresInSeconds": 600 } }em ambos os casos. Quando o número não existe, nenhuma mensagem é enviada.- Número bloqueado não produz erro próprio. A resposta é o mesmo
200, com o mesmo corpo, e nenhuma mensagem é enviada. Devolver403para número bloqueado confirmaria a existência do cadastro e tornaria o oráculo ainda mais barato do que o limite de taxa. - O cadastro com telefone já existente não responde
409. Ele responde200e envia ao número já cadastrado uma mensagem informando que houve uma tentativa de novo cadastro e como acessar o painel. Assim, quem controla o número recebe a informação; quem apenas testa números não aprende nada. - Mensagens de erro de login administrativo são idênticas para e-mail inexistente, senha errada
e TOTP errado:
INVALID_CREDENTIALS. - Comparações de segredo usam
timingSafeEqualsobre buffers de tamanho igual, sempre.
22.6.3 Detecção de cadastro em massa #
Sinais avaliados no momento do cadastro, combinados em uma pontuação de risco de 0 a 100:
| Sinal | Peso |
|---|---|
| Mais de 3 cadastros do mesmo IP em 24 h quando o IP não pertence a faixa de operadora móvel brasileira conhecida | 25 |
| Mais de 25 cadastros da mesma sub-rede /24 nas últimas 24 h | 20 |
Mesmo cpf_hmac em mais de duas contas ativas (22.5.4) |
25 |
| Telefones sequenciais (diferença ≤ 3 no sufixo) entre cadastros recentes do mesmo IP | 30 |
| Verificação anti-bot ausente ou com pontuação baixa | 25 |
| Preenchimento do formulário em menos de 3 segundos | 15 |
| E-mail de domínio descartável | 20 |
| Nome com padrão gerado (só consoantes, repetição, apenas dígitos) | 10 |
| DDD inexistente na lista oficial brasileira | 20 |
| Faixa | Ação |
|---|---|
| 0–39 | Segue normalmente |
| 40–69 | Segue, mas o OTP passa a exigir novo desafio anti-bot e o cadastro é marcado para revisão |
| 70–100 | Recusa com 422 SIGNUP_BLOCKED e mensagem neutra: "Não foi possível concluir o cadastro agora. Fale com o suporte." O evento é registrado em log com nível warn e incrementa signup_blocked_total (Seção 23.4) |
A lista de faixas de operadora móvel fica em settings sob security.mobile_carrier_ranges. O
ajuste dos dois primeiros sinais existe porque as operadoras brasileiras usam CGNAT em larga
escala: milhares de pessoas sem relação entre si compartilham o mesmo endereço público, e tratar
isso como sinal de fraude penalizaria justamente o padrão de aquisição desejado — quarenta
pessoas da mesma comunidade se cadastrando na mesma tarde, pelo Wi-Fi da igreja ou pela mesma
operadora. Sem o ajuste, o melhor dia de aquisição do produto vira uma fila de suporte.
Um pico de bloqueios dispara o alerta signup_abuse_spike (Seção 23.8). Falso positivo é
resolvido pelo suporte, que pode liberar o cadastro manualmente — a ação fica em
admin_audit_log. A recusa por pontuação é uma decisão automatizada no sentido do Art. 20 e
está declarada como tal em 22.7.4, com as garantias correspondentes.
22.6.4 Verificação anti-bot, bloqueio de descartáveis e proteção de webhook #
Decisão de ferramenta anti-bot: Cloudflare Turnstile, no modo invisível. O modo invisível é
o padrão no cadastro e no pedido de código de acesso, porque a promessa de acessibilidade da
interface (Seção 9.10) não admite quebra-cabeça visual nesses fluxos. O modo gerenciado
(managed) fica reservado ao formulário de contato, onde o atrito é aceitável e o volume de
abuso é maior.
Justificativa da ferramenta: é gratuita em volume ilimitado, não exige conta paga, e o token é
validado server-side em https://challenges.cloudflare.com/turnstile/v0/siteverify — chamada
que consta da lista de destinos permitidos de safeFetch (22.2.6). O widget é carregado do
domínio do provedor e, por isso, challenges.cloudflare.com consta de script-src, frame-src
e connect-src apenas nas páginas que contêm formulário, pela terceira variante de política
descrita em 22.3.1. As duas liberações — CSP e lista de egresso — são condição para que exista
qualquer cadastro no dia do lançamento, e são itens nominais do checklist de 22.13.
Alternativa aceita, caso o produto precise eliminar dependência externa: prova de trabalho
auto-hospedada (altcha), com custo de conversão levemente maior. Rejeitado: reCAPTCHA, por
transferência internacional de dados comportamentais e por exigir consentimento.
Aplicação: formulário de cadastro, pedido de OTP a partir da web e formulário de contato.
Ausência ou invalidade do token gera 422 BOT_CHECK_FAILED. A validação server-side confere
também o campo hostname da resposta, para impedir reuso do token em outro site.
Bloqueio de números descartáveis. Três camadas: (a) rejeição de DDI diferente de 55, com
mensagem clara, conforme a regra de normalização da Seção 11; (b) validação do DDD contra a
lista oficial de códigos brasileiros em uso, e do prefixo de celular (o nono dígito precisa ser
9); (c) lista de faixas conhecidas de números virtuais e de serviços de recebimento de SMS
descartável, mantida em settings sob a chave security.blocked_phone_prefixes, atualizável sem
deploy.
Um telefone bloqueado recebe 422 PHONE_NOT_ALLOWED. E-mails descartáveis são barrados por lista
de domínios em settings sob security.blocked_email_domains; como o e-mail é opcional no
produto, o bloqueio apenas impede o cadastro daquele e-mail, não do assinante.
Proteção dos endpoints de webhook. Ambos são públicos por necessidade e, por isso, recebem tratamento específico:
| Controle | POST /api/webhooks/asaas |
POST /api/webhooks/whatsapp |
|---|---|---|
| Autenticação | Header asaas-access-token comparado em tempo constante com o segredo configurado |
HMAC SHA-256 do corpo cru em X-Hub-Signature-256, comparado em tempo constante com o segredo do aplicativo |
| Corpo cru | Lido antes de qualquer parse, para que a assinatura seja calculada sobre os bytes exatos | Idem |
| Tamanho máximo | 512 KB; acima disso, 413 sem processar |
512 KB |
| Resposta a segredo inválido | 401 sem detalhe, com log em warn e incremento de webhook_auth_failed_total |
401, idem |
| Tempo de resposta | Persiste o evento e enfileira; responde em menos de 2 s (exigência registrada na Seção 12) | Idem, conforme Seção 17 |
| Idempotência | Índice único no identificador do evento; reentrega devolve 200 sem reprocessar |
Índice único no identificador da mensagem |
| Rate limit | 600 requisições por minuto por IP, com IPs do provedor em lista de tolerância maior | Idem |
| Replay | Eventos com carimbo de tempo mais de 24 h antigo são registrados e descartados | Idem |
| Corpo inválido | Nunca produz resposta não-2xx. Autenticado o remetente, corpo que não é JSON, schema reprovado ou evento desconhecido respondem 200 com corpo vazio e persistem o ocorrido (Seção 7.15.1) |
Idem |
| CSRF | Isento e declarado como tal (22.2.5) | Isento |
A linha de corpo inválido é de segurança, não de conveniência: 4xx e 5xx levam o provedor a
retentar e, em seguida, a desativar a assinatura do webhook. Perder o canal de eventos de
cobrança é catastroficamente pior do que engolir um corpo malformado, porque é por ele que a
revogação imediata de acesso pago acontece. A autenticação do remetente é a única condição que
autoriza 401, e o único 4xx restante é 413 para corpo acima de 512 KB, medido antes da
leitura. O alerta webhook_silence (Seção 23.8) é o detector desse estado.
GET /api/webhooks/whatsapp responde ao desafio de verificação comparando hub.verify_token
com WHATSAPP_VERIFY_TOKEN em tempo constante e devolvendo hub.challenge como texto puro;
qualquer divergência devolve 403 sem corpo. O desafio da plataforma é sempre numérico, e o
valor só é ecoado depois de casar com ^[0-9]{1,32}$ — validar o formato antes de ecoar impede
que o endpoint sirva de refletor caso o token de verificação vaze.
22.7 LGPD #
22.7.1 Bases legais por finalidade #
| Finalidade | Dados | Base legal | Dispositivo |
|---|---|---|---|
| Entrega do devocional no WhatsApp (conteúdo religioso) | Telefone, wa_id, nome, preferências |
Consentimento específico e destacado para dado sensível | Art. 11, I |
| Execução do serviço pago | Telefone, nome, e-mail, dados da assinatura | Execução de contrato | Art. 7º, V |
| Cobrança e prevenção à fraude no pagamento | CPF, nome, dados da cobrança | Execução de contrato e cumprimento de obrigação legal | Art. 7º, V e II |
| Emissão e guarda de registros fiscais e contábeis | CPF, nome, valores, datas | Cumprimento de obrigação legal | Art. 7º, II |
| Comunicações de marketing e novidades do produto | Telefone, e-mail, nome | Consentimento, separado do consentimento do serviço | Art. 7º, I |
| Segurança da informação, registro de acesso e prevenção a abuso | IP, user-agent, requestId, registros de tentativa |
Legítimo interesse, com teste de balanceamento documentado | Art. 7º, IX e Art. 10 |
| Guarda de registros de acesso a aplicações de internet | IP, data e hora | Cumprimento de obrigação legal | Marco Civil da Internet, Art. 15 |
| Métricas agregadas de produto | Dados agregados, sem identificação | Fora do escopo da LGPD por anonimização | Art. 12 |
| Analytics de navegação anônimo | Página, referenciador, UTM, categoria de dispositivo, identificador diário não reidentificável | Legítimo interesse, com opt-out disponível | Art. 7º, IX |
| Atendimento e suporte | Telefone, histórico de mensagens | Execução de contrato e legítimo interesse | Art. 7º, V e IX |
O ponto que estrutura toda a tabela: o consentimento para a finalidade principal é específico, destacado e separado dos demais aceites. Na prática, o formulário tem três caixas independentes, nenhuma marcada previamente: aceite dos termos e da política; consentimento para tratamento de dado sensível relacionado a convicção religiosa, com texto que explica exatamente isso; e consentimento opcional de marketing. Recusar o de marketing não impede o cadastro. Recusar o de dado sensível impede — e a tela explica por quê, porque sem ele não há como prestar o serviço.
22.7.2 Inventário de dados pessoais #
| Dado | Categoria | Onde | Finalidade | Base legal | Retenção | Destinatários |
|---|---|---|---|---|---|---|
| Telefone (E.164) | Pessoal, identificador | subscribers.phone_e164 (cifrado), subscribers.phone_hmac |
Identidade e entrega | Consentimento e contrato | Vida da conta + 30 dias | Meta (Cloud API) |
wa_id |
Pessoal, identificador | subscribers.wa_id (cifrado), subscribers.wa_id_hmac |
Roteamento de mensagens | Consentimento e contrato | Idem | Meta |
| Nome | Pessoal | subscribers.display_name |
Personalização e cobrança | Contrato | Idem | Asaas |
| Pessoal | subscribers.email (cifrado), subscribers.email_hmac |
Login alternativo, recibos, suporte | Contrato e consentimento | Idem | Resend | |
| CPF | Pessoal, identificador fiscal | subscriber_profiles.cpf (cifrado), subscriber_profiles.cpf_hmac |
Exigência do processador de pagamento | Contrato e obrigação legal | 5 anos após o último pagamento | Asaas |
| Convicção religiosa (inferida da assinatura) | Sensível | Implícita na existência do registro | Prestação do serviço | Consentimento específico (Art. 11, I) | Vida da conta + 30 dias | Meta, provedor de TTS (apenas conteúdo, não titular) |
| Registros de consentimento | Pessoal | consent_events |
Prova de conformidade | Obrigação legal e legítimo interesse | 5 anos após o fim da relação | Nenhum |
| Histórico de mensagens enviadas | Pessoal | message_logs |
Operação, suporte, métricas | Contrato e legítimo interesse | 18 meses | Meta |
| Mensagens recebidas | Pessoal | inbound_messages |
Atendimento e comandos | Contrato | 18 meses | Nenhum |
| Dados de cobrança (valores, status, vencimento) | Pessoal e financeiro | payments, payment_events |
Cobrança e conciliação | Contrato e obrigação legal | 5 anos | Asaas |
| Número, CVV e validade do cartão | Pessoal e financeiro | Não armazenados em lugar algum | Cobrança recorrente | Contrato | Não aplicável | Asaas (tokenização) |
| Token do cartão, bandeira, últimos quatro dígitos, mês e ano de validade | Pessoal e financeiro, não sensível no sentido do padrão de cartões | subscriptions |
Cobrança recorrente e exibição no painel | Contrato | Vida da assinatura + 5 anos | Asaas |
| IP e user-agent | Pessoal | consent_events, sessions, logs de acesso |
Segurança e prova de consentimento | Legítimo interesse e obrigação legal | 6 meses nos logs; junto do consentimento, 5 anos | Nenhum |
| Sessões | Pessoal | sessions |
Autenticação | Contrato | TTL da sessão + 30 dias | Nenhum |
| Códigos OTP | Pessoal, transitório | otp_codes |
Autenticação | Contrato | 24 h após expirar | Meta (envio) |
| Áudio do devocional | Não pessoal | audio_assets, storage |
Produto | Não aplicável | Vida do acervo | Provedor de TTS, Meta |
| Métricas agregadas | Anonimizado | daily_metrics |
Gestão | Fora do escopo | Indefinida | Nenhum |
Dado de cartão nunca toca o servidor em texto persistido. A tokenização é feita pelo processador de pagamento; o payload de tokenização, quando trafega server-side, não é gravado nem registrado em log (regra reforçada pela configuração de redação da Seção 23.1). Não existe coluna de cartão em nenhuma tabela.
22.7.3 Registro de consentimento #
consent_events é append-only: a aplicação nunca faz UPDATE nem DELETE. Revogar um
consentimento grava um evento novo, do mesmo type, com granted = false; o evento original
permanece, porque ele é a prova de que o consentimento existiu no passado.
Conteúdo obrigatório de cada evento: identificador do assinante; type (SERVICE_TERMS,
SENSITIVE_DATA, MARKETING, COOKIE_CONSENT, OPT_IN_WEB, OPT_IN_WHATSAPP, OPT_OUT,
RE_OPT_IN, PAUSE_STARTED, PAUSE_ENDED); channel (WEB, WHATSAPP, ADMIN);
policy_version (identificador da versão exata do texto exibido, por exemplo v3);
consent_text_hash (SHA-256 do texto integral apresentado, o que permite provar o teor sem
depender de arquivo externo); granted (booleano); evidence (objeto JSON com o contexto do
evento); ip; user_agent; e created_at. Os nomes de coluna são os da Seção 6.5, que é a dona
da definição física: não existem colunas text_version nem text_hash. O enum completo é o
ConsentType da Seção 6, que é a dona da definição física; nenhuma seção introduz valor fora
dele, e a grafia canônica é a de lá.
consent_text_hash é o SHA-256 do texto após normalização canônica: NFC, quebras de linha
convertidas para \n, espaços em sequência colapsados em um, e espaços removidos das pontas.
Sem essa normalização, uma reindentação do arquivo de textos mudaria o hash de uma versão já
publicada, o teste de imutabilidade falharia, alguém o atualizaria para o valor novo — e a prova
de qual texto foi apresentado deixaria de existir para todos os consentimentos anteriores. O
teste compara contra uma tabela de hashes fixos, versionada em separado e protegida por
verificação de caminho no pull request.
Imutabilidade imposta em três camadas. O gatilho nunca é desabilitado — desabilitar exige ser dono da tabela e abriria uma janela em que qualquer escrita passa. Em vez disso, ele reconhece dois papéis nomeados e confere, coluna a coluna, que a operação é exatamente a permitida:
-- 1) Privilégio: o usuário da aplicação só insere e lê.
REVOKE UPDATE, DELETE ON consent_events FROM app_user;
GRANT INSERT, SELECT ON consent_events TO app_user;
-- 2) Papéis nomeados, cada um com uma única razão de existir.
CREATE ROLE privacy_operator LOGIN;
GRANT SELECT, UPDATE ON consent_events TO privacy_operator;
CREATE ROLE retention_operator LOGIN;
GRANT SELECT, DELETE ON consent_events TO retention_operator;
-- 3) Gatilho: barra alteração mesmo por engano de um papel privilegiado,
-- mas não impede o cumprimento de um direito do titular nem da retenção legal.
CREATE OR REPLACE FUNCTION consent_events_immutable() RETURNS trigger AS $$
BEGIN
-- Exceção 1: eliminação por LGPD, restrita a zerar identificadores de rede.
IF TG_OP = 'UPDATE' AND current_user = 'privacy_operator'
AND NEW.id = OLD.id AND NEW.subscriber_id = OLD.subscriber_id
AND NEW.type = OLD.type AND NEW.granted = OLD.granted
AND NEW.policy_version = OLD.policy_version
AND NEW.consent_text_hash = OLD.consent_text_hash
AND NEW.created_at = OLD.created_at
AND NEW.ip IS NULL AND NEW.user_agent IS NULL THEN
RETURN NEW;
END IF;
-- Exceção 2: expurgo por retenção, restrito a linhas fora do prazo legal.
IF TG_OP = 'DELETE' AND current_user = 'retention_operator'
AND OLD.created_at < now() - interval '5 years' THEN
RETURN OLD;
END IF;
RAISE EXCEPTION 'consent_events is append-only';
END $$ LANGUAGE plpgsql;
CREATE TRIGGER consent_events_no_update_delete
BEFORE UPDATE OR DELETE ON consent_events
FOR EACH ROW EXECUTE FUNCTION consent_events_immutable();O conteúdo probatório do consentimento — tipo, versão, hash do texto, data e granted — é
imutável inclusive para esses dois papéis: o gatilho compara cada uma dessas colunas e recusa
a operação se qualquer uma mudar. O mesmo padrão, com os mesmos dois papéis, é aplicado a
admin_audit_log (23.11.3).
A razão de o controle ter exceções codificadas, e não uma porta de desabilitação, é direta: um controle de integridade que impeça o cumprimento de um direito do titular deixa de ser controle e vira descumprimento. Sem a exceção 1, o primeiro pedido de eliminação abortaria a transação inteira e a anonimização nunca aconteceria para titular nenhum, com o prazo de 30 dias do Art. 18, VI vencendo sem que ninguém soubesse. Sem a exceção 2, o expurgo de 22.8 seria impossível.
A terceira camada é o versionamento do texto: os textos de consentimento vivem em
packages/core/src/consent/texts.ts, versionados junto com o código, e cada versão é
imutável — uma correção, ainda que de vírgula, cria v4, nunca altera v3. Um teste garante
que o hash de cada versão publicada não mudou (Seção 24). Quando o texto muda, assinantes
existentes continuam vinculados à versão que aceitaram; um novo consentimento só é pedido se a
mudança alterar finalidade ou destinatário — mudança de redação não gera novo pedido.
Exemplo de evento gravado no cadastro:
{
"id": "cse_01J9K2M4R7T8V0X1Y2Z3A4B5C6",
"subscriberId": "sub_01J9K2M4R7T8V0X1Y2Z3A4B5C6",
"type": "SENSITIVE_DATA",
"channel": "WEB",
"granted": true,
"textVersion": "v3",
"textHash": "9f2c1a7d4e8b0c3f5a6d9e2b1c4f7a8d0e3b6c9f2a5d8e1b4c7f0a3d6e9b2c5f",
"ip": "189.45.12.203",
"userAgent": "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36",
"createdAt": "2026-08-25T12:04:11.882Z"
}22.7.4 Direitos do titular #
Canal único: privacidade@palavradiaria.com.br, mais a área Meus dados do painel do
assinante, que atende os pedidos mais comuns sem intervenção humana. Todo pedido, venha por qual
canal vier, gera protocolo e registro em admin_audit_log.
| Direito | Dispositivo | Como é atendido | Prazo |
|---|---|---|---|
| Confirmação de tratamento e acesso | Art. 18, I e II | Painel: resposta imediata em tela. Por e-mail: relatório em PDF e JSON | Imediato no painel; 15 dias por e-mail |
| Acesso simplificado | Art. 19, § 2º | Tela Meus dados com todas as categorias tratadas | Imediato |
| Correção | Art. 18, III | Nome e e-mail editáveis no painel. Telefone e CPF exigem verificação por OTP e conferência do suporte | Imediato no painel; 5 dias úteis nos casos verificados |
| Portabilidade | Art. 18, V | Exportação JSON estruturada, gerada sob demanda e baixada dentro do painel, sob sessão plena. A geração exige sessão plena (nunca restrita, conforme 8.15.1.1) e verificação por código de acesso no momento do pedido | Até 5 dias úteis (geração assíncrona) |
| Eliminação | Art. 18, VI | Botão no painel, com dupla confirmação e verificação por OTP. Anonimização executada em até 30 dias, com dados de retenção legal preservados (22.9) | Confirmação imediata; execução em até 30 dias |
| Revogação do consentimento | Art. 8º, § 5º | Botão no painel e palavras-chave no WhatsApp. Efeito imediato sobre os envios | Imediato |
| Oposição ao tratamento por legítimo interesse | Art. 18, § 2º | Pedido ao encarregado, com análise motivada | 15 dias |
| Informação sobre compartilhamento | Art. 18, VII | Lista de destinatários publicada na Política de Privacidade e repetida no relatório de acesso | Imediato |
| Informação sobre a possibilidade de não consentir | Art. 18, VIII | Texto no formulário de cadastro, antes do aceite | Imediato |
| Revisão de decisão automatizada | Art. 20 | Aplicável. A pontuação anti-abuso de 22.6.3 é uma decisão automatizada que pode recusar o cadastro e, portanto, afeta o titular. Garantias efetivas: a recusa nunca informa o motivo detalhado, porque informá-lo ensinaria o atacante, mas sempre oferece contato humano identificado; a solicitação de revisão é atendida por pessoa, com análise dos sinais que compuseram a pontuação; a liberação manual é registrada em admin_audit_log; e o titular pode pedir informação sobre os critérios gerais utilizados, descritos em linguagem acessível na Política de Privacidade, sem revelar os pesos. O Relatório de Impacto de 22.7.7 descreve esta decisão automatizada como tal |
2 dias úteis |
Exportação de portabilidade — estrutura entregue:
{
"exportedAt": "2026-08-25T12:00:00.000Z",
"formatVersion": "1.0",
"subscriber": { "id": "sub_01J9…", "displayName": "Maria Souza", "phone": "+5511987654321",
"email": "maria@exemplo.com.br", "tier": "PAID",
"createdAt": "2026-02-11T09:12:03.000Z",
"optInConfirmedAt": "2026-02-11T09:14:41.000Z" },
"consents": [ { "type": "SENSITIVE_DATA", "granted": true, "textVersion": "v3",
"channel": "WEB", "createdAt": "2026-02-11T09:12:03.000Z" } ],
"subscriptions": [ { "id": "sbs_01J9…", "plan": "plan_monthly", "status": "ACTIVE",
"billingType": "CREDIT_CARD", "startedAt": "2026-02-11T09:20:00.000Z" } ],
"payments": [ { "id": "pay_01J9…", "amountCents": 1990, "status": "RECEIVED",
"dueDate": "2026-08-11", "confirmedAt": "2026-08-11T13:02:00.000Z" } ],
"messages": [ { "sentAt": "2026-08-24T09:00:12.000Z", "type": "template",
"templateName": "devocional_diario_v1", "status": "read" } ],
"inboundMessages": [ { "receivedAt": "2026-08-24T09:03:44.000Z", "textBody": "Ler e ouvir agora" } ]
}O arquivo é gerado pelo job privacy.export_subscriber_data, cifrado no storage sob o prefixo
exports/, e não é entregue por URL portadora. O titular recebe apenas o aviso de que a
exportação está pronta; o download acontece dentro do painel, sob sessão plena autenticada
(nunca restrita, conforme 8.15.1.1), por uma URL assinada de 15 minutos gerada no momento do
clique e vinculada à sessão. O arquivo é removido do storage após 7 dias, o download é limitado
a 3 vezes, cada download é registrado em admin_audit_log com
action = 'DATA_EXPORT_DOWNLOADED', e a geração é limitada a 2 pedidos por assinante por mês.
O motivo é concreto: um link válido por 72 horas é a credencial de acesso ao dossiê completo do titular — telefone, CPF, histórico financeiro e 18 meses de mensagens —, e mensagens de WhatsApp são encaminhadas o tempo todo. Uma titular que peça ajuda ao marido para abrir o arquivo entrega, junto, o dossiê inteiro a quem quer que veja a mensagem encaminhada.
Troca de número e chip reciclado. Números brasileiros são reemitidos pelas operadoras, e a
posse do número é o único fator de autenticação do assinante. Por isso: qualquer mudança do
wa_id associado a um telefone invalida todas as sessões daquele assinante e exige nova
verificação por código antes de liberar qualquer dado pessoal. Nenhuma conta, CPF ou histórico é
entregue a um número apenas porque ele respondeu no WhatsApp. A regra completa de sessão restrita
por dormência está em 8.15.1.1, e a exportação de portabilidade a exige nominalmente.
22.7.5 Encarregado de dados #
Encarregado nomeado formalmente, com identificação e canal publicados de forma acessível, como
exige o Art. 41, § 1º. Canal público: privacidade@palavradiaria.com.br, divulgado no rodapé de
todas as páginas, na Política de Privacidade e na mensagem de boas-vindas do WhatsApp.
Atribuições: receber comunicações de titulares e da ANPD, orientar a equipe, manter o inventário
de 22.7.2 e o registro das operações de tratamento, e conduzir o procedimento de incidente. O
prazo interno de primeira resposta é de 2 dias úteis, e o de conclusão é o da tabela de 22.7.4.
A caixa é monitorada em dias úteis e tem alerta configurado para mensagens sem resposta em 48 h.
22.7.6 Transferência internacional #
| Destinatário | Dado transferido | País | Salvaguarda | Aviso |
|---|---|---|---|---|
| Meta Platforms (WhatsApp Cloud API) | Telefone, wa_id, conteúdo das mensagens |
Estados Unidos e Irlanda | Cláusulas-padrão contratuais da ANPD (Resolução CD/ANPD nº 19/2024), cuja adoção pelo provedor precisa ser verificada e arquivada antes do lançamento | Nominal na Política de Privacidade, com finalidade e categoria |
| ElevenLabs (TTS primário) | Nenhum dado pessoal. Apenas o texto do devocional, que é conteúdo editorial | Estados Unidos | Contrato de processamento de dados; cláusulas-padrão da ANPD verificadas e arquivadas antes do lançamento | Declarado, com a observação de que não há dado de titular |
| Google Cloud Text-to-Speech (fallback) | Nenhum dado pessoal. Idem | Estados Unidos | Idem | Idem |
| Resend (e-mail transacional) | E-mail, nome, conteúdo da mensagem | Estados Unidos | Cláusulas-padrão contratuais da ANPD, verificadas e arquivadas antes do lançamento; contrato de processamento | Nominal |
| Asaas (pagamentos) | Nome, CPF, e-mail, telefone, valores | Brasil | Não há transferência internacional | Declarado como processador nacional |
| Storage de mídia | Áudio e imagens; sem dado pessoal | Brasil, região São Paulo | Não há transferência internacional | Declarado |
| Analytics | Navegação anônima | Brasil, mesmo servidor | Não há transferência internacional | Declarado |
Decisão de arquitetura com consequência jurídica direta, registrada aqui: o storage de mídia e o analytics são contratados em região brasileira, e o provedor de pagamento é nacional. Isso reduz a transferência internacional a três destinatários, dois dos quais não recebem dado pessoal algum. A Política de Privacidade nomeia cada destinatário, a finalidade, a categoria de dado e o país — o aviso genérico "podemos compartilhar com parceiros" é insuficiente e não é usado.
A salvaguarda é um documento arquivado, não uma conclusão. Afirmar que as cláusulas-padrão estão "previstas nos termos do provedor" é uma conclusão jurídica sobre um contrato de adesão de terceiro, e numa fiscalização ela não substitui a demonstração. Por isso: a adoção das cláusulas por cada destinatário é verificada e o documento comprobatório é arquivado antes do lançamento, como item nominal do checklist de 22.13. Quando o provedor não as adotar no instrumento, a alternativa é o contrato específico ou a demonstração de outra hipótese do Art. 33; a decisão e o documento ficam sob guarda do encarregado. Enquanto não houver comprovação arquivada, a transferência não é declarada como salvaguardada na Política de Privacidade — o dado transferido é sensível, e uma declaração sem lastro é pior do que a ausência dela.
22.7.7 Relatório de impacto à proteção de dados #
O relatório é obrigatório neste produto e é elaborado antes do lançamento. O gatilho é direto: há tratamento de dado pessoal sensível (convicção religiosa) em larga escala, com uso de comunicação direta e de decisão automatizada de bloqueio no cadastro. Nessas condições, a elaboração não é discricionária.
Conteúdo mínimo: descrição das operações de tratamento; finalidade e base legal de cada uma; categorias de titulares e de dados, com destaque para o dado sensível; fluxo dos dados, incluindo os destinatários de 22.7.6; medidas técnicas de 22.2 a 22.6; avaliação dos riscos de 22.1.4 com probabilidade, impacto e controle correspondente; riscos residuais aceitos, com justificativa; análise de necessidade e proporcionalidade da coleta de CPF; e plano de reavaliação.
Revisão: anual, ou imediatamente quando houver novo destinatário, nova finalidade, novo tipo de dado coletado, mudança material de arquitetura ou incidente de segurança relevante. O documento fica sob guarda do encarregado, à disposição da ANPD.
22.7.8 Notificação de incidente #
Fluxo, com prazos contados a partir do conhecimento do incidente.
| Prazo | Ação |
|---|---|
| Imediato | Contenção técnica: revogar credencial, isolar serviço, bloquear acesso. |
| Até 6 h | Registro inicial do incidente pelo encarregado: o que aconteceu, quando, quais sistemas, quais dados podem ter sido afetados e quantos titulares. |
| Até 24 h | Avaliação de risco: houve acesso a dado pessoal? Havia cifra? Havia chave junto? O dado é sensível? Dado sensível eleva a gravidade por definição. |
| Até 3 dias úteis | Comunicação à ANPD, quando o incidente puder acarretar risco ou dano relevante ao titular, pelo canal oficial da Autoridade (prazo da Resolução CD/ANPD nº 15/2024). |
| Mesmo prazo | Comunicação aos titulares afetados, em linguagem clara, pelos canais e na ordem definidos na Seção 27.7 — página pública de estado primeiro, e-mail em seguida, canal de mensagens apenas se ele for o canal saudável, e aviso dentro do painel. Comunicar a queda do canal de mensagens pelo próprio canal é impossível e não pode ser o plano. |
| Até 20 dias | Complemento das informações à ANPD, se a apuração inicial foi parcial. |
| Até 30 dias | Relatório pós-incidente completo (Seção 23.10) e implantação das correções. |
Conteúdo da comunicação ao titular, conforme o Art. 48, § 1º: descrição da natureza dos dados afetados; informações sobre os titulares envolvidos; medidas técnicas e de segurança que estavam em uso; riscos relacionados; motivo da demora, se a comunicação não foi imediata; e as medidas adotadas para reverter ou mitigar os efeitos. A comunicação nunca minimiza o ocorrido nem usa linguagem evasiva.
Registro: todo incidente, mesmo o que não atinge o limiar de comunicação, é anotado em um registro interno mantido pelo encarregado, com a análise que fundamentou a decisão de não comunicar. Essa análise é o que sustenta a decisão perante a Autoridade.
22.8 Retenção e eliminação #
| Tabela | Prazo | Fundamento |
|---|---|---|
subscribers |
Anonimização em até 30 dias após pedido de eliminação ou após 24 meses sem qualquer atividade e sem assinatura ativa | Art. 15, I e III: fim da finalidade e pedido do titular |
subscriber_profiles |
Igual a subscribers, exceto CPF, retido por 5 anos após o último pagamento |
Obrigação fiscal e prescrição de pretensões |
consent_events |
5 anos após o fim da relação | Prova de conformidade; Art. 37 e Art. 16, I |
sessions |
Expurgo diário das sessões expiradas há mais de 30 dias | Fim da finalidade |
otp_codes |
Expurgo horário dos códigos expirados há mais de 24 h | Fim da finalidade; dado transitório |
unsubscribe_tokens |
Expurgo horário dos tokens expirados há mais de 24 h, no mesmo job de otp_codes |
Fim da finalidade; dado transitório |
admin_users |
Enquanto houver vínculo; anonimização 12 meses após o desligamento | Fim da finalidade, preservando a integridade da auditoria |
admin_audit_log |
5 anos | Prova de conformidade e apuração de incidentes |
plans |
Indefinida | Não contém dado pessoal |
subscriptions, subscription_events |
5 anos após o encerramento | Prescrição de pretensões contratuais (Código Civil, Art. 206) |
payments, payment_events |
5 anos após o pagamento | Obrigação fiscal e contábil |
devotionals, devotional_revisions, audio_assets |
Indefinida | Conteúdo editorial; não contém dado pessoal |
whatsapp_templates |
Indefinida | Configuração |
message_logs |
18 meses, valor dirigido pela chave privacy.message_log_retention_months |
Necessidade operacional e de suporte; além disso, o dado perde utilidade |
inbound_messages |
18 meses, pela mesma chave | Idem |
delivery_attempts |
13 meses | Idempotência e apuração de falhas |
send_batches, job_runs |
13 meses | Operação e apuração |
webhook_deliveries |
6 meses | Depuração de integração |
media_uploads |
Vida do conteúdo associado | Conteúdo editorial |
settings, feature_flags |
Indefinida | Configuração |
daily_metrics |
Indefinida | Agregado anonimizado; fora do escopo da LGPD (Art. 12) |
| Registros de acesso a aplicação (logs) | 6 meses | Marco Civil da Internet, Art. 15 |
| Backups | 35 dias | Continuidade do negócio |
O prazo de 18 meses das duas tabelas de mensagem é digitado em um único lugar: a chave
privacy.message_log_retention_months de settings (Seção 26.8.1). O job de retenção lê a
chave; nenhuma seção repete o número como constante de código.
Os jobs de privacidade e de retenção citados nesta seção — maintenance.enforce_retention,
privacy.anonymize_subscriber, privacy.export_subscriber_data, security.reencrypt_fields e
security.reindex_blind — são nomes de job, e todos são enfileirados na fila
maintenance.cleanup do catálogo de treze filas da Seção 18.8. Nome de job não é nome de fila, e
nenhuma fila nova é criada por esta seção.
O job maintenance.enforce_retention roda diariamente às 03:40 (America/Sao_Paulo), depois do
rollup de métricas das 03:10, em lotes de 5.000 linhas com pausa entre lotes para não travar o
banco. Cada execução registra em job_runs quantas linhas foram removidas ou anonimizadas por
tabela. O expurgo de consent_events e de admin_audit_log é executado pela conexão do papel
retention_operator, cuja exceção está codificada no gatilho de 22.7.3; nenhum gatilho é
desabilitado em momento algum.
Interação entre retenção e backups, declarada explicitamente porque é a pergunta que aparece em toda auditoria: um pedido de eliminação é executado na base ativa em até 30 dias, mas os backups existentes continuam contendo o dado até expirarem, em no máximo 35 dias. Backup não é reescrito — reescrever backup destrói sua integridade e sua função. Se um backup precisar ser restaurado dentro dessa janela, o job de retenção é executado imediatamente após a restauração, e o procedimento consta do runbook de restauração da Seção 27. A Política de Privacidade informa essa janela ao titular.
22.9 Anonimização #
Quando o titular pede eliminação, o sistema não apaga a linha de subscribers. Apagar
quebraria as chaves estrangeiras de payments e subscriptions, que precisam ser preservados
por obrigação fiscal, e destruiria a integridade contábil. A operação tem dois estágios, com
regimes jurídicos diferentes, e a distinção não é semântica.
Estágio 1 — pseudonimização (imediato, em até 30 dias do pedido). Todos os identificadores
diretos saem, conforme a tabela abaixo, mas cpf e cpf_hmac permanecem enquanto durar a
retenção fiscal, e asaas_customer_id continua apontando para um cadastro identificado no
processador de pagamento. Nesse estágio a linha continua sendo dado pessoal, permanece
integralmente no escopo da LGPD com base no Art. 16, I, conta na apuração de qualquer incidente,
e está sujeita ao mesmo controle de acesso das linhas ativas.
Estágio 2 — anonimização (5 anos após o último pagamento). cpf, cpf_hmac,
asaas_customer_id, asaas_subscription_id e asaas_payment_id são zerados, e é enviada
solicitação de exclusão do cadastro correspondente ao processador. Só então o Art. 12 se aplica.
O job maintenance.enforce_retention executa o estágio 2 diariamente sobre as linhas cuja
retenção fiscal venceu, e registra a transição em admin_audit_log com
action = 'SUBSCRIBER_FULLY_ANONYMIZED'.
Chamar o estágio 1 de anonimização levaria a equipe a excluir essas pessoas da contagem de titulares em um incidente — que é exatamente o erro que a Autoridade procura, e o que inviabilizaria a declaração de conformidade do relatório de impacto inteiro.
Regra de alcance. A anonimização percorre todas as tabelas que contenham telefone,
wa_id, e-mail, CPF ou conteúdo de mensagem do titular, e não apenas subscribers. A lista é
derivada da coluna, não da tabela: qualquer coluna cujo nome esteja em
('phone_e164','wa_id','email','cpf','payload_excerpt','text_body') e cuja linha se relacione ao
assinante é zerada, inclusive nos registros de mensagem. O que a lei obriga a reter — registros
fiscais e de consentimento — é retido de forma pseudonimizada, ligado apenas ao identificador
interno. Um teste de integração cria um assinante, gera tráfego em todas as tabelas, executa a
anonimização e falha se SELECT por telefone, por wa_id ou pelo e-mail original devolver
qualquer linha em qualquer tabela.
Sem essa regra o resultado é indefensável: o painel confirmaria "conta anonimizada" enquanto
message_logs guardaria o número completo, o horário de leitura de cada devocional e o trecho do
conteúdo religioso, por até 18 meses.
Transformação, coluna por coluna:
| Coluna | Antes | Depois | Observação |
|---|---|---|---|
subscribers.display_name |
Maria Souza |
Assinante removido |
Constante |
subscribers.phone_e164 |
envelope cifrado | NULL |
Chave de decifra deixa de ter alvo |
subscribers.phone_hmac |
HMAC real | HMAC de anon:{id} |
Preserva unicidade sem permitir busca pelo telefone real |
subscribers.wa_id / wa_id_hmac |
envelope / HMAC | NULL / NULL |
Índice parcial tolera nulo |
subscribers.tier |
PAID |
inalterado | Não identifica |
subscribers.opt_out_at |
NULL |
now() |
Impede qualquer envio futuro |
subscribers.deleted_at |
NULL |
now() |
Soft delete, conforme a convenção da Seção 6 |
subscribers.anonymized_at |
NULL |
now() |
Marca a operação como concluída |
subscribers.email / email_hmac |
envelope / HMAC | NULL / NULL |
— |
subscriber_profiles.cpf / cpf_hmac |
envelope / HMAC | preservados até 5 anos após o último pagamento; depois NULL (estágio 2) |
Obrigação fiscal prevalece sobre eliminação (Art. 16, I) |
subscribers.asaas_customer_id |
valor | preservado até o fim da retenção fiscal; depois NULL (estágio 2) |
Enquanto existir, recupera o cadastro identificado no processador: é pseudonimização, não anonimização |
subscriptions.asaas_card_token, card_last4, card_brand, card_exp_month, card_exp_year |
valores | NULL |
Token inutilizável após a anonimização; metadados deixam de ter titular |
consent_events.ip / user_agent |
valor | NULL |
O evento permanece como prova; os identificadores de rede saem. Executado pelo papel privacy_operator (22.7.3) |
message_logs.subscriber_id |
ULID | inalterado | Aponta para linha já pseudonimizada; a coluna em si não identifica |
message_logs.phone_e164 |
+5511987654321 |
NULL |
A coluna passa a ser anulável na Seção 6; o roteamento histórico não precisa do número |
message_logs.wa_id |
valor | NULL |
Idem |
message_logs.payload_excerpt |
trecho do conteúdo | NULL |
Pode conter o nome do titular em parâmetro de template e texto por ele digitado |
inbound_messages.phone_e164 / wa_id |
valor | NULL / NULL |
Idem |
inbound_messages.text_body |
texto livre | [removido a pedido do titular] |
Pode conter dado pessoal digitado pelo titular |
delivery_attempts (colunas de destino, quando houver) |
valor | NULL |
Idem |
payments, subscriptions |
valores e status | inalterados, exceto os identificadores do processador no estágio 2 | Registro fiscal, retido de forma pseudonimizada e ligado apenas ao identificador interno |
sessions, otp_codes, unsubscribe_tokens |
linhas | apagadas | Hard delete |
Script de execução:
// apps/worker/src/tasks/privacy-anonymize-subscriber.ts
// Fica em tasks/, e não em jobs/: jobs/ tem exatamente um arquivo por fila (Seção 4.5).
export async function anonymizeSubscriber(subscriberId: string, reason: AnonymizeReason) {
await db.$transaction(async (tx) => {
const s = await tx.subscriber.findUniqueOrThrow({ where: { id: subscriberId } })
if (s.anonymizedAt) return // idempotente
const keepCpf = await hasFiscalHold(tx, subscriberId) // pagamento nos últimos 5 anos
await tx.subscriber.update({
where: { id: subscriberId },
data: {
displayName: 'Assinante removido',
phoneE164: null,
phoneHmac: blindIndex(`anon:${subscriberId}`, 'phone', currentIndexKey()),
waId: null,
waIdHmac: null,
email: null,
emailHmac: null,
...(keepCpf ? {} : { asaasCustomerId: null }),
optOutAt: s.optOutAt ?? new Date(),
deletedAt: new Date(),
anonymizedAt: new Date(),
},
})
await tx.subscriberProfile.updateMany({
where: { subscriberId },
data: { ...(keepCpf ? {} : { cpf: null, cpfHmac: null }) },
})
await tx.subscription.updateMany({
where: { subscriberId },
data: { asaasCardToken: null, cardLast4: null, cardBrand: null,
cardExpMonth: null, cardExpYear: null },
})
// Alcance obrigatório: os registros de mensagem também guardam identificador pessoal.
await tx.$executeRaw`UPDATE message_logs
SET phone_e164 = NULL, wa_id = NULL, payload_excerpt = NULL
WHERE subscriber_id = ${subscriberId}`
await tx.$executeRaw`UPDATE inbound_messages
SET phone_e164 = NULL, wa_id = NULL,
text_body = '[removido a pedido do titular]'
WHERE subscriber_id = ${subscriberId}`
// Conexão do papel privacy_operator: a exceção está codificada no gatilho de 22.7.3,
// que continua barrando qualquer alteração do conteúdo probatório.
await tx.$executeRaw`UPDATE consent_events SET ip = NULL, user_agent = NULL
WHERE subscriber_id = ${subscriberId}`
await tx.session.deleteMany({ where: { subscriberId } })
await tx.otpCode.deleteMany({ where: { subscriberId } })
await tx.adminAuditLog.create({
data: { action: 'SUBSCRIBER_ANONYMIZED', entityType: 'subscriber',
entityId: subscriberId, afterJson: { reason, cpfRetained: keepCpf } },
})
})
}Notas: a atualização de consent_events é executada pela conexão do papel privacy_operator,
cuja exceção está codificada no próprio gatilho de 22.7.3. Nenhum gatilho é desabilitado em
momento algum — desabilitar exige ser dono da tabela, o que o papel não é, e abriria uma janela
em que qualquer escrita passa. A exceção existe porque o direito à eliminação prevalece sobre a
conveniência da imutabilidade quanto aos identificadores de rede; o conteúdo probatório do
consentimento (tipo, versão, hash do texto, data e granted) permanece intacto e o gatilho o
verifica coluna a coluna. A operação é idempotente: rodar de novo não muda nada, porque
anonymizedAt já está preenchido.
Um teste de integração executa a anonimização de ponta a ponta e o expurgo de retenção contra um
banco com o gatilho ativo; ele falha se qualquer um lançar exceção, e falha também se um UPDATE
que altere type, granted, consent_text_hash ou created_at for aceito com o papel
privacy_operator.
O comando de operação equivalente é pnpm ops privacy:anonymize --subscriber sub_01J9… --reason USER_REQUEST,
com confirmação interativa e registro em auditoria.
22.10 Termos de Uso e Política de Privacidade #
22.10.1 Estrutura obrigatória dos Termos de Uso #
- Identificação do fornecedor: razão social, CNPJ, endereço e canal de atendimento.
- Objeto: descrição do serviço, incluindo que o conteúdo é de natureza religiosa cristã.
- Cadastro e requisitos: idade mínima de 18 anos; menores apenas com consentimento de responsável, conforme o Art. 14 da LGPD.
- Planos, preços, forma de pagamento e ciclo de cobrança, com valor expresso em reais.
- Renovação automática, com aviso claro e destacado antes da contratação.
- Cancelamento e efeito: cancelamento a qualquer momento pelo painel; o acesso pago permanece até o fim do ciclo já pago; após isso, o assinante volta ao plano gratuito.
- Inadimplência: falha de pagamento revoga o acesso pago imediatamente, sem carência. Esta cláusula é escrita em linguagem simples, aparece em destaque no checkout e é a mesma regra descrita na Seção 13.
- Direito de arrependimento: 7 dias a partir da contratação, com devolução integral, conforme o Art. 49 do Código de Defesa do Consumidor. Cláusula obrigatória por se tratar de contratação fora do estabelecimento.
- Disponibilidade e limitações: o serviço depende da plataforma de mensageria de terceiro; indisponibilidade dessa plataforma não caracteriza descumprimento.
- Regras de uso e opt-out: palavras-chave de saída e o efeito de cada uma.
- Propriedade intelectual do conteúdo e das gravações, com a licença de uso pessoal e não comercial concedida ao assinante.
- Limitação de responsabilidade, redigida dentro dos limites do Código de Defesa do Consumidor.
- Alterações dos termos, com aviso prévio de 30 dias e direito de rescisão sem ônus.
- Foro e legislação aplicável: legislação brasileira; foro do domicílio do consumidor.
- Data de vigência e versão.
22.10.2 Estrutura obrigatória da Política de Privacidade #
- Identificação do controlador e do encarregado, com o canal de 22.7.5.
- Quais dados são coletados, por categoria, replicando o inventário de 22.7.2 em linguagem acessível.
- Declaração destacada sobre dado sensível: a assinatura revela convicção religiosa, esse dado é tratado com base em consentimento específico, e o consentimento pode ser revogado a qualquer momento.
- Finalidades e bases legais, na forma da tabela de 22.7.1.
- Compartilhamento: cada destinatário nomeado, com finalidade e país.
- Transferência internacional, com as salvaguardas de 22.7.6.
- Retenção, com os prazos de 22.8 e o aviso sobre a janela de backup.
- Direitos do titular e como exercê-los, com os prazos de 22.7.4.
- Segurança: descrição não técnica dos controles, incluindo a criptografia de telefone e CPF.
- Cookies e tecnologias similares, replicando a classificação de 21.11.
- Menores de idade.
- Alterações da política, com histórico de versões acessível.
- Data de vigência e versão.
Ambos os documentos são versionados no repositório, publicados em /termos e /privacidade, e
o identificador da versão vigente é gravado em cada evento de consentimento (22.7.3). Nenhum dos
dois é publicado como PDF apenas: o texto fica em HTML acessível e indexável.
22.10.3 Direitos autorais das versões bíblicas #
Risco real e frequentemente ignorado: as traduções bíblicas modernas em português — NVI, NTLH, ARA, NAA, entre outras — são obras protegidas por direito autoral, com titulares ativos que licenciam e fiscalizam o uso. Reproduzir versículos dessas versões em um produto comercial, em volume diário e por tempo indeterminado, sem licença, expõe o negócio a notificação, remoção de conteúdo e indenização. O limite de "citação para fins de estudo" do Art. 46 da Lei nº 9.610/98 não cobre uso comercial recorrente que constitui o núcleo do produto.
Decisão adotada: o produto usa exclusivamente traduções em domínio público ou com licença livre expressa — a Almeida Revista e Corrigida (Almeida 1911) e a Almeida Livre / Bíblia Livre. Nenhuma versão licenciada é usada sem contrato assinado.
Controles que tornam a decisão executável, e não apenas uma intenção:
- A coluna
bible_versionsó aceita valores de uma lista fechada, mantida emsettingssob a chavecontent.allowed_bible_versions, com valor padrão["ALMEIDA_1911","BIBLIA_LIVRE"]. O painel administrativo apresenta apenas essas opções, em campo de seleção, nunca em texto livre. A siglaARCé proibida como código e como atribuição: no mercado brasileiro ela identifica a Almeida Revista e Corrigida em edição revisada por editora ativa, que é obra protegida, e não a edição de 1911 em domínio público. Ver a siglaARCno seletor levaria o editor a copiar versículos da edição moderna, e a atribuição estampada em cada mensagem seria a prova pronta contra a empresa. - A validação Zod do editor rejeita qualquer outro valor com
422 BIBLE_VERSION_NOT_ALLOWED. - Toda entrega — texto no WhatsApp, painel web e narração — inclui a atribuição da versão por
extenso, no formato
João 3:16 (Almeida 1911)ouJoão 3:16 (Bíblia Livre), nunca uma sigla ambígua. - Incluir uma versão licenciada exige três passos, nesta ordem: contrato de licença assinado
com o titular; registro do contrato e de seus limites de uso em
settings; e só então a inclusão do código na lista permitida. A ordem é obrigatória e consta do procedimento editorial da Seção 15. - A Política de Privacidade e os Termos declaram a versão em uso e a atribuição.
- O texto-fonte de cada versão é importado uma única vez, de origem verificável e registrada
em
settingssobcontent.bible_source_provenance(nome da fonte, URL, data da importação e responsável), e fica congelado no banco. O editor nunca cola versículo de fonte externa: ele seleciona a referência e o sistema busca o texto na base importada. Isso remove a via pela qual texto licenciado entraria no produto sem que ninguém decidisse isso.
O mesmo raciocínio vale para a voz sintetizada: a licença do provedor de TTS precisa permitir uso comercial do áudio gerado, e a verificação dessa cláusula é item do checklist de 22.13.
22.11 Conformidade com as políticas da Meta #
| Política | Exigência | Como o produto atende |
|---|---|---|
| Política de Mensagens de Negócios | Toda mensagem iniciada pelo negócio exige opt-in prévio, comprovável, obtido em canal onde o usuário entendeu o que receberia | Opt-in em duas etapas: consentimento explícito na web e confirmação ativa no WhatsApp. Nenhum envio ocorre antes da confirmação (regra da Seção 11). Prova em consent_events (22.7.3) |
| Política de Mensagens de Negócios | Respeitar pedidos de parada | Palavras-chave reconhecidas, efeito imediato, confirmação única (Seção 20) |
| Política de Mensagens de Negócios | Templates precisam refletir o conteúdo real | O template diário anuncia o devocional e entrega o devocional. Sem isca, sem promoção disfarçada |
| Política de Comércio | Proíbe venda de itens vedados | O produto vende assinatura de conteúdo, categoria permitida. Não há venda de produto físico nem catálogo |
| Política de Comércio e de Anúncios | Restrições sobre conteúdo religioso | O conteúdo é próprio, informativo e devocional. Não há alegação de cura, não há pedido de doação, não há conteúdo que atribua ou infira crença de terceiro sem consentimento |
| Política de Uso Aceitável | Proíbe envio em massa não solicitado | Envio somente a quem confirmou o opt-in; a taxa é limitada abaixo do teto da plataforma (Seção 17) |
| Requisitos de qualidade | Número com quality_rating baixo perde capacidade de envio |
Monitoramento contínuo com alerta imediato em RED ou FLAGGED (Seção 23.8) |
| Verificação do negócio | Conta verificada é pré-requisito para tiers maiores | Verificação concluída antes do lançamento; item do checklist de 22.13 |
O que causa banimento ou restrição, em ordem de probabilidade real:
- Volume alto de bloqueios e denúncias pelos usuários. É a causa dominante. Mitigação: o
opt-in em duas etapas, o rodapé com instrução de saída em todo template, o processamento
imediato de opt-out e o monitoramento de
opt_out_ratecomo alerta P1 (Seção 21.12). - Envio a quem nunca deu opt-in, inclusive por importação de lista. Mitigação: não existe funcionalidade de importar lista de números. A ausência é deliberada.
- Uso de template aprovado para finalidade diferente da declarada. Mitigação: um template por finalidade, revisão editorial antes da submissão, e catálogo controlado (Seção 19).
- Conteúdo que viole a política, incluindo promessa de milagre, arrecadação financeira ou conteúdo político. Mitigação: guarda editorial humana e checklist de publicação (Seção 15).
- Tentativa de contornar limites, como usar vários números para o mesmo público. Mitigação: não é feito; a escala é resolvida por elevação de tier junto à plataforma.
Consequência assumida e planejada: a perda do número é um risco existencial para o produto. Por isso existe um número secundário registrado e verificado, mantido inativo, com os templates já aprovados, para permitir migração operacional. O procedimento de troca está no runbook de qualidade do número, na Seção 27.
22.12 Segurança operacional #
22.12.1 Acesso ao servidor #
| Controle | Regra |
|---|---|
| SSH | Somente chave pública, PasswordAuthentication no, PermitRootLogin no, AllowUsers com lista nominal |
| Porta | Padrão 22, protegida por fail2ban (5 falhas em 10 min resultam em 24 h de bloqueio) |
| Firewall | ufw com política padrão de negar entrada; abertos apenas 22, 80 e 443 |
| Portas de serviço | Postgres, Redis, /metrics e o painel de monitoramento não são expostos ao host público; ficam na rede interna do Docker e são acessados por túnel SSH |
| Sudo | Concedido nominalmente, com registro em log; sem NOPASSWD |
| Atualizações | unattended-upgrades para correções de segurança do sistema operacional; reinício planejado mensal |
| Sessão | Timeout de 15 minutos de inatividade em sessão SSH interativa |
22.12.2 Menor privilégio #
Cinco papéis de banco distintos, nunca o mesmo para tudo. Cada um existe por uma razão única, e dois deles são exigidos pelas exceções codificadas no gatilho de 22.7.3:
CREATE ROLE app_user LOGIN; -- aplicação: apenas DML
CREATE ROLE migrator LOGIN; -- migrações: DDL, só na inicialização do contêiner web
CREATE ROLE readonly_user LOGIN; -- leitura analítica e suporte
CREATE ROLE privacy_operator LOGIN; -- eliminação por LGPD (22.9)
CREATE ROLE retention_operator LOGIN;-- expurgo por retenção (22.8)
GRANT ALL PRIVILEGES ON SCHEMA public TO migrator;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;
-- Sem isto, toda tabela criada por uma migration futura nasce inacessível à aplicação.
ALTER DEFAULT PRIVILEGES FOR ROLE migrator IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
ALTER DEFAULT PRIVILEGES FOR ROLE migrator IN SCHEMA public
GRANT SELECT ON TABLES TO readonly_user;
ALTER DEFAULT PRIVILEGES FOR ROLE migrator IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO app_user;
REVOKE UPDATE, DELETE ON consent_events, admin_audit_log FROM app_user;
-- Leitura analítica não alcança tabela com identificador pessoal, nem cifrado.
REVOKE SELECT ON subscribers, subscriber_profiles, message_logs, inbound_messages
FROM readonly_user;
CREATE VIEW v_subscribers_safe AS
SELECT id, tier, status, locale, opt_in_confirmed_at, opt_out_at, created_at
FROM subscribers;
GRANT SELECT ON v_subscribers_safe TO readonly_user;Duas armadilhas fechadas explicitamente por esse bloco:
GRANT ... ON ALL TABLESconcede privilégio apenas às tabelas existentes no momento do comando. SemALTER DEFAULT PRIVILEGES, a primeira tabela criada por uma migration posterior nasce sem privilégio paraapp_user, o health check não percebe porque só consulta as tabelas antigas, e o sintoma aparece horas depois do deploy comopermission deniedem um fluxo específico. Por isso a verificação pós-migration da Seção 25 passa a incluir: nenhuma tabela do schemapublicpode existir semSELECTparaapp_user; a consulta que comprova isso percorreinformation_schema.tablescontrahas_table_privilege, e uma falha bloqueia a troca de tráfego.readonly_usernunca alcança tabela que contenha telefone,wa_id, e-mail, CPF ou conteúdo de mensagem, nem mesmo em forma cifrada. O acesso analítico é servido por views que projetam apenas colunas não identificadoras. Afirmar restrição "por view" sem revogar oSELECTdas tabelas seria uma restrição inexistente: uma credencial de leitura entregue a um analista externo daria a base inteira.
Nas demais camadas: o contêiner da aplicação roda como usuário não-root, com sistema de arquivos
raiz somente leitura e no-new-privileges; as credenciais do storage têm permissão restrita ao
bucket do produto, sem direito de apagar o bucket nem de listar outros; a chave da plataforma de
pagamento é a de menor escopo que atenda às operações necessárias; e o painel administrativo
separa EDITOR (conteúdo) de ADMIN (assinantes e cobrança) e de OWNER (configuração,
segredos e criação de administradores), conforme a matriz da Seção 3.
22.12.3 MFA e revisão de acessos #
MFA é obrigatório, sem exceção, para: painel administrativo do produto (TOTP, regra da Seção 8.4); console do provedor de VPS; painel da Meta; painel do processador de pagamento; provedor de storage; provedor de DNS; repositório de código; e provedor de e-mail transacional. Um administrador sem TOTP configurado não consegue concluir o login — a configuração é forçada no primeiro acesso.
Revisão trimestral de acessos, conduzida pelo OWNER, com registro em admin_audit_log:
listar todos os admin_users ativos e confirmar necessidade e papel de cada um; listar as contas
em cada provedor externo e remover as desnecessárias; conferir chaves SSH autorizadas no
servidor; conferir tokens e chaves de API ativos e revogar os não usados nos últimos 90 dias;
conferir a data da última rotação de cada segredo (22.4.2); e revisar quem tem permissão de
exportação de dados. A revisão é um item de calendário fixo, com data-limite, e sua conclusão é
registrada em settings sob ops.last_access_review_at.
22.12.4 Cadeia de suprimentos e código #
Lockfile obrigatório e commitado; instalação em CI e em build com --frozen-lockfile;
pnpm audit executado em cada pull request, falhando o build em vulnerabilidade de severidade
alta ou crítica sem correção aplicada; atualizações de dependência revisadas por pessoa, nunca
mescladas automaticamente; imagens base fixadas por digest, não por tag móvel; varredura de
imagem antes da publicação; e verificação de segredo no pré-commit e no CI. Nenhuma dependência
nova entra sem justificativa registrada no pull request — cada pacote adicionado é superfície de
ataque nova.
22.12.5 Saída de um administrador #
Executado em até 4 horas a partir do desligamento, na ordem abaixo, com registro de cada
passo em admin_audit_log:
- Desativar o
admin_userscorrespondente (deleted_atpreenchido; a linha permanece para preservar a integridade do histórico de auditoria). - Revogar todas as sessões daquele administrador (
DELETE FROM sessions WHERE admin_user_id = …). - Remover a chave SSH do servidor e conferir que não há sessão SSH aberta em nome dele.
- Remover o acesso em cada provedor externo, conforme a lista de 22.12.3.
- Remover do repositório de código e dos canais de alerta.
- Rotacionar todo segredo a que a pessoa teve acesso, com prioridade para chave de criptografia, chave de índice, token da plataforma de mensageria e chave de pagamento. Este passo não é opcional nem adiável: conhecimento de segredo não é revogável por remoção de conta.
- Revisar as ações dos últimos 90 dias em
admin_audit_log, com atenção a exportações e a revelações de dado pessoal. - Registrar a conclusão do procedimento com data, responsável e itens executados.
22.13 Checklist de segurança pré-lançamento #
Cada item é verificável e tem um responsável. O lançamento não ocorre com item pendente.
Aplicação
- Todo endpoint público tem schema Zod com
.strict()e limite de tamanho em cada string. - Nenhuma ocorrência de
$queryRawUnsafe,$executeRawUnsafe,evalounew Functionno código; verificado por regra de lint que falha o build. -
dangerouslySetInnerHTMLaparece em exatamente um arquivo, o de renderização sanitizada. - Sanitização de markdown testada com vetores de XSS conhecidos (Seção 24).
- Verificação de origem e token CSRF ativos em todas as rotas que mudam estado; teste automatizado confirma que nenhuma rota escapa, exceto as duas de webhook declaradas.
-
safeFetché o único caminho de saída HTTP; lista de destinos revisada e contendochallenges.cloudflare.com. - Upload administrativo rejeita SVG, valida assinatura binária e re-codifica o arquivo, com os limites de 16 MB e 480 s para áudio.
- Impersonação administrativa é somente leitura: o invólucro de API recusa todo método não seguro em sessão de impersonação, em qualquer prefixo de rota, inclusive cancelamento de assinatura, troca de forma de pagamento, pedido de eliminação e opt-out (Seção 8.13).
Cabeçalhos e transporte
- CSP em modo de imposição, sem
unsafe-inlinee semunsafe-eval, validada em todas as páginas — incluindo nominalmente/cadastro,/contatoe a tela de pedido de código — com o console do navegador limpo. - Widget anti-bot renderiza e conclui o desafio em cada página com formulário, em navegador real; um cadastro completo é feito de ponta a ponta antes do lançamento.
- Player de áudio do plano pago toca em navegador real, o que comprova o domínio de mídia
liberado em
connect-src. - Endpoint de relatório de violação de CSP recebendo e registrando.
- Todos os cabeçalhos de 22.3 presentes na resposta, verificados em produção.
- HSTS ativo com
preload; redirecionamento de HTTP para HTTPS funcionando. - TLS 1.0 e 1.1 desabilitados; nota A ou superior em avaliador externo de TLS.
Criptografia e segredos
- Telefone,
wa_id, CPF e e-mail cifrados no banco, com as colunas e os índices cegos declarados na Seção 6 presentes desde a primeira migration; verificado por inspeção direta de uma linha real. - Índice cego funcionando: busca por telefone exato e pelas variantes do nono dígito testada com número real.
-
ENCRYPTION_KEY,PHONE_INDEX_KEYeBACKUP_ENCRYPTION_KEYgerados comopenssl rand, guardados no cofre offline e fora do banco. - Nenhum nome de segredo fora do registro canônico de 26.3; teste de pipeline verde.
- Nenhum segredo no repositório;
gitleakslimpo no histórico completo. - Backup cifrado e restauração testada com sucesso em ambiente separado.
- Datas de rotação registradas e alerta de vencimento configurado.
Abuso e autenticação
- Rate limits de 22.6.1 ativos e testados, inclusive o de OTP por número.
- Respostas de OTP indistinguíveis entre número existente e inexistente, em corpo e em tempo.
- Anti-bot ativo no cadastro, com validação server-side conferindo o
hostname. - Pontuação anti-abuso ativa; caminho de liberação manual pelo suporte testado.
- TOTP obrigatório para todos os administradores; nenhum administrador sem segundo fator.
- Webhooks validando segredo e assinatura em tempo constante; requisição forjada rejeitada
com
401em teste.
LGPD
- Política de Privacidade e Termos de Uso publicados, versionados e com data de vigência.
- Três consentimentos separados no formulário, nenhum pré-marcado, com o de dado sensível destacado.
-
consent_eventsgravando tipo, versão, hash do texto, canal, IP e user-agent. - Gatilho de imutabilidade de
consent_eventsativo; teste confirma queUPDATEda aplicação falha e que a anonimização pelo papelprivacy_operatore o expurgo pelo papelretention_operatorconcluem sem exceção. - Encarregado nomeado, canal publicado e caixa monitorada.
- Relatório de impacto elaborado e arquivado, descrevendo a decisão automatizada de 22.6.3 como tal.
- Exportação de portabilidade funcionando, baixada dentro do painel sob sessão plena, com URL de 15 minutos e limite de downloads.
- Anonimização testada de ponta a ponta em ambiente de homologação, com verificação de que
as chaves estrangeiras seguem íntegras e de que nenhuma busca por telefone,
wa_idou e-mail original devolve linha em qualquer tabela, inclusive nos registros de mensagem. - Job de retenção agendado e testado em lote.
- Destinatários e países listados nominalmente na Política de Privacidade.
- Salvaguarda de transferência internacional comprovada e arquivada para cada um dos três destinatários que recebem dado pessoal.
- Procedimento de incidente documentado, com os prazos de 22.7.8 e responsável nomeado.
Operação
- SSH sem senha, sem root, com
fail2bane firewall ativos. - Postgres, Redis,
/metricse o painel de monitoramento inacessíveis a partir da internet; verificado por varredura externa de portas. - Papéis de banco separados, com
app_usersem DDL e semUPDATEnas tabelas append-only;ALTER DEFAULT PRIVILEGESaplicado, verificado por consulta ainformation_schema. - Contêineres rodando como usuário não-root, com raiz somente leitura.
- MFA ativo em todos os provedores externos.
- Alertas de segurança de 23.8 entregando no canal correto; teste real disparado.
- Procedimento de saída de administrador escrito e com responsável designado.
- Conta da plataforma de mensageria verificada, templates aprovados e número secundário registrado.
- Licença comercial do provedor de voz conferida para uso do áudio gerado.
- Versões bíblicas restritas à lista permitida, com atribuição por extenso visível na
entrega e a sigla
ARCausente do código e da atribuição.
23. Observabilidade, Logs, Métricas e Alertas #
O produto envia mensagens uma vez por dia, em uma janela de poucos minutos, para milhares de pessoas. Se essa janela falha, ninguém percebe olhando a tela: o site continua no ar, o painel responde, e o único sinal é a ausência de uma mensagem que deveria ter chegado. Observabilidade aqui não é conforto de engenharia — é o único mecanismo capaz de detectar a falha mais grave do sistema. Toda decisão desta seção parte disso.
A pilha adotada roda no mesmo stack de Docker Compose descrito na Seção 25, em contêineres auxiliares acessíveis apenas pela rede interna e por túnel SSH (regra de exposição em 22.12.1): Prometheus para métricas, Grafana para painéis, Loki com o driver de log do Docker para logs, e Alertmanager para roteamento de alertas. Fora do servidor, um monitor externo verifica disponibilidade e um serviço de sinal de vida recebe o batimento do lote diário — os dois existem porque um servidor caído não consegue avisar que caiu.
23.1 Estratégia de logging #
23.1.1 Biblioteca e formato #
Biblioteca: pino, com pino-http no aplicativo web e o logger base compartilhado no worker e
na CLI de operação (linhas de versão na Seção 4). Formato: JSON em uma linha por evento,
escrito em stdout. Nada de arquivo de log gravado pela aplicação: o processo escreve em
stdout, o Docker captura e o driver do Loki encaminha. Isso mantém o contêiner sem estado e
elimina a classe inteira de problema de rotação dentro do contêiner.
Em desenvolvimento local, pino-pretty formata a saída para leitura humana. Em staging e
production, sempre JSON puro — log colorido em produção quebra o parser.
Campos obrigatórios em todo evento de log, sem exceção:
| Campo | Tipo | Origem | Exemplo |
|---|---|---|---|
time |
inteiro (epoch ms) | pino | 1787635200123 |
level |
inteiro, com levelLabel textual |
pino | 30 / "info" |
msg |
string curta e estável, em inglês, sem interpolação de valor | código | "whatsapp.send.failed" |
service |
web | worker | ops |
configuração | "worker" |
env |
local | staging | production |
configuração | "production" |
version |
SHA curto do commit implantado | build | "a3f91c2" |
requestId |
ULID da requisição ou do job | contexto assíncrono | "req_01J9K2M4R7T8V0X1Y2Z3A4B5C6" |
hostname |
nome do contêiner | pino | "worker-1" |
pid |
inteiro | pino | 18 |
msg é uma chave estável em inglês, no formato dominio.acao.resultado, e nunca uma frase
com valores embutidos. "whatsapp.send.failed" com errorCode: 131047 em campo próprio é
consultável; "Falha ao enviar para +5511987654321: erro 131047" não é, e ainda vaza dado
pessoal. Essa regra é o que torna o log agregável.
Campos adicionais conforme o contexto: subscriberId, devotionalId, subscriptionId,
jobId, queue, attempt, durationMs, statusCode, method, path, provider,
errorCode, errorName, traceId, spanId, batchId.
23.1.2 Níveis e quando usar cada um #
| Nível | Valor | Quando usar | Vai para alerta? |
|---|---|---|---|
fatal |
60 | O processo não pode continuar: falta de segredo obrigatório, falha de migração, incapacidade de conectar ao banco na inicialização. O processo encerra logo em seguida. | Sim, imediato |
error |
50 | Operação falhou e não será recuperada automaticamente, ou esgotou as tentativas. Exemplo: envio que falhou nas três tentativas; webhook cujo processamento estourou a fila de retentativas. | Sim, por limiar |
warn |
40 | Comportamento anormal absorvido pelo sistema: uso do provedor de TTS de fallback, tentativa de envio recusada por erro esperado, violação de CSP, tentativa de autenticação de webhook malsucedida, invariante de métrica violada. | Por limiar |
info |
30 | Marcos de negócio: assinante confirmou opt-in, assinatura ativada, lote iniciado e concluído, devocional publicado, job concluído. Padrão em produção. | Não |
debug |
20 | Detalhe de execução útil em investigação: corpo de requisição já redigido, decisão de roteamento, resultado intermediário. Desligado em produção; ligável por assinante ou por rota (23.1.5). | Não |
trace |
10 | Somente desenvolvimento local. Nunca habilitado em produção. | Não |
Regra prática que evita os dois erros mais comuns: uma falha esperada e tratada é warn, não
error — o erro 131047 da plataforma de mensageria, que indica janela de 24 h fechada, é
comportamento previsto no desenho e registrado como warn. E um error sem ação humana
possível é ruído: se ninguém pode fazer nada, o nível correto é warn.
23.1.3 Correlação entre web e worker #
O requestId nasce na borda HTTP: o middleware lê X-Request-Id se presente e válido, ou gera
um ULID novo, e o devolve no header de resposta (convenção registrada na Seção 7). Dentro do
processo, ele viaja por AsyncLocalStorage, de modo que nenhuma função precisa recebê-lo como
parâmetro.
Quando a requisição enfileira um job, o requestId é copiado para os dados do job junto com o
contexto de rastreamento. O worker restaura o contexto ao consumir:
// packages/core/src/observability/context.ts
import { AsyncLocalStorage } from 'node:async_hooks'
export type LogContext = {
requestId: string
traceparent?: string
subscriberId?: string
jobId?: string
batchId?: string
}
export const logContext = new AsyncLocalStorage<LogContext>()
export const childLogger = () => baseLogger.child(logContext.getStore() ?? {})// apps/web — ao enfileirar. O nome da fila é o canônico da Seção 18.8; não há apelido.
await queue.add('send.dispatch', {
...payload,
_ctx: { requestId: current().requestId, traceparent: currentTraceparent() },
})
// apps/worker — ao consumir
worker.on('active', (job) => { /* contexto restaurado pelo wrapper abaixo */ })
export const withJobContext = <T>(job: Job, fn: () => Promise<T>) =>
logContext.run(
{ requestId: job.data._ctx?.requestId ?? `job_${ulid()}`,
traceparent: job.data._ctx?.traceparent,
jobId: job.id, subscriberId: job.data.subscriberId },
fn,
)Resultado prático: dado um requestId, uma única consulta no Loki devolve a linha do tempo
completa — a requisição HTTP no web, o enfileiramento, a execução no worker, a chamada ao
provedor externo e o webhook de status que chegou minutos depois, porque o webhook também
recupera o requestId original a partir do identificador da mensagem.
Para o lote diário, que não nasce de requisição HTTP, o job de planejamento gera um batchId
que é propagado a todos os jobs de envio derivados. Assim, batchId responde "o que aconteceu
no envio de hoje" e requestId responde "o que aconteceu com este assinante".
23.1.4 Redação de dados sensíveis #
Nenhum log, em nenhum nível, em nenhum ambiente, pode conter: telefone, wa_id, CPF, e-mail,
nome completo, código OTP, token de sessão, cabeçalho Authorization ou Cookie, chave de API,
segredo de webhook, dado de cartão, corpo de mensagem recebida do assinante, o conteúdo do
payload de tokenização de pagamento, ou qualquer URL assinada.
URL assinada de storage e URL de mídia da plataforma de mensagens são credenciais, não
endereços: quem tem a URL tem o arquivo, e o padrão da URL ainda revela a estrutura do bucket.
Nenhuma das duas é registrada em nenhum nível de log. A varredura semanal por telefone, CPF e
e-mail nunca detectaria isso, porque URL não casa com nenhum desses padrões — por isso a
proibição é explícita e tem padrão próprio. Logs referem-se a mídia por mediaKey — o caminho no
bucket, sem host e sem assinatura — ou pelo identificador de mídia da plataforma.
A redação é aplicada em duas camadas, porque uma só sempre falha:
Camada 1 — redação declarativa do pino, por caminho:
// packages/core/src/observability/logger.ts
import pino from 'pino'
export const baseLogger = pino({
level: process.env.LOG_LEVEL ?? 'info',
base: { service: SERVICE, env: ENV, version: BUILD_SHA },
formatters: { level: (label, n) => ({ level: n, levelLabel: label }) },
redact: {
paths: [
'req.headers.authorization', 'req.headers.cookie', 'req.headers["x-csrf-token"]',
'req.headers["asaas-access-token"]', 'req.headers["x-hub-signature-256"]',
'res.headers["set-cookie"]',
'phone', 'phoneE164', 'phone_e164', 'waId', 'wa_id', 'to', 'from',
'cpf', 'cpfCnpj', 'email', 'name', 'fullName',
'otp', 'code', 'otpCode', 'token', 'accessToken', 'refreshToken',
'apiKey', 'secret', 'password', 'creditCard', 'creditCardToken', 'holderName',
'body.phone', 'body.cpf', 'body.email', 'body.name', 'body.code',
'*.phone', '*.cpf', '*.email', '*.otp', '*.token',
'inboundMessage.textBody', 'message.text.body',
// URL assinada é credencial: quem tem a URL tem o arquivo.
'url', 'mediaUrl', 'signedUrl', 'downloadUrl', 'audioUrl', 'videoUrl',
'presignedUrl', 'invoiceUrl', '*.url',
],
censor: '[REDACTED]',
},
serializers: {
err: pino.stdSerializers.err,
req: (req) => ({ method: req.method, path: req.url?.split('?')[0], id: req.id }),
res: (res) => ({ statusCode: res.statusCode }),
},
})Reparar em dois detalhes deliberados: a query string é descartada do path registrado, porque
parâmetros de busca podem conter telefone digitado por um administrador; e o serializador de
requisição não registra o corpo, jamais — corpo só aparece em debug, já redigido, e debug
está desligado em produção.
Camada 2 — identificadores derivados no lugar do dado. Quando o log precisa identificar uma
pessoa, ele usa subscriberId (um ULID, que não revela nada sobre o titular) ou, quando o
assinante ainda não existe, phoneFingerprint: os oito primeiros caracteres do índice cego
descrito em 22.5.4. O fingerprint permite correlacionar tentativas do mesmo número sem
armazenar o número, e não é reversível sem a chave, que não está no sistema de logs.
export const phoneFingerprint = (e164: string) => phoneIndex(e164).slice(0, 8)Camada 3 — saneamento de texto livre. scrubText roda sobre qualquer string que chegue ao
logger fora de um campo nomeado, e aplica também o padrão
https?://\S*(X-Amz-Signature|access_token|lookaside\.fbsbx\.com)\S*, que captura a URL assinada
mesmo quando ela aparece no meio de uma mensagem de erro de biblioteca de terceiro — o caminho
mais comum de vazamento, porque nenhum campo nomeado a contém.
Verificação automatizada. Um teste da suíte (Seção 24) captura a saída do logger durante uma
execução de ponta a ponta e falha se qualquer linha casar com os padrões de telefone brasileiro
(\+?55\d{10,11}), CPF (\d{3}\.?\d{3}\.?\d{3}-?\d{2}), e-mail, sequência de seis dígitos
isolada, ou o padrão de URL assinada acima. O mesmo teste cobre segredo: nenhuma linha pode
conter o código de acesso gerado, o valor do token de sessão ou de refresh, nem o valor de
qualquer variável de ambiente cujo nome termine em _TOKEN, _KEY, _SECRET ou _PASSWORD. Ele
roda também com o assinante incluído na lista de depuração de 23.1.5, para cobrir o nível
debug — um logger.debug acrescentado para depurar entrega de código sobrevive ao commit e só
vaza quando alguém liga a depuração pontual, que é justamente o momento em que ninguém está
olhando para isso. Além disso, uma consulta agendada semanal roda os mesmos padrões sobre os logs
retidos e alerta em caso de correspondência — porque redação quebra silenciosamente quando
alguém adiciona um campo novo.
Exemplos de log correto:
{"time":1787635212004,"level":30,"levelLabel":"info","service":"worker","env":"production",
"version":"a3f91c2","requestId":"req_01J9K2M4R7T8V0X1Y2Z3A4B5C6","batchId":"bat_01J9K2…",
"subscriberId":"sub_01J9K2…","msg":"whatsapp.send.succeeded","templateName":"devocional_diario_v1",
"pricingCategory":"UTILITY","durationMs":312}{"time":1787635213880,"level":40,"levelLabel":"warn","service":"worker","env":"production",
"version":"a3f91c2","requestId":"req_01J9K2…","subscriberId":"sub_01J9K2…",
"msg":"whatsapp.send.deferred","errorCode":131049,"reason":"ecosystem_health","retryOn":"next_day"}23.1.5 Depuração pontual em produção #
Ligar debug globalmente em produção multiplicaria o volume por dez e vazaria contexto
desnecessário. Em vez disso, existe amostragem dirigida: a chave ops.debug_subscriber_ids de
settings (Seção 26.8.1) aceita uma lista de até 20 identificadores; quando o contexto de log
tem um subscriberId dessa lista, o nível efetivo daquele fluxo cai para debug. A lista expira
sozinha após 24 h — o campo guarda um carimbo de tempo junto —, e a inclusão de um identificador
é registrada em admin_audit_log. O mesmo mecanismo existe por rota, sob ops.debug_paths.
Mesmo com a depuração ligada, a redação de 23.1.4 continua valendo integralmente. Nível debug
aumenta o detalhe do fluxo, nunca a permissão de registrar dado pessoal ou segredo.
23.2 Retenção, volume e custo dos logs #
Estimativa de volume, feita para a base do primeiro ano (10.000 assinantes) e para o cenário dimensionado (50.000):
| Fonte | Linhas/dia (10 mil) | Linhas/dia (50 mil) |
|---|---|---|
Requisições HTTP (web), incluindo painéis e API |
45.000 | 220.000 |
| Envio diário: planejamento, envio, resultado, status | 60.000 | 300.000 |
| Webhooks recebidos (status e mensagens) | 30.000 | 150.000 |
| Jobs de manutenção, rollup, cobrança, TTS | 3.000 | 6.000 |
| Erros e avisos | 1.500 | 8.000 |
| Total aproximado | 140.000 | 684.000 |
Com média de 600 bytes por linha JSON, são cerca de 84 MB/dia na escala do primeiro ano e 410 MB/dia na escala dimensionada. Comprimidos pelo Loki, a taxa típica fica em torno de 10:1, resultando em 8 MB/dia e 41 MB/dia respectivamente.
| Política | Valor | Justificativa |
|---|---|---|
| Retenção no Loki | 30 dias | Cobre a investigação operacional normal e o ciclo de fechamento mensal |
| Arquivo comprimido em storage | 180 dias | Apuração de incidente e resposta a questionamento de titular |
| Registros de acesso (IP, data e hora) | 6 meses | Prazo do Marco Civil da Internet, replicado em 22.8 |
| Descarte | Automático, sem exceção | Log retido além do necessário é passivo, não ativo |
Custo: 30 dias de log comprimido ocupam cerca de 240 MB na escala do primeiro ano e 1,2 GB na escala dimensionada — desprezível no disco do VPS. O arquivo de 180 dias em storage fica abaixo de 8 GB no pior caso, com custo mensal de poucos reais. Não há custo de plataforma de logs, porque a pilha é auto-hospedada; essa foi a razão principal de escolher Loki em vez de um serviço cobrado por volume ingerido, cujo preço cresceria linearmente com a base.
Rotação: o Loki aplica a retenção por compactação automática. O driver de log do Docker é
configurado com max-size: 20m e max-file: 3 como rede de segurança, para o caso de o Loki
ficar indisponível e o buffer local crescer. Se o Loki cair, a aplicação continua escrevendo em
stdout normalmente — logging nunca é caminho crítico, e uma falha do coletor jamais derruba o
produto. O alerta loki_down (23.8) avisa que a visibilidade foi perdida.
Antes do descarte, o arquivamento roda diariamente às 04:20 e envia o bloco do dia anterior,
comprimido e cifrado, para o mesmo bucket privado do storage de mídia, sob o prefixo
logs/{YYYY}/{MM}/{DD}.jsonl.zst.age.
23.3 Instrumentação de métricas #
Cliente prom-client no web e no worker. O worker expõe um servidor HTTP interno com
Fastify (linha de versão na Seção 4) na porta 9091, com os mesmos caminhos sob
/api/internal/; o web expõe a rota GET /api/internal/metrics no mesmo processo Next.js, na
porta interna 3000.
| Item | web |
worker |
|---|---|---|
| Endpoint | GET /api/internal/metrics |
GET /api/internal/metrics na porta 9091 |
| Exposição | Somente rede interna do Docker; não publicado no proxy | Idem |
| Autenticação | Authorization: Bearer <METRICS_TOKEN>, comparado em tempo constante |
Idem |
| Formato | Exposição de texto do Prometheus, versão 0.0.4 | Idem |
| Métricas padrão de processo | Habilitadas com prefixo nodejs_ |
Habilitadas |
| Intervalo de coleta | 15 s | 15 s |
A dupla proteção — rede interna e token — existe porque o endpoint de métricas revela volume de negócio, nomes de fila e topologia. Se um erro de configuração expuser a porta, o token ainda protege.
Regra de cardinalidade, obrigatória: nenhum rótulo recebe valor não limitado. Proibidos como
rótulo: subscriberId, phone, devotionalId, requestId, jobId e caminho de URL cru. O
rótulo route usa o padrão da rota (/api/devotionals/[id]), nunca o caminho concreto. Um
único rótulo de alta cardinalidade transforma o Prometheus em consumidor de memória sem limite.
Uma verificação de inicialização percorre o registro de métricas e falha o processo se algum
rótulo declarado estiver na lista negra.
23.4 Catálogo de métricas expostas #
23.4.1 HTTP #
| Métrica | Tipo | Rótulos | Significado |
|---|---|---|---|
http_requests_total |
Counter | method, route, status_code |
Requisições atendidas |
http_request_duration_seconds |
Histogram | method, route |
Latência; baldes 0.01, 0.05, 0.1, 0.2, 0.4, 0.8, 1.5, 3, 10 |
http_request_size_bytes |
Histogram | route |
Tamanho do corpo recebido |
http_response_size_bytes |
Histogram | route |
Tamanho do corpo enviado |
http_requests_in_flight |
Gauge | route |
Requisições em processamento |
http_rate_limited_total |
Counter | route, dimension |
Bloqueios por limite de taxa (22.6.1) |
csp_violation_total |
Counter | directive |
Violações de política de conteúdo (22.3.1) |
csrf_rejected_total |
Counter | reason |
Rejeições por origem ou token inválido |
Os baldes do histograma foram escolhidos em torno do alvo de p95 registrado na Seção 9.12, com resolução fina abaixo dele e grossa acima — baldes uniformes desperdiçariam precisão justamente na faixa que importa.
23.4.2 Banco de dados #
| Métrica | Tipo | Rótulos | Significado |
|---|---|---|---|
db_query_duration_seconds |
Histogram | model, operation |
Duração das operações do ORM |
db_queries_total |
Counter | model, operation, result |
Volume e taxa de erro |
db_pool_connections |
Gauge | state (active, idle, waiting) |
Ocupação do pool |
db_pool_acquire_duration_seconds |
Histogram | — | Espera por conexão; subida indica esgotamento |
db_transaction_duration_seconds |
Histogram | name |
Duração das transações nomeadas |
db_deadlocks_total |
Counter | — | Conflitos de bloqueio detectados |
db_migration_pending |
Gauge | — | 1 quando há migração não aplicada |
23.4.3 Filas e jobs #
| Métrica | Tipo | Rótulos | Significado |
|---|---|---|---|
queue_jobs_waiting |
Gauge | queue |
Jobs aguardando |
queue_jobs_active |
Gauge | queue |
Jobs em execução |
queue_jobs_delayed |
Gauge | queue |
Jobs agendados para o futuro |
queue_jobs_failed |
Gauge | queue |
Jobs na lista de falhas |
queue_job_duration_seconds |
Histogram | queue, job_name |
Duração de execução |
queue_job_wait_duration_seconds |
Histogram | queue |
Tempo entre enfileirar e iniciar |
queue_jobs_processed_total |
Counter | queue, job_name, result (completed, failed) |
Volume processado |
queue_job_retries_total |
Counter | queue, job_name |
Retentativas |
queue_oldest_waiting_age_seconds |
Gauge | queue |
Idade do job mais antigo na espera; melhor sinal de fila travada que a profundidade |
worker_concurrency |
Gauge | queue |
Concorrência configurada |
23.4.4 Integrações externas #
| Métrica | Tipo | Rótulos | Significado |
|---|---|---|---|
external_request_duration_seconds |
Histogram | provider, operation |
Latência das chamadas de saída |
external_requests_total |
Counter | provider, operation, status_class |
Volume e resultado |
external_request_errors_total |
Counter | provider, operation, error_kind (timeout, network, http_4xx, http_5xx) |
Falhas por natureza |
circuit_breaker_state |
Gauge | provider |
0 fechado, 1 meio-aberto, 2 aberto |
whatsapp_messages_sent_total |
Counter | message_type, template_name, pricing_category, result |
Envios pela plataforma de mensageria |
whatsapp_send_errors_total |
Counter | error_code |
Erros nomeados da plataforma |
whatsapp_message_status_total |
Counter | status (sent, delivered, read, failed) |
Status recebidos por webhook |
whatsapp_quality_rating |
Gauge | phone_number_id |
3 verde, 2 amarelo, 1 vermelho, 0 sinalizado |
whatsapp_messaging_tier_limit |
Gauge | phone_number_id |
Teto de destinatários únicos em 24 h |
whatsapp_media_upload_total |
Counter | result |
Envios de mídia para a plataforma |
asaas_webhook_events_total |
Counter | event_type, result |
Eventos de cobrança recebidos |
asaas_webhook_lag_seconds |
Gauge | — | Diferença entre o carimbo do evento e a hora de recebimento |
asaas_reconcile_discrepancies_total |
Counter | kind |
Divergências encontradas na conciliação diária |
tts_generation_duration_seconds |
Histogram | provider |
Duração da síntese de voz |
tts_generation_total |
Counter | provider, result |
Volume e resultado |
tts_characters_total |
Counter | provider |
Caracteres sintetizados |
storage_operation_duration_seconds |
Histogram | operation |
Latência do storage de mídia |
email_sent_total |
Counter | template, result |
E-mails transacionais |
external_contract_validation_total |
Counter | provider, operation, result |
Resultado da sonda de contrato (Seção 24.6.4) |
external_contract_last_success_timestamp_seconds |
Gauge | provider |
Última validação de contrato bem-sucedida |
23.4.5 Negócio #
Gauges atualizados a cada 60 s por um coletor que lê daily_metrics — no formato longo definido
pela Seção 6, com metric_key e dimension — e alguns contadores ao vivo. As definições e
fórmulas são as da Seção 21.2; aqui elas apenas ganham exposição para alerta em tempo quase
real. Nenhuma fórmula é redefinida.
Os gauges com sufixo _brl expõem valor em reais apenas para leitura humana no painel. A fonte é
sempre inteiro: centavos em *_amount_cents e milionésimos em *_cost_micros (Seção 21.2).
Nenhum cálculo monetário é feito sobre o valor exposto, e nenhum alerta compara dois valores em
unidades diferentes sem conversão explícita.
| Métrica | Tipo | Rótulos | Significado |
|---|---|---|---|
business_active_subscribers |
Gauge | tier |
Assinantes ativos |
business_active_subscriptions |
Gauge | status |
Assinaturas por estado |
business_mrr_brl |
Gauge | — | Receita recorrente mensal |
business_delivery_rate |
Gauge | — | Taxa de entrega do dia anterior consolidado |
business_optout_today_total |
Counter | — | Opt-outs no dia corrente |
business_signups_today_total |
Counter | tier |
Cadastros confirmados no dia |
business_daily_cost_brl |
Gauge | kind |
Custo do dia por natureza |
business_content_pipeline_days |
Gauge | — | Dias de conteúdo pronto à frente |
business_devotional_ready_today |
Gauge | — | 1 se o devocional de hoje está pronto para envio |
business_audio_ready_today |
Gauge | — | 1 se o áudio de hoje está pronto |
23.4.6 Lote diário e operação #
| Métrica | Tipo | Rótulos | Significado |
|---|---|---|---|
send_batch_started_timestamp_seconds |
Gauge | — | Início do lote mais recente |
send_batch_completed_timestamp_seconds |
Gauge | — | Conclusão do lote mais recente |
send_batch_duration_seconds |
Gauge | — | Duração do último lote |
send_batch_planned_total |
Gauge | tier |
Envios planejados no lote |
send_batch_sent_total |
Gauge | tier, strategy |
Envios executados por estratégia |
send_batch_failed_total |
Gauge | tier |
Falhas no lote |
metrics_rollup_last_success_timestamp_seconds |
Gauge | — | Última consolidação bem-sucedida (Seção 21.5) |
metrics_sanity_violation_total |
Counter | invariant |
Invariantes de métrica violadas (Seção 21.13.5) |
signup_blocked_total |
Counter | reason |
Cadastros barrados pelo controle de abuso (22.6.3) |
webhook_auth_failed_total |
Counter | provider |
Autenticações de webhook rejeitadas (22.6.4) |
webhook_payload_rejected_total |
Counter | provider, result |
Corpos de webhook rejeitados por schema ou parse, respondidos com 200 (Seção 7.15.1) |
backup_last_success_timestamp_seconds |
Gauge | — | Último backup concluído |
retention_rows_purged_total |
Counter | table |
Linhas removidas pela política de retenção (22.8) |
admin_subscriber_viewed_total |
Counter | actor_role |
Fichas de assinante abertas por administradores (23.11.1) |
admin_pii_revealed_total |
Counter | field |
Revelações de dado pessoal em tela (23.11.1) |
As duas últimas existem porque um controle detectivo sem gatilho é um controle inexistente. O
registro de SUBSCRIBER_VIEWED e PII_REVEALED é o único mecanismo capaz de detectar um
administrador vasculhando a base; sem métrica e sem alerta, ele serve para a apuração posterior e
não para a prevenção — o que, em um comprometimento de credencial administrativa, é a diferença
entre notar em duas horas e notar em semanas.
23.5 Tracing distribuído #
SDK do OpenTelemetry para Node.js, com exportação por OTLP para o mesmo coletor que alimenta o
Grafana. Instrumentação automática habilitada para HTTP, Prisma, ioredis e fetch;
instrumentação manual nos pontos de negócio.
Spans mantidos manualmente:
| Span | Atributos | Onde |
|---|---|---|
signup.create_subscriber |
tier, hasEmail, riskScore |
web |
auth.otp.request / auth.otp.verify |
channel, attempt |
web |
checkout.create_subscription |
plan, billingType |
web |
webhook.asaas.receive |
eventType, persisted |
web |
webhook.whatsapp.receive |
kind (status, message), count |
web |
batch.plan_daily |
devotionalDate, plannedCount |
worker |
send.deliver_subscriber |
strategy, tier, messageCount |
worker |
whatsapp.send_message |
messageType, templateName, pricingCategory |
worker |
tts.generate |
provider, charCount, fallbackUsed |
worker |
media.upload_to_meta |
sizeBytes, reused |
worker |
billing.reconcile |
checked, discrepancies |
worker |
metrics.rollup |
dates, rowCount |
worker |
Propagação de contexto entre web, fila e worker: o cabeçalho traceparent do padrão W3C é
capturado na borda HTTP, copiado para job.data._ctx.traceparent no enfileiramento (código em
23.1.3) e restaurado no worker como contexto pai. O span do worker fica assim ligado ao span da
requisição que o originou, mesmo com horas de distância entre eles. Para o lote diário, o span
raiz é criado pelo job de planejamento e cada envio vira um span filho, o que produz um único
rastro com milhares de filhos — por isso o rastro do lote é amostrado de forma especial, como
descrito abaixo.
Amostragem:
| Situação | Taxa |
|---|---|
| Requisições HTTP normais | 10%, baseada no pai (ParentBased(TraceIdRatioBased(0.1))) |
| Requisições que terminaram em erro 5xx | 100%, por amostragem tardia no coletor |
| Requisições com latência acima de 1 s | 100%, por amostragem tardia |
| Webhooks | 5%, mais 100% dos que falharam |
| Jobs de envio dentro do lote diário | 1% dos envios, mais 100% das falhas; o span raiz do lote é sempre mantido |
| Jobs de manutenção, cobrança e TTS | 100% (volume baixo) |
Ambiente staging |
100% |
Atributos de span seguem a mesma proibição de dado pessoal dos logs (23.1.4): nunca telefone,
CPF, e-mail ou conteúdo de mensagem. subscriberId é permitido, porque é um ULID opaco.
Retenção de rastros: 7 dias. Rastro é ferramenta de diagnóstico imediato; para análise histórica existem métricas e logs.
23.6 Health checks #
Dois endpoints, com propósitos deliberadamente diferentes. Confundi-los é o erro que faz um orquestrador reiniciar em laço um serviço saudável cujo banco está lento.
23.6.1 GET /api/internal/health — liveness #
Verifica apenas que o processo está vivo e capaz de responder. Não toca em nenhuma dependência. Sem autenticação, sem log (para não poluir o volume), resposta em menos de 5 ms, com limite de 60 requisições por minuto por endereço.
{ "status": "ok" }O corpo é exatamente esse: sem version, sem service, sem uptimeSeconds. Os três campos
pareciam inofensivos e não são: version e uptimeSeconds, consultados de fora, revelam o
instante exato de cada implantação, que é a informação de que um atacante precisa para escolher a
janela. Quem precisa da versão implantada consulta GET /api/internal/version, que não é público.
Sempre 200 enquanto o processo responder. Se não responder, o Docker reinicia o contêiner. Se
/api/internal/health consultasse o banco, uma indisponibilidade momentânea do banco causaria
reinício em massa de contêineres saudáveis — exatamente no pior momento.
23.6.2 GET /api/internal/ready — readiness #
Verifica se o processo pode receber tráfego agora. Consulta as dependências, com tempo limite curto e resultado em cache por 5 s para não transformar a sondagem em carga.
/api/internal/ready não é publicado pelo proxy. O monitor externo o consulta apresentando
Authorization: Bearer <METRICS_TOKEN>; sem o token, o proxy nem encaminha a requisição. A
matriz de checks, com latências e estado de disjuntor de cada provedor, só é devolvida com o
token. O motivo é concreto: o estado de degradação de cada provedor é informação operacional
que, exposta, entrega a um atacante o momento exato para uma campanha de phishing convincente —
"seu pagamento falhou, atualize seu cartão", disparada justamente quando o pagamento está
fora do ar.
| Verificação | Como | Tempo limite | Falha derruba a prontidão? |
|---|---|---|---|
| Banco de dados | SELECT 1 |
1.000 ms | Sim |
| Migrações | Nenhuma migração pendente | 500 ms | Sim |
| Redis | PING |
500 ms | Sim |
Filas (apenas worker) |
Workers registrados e conectados | 500 ms | Sim |
| Storage de mídia | HeadBucket |
1.500 ms | Não — degrada |
| Plataforma de mensageria | Estado do disjuntor | imediato | Não — degrada |
| Provedor de TTS | Estado do disjuntor | imediato | Não — degrada |
| Plataforma de pagamento | Estado do disjuntor | imediato | Não — degrada |
Resposta 200 quando tudo está ok ou apenas dependências não críticas estão degraded:
{
"status": "degraded",
"service": "web",
"version": "a3f91c2",
"checks": [
{ "name": "database", "status": "ok", "latencyMs": 3 },
{ "name": "migrations", "status": "ok", "pending": 0 },
{ "name": "redis", "status": "ok", "latencyMs": 1 },
{ "name": "storage", "status": "ok", "latencyMs": 42 },
{ "name": "whatsapp", "status": "degraded", "detail": "circuit_half_open" },
{ "name": "tts", "status": "ok" },
{ "name": "payments", "status": "ok" }
],
"checkedAt": "2026-08-25T09:00:00.000Z"
}Resposta 503 com o mesmo formato e "status": "unavailable" quando uma dependência crítica
falha. O proxy retira a instância da rotação; nenhum reinício é acionado por
/api/internal/ready.
Comportamento em dependência degradada, decidido explicitamente por serviço:
- Plataforma de mensageria fora do ar: o
webcontinua pronto — o site, o checkout e os painéis funcionam. Oworkercontinua pronto e mantém os jobs de envio na fila com retentativa exponencial. O produto atrasa o envio; não o perde. - Provedor de TTS primário fora do ar: nenhum impacto na prontidão; o pipeline usa o
provedor de fallback (Seção 16) e registra
warn. - Storage fora do ar: o
webfica degradado; o player web falha, mas o cadastro e o checkout seguem. Oworkeradia a geração de áudio. - Plataforma de pagamento fora do ar: o
webfica degradado; o checkout exibe mensagem específica e o restante do produto funciona. Webhooks perdidos são recuperados pela conciliação diária (Seção 12). - Banco ou Redis fora do ar:
503. Não há operação possível sem eles.
23.7 Painéis de monitoramento #
Cinco painéis no Grafana, cada um respondendo a uma pergunta específica.
| Painel | Pergunta que responde | Gráficos |
|---|---|---|
| Visão operacional | O sistema está de pé agora? | Prontidão de web e worker; taxa de requisições por status; p50/p95/p99 de latência HTTP; profundidade das quatro filas; idade do job mais antigo em espera; uso de CPU, memória e disco; conexões do banco |
| Lote diário | O envio de hoje funcionou? | Horário de início e de conclusão do lote; duração comparada aos 14 dias anteriores; planejados versus enviados versus falhos por tier; distribuição por estratégia de envio; taxa de envio por segundo ao longo da janela; falhas por código de erro; retentativas |
| Mensageria | O canal está saudável? | Qualidade do número ao longo do tempo; consumo do tier de mensagens contra o teto; volume por categoria de cobrança; status recebidos por webhook; latência da API externa; estado do disjuntor; erros por código |
| Integrações e jobs | As integrações estão respondendo? | Latência e taxa de erro por provedor; eventos de cobrança recebidos e atraso do webhook; divergências da conciliação; duração e resultado dos jobs; profundidade da lista de falhas; gerações de TTS por provedor |
| Negócio em tempo quase real | Os números fazem sentido hoje? | Assinantes ativos por tier; MRR; cadastros e opt-outs do dia; taxa de entrega consolidada; custo do dia por natureza; dias de conteúdo pronto; horário da última consolidação de métricas |
O painel administrativo do produto (Seção 21.8) e o Grafana têm públicos diferentes e não se sobrepõem: o primeiro é para decisão de negócio, com definições estáveis e histórico longo; o segundo é para diagnóstico técnico, com granularidade de segundos e retenção curta. Números de negócio apresentados no Grafana carregam a nota "valor operacional; a fonte oficial é o painel administrativo".
Retenção do Prometheus: 90 dias em disco local, com regras de gravação que pré-agregam as séries usadas nos painéis de longo prazo, para manter a consulta rápida.
23.8 Alertas #
Roteamento pelo Alertmanager. Canais: #alertas-p1, #alertas-p2 e #alertas-negocio no
Slack, via webhook de entrada; e-mail para a lista de plantão pelo provedor de e-mail
transacional; e, apenas para P1, ligação telefônica automática disparada pelo monitor externo
para o número do responsável técnico. O canal de alerta nunca é o WhatsApp do produto —
usar o número comercial para operação interna coloca em risco a reputação que sustenta o
negócio.
Severidades:
| Sev. | Significado | Resposta esperada |
|---|---|---|
| P1 | O produto está quebrado ou vai quebrar em horas, com dano irreversível ou perda de entrega para todos | Imediata, inclusive fora do horário |
| P2 | Degradação relevante ou risco que se materializa em dias | Mesmo dia útil, até 4 h dentro do horário |
| P3 | Anomalia que merece investigação, sem urgência | Próximo dia útil |
Toda regra tem for: — um período mínimo de persistência antes de disparar —, porque alerta que
dispara em pico instantâneo treina a equipe a ignorá-lo. Todo alerta carrega no corpo: valor
observado, limiar, janela, link para o painel filtrado e link para o runbook correspondente na
Seção 27.
Regra de formato, obrigatória. Toda linha traz a expressão completa na coluna de condição, mesmo quando a definição de negócio vive em outra seção; a referência cruzada aparece como comentário ao lado da expressão, nunca no lugar dela. Um alerta cuja condição não pode ser lida no catálogo não pode ser revisado na revisão trimestral, e um alerta que não é revisado acaba silenciado em definitivo.
| # | Nome | Condição exata | Sev. | Canal | Destinatário | Resposta | Runbook (Seção 27) |
|---|---|---|---|---|---|---|---|
| 1 | daily_batch_not_started |
time() - send_batch_started_timestamp_seconds > 3600 avaliado às 06:10 America/Sao_Paulo; ou sinal de vida do lote não recebido até 06:10 |
P1 | Slack P1 + e-mail + ligação | Plantão técnico | 15 min | daily-batch-not-started |
| 2 | daily_batch_not_completed |
send_batch_completed_timestamp_seconds < send_batch_started_timestamp_seconds por mais de 45 min após o início |
P1 | Slack P1 + e-mail + ligação | Plantão técnico | 15 min | daily-batch-stuck |
| 3 | daily_batch_slow |
send_batch_duration_seconds > 1200 (alvo de 20 min registrado na Seção 18.14) |
P2 | Slack P2 | Plantão técnico | 4 h | daily-batch-slow |
| 4 | daily_batch_partial_warn |
send_batch_failed_total / send_batch_planned_total > 0.01 ou send_batch_failed_total > 50, avaliado durante o lote a partir de 300 tentativas registradas |
P2 | Slack P2 | Produto | 4 h | send-failures |
| 4b | daily_batch_partial |
send_batch_failed_total / send_batch_planned_total > 0.03 ou send_batch_failed_total > 200, avaliado durante o lote a partir de 300 tentativas registradas |
P1 | Slack P1 + e-mail | Plantão técnico | 30 min | send-failures |
| 5 | whatsapp_send_failure_rate_high |
sum(rate(whatsapp_messages_sent_total{result="failed"}[15m])) / sum(rate(whatsapp_messages_sent_total[15m])) > 0.10 por 10 min, excluídos os códigos esperados 131049 e 131050 |
P1 | Slack P1 + e-mail | Plantão técnico | 30 min | send-failures |
| 6 | whatsapp_quality_degraded |
whatsapp_quality_rating <= 2 por 5 min |
P2 | Slack P2 + e-mail | Plantão técnico e Produto | 4 h | whatsapp-quality |
| 7 | whatsapp_quality_critical |
whatsapp_quality_rating <= 1 por 5 min |
P1 | Slack P1 + e-mail + ligação | Plantão técnico e Owner | 15 min | whatsapp-quality |
| 8 | whatsapp_tier_near_limit |
send_batch_planned_total > whatsapp_messaging_tier_limit * 0.8 na hora do planejamento |
P2 | Slack P2 | Plantão técnico | 4 h | whatsapp-tier-upgrade |
| 9 | whatsapp_rate_limited |
increase(whatsapp_send_errors_total{error_code="130429"}[10m]) > 20 |
P2 | Slack P2 | Plantão técnico | 1 h | whatsapp-rate-limit |
| 10 | whatsapp_template_rejected |
increase(whatsapp_send_errors_total{error_code=~"132.*"}[10m]) > 5 |
P1 | Slack P1 + e-mail | Plantão técnico e Produto | 30 min | template-problem |
| 11 | queue_backlog_growing |
queue_jobs_waiting > 1000 e derivada positiva por 10 min, fora da janela de envio |
P2 | Slack P2 | Plantão técnico | 1 h | queue-backlog |
| 12 | queue_job_stuck |
queue_oldest_waiting_age_seconds > 900 por 5 min |
P2 | Slack P2 | Plantão técnico | 1 h | queue-stuck-job |
| 13 | queue_failed_jobs_high |
queue_jobs_failed > 100 por 15 min |
P2 | Slack P2 | Plantão técnico | 4 h | queue-failed-jobs |
| 14 | worker_down |
up{job="worker"} == 0 por 2 min |
P1 | Slack P1 + e-mail + ligação | Plantão técnico | 15 min | service-down |
| 15 | web_down |
Monitor externo com 3 falhas consecutivas em /api/internal/health (intervalo de 60 s) |
P1 | Slack P1 + e-mail + ligação | Plantão técnico | 15 min | service-down |
| 16 | asaas_webhook_stalled |
Nenhum evento em asaas_webhook_events_total por 6 h em dia útil, tendo havido cobranças com vencimento no dia |
P1 | Slack P1 + e-mail | Plantão técnico e Financeiro | 30 min | asaas-webhook-paused |
| 17 | asaas_webhook_auth_failures |
increase(webhook_auth_failed_total{provider="asaas"}[15m]) > 10 |
P2 | Slack P2 | Plantão técnico | 4 h | webhook-auth-failures |
| 18 | asaas_webhook_lag_high |
asaas_webhook_lag_seconds > 900 por 15 min |
P2 | Slack P2 | Plantão técnico | 4 h | asaas-webhook-lag |
| 19 | billing_reconcile_discrepancy |
increase(asaas_reconcile_discrepancies_total[1h]) > 5 após a conciliação das 04:00 |
P2 | Slack P2 + e-mail | Financeiro e Plantão técnico | 4 h | billing-reconciliation |
| 20 | billing_reconcile_not_run |
Sem execução bem-sucedida de billing.reconcile nas últimas 26 h |
P2 | Slack P2 | Plantão técnico | 4 h | job-not-running |
| 21 | devotional_missing_today |
business_devotional_ready_today == 0 avaliado às 20:00 do dia anterior e às 05:00 do dia |
P1 | Slack P1 + e-mail + ligação | Editorial e Owner | 15 min | content-missing |
| 22 | content_pipeline_low |
business_content_pipeline_days < 3 por 1 h |
P1 | Slack P1 + e-mail | Editorial e Owner | 4 h | content-pipeline |
| 23 | audio_missing_today |
business_audio_ready_today == 0 às 05:00 do dia do envio |
P1 | Slack P1 + e-mail | Plantão técnico e Editorial | 30 min | audio-not-generated |
| 24 | tts_failure_rate_high |
sum(rate(tts_generation_total{result="failed"}[1h])) / sum(rate(tts_generation_total[1h])) > 0.3 |
P2 | Slack P2 | Plantão técnico | 4 h | tts-failures |
| 25 | tts_fallback_in_use |
increase(tts_generation_total{provider="google"}[24h]) > 3 |
P3 | Slack negócio | Plantão técnico | Próximo dia útil | tts-fallback |
| 26 | http_5xx_rate_high |
sum(rate(http_requests_total{status_code=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) > 0.02 por 5 min |
P1 | Slack P1 + e-mail | Plantão técnico | 15 min | http-5xx |
| 27 | http_latency_high |
histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))) > 0.8 por 10 min (alvo de 400 ms na Seção 9.12) |
P2 | Slack P2 | Plantão técnico | 4 h | latency-high |
| 28 | webhook_handler_slow |
p99 de http_request_duration_seconds nas rotas de webhook acima de 2 s por 5 min |
P1 | Slack P1 | Plantão técnico | 30 min | webhook-slow |
| 29 | db_connections_exhausted |
db_pool_connections{state="waiting"} > 5 por 5 min, ou db_pool_acquire_duration_seconds p95 acima de 1 s |
P1 | Slack P1 + e-mail | Plantão técnico | 15 min | db-pool-exhausted |
| 30 | db_slow_queries |
p95 de db_query_duration_seconds acima de 500 ms por 15 min |
P2 | Slack P2 | Plantão técnico | 4 h | db-slow |
| 31 | db_deadlocks |
increase(db_deadlocks_total[15m]) > 3 |
P2 | Slack P2 | Plantão técnico | 4 h | db-deadlocks |
| 32 | disk_space_low |
Espaço livre no volume de dados abaixo de 20% por 10 min | P2 | Slack P2 + e-mail | Plantão técnico | 4 h | disk-space |
| 33 | disk_space_critical |
Espaço livre abaixo de 10% | P1 | Slack P1 + e-mail + ligação | Plantão técnico | 15 min | disk-space |
| 34 | memory_pressure |
Uso de memória do contêiner acima de 90% do limite por 10 min | P2 | Slack P2 | Plantão técnico | 4 h | memory-pressure |
| 35 | tls_certificate_expiring |
Validade do certificado abaixo de 21 dias | P2 | Slack P2 + e-mail | Plantão técnico | 1 dia útil | tls-renewal |
| 36 | tls_certificate_expiring_critical |
Validade abaixo de 7 dias | P1 | Slack P1 + e-mail | Plantão técnico | 4 h | tls-renewal |
| 37 | backup_missing |
time() - backup_last_success_timestamp_seconds > 93600 (26 h) |
P1 | Slack P1 + e-mail | Plantão técnico | 4 h | backup-failure |
| 38 | metrics_rollup_failed |
time() - metrics_rollup_last_success_timestamp_seconds > 108000 (30 h) |
P2 | Slack P2 | Plantão técnico | 4 h | metrics-rollup |
| 39 | metrics_sanity_violation |
increase(metrics_sanity_violation_total[24h]) > 0 |
P2 | Slack P2 | Plantão técnico | 4 h | metrics-sanity |
| 40 | daily_cost_over_budget |
sum(business_daily_cost_brl) > ops.cost_alert_threshold_cents / 100 por 1 h — a chave é de settings (Seção 26.8.1) e a divisão por 100 é a conversão explícita de centavos para reais |
P2 | Slack P2 + e-mail | Owner e Plantão técnico | 4 h | cost-overrun |
| 41 | signup_abuse_spike |
increase(signup_blocked_total[1h]) > 50 |
P2 | Slack P2 | Plantão técnico | 4 h | signup-abuse |
| 42 | rate_limit_spike |
increase(http_rate_limited_total[15m]) > 500 |
P3 | Slack negócio | Plantão técnico | Próximo dia útil | rate-limit-spike |
| 43 | csp_violations_spike |
increase(csp_violation_total[1h]) > 100 |
P3 | Slack negócio | Plantão técnico | Próximo dia útil | csp-violations |
| 44 | secret_rotation_due |
Verificação mensal: algum segredo com rotação vencida conforme 22.4.2 | P3 | Slack negócio + e-mail | Owner | 5 dias úteis | secret-rotation |
| 45 | loki_down |
up{job="loki"} == 0 por 10 min |
P3 | Slack negócio | Plantão técnico | Próximo dia útil | observability-down |
| 46 | prometheus_down |
Monitor externo não recebe o batimento do Prometheus por 15 min | P2 | Slack P2 | Plantão técnico | 4 h | observability-down |
| 47 | biz_optout_spike |
increase(business_optout_today_total[24h]) / business_active_subscribers > 0.005 — mais de 0,5% da base saindo em um dia (definição de negócio na Seção 21.12) |
P1 | Slack P1 + e-mail | Produto e Conteúdo | 4 h | optout-spike |
| 48 | biz_conversion_drop |
Cadastros confirmados nos últimos 7 dias abaixo de 50% da média dos 28 dias anteriores (definição de negócio na Seção 21.12) | P3 | Slack negócio | Produto | Próximo dia útil | conversion-drop |
| 49 | biz_delivery_rate_low |
business_delivery_rate < 0.95 no dia consolidado (definição de negócio na Seção 21.12) |
P1 | Slack P1 | Plantão técnico | 4 h | delivery-rate-low |
| 50 | biz_churn_spike |
Cancelamentos nos últimos 7 dias acima do dobro da média das 4 semanas anteriores, com no mínimo 10 cancelamentos (definição de negócio na Seção 21.12) | P2 | Slack P2 + e-mail | Produto e Owner | 1 dia útil | churn-spike |
| 51 | webhook_silence |
Nenhum evento de cobrança processado com sucesso nos últimos 90 minutos entre 08:00 e 22:00 America/Sao_Paulo | P1 | Slack P1 + e-mail + ligação | Plantão técnico | 15 min | asaas-webhook-paused |
| 52 | webhook_payload_rejected |
increase(webhook_payload_rejected_total[15m]) > 3 |
P2 | Slack P2 | Plantão técnico | 4 h | webhook-auth-failures |
| 53 | analytics_no_events |
Nenhum evento de produto recebido nos últimos 60 min entre 08:00 e 22:00 | P2 | Slack negócio | Produto | 1 dia útil | csp-violations |
| 54 | admin_bulk_pii_access |
increase(admin_subscriber_viewed_total[1h]) > 100 ou increase(admin_pii_revealed_total[1h]) > 20 pelo mesmo administrador |
P1 | Slack P1 + e-mail | Owner e Plantão técnico | 15 min | admin-bulk-access |
| 55 | admin_access_off_hours |
Qualquer abertura de ficha de assinante entre 00:00 e 05:00 America/Sao_Paulo | P3 | Slack negócio | Owner | Próximo dia útil | admin-bulk-access |
| 56 | impersonation_long_running |
Sessão de impersonação ativa há mais de 30 minutos | P2 | Slack P2 | Owner | 4 h | admin-bulk-access |
| 57 | external_contract_drift |
increase(external_contract_validation_total{result="invalid"}[30m]) > 2 |
P1 | Slack P1 + e-mail | Plantão técnico | 30 min | contract-drift |
| 58 | clock_drift |
Desvio do relógio do servidor acima de 2 segundos em relação à fonte de tempo — bem antes da tolerância de 5 s do verificador de sessão (Seção 8.15.5) | P2 | Slack P2 | Plantão técnico | 4 h | clock-drift |
Regras de agrupamento e silenciamento no Alertmanager, para que o canal continue sendo lido:
- Agrupamento por
alertnameeservice, comgroup_wait: 30s,group_interval: 5merepeat_interval: 4hpara P1,12hpara P2 e24hpara P3. - Inibição: quando
worker_downouweb_downestá ativo, todos os alertas derivados daquele serviço (fila, latência, lote) são suprimidos. Um serviço caído gera dezenas de sintomas; alertar sobre os sintomas esconde a causa. - Silenciamento planejado: janelas de manutenção são registradas antes da mudança, com duração máxima de 2 h e motivo obrigatório. Silenciamento sem prazo é proibido.
- Alerta que dispara sem ação possível é removido ou tem o limiar corrigido. A revisão dos alertas é item da análise pós-incidente (23.10) e da revisão trimestral.
- Sinal de vida do lote diário (dead man's switch): o serviço externo de batimento espera um sinal do job de planejamento todo dia até as 06:10. A ausência do sinal dispara o alerta 1 por um caminho totalmente independente do servidor — é o único alerta que funciona mesmo com o VPS inteiro fora do ar.
Três alertas desta tabela existem por uma razão que merece registro explícito, porque é a mesma em todos: eles detectam falha silenciosa, o estado em que todos os painéis mostram verde e nada funciona.
webhook_silenceé o único detector de assinatura de webhook desativada pelo provedor. Perdido o canal de eventos, nenhuma inadimplência chega, e assinantes sem pagamento continuam recebendo conteúdo pago por dias — sem nenhum outro sinal, porque a reconciliação diária corrige o estado sem alertar que os eventos pararam.analytics_no_eventsdetecta a CSP bloqueando o analytics. Sem ele, as telas de métricas mostram zero e ninguém sabe se é falta de tráfego ou falha técnica.external_contract_driftdetecta a mudança de formato no provedor externo. Uma sonda que valida apenas leituras baratas fica verde enquanto o corpo do webhook de cobrança já mudou; por isso a sonda usa os mesmos schemas do processamento de produção, nunca schemas próprios, que se tornariam uma segunda verdade e mascarariam a divergência (Seção 24.6.4).
23.9 Política de plantão #
Decisão registrada: não há plantão 24 horas por 7 dias no MVP. Uma equipe pequena não sustenta rodízio noturno, e fingir que sustenta produz alertas ignorados às três da manhã, o que é pior do que não alertar. A política abaixo é o que efetivamente será cumprido.
| Janela | Cobertura | Canal ativo | Tempo de resposta |
|---|---|---|---|
| 05:45–08:00, todos os dias | Janela crítica do envio diário. Uma pessoa de plantão explicitamente designada, com o celular ao alcance | Slack P1 com notificação sonora, e-mail, ligação | P1 em 15 min |
| 09:00–19:00, dias úteis | Horário comercial. Equipe disponível | Slack P1 e P2, e-mail | P1 em 15 min; P2 em 4 h |
| 08:00–12:00, sábado | Meia jornada. Uma pessoa acompanha os canais | Slack P1 | P1 em 1 h |
| Domingo, 05:45–09:00 | Janela crítica ampliada: domingo é o dia de envio do plano gratuito e o de maior volume | Slack P1 com notificação sonora, ligação | P1 em 15 min |
| Demais horários | Sem cobertura garantida. Alertas P1 continuam sendo enviados e disparam ligação; o atendimento é por melhor esforço | Slack P1, ligação | Melhor esforço; atendimento garantido no início da janela seguinte |
O que acontece fora do horário, de forma explícita:
- Alertas P1 continuam disparando, inclusive a ligação telefônica. Quem estiver disponível atende. Não há obrigação contratual de atender, e não há penalização por não atender.
- Alertas P2 e P3 são enfileirados e apresentados como resumo no início da janela seguinte.
O Alertmanager usa uma rota com
active_time_intervalspara isso. - O sistema é desenhado para tolerar a ausência. A fila retém os jobs; a retentativa é exponencial e persistente; a conciliação diária recupera webhooks perdidos; o envio atrasado ainda é enviado. A pergunta de projeto para toda falha é "isso se resolve sozinho até as 09:00?" — quando a resposta é não, existe um mecanismo automático de contenção.
- A janela de envio tem prioridade sobre tudo. Um incidente às 06:00 é tratado; um incidente às 23:00 espera, salvo perda de dados ou exposição de dados pessoais, que são as duas exceções que acionam contato imediato com o responsável, a qualquer hora.
- Deploy é proibido entre 05:00 e 08:00, aos domingos, e após as 17:00 de sexta-feira. Não se muda o sistema quando não há quem conserte.
A disponibilidade alvo de 99,5% mensal registrado na Seção 9.12 é compatível com esta política: 99,5% permite cerca de 3 h 39 min de indisponibilidade por mês, e a janela sem cobertura raramente coincide com uso relevante — o tráfego do produto se concentra entre 06:00 e 09:00 e no início da noite.
Rodízio: a designação de plantão da janela crítica é semanal, publicada com uma semana de antecedência, com um substituto nomeado. Quem está de plantão tem acesso completo aos runbooks da Seção 27, aos segredos necessários e ao servidor.
23.10 Análise pós-incidente #
Obrigatória para todo incidente P1, para todo incidente que afete dados pessoais (com o rito adicional de 22.7.8) e para todo P2 que se repita três vezes em 30 dias. Prazo: rascunho em 48 h, versão final em 5 dias úteis.
A análise é sem atribuição de culpa. O objetivo é encontrar a condição do sistema que permitiu a falha, não a pessoa que a acionou. Uma análise que termina em "erro humano" está incompleta por definição: a pergunta seguinte é sempre por que o sistema permitiu que aquele erro produzisse aquele efeito.
Modelo do relatório:
# Incidente <AAAA-MM-DD>-<sequencial> — <título curto e factual>
## Resumo
Duas ou três frases. O que quebrou, por quanto tempo, quem foi afetado.
## Severidade e impacto
- Severidade: P1 | P2
- Início: <timestamp> · Detecção: <timestamp> · Mitigação: <timestamp> · Resolução: <timestamp>
- Tempo até detectar: <min> · Tempo até mitigar: <min> · Duração total: <min>
- Assinantes afetados: <n> (<%> da base)
- Mensagens não entregues: <n> · Receita afetada: R$ <valor>
- Houve acesso ou exposição de dado pessoal? Sim | Não. Se sim, referência ao registro de 22.7.8.
## Linha do tempo
| Horário (America/Sao_Paulo) | Evento | Fonte |
|---|---|---|
| 06:02 | Lote diário iniciado | log `batch.plan.started`, batchId bat_01J9… |
| 06:07 | Alerta `daily_batch_partial` disparado | Alertmanager |
## Causa raiz
O que de fato causou. Sem eufemismo. Inclui a cadeia de condições, não só o gatilho.
## Detecção
Como descobrimos. O alerta funcionou? Disparou no tempo certo? Se a descoberta veio de um
assinante ou de inspeção manual, isso é uma falha de observabilidade e vira item de ação.
## Mitigação
O que foi feito para parar o sangramento e por que funcionou.
## Recuperação da entrega
As mensagens não entregues foram reenviadas? Sim | Não | Parcial. Quando. Quantas.
Se não, a justificativa explícita, aprovada por Produto.
## Comunicação
Os assinantes afetados foram avisados? Sim | Não. Canal e texto utilizado (Seção 10),
seguindo a ordem de canais da Seção 27.7. Havendo exposição de dado pessoal, referência
ao rito de 22.7.8 e à decisão sobre comunicação à Autoridade e aos titulares.
## O que funcionou bem
Registrar também o que salvou o dia: retentativa, fallback, conciliação, limite de taxa.
## Itens de ação
| # | Ação | Tipo (prevenir \| detectar \| mitigar) | Responsável | Prazo | Situação |
|---|---|---|---|---|---|
| 1 | … | prevenir | … | AAAA-MM-DD | aberto |
## Lições
O que sabemos agora que não sabíamos antes.Regras de qualidade da análise: pelo menos um item de ação da categoria detectar — se a detecção foi boa, o item é registrar por que foi, para não a perder; todo item tem responsável nomeado e prazo; itens vencidos aparecem na revisão mensal; e o relatório é arquivado em local acessível a toda a equipe, com índice por causa raiz, de modo que padrões repetidos fiquem visíveis.
Todo incidente que envolva não entrega registra explicitamente a decisão de reenvio, aprovada por Produto, e a decisão de comunicar ou não os afetados. As duas seções acima existem porque a falha clássica é outra: o relatório é escrito no prazo, aprovado, e ninguém decidiu se as pessoas que não receberam o devocional daquele dia receberiam algo — a fila já foi limpa na mitigação, e o silêncio, numa base religiosa diária, é exatamente o que gera cancelamento.
23.11 Auditoria #
Auditoria é diferente de log: log é diagnóstico técnico, com retenção curta e conteúdo redigido; auditoria é registro de responsabilidade sobre ações administrativas, com retenção de 5 anos (22.8) e valor probatório.
23.11.1 O que é auditado #
Toda ação administrativa que altere estado, acesse dado pessoal ou mude configuração:
| Grupo | Ações |
|---|---|
| Autenticação administrativa | ADMIN_LOGIN_SUCCESS, ADMIN_LOGIN_FAILED, ADMIN_LOGOUT, ADMIN_MFA_ENROLLED, ADMIN_MFA_RESET |
| Gestão de administradores | ADMIN_CREATED, ADMIN_ROLE_CHANGED, ADMIN_DISABLED, ADMIN_PASSWORD_RESET |
| Assinantes | SUBSCRIBER_VIEWED, SUBSCRIBER_UPDATED, SUBSCRIBER_TIER_CHANGED, SUBSCRIBER_OPTOUT_SET, SUBSCRIBER_REACTIVATED, SUBSCRIBER_ANONYMIZED, SUBSCRIBER_FULLY_ANONYMIZED, ADMIN_IMPERSONATION_STARTED, ADMIN_IMPERSONATION_ENDED |
| Dado pessoal | PII_REVEALED (telefone ou CPF exibido em tela), SUBSCRIBER_DATA_EXPORTED, DATA_EXPORT_DOWNLOADED, METRICS_EXPORTED |
| Cobrança | SUBSCRIPTION_CANCELED_BY_ADMIN, REFUND_ISSUED, PAYMENT_MARKED_MANUALLY, PLAN_PRICE_CHANGED |
| Conteúdo | DEVOTIONAL_CREATED, DEVOTIONAL_UPDATED, DEVOTIONAL_STATUS_CHANGED, DEVOTIONAL_DELETED, AUDIO_REGENERATED, MEDIA_UPLOADED |
| Envio | MANUAL_SEND_TRIGGERED, BATCH_RETRIED, SUBSCRIBER_RESEND_FORCED |
| Configuração | SETTING_CHANGED, FEATURE_FLAG_TOGGLED, TEMPLATE_REGISTERED, SECRET_ROTATED |
| Operação | METRICS_ROLLUP_FORCED, RETENTION_JOB_FORCED, MAINTENANCE_WINDOW_OPENED |
| Privacidade | DSR_RECEIVED, DSR_FULFILLED, CONSENT_REVOKED_BY_ADMIN, ACCESS_REVIEW_COMPLETED |
SUBSCRIBER_VIEWED merece justificativa: registrar a simples abertura da ficha de um assinante
gera volume, mas é o único controle capaz de detectar um administrador vasculhando a base sem
motivo. O registro é agregado por sessão e por assinante em janelas de 15 minutos, para não
multiplicar linhas em uma navegação normal. Ele alimenta as métricas de 23.4.6 e os alertas 54 a
56 de 23.8 — sem gatilho, o registro serviria só para a apuração posterior.
ADMIN_IMPERSONATION_STARTED e ADMIN_IMPERSONATION_ENDED são ações auditadas de primeira
classe, com reason obrigatório. Além do registro administrativo, a sessão de impersonação
aparece para o próprio assinante, na lista Acessos do suporte da tela Meus dados, com
data, duração e justificativa — nunca o nome do operador, que é dado do administrador. O Art. 18
dá ao titular o direito de saber como seus dados foram tratados, e uma sessão de suporte dentro
da conta dele é tratamento: esconder isso tornaria o direito inexequível.
23.11.2 Formato do registro #
Os nomes de coluna abaixo são exatamente os da Seção 6.9, que é a dona da definição física de
admin_audit_log. Esta subseção não renomeia nada: não existem colunas actor_admin_id,
before_json, after_json, target_type, target_id, actor_email, created_at_utc nem
created_at_brt.
| Campo | Descrição |
|---|---|
id |
ULID |
created_at |
timestamptz, sempre em UTC. As colunas em horário de Brasília que aparecem na exportação CSV (15.10.3) são projeções de apresentação, não colunas do banco |
actor_type |
ADMIN, SUBSCRIBER ou SYSTEM. Distingue "foi o sistema" de "foi o próprio assinante", o que admin_user_id nulo sozinho não faz |
admin_user_id |
Autor da ação; nulo quando actor_type não é ADMIN |
actor_role |
Papel efetivo no momento (EDITOR, ADMIN, OWNER, SYSTEM) |
action |
Valor da lista de 23.11.1 |
entity_type |
subscriber, devotional, subscription, setting, admin_user, … |
entity_id |
Identificador do alvo |
changed_fields |
Lista dos campos alterados, para filtrar a auditoria sem abrir o JSON |
before |
Estado anterior dos campos alterados, com dado pessoal já mascarado |
after |
Estado posterior, idem |
metadata |
Dados estruturados específicos da ação, por exemplo days na concessão de cortesia. Nunca contém dado pessoal em texto claro |
reason |
Justificativa, obrigatória em ações destrutivas ou de revelação de dado pessoal |
ip |
Endereço de origem |
user_agent |
Agente de origem |
request_id |
Correlaciona com os logs técnicos (23.1.3) |
record_hash |
Encadeamento de integridade descrito em 23.11.3 |
O e-mail do autor não é persistido: ele é resolvido a partir de admin_user_id no momento da
exportação. Guardar o e-mail duplicaria dado pessoal em uma tabela de 5 anos de retenção sem
acrescentar prova nenhuma.
before e after guardam apenas os campos alterados, e valores sensíveis entram
mascarados: o registro prova que o telefone foi alterado e mostra +55 11 *****-4321 antes e
+55 11 *****-9876 depois, sem reintroduzir dado em texto claro em uma tabela de 5 anos de
retenção. Para PII_REVEALED, o registro guarda qual campo foi revelado e o motivo, nunca o
valor.
Exemplo:
{
"id": "aud_01J9K2M4R7T8V0X1Y2Z3A4B5C6",
"createdAt": "2026-08-25T14:22:07.331Z",
"actorType": "ADMIN", "adminUserId": "adm_01J8…", "actorRole": "ADMIN",
"action": "SUBSCRIBER_TIER_CHANGED",
"entityType": "subscriber", "entityId": "sub_01J9…",
"changedFields": ["tier"],
"before": { "tier": "PAID" },
"after": { "tier": "FREE" },
"reason": "Chargeback confirmado pelo processador; protocolo 88213.",
"ip": "189.45.12.203",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"requestId": "req_01J9K2M4R7T8V0X1Y2Z3A4B5C6"
}23.11.3 Imutabilidade #
Mesmo tratamento de consent_events (22.7.3), com o mesmo gatilho de exceções codificadas: o
usuário de banco da aplicação tem INSERT e SELECT, e não tem UPDATE nem DELETE; o gatilho
barra alteração mesmo por papel privilegiado; e a remoção só ocorre pelo job de retenção,
executado pela conexão do papel retention_operator (22.12.2), restrita a linhas com mais de 5
anos. Nenhum gatilho é desabilitado em momento algum — a exceção vive dentro dele, verificada
linha a linha.
Camada adicional de detecção de adulteração: cada linha guarda record_hash, calculado como
SHA-256 do conteúdo canônico da linha concatenado ao record_hash da linha anterior — uma
cadeia. Um trabalho semanal recalcula a cadeia do período e alerta em caso de divergência.
const canonical = (r: AuditRow) => JSON.stringify([
r.id, r.createdAt.toISOString(), r.actorType, r.adminUserId, r.actorRole, r.action,
r.entityType, r.entityId, r.changedFields, r.before, r.after, r.metadata,
r.reason, r.ip, r.requestId,
])
const recordHash = (r: AuditRow, prevHash: string) =>
createHash('sha256').update(prevHash + canonical(r)).digest('hex')A cadeia não impede a adulteração — quem controla o banco pode recalcular tudo. O que ela faz é tornar a adulteração detectável por quem tem a cópia do último hash, que é registrada diariamente no arquivo de logs cifrado e no relatório de backup. Essa é a propriedade que interessa em uma apuração.
23.11.4 Consulta #
Tela /admin/auditoria, restrita a ADMIN e OWNER. EDITOR não tem acesso. Filtros:
intervalo de datas, autor, ação, tipo de entidade e identificador de entidade. Paginação por
cursor, conforme o padrão da Seção 7 — a resposta não devolve total; contagens agregadas
vêm dos endpoints de métricas. Ordenação padrão: mais recente primeiro.
A tela de um assinante traz o histórico de auditoria daquele assinante em aba própria, o que responde diretamente à pergunta mais comum do suporte: "quem mexeu nisto e por quê". Consultar a auditoria não gera um registro de auditoria — isso criaria recursão sem valor; o acesso à tela é registrado no log técnico, que basta.
A leitura da auditoria é servida por GET /api/admin/audit-logs, e a exportação por
POST /api/admin/audit-logs/exports, exclusiva de OWNER, formato CSV ou JSON, limite de 90
dias por exportação, com a própria exportação registrada como METRICS_EXPORTED — porque
exportar a auditoria é, ela mesma, uma ação auditável.
24. Estratégia de Testes e QA #
Esta seção define o que é testado, com que ferramenta, com que meta de cobertura e com que
critério de aprovação. Nenhum código entra em main sem passar por ela. As versões das
ferramentas estão na Seção 4 e não são repetidas aqui.
24.1 Princípios #
- O domínio é testado exaustivamente; a borda é testada por contrato. A lógica que
decide quem recebe o quê, quando e em que tier vive em
packages/coree é pura. Testar pureza é barato. É onde a densidade de testes deve ser maior. - Nenhum teste automatizado fala com a internet. Asaas, Meta e o provedor de TTS são sempre simulados. Um teste que abre socket externo é um teste que quebra no CI por motivo errado.
- Nenhum teste automatizado envia mensagem real de WhatsApp. A única exceção é a bateria manual de pré-lançamento da Seção 24.11, executada por pessoa, com número de teste declarado.
- Banco de verdade em testes de integração. SQLite em memória não reproduz
timestamptz, índices únicos parciais, enums nativos nemON CONFLICT. Todo teste de integração roda contra PostgreSQL real em contêiner. - Teste determinístico. Nenhum
Date.now()direto no código de domínio: tudo passa por uma interfaceClockinjetável (ver Seção 24.7.2). NenhumMath.random()sem seed. Nenhuma dependência de ordem de execução entre arquivos de teste. - Falha de teste é bloqueio, não aviso. Não existe teste marcado como
skipemmain. Um teste instável é corrigido ou removido no mesmo dia, com issue aberta.
24.2 Pirâmide de testes e metas de cobertura #
┌───────────────────────────┐
│ E2E (Playwright) │ ~20 cenários
│ ~5% dos testes │ ~10 min de execução
├───────────────────────────┤
┌──┤ Contrato (mock server) │ ~34 testes
│ │ ~4% dos testes │ ~40 s
│ ├───────────────────────────┤
┌───┴──┤ Integração (PG + Redis) │ ~130 testes
│ │ ~16% dos testes │ ~3 min
│ ├───────────────────────────┤
┌─────────┴──────┤ Unitários (Vitest) │ ~620 testes
│ │ ~75% dos testes │ ~25 s
└────────────────┴───────────────────────────┘Metas de cobertura por camada, aplicadas como gate no CI:
| Alvo | Linhas | Ramos | Funções | Justificativa da meta |
|---|---|---|---|---|
packages/core/** |
95% | 90% | 95% | Código puro, sem I/O. Contém entitlements, máquina de estados, normalização de telefone e cálculo de lote. Um erro aqui cobra dinheiro errado ou entrega conteúdo errado a milhares de pessoas. Cobertura alta é barata e o retorno é máximo. |
packages/integrations/** |
85% | 75% | 85% | Mapeamento de payloads externos e classificação de erros. A rede é simulada, então o que se testa é tradução e tratamento de erro — exatamente o que quebra em produção. Os 15% descobertos são wrappers de SDK sem lógica. |
packages/db/** |
60% | 50% | 60% | Majoritariamente cliente Prisma gerado e seeds. Cobrir código gerado não produz valor. O que importa aqui é validado nos testes de integração. |
apps/worker/src/jobs/** |
90% | 80% | 90% | Cada job é um efeito colateral com dinheiro ou reputação envolvida. Retry, idempotência e classificação de falha precisam de teste explícito. |
apps/worker/** (restante) |
80% | 70% | 80% | Bootstrap, registro de filas e servidor de health. Coberto por integração. |
apps/web/src/app/api/** |
85% | 75% | 85% | Handlers HTTP: validação Zod, autorização, envelope de resposta e catálogo de erros. |
apps/web/src/lib/** |
85% | 75% | 85% | Utilitários de sessão, formatação e adaptação de payload. |
apps/web/src/components/** |
sem gate numérico | — | — | Componentes de UI são validados por E2E e por inspeção visual. Perseguir cobertura de linha em JSX gera testes que afirmam que o React renderiza React. |
apps/ops/** |
70% | 60% | 70% | CLI de operação. O valor está nos comandos de correção, cobertos por integração; o parser de argumentos é trivial. |
| Gate global do repositório | 85% | 78% | 85% | Média ponderada realista das metas acima. |
O gate global é verificado por vitest --coverage com provider v8. O CI falha se
qualquer meta por alvo for violada, mesmo que a média global passe. Cobertura não pode
cair entre commits: o CI compara com o valor gravado em coverage/baseline.json e falha se
a queda for maior que 0,5 ponto percentual.
Exclusões de cobertura declaradas (e apenas estas): arquivos *.d.ts, *.config.*,
packages/db/generated/**, **/index.ts que só reexporta, apps/web/src/app/**/layout.tsx,
apps/web/src/app/**/loading.tsx, **/*.stories.tsx, e arquivos sob **/testing/**
(o próprio ferramental de teste).
24.3 Ferramentas e configuração #
| Necessidade | Ferramenta | Onde roda |
|---|---|---|
| Testes unitários e de integração | Vitest | local + CI |
| Cobertura | @vitest/coverage-v8 |
local + CI |
| Banco e Redis para integração | Testcontainers (@testcontainers/postgresql, @testcontainers/redis) |
local + CI |
| Simulação de HTTP externo (unitário) | MSW (msw) em modo Node |
local + CI |
| Simulação de HTTP externo (integração/E2E) | servidor de simulação próprio em packages/integrations/src/testing/mock-server.ts |
local + CI |
| E2E de navegador | Playwright | local + CI |
| Carga | k6 (binário) | local + máquina de staging |
| Lint | ESLint (flat config) + eslint-plugin-vitest |
local + CI |
| Formatação | Prettier | local + CI |
| Tipos | tsc --noEmit por workspace |
local + CI |
| Varredura de dependências | pnpm audit + osv-scanner |
CI |
| Varredura de segredos | gitleaks |
CI + hook de pre-commit |
| Propriedades e fuzzing | fast-check |
local + CI |
24.3.1 vitest.config.ts (raiz do monorepo) #
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
globals: false,
reporters: process.env.CI ? ['default', 'junit'] : ['default'],
outputFile: { junit: './reports/vitest-junit.xml' },
pool: 'threads',
poolOptions: { threads: { singleThread: false, maxThreads: 8 } },
coverage: {
provider: 'v8',
reporter: ['text-summary', 'json-summary', 'lcov'],
reportsDirectory: './coverage',
all: true,
exclude: [
'**/*.d.ts',
'**/*.config.*',
'packages/db/generated/**',
'**/testing/**',
'**/*.stories.tsx',
'apps/web/src/app/**/layout.tsx',
'apps/web/src/app/**/loading.tsx',
'**/index.ts',
],
thresholds: {
lines: 85,
branches: 78,
functions: 85,
'packages/core/src/**': { lines: 95, branches: 90, functions: 95 },
'packages/integrations/src/**': { lines: 85, branches: 75, functions: 85 },
'apps/worker/src/jobs/**': { lines: 90, branches: 80, functions: 90 },
'apps/web/src/app/api/**': { lines: 85, branches: 75, functions: 85 },
},
},
projects: [
{
extends: true,
test: {
name: 'unit',
include: ['packages/*/src/**/*.test.ts', 'apps/*/src/**/*.test.ts'],
environment: 'node',
setupFiles: ['./tools/testing/setup-unit.ts'],
testTimeout: 5_000,
},
},
{
extends: true,
test: {
name: 'integration',
include: ['tests/integration/**/*.int.test.ts'],
environment: 'node',
globalSetup: ['./tools/testing/global-setup-containers.ts'],
setupFiles: ['./tools/testing/setup-integration.ts'],
testTimeout: 30_000,
hookTimeout: 120_000,
fileParallelism: true,
maxConcurrency: 4,
},
},
{
extends: true,
test: {
name: 'contract',
include: ['tests/contract/**/*.contract.test.ts'],
environment: 'node',
setupFiles: ['./tools/testing/setup-contract.ts'],
testTimeout: 15_000,
},
},
],
},
});24.3.2 tools/testing/setup-unit.ts #
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { server as mswServer } from './msw-server';
process.env.TZ = 'America/Sao_Paulo';
beforeAll(() => {
mswServer.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
mswServer.resetHandlers();
vi.useRealTimers();
vi.restoreAllMocks();
});
afterAll(() => {
mswServer.close();
});onUnhandledRequest: 'error' é obrigatório. Ele garante o princípio 2 da Seção 24.1:
qualquer chamada de rede não declarada derruba o teste em vez de vazar para a internet.
24.3.3 tools/testing/global-setup-containers.ts #
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { RedisContainer, type StartedRedisContainer } from '@testcontainers/redis';
import { execFileSync } from 'node:child_process';
import type { GlobalSetupContext } from 'vitest/node';
let pg: StartedPostgreSqlContainer;
let redis: StartedRedisContainer;
export async function setup({ provide }: GlobalSetupContext) {
pg = await new PostgreSqlContainer('postgres:17-alpine')
.withDatabase('palavra_diaria_test')
.withUsername('test')
.withPassword('test')
// Testes não precisam de durabilidade. Isso corta ~40% do tempo da suíte.
.withCommand(['postgres', '-c', 'fsync=off', '-c', 'synchronous_commit=off',
'-c', 'full_page_writes=off', '-c', 'max_connections=200'])
.start();
redis = await new RedisContainer('redis:8-alpine').start();
const databaseUrl = pg.getConnectionUri();
process.env.DATABASE_URL = databaseUrl;
// Schema aplicado uma única vez por execução da suíte, a partir das migrations reais.
execFileSync('pnpm', ['--filter', '@palavra-diaria/db', 'exec', 'prisma', 'migrate', 'deploy'], {
env: { ...process.env, DATABASE_URL: databaseUrl },
stdio: 'inherit',
});
provide('databaseUrl', databaseUrl);
provide('redisUrl', redis.getConnectionUrl());
}
export async function teardown() {
await redis?.stop();
await pg?.stop();
}Rodar prisma migrate deploy — e não db push — é decisão deliberada: garante que a
sequência real de migrations que vai para produção é executável do zero. Uma migration
quebrada é detectada no CI, não no deploy.
24.3.4 playwright.config.ts (em apps/web) #
import { defineConfig, devices } from '@playwright/test';
const PORT = Number(process.env.E2E_PORT ?? 3100);
const BASE_URL = `http://127.0.0.1:${PORT}`;
export default defineConfig({
testDir: './tests/e2e',
outputDir: './test-results',
fullyParallel: false, // cenários compartilham o mesmo banco efêmero
workers: 1,
retries: process.env.CI ? 1 : 0,
timeout: 60_000,
expect: { timeout: 10_000 },
reporter: process.env.CI
? [['list'], ['html', { open: 'never' }], ['junit', { outputFile: 'reports/e2e-junit.xml' }]]
: [['list']],
use: {
baseURL: BASE_URL,
locale: 'pt-BR',
timezoneId: 'America/Sao_Paulo',
trace: 'retain-on-failure',
video: 'retain-on-failure',
screenshot: 'only-on-failure',
actionTimeout: 10_000,
},
projects: [
{ name: 'setup', testMatch: /global\.setup\.ts/ },
{
name: 'chromium-desktop',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
},
{
name: 'mobile-android',
use: { ...devices['Pixel 7'] },
dependencies: ['setup'],
testMatch: /.*\.mobile\.spec\.ts/,
},
],
webServer: [
{
command: 'pnpm --filter @palavra-diaria/integrations mock:serve',
url: 'http://127.0.0.1:4010/__mock/health',
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
{
command: `pnpm --filter @palavra-diaria/web start -p ${PORT}`,
url: `${BASE_URL}/api/internal/health`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
NODE_ENV: 'production',
E2E_TEST_HOOKS: 'true',
E2E_HOOK_SECRET: 'e2e-local-secret',
ASAAS_API_BASE_URL: 'http://127.0.0.1:4010/asaas/v3',
WHATSAPP_API_BASE_URL: 'http://127.0.0.1:4010/meta/v26.0',
ELEVENLABS_BASE_URL: 'http://127.0.0.1:4010/elevenlabs/v1',
},
},
],
});Decisão: o E2E roda contra o build de produção (next start), não contra next dev.
Modo de desenvolvimento tem comportamento de hidratação e de cache diferente; testar o
artefato que vai para produção é o único jeito de o teste significar alguma coisa.
24.3.5 Scripts do package.json da raiz #
{
"scripts": {
"lint": "eslint . --max-warnings=0",
"format:check": "prettier --check .",
"typecheck": "pnpm -r exec tsc --noEmit",
"test": "vitest run --project unit",
"test:watch": "vitest --project unit",
"test:integration": "vitest run --project integration",
"test:contract": "vitest run --project contract",
"test:coverage": "vitest run --coverage",
"test:e2e": "pnpm --filter @palavra-diaria/web exec playwright test",
"test:e2e:ui": "pnpm --filter @palavra-diaria/web exec playwright test --ui",
"test:load": "k6 run tools/load/daily-batch.js",
"test:all": "pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm test:integration && pnpm test:contract && pnpm test:e2e"
}
}24.4 Testes unitários — módulos críticos #
Cada módulo abaixo é obrigatório. Um pull request que altere qualquer um deles sem alterar o arquivo de teste correspondente é rejeitado automaticamente pela regra descrita na Seção 24.12.
24.4.1 packages/core/src/phone.ts — normalização de telefone brasileiro #
Contrato da função:
export type PhoneError =
| 'PHONE_REQUIRED'
| 'PHONE_INVALID'
| 'PHONE_TOO_SHORT'
| 'PHONE_TOO_LONG'
| 'PHONE_INVALID_AREA_CODE'
| 'PHONE_NOT_MOBILE'
| 'PHONE_COUNTRY_NOT_SUPPORTED';
export type PhoneResult =
| { ok: true; e164: string; countryCode: '55'; areaCode: string; localNumber: string }
| { ok: false; code: PhoneError };
export function normalizePhone(input: string | null | undefined): PhoneResult;
/** Variantes aceitáveis para casar com o `wa_id` devolvido pela Meta. */
export function waIdVariants(e164: string): string[];Algoritmo obrigatório, na ordem:
- Rejeitar
null,undefinede string que, apóstrim(), seja vazia →PHONE_REQUIRED. - Normalizar Unicode com
String.prototype.normalize('NFKC')(converte dígitos de largura total em ASCII) e remover caracteres de controle e de largura zero. - Remover prefixo de esquema
tel:ouwhatsapp:se presente. - Guardar se havia
+inicial. Remover todo caractere que não seja dígito. - Se a string de dígitos começar com
00, remover os dois primeiros dígitos e tratar como discagem internacional. - Se não havia
+e a string tiver 10 ou 11 dígitos, assumir DDI55. Se tiver 11 ou 12 dígitos começando com0(prefixo de operadora), remover o0e assumir DDI55. - Aplicar
parsePhoneNumberFromStringdelibphonenumber-jscom região padrãoBR. - Se o país resolvido não for
BR→PHONE_COUNTRY_NOT_SUPPORTED. - Validar o DDD contra a lista fechada de DDDs válidos do Brasil. Fora dela →
PHONE_INVALID_AREA_CODE. - Se o número nacional tiver 10 dígitos (DDD + 8) e o primeiro dígito do assinante for
6,7,8ou9, inserir o nono dígito9. Se for2,3,4ou5, é fixo →PHONE_NOT_MOBILE. - Se o número nacional tiver 11 dígitos e o primeiro do assinante não for
9→PHONE_NOT_MOBILE. - Emitir
+55+ DDD + 9 dígitos.
Lista fechada de DDDs válidos (constante BR_AREA_CODES, testada por igualdade de
conjunto): 11–19, 21, 22, 24, 27, 28, 31–35, 37, 38, 41–49, 51, 53, 54, 55, 61–69, 71, 73,
74, 75, 77, 79, 81–89, 91–99.
Tabela de casos obrigatórios. Cada linha vira um caso de it.each. O celular de
referência é (11) 98765-4321; o fixo de referência é (11) 3123-4567.
| # | Entrada | Resultado esperado | Motivo |
|---|---|---|---|
| 1 | "11987654321" |
+5511987654321 |
Nacional, 11 dígitos, já com nono dígito |
| 2 | "+5511987654321" |
+5511987654321 |
E.164 canônico, sem alteração |
| 3 | "5511987654321" |
+5511987654321 |
DDI sem + |
| 4 | "(11) 98765-4321" |
+5511987654321 |
Formatação brasileira usual |
| 5 | "(11) 9 8765-4321" |
+5511987654321 |
Nono dígito destacado |
| 6 | "11 98765 4321" |
+5511987654321 |
Espaços como separador |
| 7 | "11.98765.4321" |
+5511987654321 |
Pontos como separador |
| 8 | "+55 (11) 98765-4321" |
+5511987654321 |
Mistura de +, parênteses e hífen |
| 9 | "011987654321" |
+5511987654321 |
Prefixo 0 de discagem |
| 10 | "0 11 98765-4321" |
+5511987654321 |
Prefixo 0 com espaço |
| 11 | "0219876543211" |
PHONE_INVALID |
Código de operadora (021) não é suportado; regra explícita |
| 12 | "005511987654321" |
+5511987654321 |
Discagem internacional com 00 |
| 13 | "1187654321" |
+5511987654321 |
10 dígitos, celular legado; nono dígito inserido |
| 14 | "551187654321" |
+5511987654321 |
12 dígitos com DDI; nono dígito inserido |
| 15 | "+551187654321" |
+5511987654321 |
E.164 sem nono dígito (formato que a Meta às vezes devolve) |
| 16 | "1131234567" |
PHONE_NOT_MOBILE |
Fixo de 8 dígitos começando em 3 |
| 17 | "+551131234567" |
PHONE_NOT_MOBILE |
Fixo em E.164 |
| 18 | "11987654321 " |
+5511987654321 |
Espaço à direita |
| 19 | " 11987654321" |
+5511987654321 |
Espaço à esquerda |
| 20 | "+5511987654321\n" |
+5511987654321 |
Quebra de linha colada do WhatsApp |
| 21 | "<ZWSP>+5511987654321" |
+5511987654321 |
Espaço de largura zero (U+200B) |
| 22 | "+5511987654321" |
+5511987654321 |
Dígitos de largura total (teclado iOS/japonês) |
| 23 | "tel:+5511987654321" |
+5511987654321 |
Esquema tel: |
| 24 | "whatsapp:+5511987654321" |
+5511987654321 |
Esquema whatsapp: (formato de outros provedores) |
| 25 | "📱 (11) 98765-4321" |
+5511987654321 |
Emoji e ruído descartados |
| 26 | "Meu zap é 11 98765-4321" |
+5511987654321 |
Texto ao redor; só dígitos importam |
| 27 | "11 98765-4321 ramal 22" |
PHONE_TOO_LONG |
Ramal gera dígitos extras; rejeitar em vez de adivinhar |
| 28 | "" |
PHONE_REQUIRED |
Vazio |
| 29 | " " |
PHONE_REQUIRED |
Só espaços |
| 30 | null |
PHONE_REQUIRED |
Nulo |
| 31 | undefined |
PHONE_REQUIRED |
Indefinido |
| 32 | "abcdefghij" |
PHONE_INVALID |
Sem dígito nenhum |
| 33 | "999" |
PHONE_TOO_SHORT |
Curto demais |
| 34 | "+5511987" |
PHONE_TOO_SHORT |
E.164 truncado |
| 35 | "+55119876543210000" |
PHONE_TOO_LONG |
Excede 15 dígitos de E.164 |
| 36 | "+14155552671" |
PHONE_COUNTRY_NOT_SUPPORTED |
DDI 1 (EUA) — aceito pelo schema, rejeitado no cadastro |
| 37 | "+351912345678" |
PHONE_COUNTRY_NOT_SUPPORTED |
DDI 351 (Portugal) |
| 38 | "+5491123456789" |
PHONE_COUNTRY_NOT_SUPPORTED |
DDI 54 (Argentina) — confusão comum com 55 |
| 39 | "+5510987654321" |
PHONE_INVALID_AREA_CODE |
DDD 10 não existe |
| 40 | "+5520987654321" |
PHONE_INVALID_AREA_CODE |
DDD 20 não existe |
| 41 | "+5523987654321" |
PHONE_INVALID_AREA_CODE |
DDD 23 não existe |
| 42 | "+5552987654321" |
PHONE_INVALID_AREA_CODE |
DDD 52 não existe |
| 43 | "+5599987654321" |
+5599987654321 |
DDD 99 (MA) é válido — não confundir com lixo |
| 44 | "+5568987654321" |
+5568987654321 |
DDD 68 (AC) é válido |
| 45 | "+5511587654321" |
PHONE_NOT_MOBILE |
11 dígitos mas começa em 5: não é celular |
| 46 | "+5511087654321" |
PHONE_NOT_MOBILE |
11 dígitos começando em 0 |
| 47 | "+55011987654321" |
PHONE_INVALID |
0 logo após o DDI |
| 48 | "11987654321" normalizado duas vezes |
mesma saída | Idempotência |
Casos obrigatórios adicionais, fora da tabela:
- Propriedade de idempotência, verificada com
fast-checksobre 1.000 entradas geradas: para todoinputcujo resultado sejaok,normalizePhone(result.e164)devolve o mesmoe164. waIdVariants('+5511987654321')devolve exatamente['5511987654321', '551187654321'], nessa ordem (com nono dígito primeiro).waIdVariants('+551133334444')— para número que não é celular a função devolve apenas['551133334444'], sem inventar variante.waIdVariantspara DDD ≥ 31: o comportamento é idêntico ao de DDD ≤ 30. A regra do nono dígito é nacional; não existe exceção por região no código.- Ordem de resolução do assinante (testada em
packages/core/src/subscriber-lookup.ts, com repositório falso): dado um webhook comwa_id = '551187654321'e um assinante cujo telefone normalizado é+5511987654321e que ainda não temwa_id, a busca precisa encontrá-lo pela variante sem nono dígito e, como efeito colateral, gravar owa_id. Um segundo webhook com o mesmowa_idprecisa resolver na primeira tentativa, sem tocar nas variantes. Toda busca é por índice cego: a consulta comparawa_id_hmacephone_hmac, nunca a coluna cifrada (Seção 6.3). Um teste afirma que nenhuma consulta do repositório de assinantes contémWHERE phone_e164 =ouWHERE wa_id =.
24.4.2 packages/core/src/cpf.ts — validação de CPF #
export function isValidCpf(input: string): boolean;
export function normalizeCpf(input: string): string | null; // 11 dígitos, sem máscara
export function formatCpf(digits: string): string; // 000.000.000-00Regras: remover máscara; exigir exatamente 11 dígitos; rejeitar as onze sequências de dígitos repetidos; validar os dois dígitos verificadores pelo algoritmo de módulo 11.
| Entrada | isValidCpf |
Motivo |
|---|---|---|
"529.982.247-25" |
true |
CPF válido com máscara |
"52998224725" |
true |
Mesmo CPF sem máscara |
" 529 982 247 25 " |
true |
Espaços descartados |
"529.982.247-24" |
false |
Segundo dígito verificador errado |
"529.982.247-15" |
false |
Primeiro dígito verificador errado |
"111.111.111-11" |
false |
Sequência repetida (passa no módulo 11, mas é inválida) |
"000.000.000-00" |
false |
Sequência repetida |
"999.999.999-99" |
false |
Sequência repetida |
"12345678909" |
true |
CPF de teste clássico, matematicamente válido |
"1234567890" |
false |
10 dígitos |
"123456789012" |
false |
12 dígitos |
"" |
false |
Vazio |
"abcdefghijk" |
false |
Sem dígitos |
"529.982.247-2A" |
false |
Letra no lugar de dígito |
"52998224725" com largura zero no fim |
true |
Caractere invisível removido antes da validação |
"CPF: 529.982.247-25" |
false |
normalizeCpf só aceita a string do campo, não texto livre |
Caso obrigatório extra: formatCpf('52998224725') === '529.982.247-25' e
normalizeCpf(formatCpf(x)) === x para 200 CPFs válidos gerados.
Caso obrigatório de segurança: o CPF nunca aparece em log. O teste
cpf-never-logged.test.ts instala um transporte de log falso, executa o fluxo de criação de
cliente na Asaas com CPF 52998224725 e afirma que nenhuma linha emitida contém a
substring 52998224725 nem 529.982.247-25. O campo é redigido como ***.***.***-25
(apenas os dois últimos dígitos), conforme a política de redação da Seção 22.
24.4.2.1 secrets-never-logged.test.ts — nenhum segredo vai para o log #
O CPF é apenas um dos dados que não podem vazar por log, e é o único que tinha teste. Este arquivo cobre o resto e é bloqueador de pull request.
Ele instala o mesmo transporte de log falso e executa, em sequência: emissão e verificação de código de acesso; criação, rotação e revogação de sessão; uma chamada a cada provedor externo simulado; e a geração de uma URL assinada de mídia. Depois afirma que nenhuma linha emitida contém:
- o código de acesso gerado, em nenhuma das suas formas (
123456,123 456,123-456); - o valor do token de sessão nem o do token de renovação;
- o valor de qualquer variável de ambiente cujo nome termine em
_TOKEN,_KEY,_SECRETou_PASSWORD— a lista é derivada deprocess.envem tempo de execução, e não escrita à mão, para que uma variável nova nasça coberta; - a substring
X-Amz-Signature, o host de mídia da plataforma de mensagens, nem qualquer URL contendoaccess_token=.
O teste roda duas vezes: uma com o nível de log em info e outra com o assinante incluído
na lista de depuração da Seção 23.1.5, que liga o nível debug. Sem essa segunda execução, um
logger.debug({ otpPlain, phone }) acrescentado para investigar um problema de entrega
passaria despercebido — e é exatamente ao investigar entrega de código que alguém coloca o
assinante na lista de depuração. A proibição da regra 12 da Seção 30.3 vale também em
desenvolvimento.
24.4.3 packages/core/src/entitlements.ts — resolveEntitlements #
Fonte única da verdade dos entitlements, cuja matriz é definida na Seção 13. Nenhum outro módulo pode recalcular. O teste principal é uma tabela de estados de entrada para saída esperada.
export type Entitlements = {
tier: 'FREE' | 'PAID';
sendsDaily: boolean;
sendWeekday: number | null; // 0..6, null quando diário
hasAudio: boolean;
archiveDays: number | null; // null = ilimitado
manualResendsPerDay: number;
supportSla: 'FAQ_EMAIL' | 'EMAIL_1_BUSINESS_DAY';
};
export function resolveEntitlements(input: {
tier: 'FREE' | 'PAID';
subscriptionStatus: SubscriptionStatus | null;
currentPeriodEnd: Date | null;
optOutAt: Date | null;
deletedAt: Date | null;
now: Date;
}): Entitlements;| Cenário | tier |
status |
current_period_end |
Saída esperada |
|---|---|---|---|---|
| Assinante gratuito comum | FREE | null |
null |
FREE, semanal domingo, sem áudio, acervo 7 dias, 1 reenvio |
| Pago em dia | PAID | ACTIVE |
futuro | PAID, diário, com áudio, acervo ilimitado, 3 reenvios |
| Pago com período vencido no relógio | PAID | ACTIVE |
passado | FREE — o vencimento manda, mesmo com tier desatualizado no registro |
| Cancelou, ciclo ainda pago | PAID | CANCELED |
futuro | PAID — ele pagou por este período (Seção 13) |
| Cancelou, ciclo terminou | PAID | CANCELED |
passado | FREE |
| Inadimplente | PAID | EXPIRED |
qualquer | FREE — revogação imediata, sem carência |
| Reembolsado | PAID | REFUNDED |
futuro | FREE — revogação imediata mesmo com período em aberto |
| Aguardando primeiro pagamento | FREE | PENDING_PAYMENT |
null |
FREE — só paga depois do PAYMENT_CONFIRMED |
| Opt-out ativo | PAID | ACTIVE |
futuro | PAID nos entitlements, mas o bloqueio de envio é do motor de envio (Seção 18), não dos entitlements |
| Excluído (soft delete) | PAID | ACTIVE |
futuro | Lança EntitlementsError('SUBSCRIBER_DELETED') — estado impossível de ser consultado |
Casos de borda obrigatórios:
current_period_endexatamente igual anow(milissegundo idêntico): trata-se como ainda válido. A comparação écurrentPeriodEnd >= now, não>. Um teste fixa isso para impedir que alguém "otimize" o operador.tier = 'PAID'comsubscriptionStatus = null: estado impossível. A função lançaEntitlementsError('INCONSISTENT_STATE'). Um assinante PAID sem assinatura é bug de escrita, não caso a tratar silenciosamente.- Fuso:
nowem2026-08-25T02:30:00Zé2026-08-24T23:30:00-03:00. Umcurrent_period_endde2026-08-25T00:00:00-03:00ainda é futuro. O teste usa esses valores exatos para impedir comparação ingênua em UTC de calendário. - A saída é congelada:
Object.isFrozen(resolveEntitlements(...)) === true. Ninguém muta entitlements depois de resolvidos.
24.4.4 packages/core/src/subscription-state.ts — máquina de estados #
export function applySubscriptionEvent(
current: SubscriptionStatus,
event: SubscriptionEventType,
): { next: SubscriptionStatus; tierAfter: 'FREE' | 'PAID' } | { error: 'INVALID_TRANSITION' };Matriz completa de transições. Linha = estado atual, coluna = evento. — significa
transição inválida, que deve devolver INVALID_TRANSITION e não lançar exceção (o
processador de webhook registra e descarta, conforme a Seção 12).
| De \ Evento | PAYMENT_CONFIRMED |
PAYMENT_RECEIVED |
PAYMENT_OVERDUE |
PAYMENT_DELETED |
PAYMENT_REFUNDED |
PAYMENT_CHARGEBACK_REQUESTED |
CANCEL_REQUESTED |
PERIOD_ENDED |
|---|---|---|---|---|---|---|---|---|
PENDING_PAYMENT |
ACTIVE / PAID |
ACTIVE / PAID |
EXPIRED / FREE |
EXPIRED / FREE |
— | — | CANCELED / FREE |
EXPIRED / FREE |
ACTIVE |
ACTIVE / PAID |
ACTIVE / PAID |
EXPIRED / FREE |
EXPIRED / FREE |
REFUNDED / FREE |
EXPIRED / FREE |
CANCELED / PAID |
EXPIRED / FREE |
CANCELED |
ACTIVE / PAID |
ACTIVE / PAID |
EXPIRED / FREE |
— | REFUNDED / FREE |
EXPIRED / FREE |
— | EXPIRED / FREE |
EXPIRED |
ACTIVE / PAID |
ACTIVE / PAID |
— | — | REFUNDED / FREE |
— | — | — |
REFUNDED |
— | — | — | — | — | — | — | — |
Casos obrigatórios:
- Renovação normal:
ACTIVE+PAYMENT_CONFIRMED→ACTIVE/PAID. O teste também afirma quecurrent_period_endavança exatamente 1 mês paraplan_monthlye 1 ano paraplan_annual, calculado comdate-fnsemAmerica/Sao_Paulo. - Cancelamento não revoga imediatamente:
ACTIVE+CANCEL_REQUESTED→CANCELED/PAID. É o único caso em que o estado deixa de serACTIVEe o tier permanece PAID. Esse teste é a garantia contra a confusão entre "cancelou" e "inadimplente". - Inadimplência revoga imediatamente:
ACTIVE+PAYMENT_OVERDUE→EXPIRED/FREE. O teste afirma que não existe estado intermediário e que não há nenhum campo do tipograce_untilno resultado. REFUNDEDé terminal: todos os oito eventos aplicados aREFUNDEDdevolvemINVALID_TRANSITION. Umit.eachpercorre a lista inteira de eventos.- Reativação após expiração:
EXPIRED+PAYMENT_CONFIRMED→ACTIVE/PAID. Um assinante que voltou a pagar recupera o acesso na mesma transação. - Idempotência do evento: aplicar
PAYMENT_CONFIRMEDduas vezes aACTIVEproduz o mesmo estado e o mesmocurrent_period_endquando oevent.idé o mesmo. O teste de integração da Seção 24.5 cobre a persistência; o unitário cobre a função pura. - Cobertura total da matriz: um teste gera o produto cartesiano
estados × eventos(5 × 8 = 40 combinações) e verifica que cada célula corresponde à tabela acima. Se alguém adicionar um estado ou evento sem atualizar a matriz, o teste falha por contagem.
24.4.5 packages/core/src/teaser.ts — sanitização do teaser do template #
O teaser é o parâmetro {{2}} do template diário. A Meta rejeita parâmetro com quebra de
linha, tabulação ou 4 ou mais espaços consecutivos, e o corpo do template tem limite de
1024 caracteres. A função a seguir é a única fonte de teaser válido.
export function sanitizeTeaser(raw: string, maxLength = 300): string;
export function isTemplateParamSafe(value: string): boolean;Passos obrigatórios, na ordem: normalizar NFC; substituir \r\n, \r, \n, \t e espaço
não separável por espaço simples; colapsar 2 ou mais espaços em um; remover marcação
Markdown (**, *, _, #, >, crase, links [texto](url) viram texto); remover
caracteres de controle e de largura zero; trim(); truncar em maxLength na fronteira
da última palavra completa, removendo pontuação pendente e acrescentando reticências …
quando houve truncamento.
| Entrada | Saída esperada |
|---|---|
"A graça basta." |
"A graça basta." |
"Linha um\nLinha dois" |
"Linha um Linha dois" |
"Linha um\r\n\r\nLinha dois" |
"Linha um Linha dois" |
"Coluna\tvalor" |
"Coluna valor" |
"palavra espaçada" (5 espaços) |
"palavra espaçada" |
"palavra espaçada" (exatamente 4 espaços — o caso que a Meta rejeita) |
"palavra espaçada" |
"palavra espaçada" (2 espaços) |
"palavra espaçada" |
"**Confie** no _Senhor_" |
"Confie no Senhor" |
"# Título\n## Subtítulo" |
"Título Subtítulo" |
"Veja [aqui](https://exemplo.com)" |
"Veja aqui" |
"> Citação bíblica" |
"Citação bíblica" |
"Texto com" + espaço não separável + "NBSP" |
"Texto com NBSP" com espaço comum |
" espaços nas pontas " |
"espaços nas pontas" |
"Fé" + largura zero + "quebrada" |
"Féquebrada" (largura zero removida sem virar espaço) |
String de 340 caracteres terminando em "...misericórdia infinita do Senhor." |
Truncada em ≤ 300, terminando em palavra completa + … |
| String de 300 caracteres exatos | Inalterada, sem reticências |
| String de 301 caracteres | Truncada, com reticências, comprimento final ≤ 300 |
"Palavra, do dia!" truncado em "Palavra," |
"Palavra…" — vírgula pendente removida antes das reticências |
"" |
"" |
" \n\t " |
"" |
"🙏 Ore hoje 🙏" |
"🙏 Ore hoje 🙏" — emoji é permitido pela Meta e preservado |
| Texto com 250 caracteres + emoji no fim | Preservado; o truncamento conta pontos de código, não unidades UTF-16, e nunca parte um par substituto |
Propriedades obrigatórias, verificadas com fast-check sobre 2.000 strings arbitrárias
(incluindo Unicode e emoji):
isTemplateParamSafe(sanitizeTeaser(x))é sempretrue.[...sanitizeTeaser(x)].length <= 300sempre.sanitizeTeaser(sanitizeTeaser(x)) === sanitizeTeaser(x)(idempotência).sanitizeTeaser(x)nunca contém\n,\r,\tnem quatro espaços consecutivos.sanitizeTeaser(x)nunca termina em par substituto quebrado.
24.4.6 packages/core/src/daily-batch.ts — cálculo do lote diário #
Função pura que, dada a lista de assinantes elegíveis e a data-alvo, devolve o plano de envio. O motor de envio da Seção 18 apenas executa o que esta função decide.
export type PlannedSend = {
subscriberId: string;
devotionalDate: string; // YYYY-MM-DD, America/Sao_Paulo
channel: 'TEMPLATE_TEXT' | 'FREEFORM_BUNDLE' | 'TEMPLATE_VIDEO';
includeAudio: boolean;
templateName: string | null;
idempotencyKey: string; // send:{subscriberId}:{devotionalDate}
};
export function planDailyBatch(input: {
targetDate: Date; // instante do disparo (05:40 local)
freeSendWeekday: number; // 0 = domingo
subscribers: PlanCandidate[];
}): { sends: PlannedSend[]; skipped: Array<{ subscriberId: string; reason: SkipReason }> };SkipReason é um enum fechado: NOT_OPTED_IN, OPTED_OUT, DELETED, NOT_FREE_SEND_DAY,
ALREADY_SENT, NO_AUDIO_AVAILABLE, INVALID_PHONE, SUPPRESSED_BY_HARD_BOUNCE.
Casos obrigatórios:
| Cenário | Entrada | Resultado esperado |
|---|---|---|
| PAID, janela fechada | tier PAID, service_window_expires_at no passado |
channel: TEMPLATE_TEXT, templateName: 'devocional_diario_v1', includeAudio: false (o áudio vem depois, na janela) |
| PAID, janela aberta | tier PAID, service_window_expires_at = agora + 3 h |
channel: FREEFORM_BUNDLE, includeAudio: true, templateName: null — caminho preferencial |
| PAID, janela expira em 1 minuto | service_window_expires_at = agora + 60 s |
FREEFORM_BUNDLE — a comparação usa o instante do planejamento; a expiração durante o envio é tratada como erro 131047 pelo motor (Seção 27.2) |
| PAID sem abrir janela há 3 dias | consecutive_window_misses = 3 |
channel: TEMPLATE_VIDEO, templateName: 'devocional_diario_video_v1', includeAudio: true |
| PAID sem abrir há 2 dias | consecutive_window_misses = 2 |
TEMPLATE_TEXT — o fallback só entra no 4º dia |
| PAID sem abrir há 7 dias | consecutive_window_misses = 7 |
TEMPLATE_VIDEO — continua no fallback |
| PAID, fallback de vídeo sem MP4 pronto | videoMediaId = null |
TEMPLATE_TEXT + registro em skipped com NO_AUDIO_AVAILABLE para o áudio; o texto sempre sai |
| FREE em domingo | tier FREE, targetDate é domingo |
TEMPLATE_TEXT, includeAudio: false |
| FREE em segunda | tier FREE, targetDate é segunda |
ausente de sends; skipped com NOT_FREE_SEND_DAY |
| FREE em domingo com janela aberta | tier FREE, janela aberta | FREEFORM_BUNDLE com includeAudio: false — economiza o template, mas não dá áudio |
| Sem opt-in confirmado | opt_in_confirmed_at = null |
skipped com NOT_OPTED_IN |
| Opt-out ativo | opt_out_at preenchido |
skipped com OPTED_OUT |
| Soft delete | deleted_at preenchido |
skipped com DELETED |
| Já enviado hoje | delivery_attempts já contém a chave |
skipped com ALREADY_SENT |
| Telefone inválido gravado | phone_e164 não passa em normalizePhone |
skipped com INVALID_PHONE |
Erro 131026 permanente registrado |
blocked_at e blocked_reason preenchidos |
skipped com SUPPRESSED_BY_HARD_BOUNCE |
Casos de borda obrigatórios:
- Chave de idempotência estável: para o mesmo assinante e a mesma data, a chave é
byte a byte idêntica entre duas execuções. O teste compara duas chamadas com
targetDatediferindo em 4 minutos dentro do mesmo dia local. - Virada de dia no fuso local:
targetDate = 2026-08-26T08:39:00Zé2026-08-26T05:39:00-03:00→devotionalDate = '2026-08-26'.targetDate = 2026-08-26T02:59:00Zé2026-08-25T23:59:00-03:00→devotionalDate = '2026-08-25'. Os dois casos são fixados em teste para travar a conversão de fuso. - Domingo é calculado no fuso local:
2026-08-30T02:00:00Zé sábado 23:00 em São Paulo, portanto não é dia de envio FREE, mesmo sendo domingo em UTC. - Ordenação determinística:
sendssai ordenado porsubscriberIdascendente. Duas execuções com a mesma entrada embaralhada produzem arrays idênticos. Isso torna o lote reproduzível e a simulação do plano comparável entre execuções. - Volume: com 50.000 candidatos sintéticos a função executa em menos de 250 ms. O teste
usa
performance.now()e falha acima de 500 ms, com margem para máquinas lentas de CI. - Nenhum duplicado: o conjunto de
idempotencyKeyemsendstem tamanho igual ao tamanho desends. Verificado comnew Set(...).size. - Soma fechada:
sends.length + skipped.length === subscribers.length. Nenhum assinante desaparece silenciosamente do plano. - O dia do envio gratuito é dirigido por parâmetro, não fixado no código. Um caso
parametrizado percorre
freeSendWeekdayde0a6; para cada valor, monta sete datas-alvo — uma por dia da semana, às 05:40 no fuso local — e afirma que o assinante gratuito entra emsendsexatamente quando o dia local corresponde ao parâmetro, e aparece emskippedcomNOT_FREE_SEND_DAYnos outros seis. O teste falha se o resultado for idêntico para dois valores diferentes do parâmetro, que é a assinatura de uma comparação fixa com domingo dentro da função. Sem esse caso, mudarsend.free_tier_weekdaypara quarta-feira no painel não teria efeito nenhum e a suíte continuaria verde. - Reavaliação no disparo não pertence a esta função.
planDailyBatchdecide o plano; quem envia relê o assinante imediatamente antes de chamar o provedor (Seção 18.5). Um teste de comentário não basta: a cobertura desse comportamento está em S-13 a S-16 da Seção 24.8.3.
24.4.7 packages/core/src/narration.ts — buildNarrationScript #
Monta o texto que vai ao provedor de TTS a partir do devocional. A ordem e o conteúdo são fixados pelo pipeline de áudio da Seção 16.
export function buildNarrationScript(d: {
title: string;
bibleReference: string;
bibleText: string;
bibleVersion: string;
reflectionMd: string;
prayer: string;
}): string;Casos obrigatórios:
| Caso | Entrada relevante | Esperado |
|---|---|---|
| Ordem dos blocos | devocional completo | título → referência falada → texto bíblico → reflexão → oração, separados por parágrafo em branco |
| Markdown removido | "**Deus** é _fiel_" |
"Deus é fiel" |
| Cabeçalho Markdown | "## Reflexão\nTexto" |
"Reflexão. Texto" — cabeçalho vira frase com ponto, para a prosódia não engolir |
| Lista Markdown | "- primeiro\n- segundo" |
"Primeiro. Segundo." — marcadores viram frases |
| Link Markdown | "veja [o site](https://x)" |
"veja o site" — URL nunca é narrada |
| Referência abreviada | "Jo 3:16" |
"João, capítulo 3, versículo 16" |
| Referência com faixa | "Sl 23:1-6" |
"Salmos, capítulo 23, versículos 1 a 6" |
| Referência com livro numerado | "1Co 13:4" |
"Primeira aos Coríntios, capítulo 13, versículo 4" |
| Referência com livro numerado por extenso | "2 Timóteo 1:7" |
"Segunda a Timóteo, capítulo 1, versículo 7" |
| Referência com versículos avulsos | "Pv 3:5,6" |
"Provérbios, capítulo 3, versículos 5 e 6" |
| Referência desconhecida | "Xyz 1:1" |
Mantida literal, sem expandir, e emite aviso estruturado (não lança) |
| Versículo numerado no texto | "1 No princípio... 2 E a terra..." |
Números de versículo removidos do corpo narrado |
| Aspas tipográficas | aspas curvas | Convertidas para aspas simples ASCII, para o TTS não pronunciar |
| Reticências | "..." |
"…" normalizado |
| Espaço duplo | "a b" |
"a b" |
| Nome da versão | bibleVersion = 'ALMEIDA_1911' |
Frase final da leitura bíblica: "Almeida 1911." A sigla ARC é proibida como código e como atribuição (Seção 1.13) |
| Comprimento máximo | script > 5.000 caracteres | Lança NarrationError('SCRIPT_TOO_LONG') com o comprimento no erro — protege o orçamento de TTS |
| Campos vazios | prayer = '' |
Bloco de oração omitido, sem parágrafo em branco duplicado |
| Determinismo | mesma entrada, duas chamadas | Strings idênticas — o script é a chave de cache do áudio |
Propriedade obrigatória: o script resultante nunca contém #, *, _, crase, [, ],
(http, nem sequência de três ou mais quebras de linha.
24.4.8 Outros módulos com teste unitário obrigatório #
| Módulo | Casos mínimos |
|---|---|
packages/core/src/idempotency.ts |
Chave plan:{date} e send:{id}:{date} geradas conforme especificado; colisão impossível entre os dois prefixos; chave é ASCII e ≤ 120 caracteres. |
packages/core/src/keywords.ts |
normalizeKeyword() termina em .toUpperCase() — um teste fixa isso, porque a comparação em maiúsculas é a implementação canônica (Seção 20.3.2). Reconhecimento das sete palavras de saída SAIR, PARAR, PARE, CANCELAR, STOP, DESCADASTRAR e REMOVER, e das palavras de retorno VOLTAR, RETORNAR, QUERO VOLTAR e REATIVAR, insensível a caixa, acento e pontuação (sair, Sair, SAÍR, sair , Sair!); não reconhece dentro de frase ("não quero sair do grupo" não é opt-out); CANCELAR sozinho é opt-out e CANCELAR ASSINATURA é pedido de cancelamento de cobrança, o que é garantido pela exigência de correspondência da mensagem inteira. |
packages/core/src/money.ts |
Duas unidades e dois divisores, nunca float: centavos (/100) para valor monetário e micros (/1000000) para custo unitário. formatBRL(1990) === 'R$ 19,90'; formatBRL(19900) === 'R$ 199,00'; microsToBRL(1990000) === 'R$ 1,99'; a divisão anual por 12 arredonda para baixo e o resto fica no primeiro mês. Um teste afirma que somar um valor em centavos com um valor em micros sem conversão explícita é erro de tipo em tempo de compilação — as duas unidades usam tipos nominais distintos (Cents e Micros). |
packages/core/src/window.ts |
isServiceWindowOpen(expiresAt, now) com limite exato (igual = fechado, porque a Meta expira no instante); janela nula = fechada; janela de 24 h calculada a partir do timestamp da mensagem recebida, não do processamento. |
packages/integrations/src/whatsapp/errors.ts |
Mapeamento de cada código da Meta para a classe de erro e ação: 131047 → transitório/adiar; 131026 → permanente/suprimir; 131049 e 131050 → adiar sem contar falha; 132000–132015 → contrato/alerta de template; 130429 → transitório com backoff longo; 133xxx → integração/alerta crítico; código desconhecido → transitório com no máximo 2 tentativas. |
packages/integrations/src/asaas/mapper.ts |
Conversão de value decimal da Asaas (19.9) para centavos (1990) sem erro de ponto flutuante; 19.90, 19.9, 199, 199.00 e 0.01 testados; valor negativo lança. |
packages/core/src/redaction.ts |
CPF, cartão, token e telefone redigidos em log; telefone vira +55119****4321; token vira ***; objeto aninhado é redigido em profundidade; array de objetos também. |
24.5 Testes de integração #
Rodam contra PostgreSQL 17 e Redis 8 reais, subidos por Testcontainers (Seção 24.3.3).
Cobrem o que só existe quando código e banco se encontram: transações, índices únicos,
ON CONFLICT, enums, cascatas e concorrência.
24.5.1 Isolamento entre testes #
Decisão: truncamento seletivo em beforeEach, não transação com rollback.
A alternativa comum — abrir transação no beforeEach e dar rollback no afterEach — foi
descartada porque o código sob teste usa prisma.$transaction internamente, e transações
aninhadas no Postgres exigem savepoints que mudam o comportamento de bloqueio e de
ON CONFLICT. Testar com semântica diferente da produção derrota o propósito do teste.
// tools/testing/setup-integration.ts
import { afterAll, afterEach, beforeEach, inject } from 'vitest';
import { PrismaClient } from '@palavra-diaria/db';
import Redis from 'ioredis';
const databaseUrl = inject('databaseUrl');
const redisUrl = inject('redisUrl');
export const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
export const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
// Tabelas nunca truncadas: apenas as populadas por migration/seed imutável.
const PRESERVED = new Set(['_prisma_migrations', 'plans']);
let truncateStatement: string | null = null;
beforeEach(async () => {
if (!truncateStatement) {
const rows = await prisma.$queryRaw<Array<{ tablename: string }>>`
SELECT tablename FROM pg_tables WHERE schemaname = 'public'
`;
const targets = rows
.map((r) => r.tablename)
.filter((t) => !PRESERVED.has(t))
.map((t) => `"public"."${t}"`)
.join(', ');
truncateStatement = `TRUNCATE TABLE ${targets} RESTART IDENTITY CASCADE`;
}
await prisma.$executeRawUnsafe(truncateStatement);
await redis.flushdb();
});
afterEach(async () => {
// Nenhum timer de BullMQ pode sobreviver ao teste.
await redis.flushdb();
});
afterAll(async () => {
await prisma.$disconnect();
redis.disconnect();
});Cada arquivo de teste de integração roda em um worker Vitest próprio, e cada worker recebe
um schema Postgres dedicado quando maxConcurrency > 1: o DATABASE_URL do worker
recebe ?schema=test_w{workerId}. Isso permite paralelismo real sem que um TRUNCATE
derrube o teste do vizinho. Com 4 workers a suíte de integração cai de ~11 min para ~3 min.
24.5.2 Factories e fixtures #
Factories vivem em tools/testing/factories/ e seguem três regras:
- Toda factory aceita
overridesparciais e preenche o resto com valores válidos. - Nenhuma factory grava dependência implícita: se um
subscriptionprecisa desubscriber, a factory cria um a menos que recebasubscriberId. - Valores variáveis (telefone, e-mail, CPF, ULID) vêm de um contador determinístico
semeado por arquivo de teste, nunca de
Math.random(). Duas execuções da suíte geram os mesmos dados, o que torna falha reproduzível.
// tools/testing/factories/subscriber.ts
import { prisma } from '../setup-integration';
import { nextSeq } from '../seq';
import { ulid } from '@palavra-diaria/core';
export async function makeSubscriber(overrides: Partial<SubscriberInput> = {}) {
const n = nextSeq('subscriber'); // 1, 2, 3, ... por arquivo de teste
const local = String(900000000 + n).padStart(9, '0');
return prisma.subscriber.create({
data: {
id: overrides.id ?? ulid(),
phoneE164: overrides.phoneE164 ?? `+5511${local}`,
waId: overrides.waId ?? null,
tier: overrides.tier ?? 'FREE',
optInConfirmedAt: overrides.optInConfirmedAt ?? new Date('2026-01-01T12:00:00Z'),
optOutAt: overrides.optOutAt ?? null,
serviceWindowExpiresAt: overrides.serviceWindowExpiresAt ?? null,
consecutiveUnopenedDays: overrides.consecutiveUnopenedDays ?? 0,
deletedAt: overrides.deletedAt ?? null,
createdAt: overrides.createdAt ?? new Date('2026-01-01T12:00:00Z'),
...overrides,
},
});
}
export const makePaidSubscriber = (o: Partial<SubscriberInput> = {}) =>
makeSubscriber({ tier: 'PAID', ...o });Factories obrigatórias: makeSubscriber, makePaidSubscriber, makeSubscription,
makePayment, makeDevotional, makeAudioAsset, makeAdminUser, makeSession,
makeSendBatch, makeMessageLog, makeInboundMessage, makePaymentEvent.
Fixtures estáticos (arquivos JSON versionados em tools/testing/fixtures/): payloads
brutos reais de webhook, com dados anonimizados. Cada arquivo carrega um cabeçalho
_meta com data de captura e versão da API de origem.
fixtures/
asaas/payment-created.json
asaas/payment-confirmed.json
asaas/payment-overdue.json
asaas/payment-refunded.json
asaas/payment-chargeback-requested.json
asaas/subscription-created.json
asaas/subscription-deleted.json
meta/inbound-text.json
meta/inbound-button-open.json
meta/inbound-button-snooze.json
meta/inbound-audio.json
meta/status-sent.json
meta/status-delivered.json
meta/status-read.json
meta/status-failed-131047.json
meta/status-failed-131026.json
meta/status-failed-131049.json
meta/account-quality-red.json
meta/template-status-rejected.json24.5.3 Fluxos cobertos por teste de integração #
| # | Fluxo | O que se afirma |
|---|---|---|
| I-01 | Cadastro + OTP + opt-in | subscribers criado com phone_e164 normalizado; otp_codes com hash, não o código em claro; consent_events com duas entradas (web e WhatsApp); opt_in_confirmed_at só preenchido após a confirmação no WhatsApp |
| I-02 | Unicidade de telefone | Dois cadastros concorrentes com 11987654321 e +551187654321 — apenas um registro é criado; o segundo recebe erro de conflito, não uma linha duplicada |
| I-03 | Consumo de OTP | Código correto marca consumed_at e a segunda tentativa com o mesmo código falha; 5 tentativas erradas invalidam o código; TTL de 10 minutos expira com relógio controlado |
| I-04 | Limite de pedidos de OTP conta pedidos, não envios | 3 pedidos por hora por número; o 4º devolve o erro de limite e não grava linha em otp_codes. O contador é incrementado antes de qualquer consulta ao banco e vale igualmente para número cadastrado e não cadastrado. O teste executa quatro pedidos para um número existente e quatro para um inexistente e afirma que as quatro respostas são idênticas em código HTTP, corpo e error.code nos dois casos — se o contador só subisse para número existente, a diferença no quarto pedido seria, sozinha, um oráculo completo de enumeração da base |
| I-05 | Checkout cartão | subscriptions em PENDING_PAYMENT; externalReference gravado; nenhuma coluna do banco contém número de cartão (varredura por regex em todas as colunas text de todas as tabelas) |
| I-06 | Webhook PAYMENT_CONFIRMED |
payment_events gravado; subscriptions.status = ACTIVE; subscribers.tier = PAID; current_period_end correto — tudo na mesma transação |
| I-07 | Idempotência de webhook | O mesmo event.id entregue 3 vezes gera 1 linha em payment_events e 1 transição em subscription_events; o índice único é o guarda, não um SELECT prévio |
| I-08 | Webhook fora de ordem | PAYMENT_CONFIRMED chega depois de PAYMENT_OVERDUE do mesmo ciclo: o estado final é ACTIVE, decidido pela ordem de event.dateCreated, não pela ordem de chegada |
| I-09 | Revogação imediata | PAYMENT_OVERDUE em assinatura ACTIVE: status = EXPIRED e tier = FREE na mesma transação; um planDailyBatch executado logo em seguida já classifica o assinante como FREE |
| I-10 | Cancelamento pelo painel | status = CANCELED e tier permanece PAID; após PERIOD_ENDED, tier = FREE |
| I-11 | Reconciliação | Banco com ACTIVE e Asaas simulada devolvendo INACTIVE: a reconciliação corrige o banco, grava subscription_events com origem RECONCILIATION e emite alerta |
| I-12 | Ciclo editorial | DRAFT → READY dispara tts.generate; AUDIO_READY → PUBLISHED só é permitido com audio_assets presente; transição inválida é rejeitada com erro de contrato |
| I-13 | Revisões de devocional | Cada edição cria linha em devotional_revisions com o diff; a exclusão do devocional é soft delete e as revisões permanecem |
| I-14 | Planejamento do lote | 200 assinantes sintéticos, mistura de tiers e estados: send_batches criado, delivery_attempts com chave única, contagens conferem com o resultado de planDailyBatch |
| I-15 | Idempotência do lote | plan:2026-08-26 executado duas vezes concorrentemente cria um send_batches; a segunda execução termina sem erro e sem linhas extras |
| I-16 | Concorrência de envio | Dois workers processam o mesmo delivery_attempt: apenas um envia; o outro recebe violação de índice único e encerra como ALREADY_SENT, sem retry |
| I-17 | Abertura de janela | Webhook de mensagem recebida atualiza service_window_expires_at = inbound.timestamp + 24 h; um segundo webhook mais antigo não retrocede o valor |
| I-18 | Resolução por wa_id |
Webhook com wa_id sem nono dígito encontra o assinante e grava wa_id; segundo webhook resolve pelo índice de wa_id |
| I-19 | Opt-out | SAIR grava opt_out_at, envia confirmação uma única vez e o lote seguinte pula o assinante; segundo SAIR não envia nova confirmação |
| I-20 | Opt-out suspende a cobrança sem cancelar a assinatura | Após SAIR, subscriptions.status continua ACTIVE — um SAIR acidental não destrói a contratação — mas a cobrança do ciclo seguinte fica suspensa na mesma transação, o painel expõe o estado "envios pausados, cobrança suspensa" com as duas ações possíveis, e o teste avança o relógio 30 dias sem reativação e afirma que a assinatura é então encerrada ao fim do período já pago |
| I-21 | Reativação | VOLTAR limpa opt_out_at e grava consent_events; o lote seguinte inclui o assinante |
| I-22 | Status de mensagem | sent → delivered → read atualizam message_logs com timestamps próprios; um delivered que chega depois de read não regride o status |
| I-23 | Falha permanente | Status failed com 131026 marca subscribers.blocked_at e blocked_reason e suprime o assinante nos lotes seguintes. Não existe coluna hard_bounce_at (Seção 6.3) |
| I-24 | Falha por janela | Status failed com 131047 reenfileira como template no mesmo dia, no máximo uma vez |
| I-25 | Pipeline de áudio | tts.generate grava audio_assets com provider, duration_ms, chaves OGG e MP3; segunda execução com o mesmo script reaproveita o asset em vez de gerar de novo |
| I-26 | Fallback de TTS | 3 falhas do provedor primário acionam o secundário; audio_assets.provider registra o secundário; a 4ª tentativa não chama mais o primário |
| I-27 | Reupload de mídia | whatsapp_media_id com mais de 30 dias dispara reupload e atualiza media_uploads |
| I-28 | Sessão | Login grava sessions; logout revoga; JWT de sessão revogada é rejeitado; rotação emite novo token e invalida o anterior |
| I-29 | Autorização por papel | EDITOR não consegue acessar rotas de ADMIN; SUBSCRIBER não consegue ler devocional de outro assinante; cada negativa devolve o code correto |
| I-30 | Auditoria administrativa | Toda ação de admin grava admin_audit_log com ator, alvo, ação e diff |
| I-31 | Métricas diárias | daily_metrics calculado para uma data com dados conhecidos bate com as fórmulas da Seção 21. O formato é o longo: uma linha por (metric_date, metric_key, dimension), com value, numerator, denominator, rollup_version e is_final. O teste afirma que a chave única impede duas linhas para a mesma tripla e que dimension usa string vazia, nunca NULL |
| I-32 | Exportação LGPD | O export JSON do assinante contém todas as tabelas previstas na Seção 22 e nenhum dado de terceiro |
| I-33 | Eliminação LGPD alcança todas as tabelas | Cria um assinante, gera tráfego em todas as tabelas que guardam identificador pessoal, executa a anonimização e falha se uma busca pelo telefone original, pelo wa_id ou pelo e-mail devolver qualquer linha em qualquer tabela. Verifica explicitamente que message_logs.phone_e164, message_logs.wa_id, message_logs.payload_excerpt, inbound_messages.phone_e164 e inbound_messages.wa_id ficaram nulos; que nome, e-mail e CPF saíram de subscribers e subscriber_profiles; e que consent_events e payment_events permanecem íntegros no conteúdo probatório, ligados apenas ao identificador interno |
| I-34 | Retenção | O job de retenção apaga message_logs com mais de 18 meses e preserva payment_events de 3 anos |
| I-35 | Guarda de ganchos de teste | Subir a aplicação com E2E_TEST_HOOKS=true e NODE_ENV=production faz o processo terminar com código diferente de zero e a mensagem test hooks are forbidden in production |
| I-36 | Registro do agendador | queue.getRepeatableJobs() contém plan-daily-batch com padrão 40 5 * * * e tz: 'America/Sao_Paulo'. As duas asserções são obrigatórias: sem o fuso, o job dispara às 05:40 UTC, ou seja, 02:40 em Brasília. O nome do job repetível é plan-daily-batch em todos os pontos do sistema |
| I-37 | Criptografia de campo e índice cego | Grava um assinante e o relê. Afirma que a coluna phone_e164 no banco não contém o telefone em claro e casa com o formato de envelope; que phone_hmac tem 64 caracteres hexadecimais; e que a busca por phone_hmac encontra a linha. Percorre information_schema.columns e falha se alguma coluna nomeada phone_e164, wa_id, email ou cpf não for text com CHECK de envelope, ou se a coluna _hmac correspondente estiver ausente |
| I-38 | Gatilho append-only não impede direito do titular | Executa a anonimização por LGPD de ponta a ponta e o expurgo de retenção contra um banco com o gatilho de consent_events ativo. Falha se qualquer um dos dois lançar exceção, e falha também se um UPDATE que altere type, granted, consent_text_hash ou created_at for aceito com o papel privilegiado. Nenhum gatilho é desabilitado em momento algum |
| I-39 | Atributos dos cookies de sessão | Percorre todo Set-Cookie emitido pelas rotas de autenticação e afirma que todo cookie cujo nome comece com __Host- contém Secure, contém Path=/ e não contém Domain. Afirma que os nomes emitidos são exatamente __Host-session, __Host-refresh, __Host-admin-session, __Host-admin-refresh, __Host-admin-device, __Host-auth-challenge e __Host-csrf, e que o nome csrf-token não é emitido em lugar nenhum |
| I-40 | Precedência absoluta da palavra de saída | Envia cada uma das sete palavras de saída em cada estado possível do assinante — inclusive pausado, bloqueado, em atendimento humano e em modo silencioso pela proteção anti-laço — e falha se opt_out_at não for gravado em qualquer combinação. Nenhum caminho de código pode consumir a mensagem antes desse teste |
| I-41 | Privilégio de tabela nova | Cria uma tabela por migration com o papel de migração e afirma que app_user tem SELECT, INSERT, UPDATE e DELETE nela sem nenhum GRANT adicional, o que prova que os ALTER DEFAULT PRIVILEGES da Seção 22.12.2 estão aplicados |
24.6 Testes de contrato com serviços externos #
Três integrações externas: Asaas, WhatsApp Cloud API (Meta) e provedor de TTS. Nenhuma é chamada de verdade em teste automatizado. O risco disso é óbvio: o simulador pode divergir do serviço real e os testes passam enquanto a produção quebra. A estratégia abaixo existe para controlar exatamente esse risco.
24.6.1 Três níveis de simulação #
| Nível | Onde | Como | Para quê |
|---|---|---|---|
| Handler MSW | Testes unitários | Interceptação em memória por msw |
Testar o cliente tipado: montagem de requisição, parsing, classificação de erro |
| Servidor de simulação | Integração, E2E e desenvolvimento local | Processo HTTP em 127.0.0.1:4010 |
Testar o sistema inteiro com dependências previsíveis e cenários de erro provocáveis |
| Reprodução de gravação | Testes de contrato | Respostas gravadas do serviço real, em disco | Provar que o cliente ainda entende a forma real do payload |
24.6.2 Gravação e reprodução #
Gravações ficam em tools/testing/cassettes/<serviço>/<operação>.json. Cada arquivo tem
requisição sanitizada, resposta bruta, status, cabeçalhos relevantes e um cabeçalho
_meta com data da captura e ambiente de origem (sempre sandbox ou número de teste).
{
"_meta": {
"service": "asaas",
"operation": "createSubscription",
"capturedAt": "2026-08-20T14:11:03.000Z",
"environment": "sandbox",
"apiVersion": "v3"
},
"request": {
"method": "POST",
"path": "/v3/subscriptions",
"headers": { "access_token": "***" },
"body": {
"customer": "cus_000005219999",
"billingType": "CREDIT_CARD",
"cycle": "MONTHLY",
"value": 19.9,
"nextDueDate": "2026-09-20",
"externalReference": "01K3Q9X0000000000000000000"
}
},
"response": {
"status": 200,
"body": {
"object": "subscription",
"id": "sub_9v8x7c6b5a",
"customer": "cus_000005219999",
"billingType": "CREDIT_CARD",
"cycle": "MONTHLY",
"value": 19.9,
"nextDueDate": "2026-09-20",
"status": "ACTIVE",
"externalReference": "01K3Q9X0000000000000000000",
"dateCreated": "2026-08-20"
}
}
}Regras de gravação, sem exceção:
- Gravação só é feita contra sandbox da Asaas, número de teste da Meta e a conta de desenvolvimento do provedor de TTS. Nunca produção.
- O gravador redige
access_token,Authorization,xi-api-key, CPF, número de cartão e telefone antes de escrever em disco. O CI rodagitleakse uma verificação própria (pnpm test:cassettes:lint) que falha se qualquer gravação contiver algo parecido com token (/[A-Za-z0-9_\-]{32,}/) fora dos campos redigidos. - Gravação é ato manual e deliberado:
pnpm test:contract --record --service=asaas --operation=createSubscription. Nunca automática, nunca no CI. - Toda alteração de gravação aparece no diff do pull request e exige revisão humana.
O teste de contrato reproduz a gravação e afirma duas direções:
// tests/contract/asaas/create-subscription.contract.test.ts
import { describe, expect, it } from 'vitest';
import { loadCassette, replay } from '../../../tools/testing/cassettes';
import { AsaasClient } from '@palavra-diaria/integrations/asaas';
import { AsaasSubscriptionSchema } from '@palavra-diaria/integrations/asaas/schemas';
describe('contrato: Asaas createSubscription', () => {
const cassette = loadCassette('asaas', 'createSubscription');
it('a requisição que produzimos é a que foi gravada', async () => {
const captured = await replay(cassette, (baseUrl) => {
const client = new AsaasClient({ baseUrl, apiKey: 'test' });
return client.createSubscription({
customerId: 'cus_000005219999',
billingType: 'CREDIT_CARD',
cycle: 'MONTHLY',
valueCents: 1990,
nextDueDate: '2026-09-20',
externalReference: '01K3Q9X0000000000000000000',
});
});
expect(captured.request.body).toEqual(cassette.request.body);
});
it('a resposta gravada satisfaz nosso schema Zod', () => {
const parsed = AsaasSubscriptionSchema.safeParse(cassette.response.body);
expect(parsed.success).toBe(true);
});
it('o schema rejeita campo obrigatório ausente', () => {
const { id, ...semId } = cassette.response.body as Record<string, unknown>;
expect(AsaasSubscriptionSchema.safeParse(semId).success).toBe(false);
});
});A terceira afirmação é o que impede o schema de virar z.any() com o tempo. Um schema
permissivo passa no teste 2 mas falha no teste 3.
24.6.3 Servidor de simulação local #
Um único processo Fastify serve as três APIs em caminhos distintos. Ele roda em
desenvolvimento (pnpm mock:serve), nos testes de integração e no E2E.
| Prefixo | Serviço simulado |
|---|---|
/asaas/v3/* |
Asaas API v3 |
/meta/v26.0/* |
WhatsApp Cloud API |
/elevenlabs/v1/* |
TTS primário |
/gcp-tts/v1/* |
TTS de fallback |
/s3/* |
Storage S3-compatível |
/__mock/* |
Painel de controle do próprio simulador |
O painel de controle é o que torna o simulador útil para testar falha:
| Rota | Efeito |
|---|---|
GET /__mock/health |
Prontidão do simulador |
POST /__mock/reset |
Zera estado e cenários |
POST /__mock/scenario |
Ativa um cenário nomeado (tabela abaixo) |
GET /__mock/requests?service=meta |
Devolve todas as requisições recebidas, para asserção |
POST /__mock/webhook/asaas |
Faz o simulador disparar um webhook contra nosso sistema |
POST /__mock/webhook/meta |
Idem, para mensagens de entrada e status |
Cenários obrigatórios, cada um com teste que o exercita:
| Cenário | Serviço | Comportamento |
|---|---|---|
asaas.ok |
Asaas | Fluxo feliz |
asaas.timeout |
Asaas | Não responde por 30 s |
asaas.500 |
Asaas | 500 em toda chamada |
asaas.429 |
Asaas | 429 com Retry-After: 5 |
asaas.401 |
Asaas | Token inválido |
asaas.card_declined |
Asaas | POST /payments devolve erro de cartão recusado |
asaas.duplicate_webhook |
Asaas | Entrega o mesmo evento 3 vezes |
asaas.out_of_order_webhook |
Asaas | Entrega PAYMENT_OVERDUE antes de PAYMENT_CONFIRMED |
asaas.subscription_drift |
Asaas | GET /subscriptions reporta estado diferente do nosso banco |
meta.ok |
Meta | Envio aceito, status sent → delivered |
meta.error_131047 |
Meta | Fora da janela de 24 h |
meta.error_131026 |
Meta | Não é possível entregar (permanente) |
meta.error_131049 |
Meta | Limitação por saúde do ecossistema |
meta.error_130429 |
Meta | Rate limit |
meta.error_132001 |
Meta | Template não existe ou não aprovado |
meta.error_133010 |
Meta | Conta/número com problema |
meta.token_expired |
Meta | 190 com subcódigo de token expirado |
meta.quality_red |
Meta | Dispara webhook phone_number_quality_update com RED |
meta.template_paused |
Meta | Dispara webhook de template pausado |
meta.slow |
Meta | Responde em 8 s, para exercitar timeout |
meta.media_expired |
Meta | media_id inválido/expirado no envio |
tts.ok |
TTS | Devolve MP3 válido de 12 s |
tts.500 |
TTS | Falha permanente do primário |
tts.quota_exceeded |
TTS | 401/429 de cota esgotada |
tts.slow |
TTS | Responde em 60 s |
storage.ok |
Storage | Upload e URL assinada funcionam |
storage.503 |
Storage | Indisponível |
Toda linha desta tabela referencia, na mesma linha do arquivo de registro do simulador, o
identificador do teste que a exercita. O teste mock-scenario-coverage.contract.test.ts
enumera os cenários registrados e falha se algum não estiver referenciado por um teste
existente. Um cenário sem teste é um caminho de produção sem cobertura, e a lista de
cenários é justamente o inventário do que se sabe que vai acontecer: token expirado,
qualidade em vermelho, template pausado, mídia expirada, cota de voz esgotada, tempo limite e
erro do provedor de pagamento não podem ficar registrados e não exercitados.
Uso em teste:
await mock.scenario('meta.error_131047');
await runDailyBatchForDate('2026-08-26');
const attempts = await prisma.deliveryAttempt.findMany({ where: { status: 'RETRY_SCHEDULED' } });
expect(attempts).toHaveLength(1);
expect(attempts[0].lastErrorCode).toBe('131047');24.6.4 Como se garante que o simulador não fica defasado #
Quatro mecanismos, todos automáticos exceto o último:
- O simulador valida com os nossos schemas Zod de resposta. Toda rota do simulador
passa o corpo que vai devolver por
AsaasSubscriptionSchema,MetaSendResponseSchemae equivalentes antes de responder. Se o schema mudar e o simulador não, o próprio simulador falha ao inicializar. - O simulador é semeado a partir das gravações. As respostas de sucesso não são
escritas à mão: são carregadas de
tools/testing/cassettes/. Uma gravação nova atualiza o simulador no mesmo commit. - Verificação de cobertura de operações. O teste
mock-server-coverage.contract.test.tsenumera todos os métodos públicos dos clientes empackages/integrationspor reflexão e afirma que cada um tem rota correspondente no simulador e gravação em disco. Adicionar um método sem simulação quebra o CI. - Recaptura trimestral obrigatória. A cada 90 dias, uma tarefa recorrente exige rodar
pnpm test:contract --record --allcontra sandbox e número de teste, revisar o diff e commitar. O CI falha com aviso (não bloqueante) quando a gravação mais antiga passa de 100 dias, e com erro (bloqueante) aos 150 dias. A idade é lida do campocapturedAt. Isso transforma "o simulador envelheceu" de surpresa em tarefa agendada.
Além disso, a sonda de integrações — executada na fila maintenance.cleanup (Seção 18.8),
que é a fila de tarefas periódicas de manutenção — chama em produção uma operação de
leitura barata de cada serviço (GET /v3/finance/balance na Asaas, GET /{PHONE_NUMBER_ID}
na Meta) a cada 15 minutos e valida a resposta com o mesmo schema Zod usado no código.
Leitura barata, porém, não é o formato que quebra. Os formatos que quebram são o corpo do webhook de cobrança e a resposta de envio de mensagem. Por isso a sonda faz duas coisas a mais:
- Valida um objeto real recente de cada serviço — a última cobrança criada, o último envio aceito — usando exatamente os mesmos schemas que o processamento de webhook usa em produção. Nunca schemas próprios: um schema próprio vira uma segunda verdade e mascara justamente a divergência que a sonda existe para achar.
- Uma rotina semanal em ambiente de teste do provedor percorre o ciclo completo — criar cobrança, confirmar, vencer, estornar — e valida os quatro corpos de webhook resultantes contra os schemas de produção.
A sonda alimenta as métricas external_contract_validation_total{provider,operation,result}
e external_contract_last_success_timestamp_seconds{provider} e o alerta
external_contract_drift, todos catalogados na Seção 23. Uma mudança de contrato em produção
vira alerta em até 15 minutos, não em uma falha de envio no dia seguinte.
24.7 Testes E2E com Playwright #
24.7.1 Ambiente de E2E #
- Banco: PostgreSQL efêmero por execução, com migrations aplicadas e seed determinístico
(
pnpm ops seed:e2e). - Externos: servidor de simulação da Seção 24.6.3.
- Aplicação: build de produção do
web, comE2E_TEST_HOOKS=true. - O
workerroda no mesmo processo do Playwright em modo "inline": os jobs são executados sincronamente por um runner de teste em vez de por BullMQ, para eliminar espera. Filas reais são exercitadas nos testes de integração e nos testes do motor de envio, não aqui.
Ganchos de teste. Quatro rotas internas existem apenas quando E2E_TEST_HOOKS=true,
todas exigindo o cabeçalho x-e2e-secret igual a E2E_HOOK_SECRET:
| Rota | Função |
|---|---|
POST /api/internal/test/clock |
Define o deslocamento do relógio ({ "offsetMs": 86400000 }) |
POST /api/internal/test/inbound |
Injeta uma mensagem de entrada do WhatsApp como se viesse da Meta |
GET /api/internal/test/otp?phone= |
Devolve o último OTP em claro para o número |
POST /api/internal/test/run-jobs |
Executa a fila pendente de forma síncrona |
Guarda obrigatória: o web falha ao iniciar se E2E_TEST_HOOKS === 'true' e
NODE_ENV === 'production' sem ALLOW_TEST_HOOKS_IN_PROD_BUILD=true. O arquivo de
composição de produção nunca define nenhuma das duas. O teste I-35 da Seção 24.5.3 fixa
esse comportamento. Como defesa em profundidade, o proxy reverso devolve 404 para todo o
prefixo /api/internal/* (Seção 25.7.2).
24.7.2 Controle de relógio #
packages/core exporta:
export interface Clock { now(): Date }
export const systemClock: Clock = { now: () => new Date() };
export class OffsetClock implements Clock {
constructor(private readonly offsetMs: () => number) {}
now() { return new Date(Date.now() + this.offsetMs()); }
}Em produção, injeta-se systemClock. Em teste e E2E, injeta-se OffsetClock lendo a chave
Redis test:clock:offset. Nenhum arquivo fora de packages/core/src/clock.ts pode chamar
new Date() sem argumento nem Date.now(). Isso é imposto por regra de ESLint
(no-restricted-syntax sobre NewExpression[callee.name='Date'][arguments.length=0] e
MemberExpression[object.name='Date'][property.name='now']), com exceção declarada apenas
para clock.ts e para arquivos de teste.
24.7.3 Cenários E2E #
Cada cenário abaixo é um arquivo em apps/web/tests/e2e/. Todos usam dados sintéticos da
Seção 24.10.
E2E-01 — Cadastro completo com opt-in duplo (signup.spec.ts)
- Passos: abrir
/; clicar em "Receber grátis"; preencher telefone(11) 99000-0001; marcar o checkbox de consentimento; enviar; ler o OTP via gancho de teste; digitar os 6 dígitos; injetar a respostaSIMvia gancho de mensagem de entrada; recarregar o painel. - Dados: telefone sintético não usado por outro cenário.
- Sucesso: painel do assinante mostra "Plano gratuito"; o banco tem
opt_in_confirmed_atpreenchido e duas linhas emconsent_events; a chamada ao simulador registra exatamente um envio de templatecodigo_acesso_v1e um deboas_vindas_v1.
E2E-02 — Cadastro rejeita telefone estrangeiro (signup-foreign.spec.ts)
- Passos: preencher
+1 415 555 2671; enviar. - Sucesso: mensagem em português informando que apenas números brasileiros são aceitos no
momento; nenhuma linha criada em
subscribers; nenhuma chamada ao simulador da Meta.
E2E-03 — Cadastro rejeita telefone fixo (signup-landline.spec.ts)
- Passos: preencher
(11) 3123-4567. - Sucesso: erro de campo indicando que é necessário um celular com WhatsApp; sem registro criado.
E2E-04 — Login por OTP com reenvio e limite (login-otp.spec.ts)
- Passos: pedir código 3 vezes seguidas; pedir a 4ª.
- Sucesso: as 3 primeiras retornam sucesso; a 4ª mostra a mensagem de limite; o banco tem 3
linhas em
otp_codespara o número.
E2E-05 — Checkout com cartão (checkout-card.spec.ts)
- Passos: logado como FREE, abrir
/assinar; escolher mensal; preencher nome, CPF529.982.247-25e cartão de teste; confirmar; o simulador disparaPAYMENT_CONFIRMED; executar os jobs pendentes; recarregar o painel. - Sucesso: painel mostra "Plano pago — próxima cobrança em 20/09/2026";
subscriptionsemACTIVE;subscribers.tier = PAID; nenhuma requisição registrada no simulador contém o número de cartão fora do endpoint de tokenização.
E2E-06 — Checkout com cartão recusado (checkout-card-declined.spec.ts)
- Passos: ativar
asaas.card_declined; repetir o checkout. - Sucesso: mensagem "Não conseguimos aprovar o pagamento. Confira os dados do cartão ou
tente outro."; a assinatura permanece
PENDING_PAYMENT; o assinante continua FREE; o botão de tentar de novo está habilitado.
E2E-07 — Checkout com PIX (checkout-pix.spec.ts)
- Passos: escolher anual + PIX; confirmar; a página mostra o QR Code e o código copia e
cola; clicar em "Copiar código"; o simulador dispara
PAYMENT_RECEIVED; a página faz polling e atualiza sem recarregar. - Sucesso: QR Code renderizado (imagem com
altdescritivo); o texto copiado bate com o payload devolvido; após o webhook, a página mostra confirmação em até 10 s;subscribers.tier = PAID;current_period_endum ano à frente.
E2E-08 — PIX expirado (checkout-pix-expired.spec.ts)
- Passos: gerar cobrança PIX; avançar o relógio 25 horas; recarregar a página de pagamento.
- Sucesso: a página informa que o código expirou e oferece gerar novo; a assinatura
continua
PENDING_PAYMENT; nenhum acesso pago liberado.
E2E-09 — Cancelamento pelo assinante (cancel.spec.ts)
- Passos: logado como PAID com
current_period_endem 10 dias, abrir/conta; clicar em "Cancelar assinatura"; confirmar no diálogo; ler a tela de confirmação. - Sucesso: a tela diz explicitamente até quando o acesso continua ("Você continua com
acesso completo até 04/09/2026");
subscriptions.status = CANCELED;tiercontinuaPAID; a Asaas recebeuDELETE /v3/subscriptions/{id}.
E2E-10 — Fim do período após cancelamento (cancel-period-end.spec.ts)
- Passos: continuar do cenário anterior; avançar o relógio 11 dias; executar os jobs.
- Sucesso:
tier = FREE; o painel mostra "Plano gratuito"; o acervo passa a exibir apenas os últimos 7 dias.
E2E-11 — Inadimplência revoga na hora (overdue.spec.ts)
- Passos: assinante PAID
ACTIVE; o simulador disparaPAYMENT_OVERDUE; executar os jobs; recarregar o painel. - Sucesso:
tier = FREEimediatamente;subscriptions.status = EXPIRED; o painel mostra o aviso de pagamento não identificado com botão para regularizar; nenhum texto na interface menciona prazo ou tolerância.
E2E-12 — Opt-out por mensagem (opt-out.spec.ts)
- Passos: injetar mensagem de entrada
sair(minúsculo, com espaço à direita) para um assinante PAID; executar os jobs; abrir o painel. - Sucesso:
opt_out_atpreenchido; o simulador registrou exatamente uma mensagem de confirmação de saída; o painel mostra o estado "envios pausados, cobrança suspensa", com a ação de voltar a receber e a ação de encerrar a assinatura, deixando a distinção explícita; um segundosairnão gera nova confirmação.
E2E-13 — Reativação por VOLTAR (opt-in-again.spec.ts)
- Passos: continuar do anterior; injetar
voltar. - Sucesso:
opt_out_atnulo; nova linha emconsent_events; mensagem de boas-vindas de retorno registrada uma vez.
E2E-14 — Painel administrativo publica um devocional (admin-publish.spec.ts)
- Passos: login como
EDITORcom e-mail, senha e TOTP (código gerado no teste a partir do segredo semeado); abrir o calendário editorial; criar devocional para2026-09-01com título, referênciaSl 23:1-6, texto, reflexão e oração; salvar comoDRAFT; marcar comoREADY; aguardar o áudio (o simulador de TTS responde na hora); conferir o player; publicar. - Sucesso:
devotionals.status = PUBLISHED;audio_assetscom OGG e MP3; o teaser gerado tem ≤ 300 caracteres e passa emisTemplateParamSafe;admin_audit_logcom três linhas (criação, transição, publicação); o player web toca o MP3 por URL assinada.
E2E-15 — Editor não pode o que é de admin (admin-rbac.spec.ts)
- Passos: logado como
EDITOR, navegar direto para/admin/assinantese para/admin/configuracoes. - Sucesso: ambas as páginas devolvem a tela de acesso negado; a chamada de API
correspondente devolve
403com ocodede permissão insuficiente definido na Seção 7; a tentativa é registrada emadmin_audit_log.
E2E-16 — Reenvio manual respeita o limite do tier (resend-limit.spec.ts)
- Passos: como FREE, clicar em "Reenviar devocional de hoje" duas vezes; como PAID, clicar quatro vezes.
- Sucesso: FREE tem sucesso na 1ª e erro de limite na 2ª; PAID tem sucesso nas 3 primeiras e erro na 4ª; as mensagens são as da Seção 10.
E2E-17 — Acervo respeita o tier (archive.spec.ts)
- Passos: com 40 devocionais publicados, abrir
/acervocomo FREE e como PAID. Em seguida, sem usar a interface, chamar a rota de listagem do acervo com o maior limite aceito e percorrer todos os cursores com a sessão do assinante gratuito, e pedir a leitura de um devocional de 30 dias atrás. - Sucesso: FREE vê no máximo 7 itens e um aviso de upgrade na interface; a rota devolve no
máximo os 7 dias mais recentes mesmo percorrendo todos os cursores, e a leitura do item de
30 dias atrás devolve
403. PAID vê todos os itens desde a primeira assinatura paga (Seção 13.6.1), com paginação por cursor funcionando (clicar em "Carregar mais" duas vezes). O limite é imposto no servidor, e não apenas na tela.
E2E-18 — Landing page e acessibilidade (landing.mobile.spec.ts)
- Passos: abrir
/no perfilmobile-android; rodar varreduraaxe-core; navegar apenas por teclado até o botão principal. - Sucesso: zero violações de severidade
seriousoucritical; foco visível em todos os elementos interativos; o botão principal é alcançável em no máximo 6Tab; LCP medido pelo Playwright abaixo de 2,5 s no perfil móvel emulado (o alvo de produção em 4G real, 2,0 s, é validado no teste de carga da Seção 24.9).
E2E-19 — Política de conteúdo não bloqueia nada (csp-violations.spec.ts)
- Passos: abrir, uma a uma, todas as rotas com formulário (
/cadastro,/contato, a tela de pedido de código), mais/app/hojecom sessão de assinante pago e o player de áudio, com a política de conteúdo em modo de imposição; coletar as mensagens de violação do console do navegador em cada rota. - Sucesso: zero violações em todas as rotas. Especificamente: o script do verificador
anti-bot carrega e o widget renderiza; o
iframedo verificador não é bloqueado; e ofetchdo player para o domínio de mídia conclui e o áudio toca. Este cenário existe porque os dois defeitos que ele pega — verificador bloqueado por falta de diretiva deframe-srce domínio de mídia ausente deconnect-src— não aparecem em nenhum teste unitário e produzem, respectivamente, taxa de cadastro zero e áudio pago que não toca em navegador nenhum.
E2E-20 — Sessão sobrevive à expiração do token de acesso (session-refresh.spec.ts)
- Passos: entrar como assinante; avançar o relógio além da validade do token de acesso; navegar para outra tela do painel.
- Sucesso: a renovação ocorre de forma transparente e o assinante continua autenticado. O
teste inspeciona os cabeçalhos
Set-Cookieemitidos no login e falha se algum cookie com prefixo__Host-tiverPathdiferente de/, tiverDomainou não tiverSecure— um cookie assim é descartado sem erro pelo navegador, e o sintoma em produção seria "o site desloga sozinho a cada 15 minutos", com causa raiz invisível.
24.8 Testes do motor de envio #
O motor de envio é o único componente que pode causar dano irreversível: mensagem enviada não volta. Ele tem uma bateria própria.
24.8.1 Como testar um lote sem enviar mensagem real #
Três camadas de proteção, todas ativas simultaneamente em ambiente de teste:
- Provedor substituível. O motor depende da interface
WhatsAppProvider, nunca do cliente HTTP direto. Em teste injeta-seRecordingWhatsAppProvider, que grava a chamada em memória e devolve uma resposta configurável. - Guarda de ambiente. O cliente HTTP real lança
ProviderMisconfiguredError('REAL_PROVIDER_IN_TEST')no construtor seNODE_ENV === 'test'eALLOW_REAL_WHATSAPP !== 'true'. Ninguém injeta o provedor real por engano. - Lista de destinos permitidos. Quando
WHATSAPP_ALLOWLISTestá definida (ambiente local e staging), o motor recusa qualquer destino fora dela comRECIPIENT_NOT_ALLOWLISTED. Em produção a variável é vazia e a regra não se aplica. Um teste de integração afirma que, com a lista preenchida com um único número, um lote de 50 assinantes envia 1 mensagem e registra 49 recusas.
Modo --dry-run do planejamento: calcula o plano, grava send_batches com
mode = 'DRY_RUN', cria as linhas de delivery_attempts com status = 'PLANNED_DRY_RUN' e
não enfileira nada. A saída é um resumo por canal e por tier, mais o caminho de um CSV
com o plano completo. É o comando usado em produção antes de qualquer mudança no motor.
24.8.2 Como avançar o relógio #
- Testes unitários:
vi.setSystemTime(new Date('2026-08-26T08:39:00Z'))comvi.useFakeTimers(). Usado para lógica pura. - Testes de integração: injeção de
OffsetClock(Seção 24.7.2). O tempo do Postgres também precisa acompanhar; por isso nenhuma coluna usaDEFAULT now()para timestamps de negócio — todos são escritos pela aplicação a partir doClock.created_atde auditoria pode usarnow()do banco, e os testes que dependem dele usam janelas de tolerância, não igualdade. - BullMQ: o agendamento repetível não é simulado com espera real. O teste chama
diretamente o processador (
processPlanDailyBatch(job)) com um job falso. O testeI-36afirma que o job repetívelplan-daily-batchestá registrado com o padrão cron40 5 * * *e comtz: 'America/Sao_Paulo'. As duas asserções são obrigatórias e nenhuma delas é opcional: sem o fuso, o job dispara às 05:40 UTC, ou seja, 02:40 em Brasília, e os assinantes recebem de madrugada. - E2E: rota
POST /api/internal/test/clock.
Teste obrigatório de fuso do agendador: com o relógio em 2026-08-26T08:39:59Z
(05:39:59 local), o job ainda não rodou; em 2026-08-26T08:40:00Z, rodou. E um teste que
afirma que o cron não usa offset fixo: a configuração é lida e comparada com a string
'America/Sao_Paulo'; qualquer -03:00 no código de agendamento é proibido por regra de
lint.
Teste obrigatório de fuso do disparo, que é distinto do teste do agendador: com um lote
planejado e pronto, o relógio é avançado até 06:00:00 no fuso America/Sao_Paulo e o teste
afirma que nenhuma chamada ao provedor de gravação ocorreu antes desse instante e que a
primeira ocorreu entre 06:00:00 e 06:00:30 locais. O mesmo teste é repetido com o
processo configurado com TZ=UTC e exige resultado idêntico — é essa segunda execução que
pega a conversão feita pelo relógio do servidor em vez do fuso nomeado. Sem esses dois casos,
remover a espera entre o planejamento das 05:40 e o disparo das 06:00 passaria pela suíte
inteira sem nenhum teste vermelho, e dez mil pessoas receberiam às 05:41.
24.8.3 Testes de idempotência do lote #
| # | Teste | Afirmação |
|---|---|---|
| S-01 | Planejamento duplo sequencial | Planejar duas vezes para 2026-08-26 cria um send_batches e o mesmo conjunto de delivery_attempts |
| S-02 | Planejamento duplo concorrente | Duas chamadas em paralelo (Promise.all): uma vence, a outra encerra com BATCH_ALREADY_PLANNED; contagem final idêntica a S-01 |
| S-03 | Envio duplo do mesmo assinante | Dois jobs com a mesma send:{id}:{date}: uma mensagem enviada, registrada no provedor de gravação; o segundo job termina como ALREADY_SENT sem chamar o provedor |
| S-04 | Retry após timeout de rede | O provedor lança timeout depois de a Meta ter aceitado. O retry é feito com o mesmo delivery_attempt; a deduplicação impede terceira tentativa. O teste afirma no máximo 2 chamadas ao provedor e exatamente 1 message_logs com wamid |
| S-05 | Reinício do worker no meio do lote | Processar 100 jobs, matar o worker no 43º, reiniciar, retomar o lote: total de mensagens enviadas é exatamente 100, sem duplicata |
| S-06 | Chave estável entre reprocessamentos | O reenvio de falhas reutiliza a chave existente; nenhum delivery_attempts novo é criado |
| S-07 | Lote de data diferente | send:{id}:2026-08-26 e send:{id}:2026-08-27 coexistem; a chave inclui a data e não colide |
| S-08 | Falha parcial | 100 jobs, 10 falham com 500 da Meta: 90 SENT, 10 RETRY_SCHEDULED; o lote fica PARTIAL; o reenvio de falhas processa só os 10 |
| S-09 | Ordem e taxa | Com WHATSAPP_SEND_RATE_PER_SECOND=20, 200 jobs levam ao menos 10 s; o limitador do BullMQ é verificado por contagem de chamadas por segundo, com tolerância de ±10% |
| S-10 | Interruptor geral durante o lote | Ativar o interruptor da Seção 27.8 no meio: os jobs restantes terminam sem enviar e ficam HALTED; nenhum é perdido; desligar o interruptor e retomar completa o lote |
| S-11 | Áudio ausente | Assinante PAID cujo devocional não tem audio_assets: o texto é enviado, o áudio não, delivery_attempts.audio_status = 'MISSING' e um alerta é emitido |
| S-12 | Janela fecha entre plano e envio | Plano decidiu FREEFORM_BUNDLE; no envio a Meta devolve 131047: o motor reclassifica para template no mesmo dia, uma única vez, e registra a reclassificação |
| S-13 | Opt-out durante o lote | O item 43 de 100 recebe opt_out_at depois do planejamento e antes de o job dele rodar. O job termina como SKIPPED_OPTED_OUT, sem nenhuma chamada ao provedor, e o provedor de gravação registra exatamente 99 envios |
| S-14 | Revogação durante o lote | PAYMENT_OVERDUE é processado depois do planejamento e antes do job de um assinante planejado como PAID. O envio ocorre no formato gratuito, sem áudio, com delivery_attempts.downgraded_at preenchido e tier_at_send_effective = 'FREE'. O teste falha se qualquer chamada de envio de áudio for registrada |
| S-15 | Exclusão durante o lote | deleted_at é preenchido depois do planejamento. Nenhuma chamada ao provedor é feita para esse item, que termina como SKIPPED_INELIGIBLE |
| S-16 | Bloqueio durante o lote | blocked_at é preenchido depois do planejamento, por bloqueio administrativo ou por erro permanente da plataforma. O item termina como SKIPPED_INELIGIBLE, sem chamada ao provedor, e o assinante volta a receber assim que for desbloqueado |
Os quatro casos S-13 a S-16 existem porque tier_at_send é registro histórico e nunca
autoridade. O congelamento das 05:40, sem revalidação no disparo, concede na prática um dia
de carência a todo inadimplente — exatamente o que a regra dura da Seção 13.3 proíbe. A
revalidação é uma leitura por chave primária, abaixo de 1 ms, sobre um lote de no máximo
12.400 itens, e vale também para as varreduras de send.followup que vão até 23:55.
24.9 Testes de carga #
Ferramenta: k6. Executados contra staging, nunca contra produção, com o servidor de simulação no lugar dos externos (para medir o nosso sistema, não a latência da Meta).
| Cenário | Perfil de carga | Meta |
|---|---|---|
| C-01 Lote diário de 15.000 envios | Enfileirar 15.000 jobs de uma vez | Lote concluído em ≤ 20 min; p95 de tempo por job ≤ 900 ms; nenhuma duplicata; memória do worker estável (variação < 15% após aquecimento) |
| C-02 Rajada de webhooks da Asaas | 200 req/s por 60 s em /api/webhooks/asaas |
p99 < 2 s; 100% de 200; zero evento perdido; a fila de processamento drena em ≤ 5 min |
| C-03 Rajada de status da Meta | 500 req/s por 60 s em /api/webhooks/whatsapp (lotes de 50 status por requisição) |
p99 < 2 s; 100% de 200; message_logs consistente ao final |
| C-04 Landing page | 100 usuários virtuais por 5 min | p95 de TTFB < 300 ms; taxa de erro < 0,1% |
| C-05 API do painel | 200 usuários virtuais autenticados por 5 min em /api/devotionals e GET /api/me/subscription |
p95 < 400 ms; sem esgotar o pool do banco |
| C-06 Pico de checkout | 20 checkouts/s por 2 min | p95 < 1,5 s; nenhuma assinatura duplicada; nenhum deadlock no Postgres |
| C-07 Enxurrada de OTP | 50 pedidos/s de números distintos por 60 s | Limite por número respeitado; p95 < 500 ms; sem estouro de conexões Redis |
O que é medido em todos: latência (p50/p95/p99), taxa de erro por code, uso de CPU e
memória por contêiner, conexões ativas no Postgres, profundidade de fila no Redis, e tempo
de drenagem da fila após o fim da carga.
// tools/load/daily-batch.js
import http from 'k6/http';
import exec from 'k6/execution';
import { check, sleep } from 'k6';
import { Trend, Counter } from 'k6/metrics';
const enqueueLatency = new Trend('enqueue_latency_ms');
const duplicates = new Counter('duplicate_sends');
export const options = {
scenarios: {
burst: {
executor: 'shared-iterations',
vus: 50,
iterations: 15000,
maxDuration: '25m',
},
},
thresholds: {
// Latência de ENFILEIRAMENTO, medida no HTTP. Não é tempo por job.
'http_req_duration{expected_response:true}': ['p(95)<500'],
'http_req_failed': ['rate<0.001'],
'duplicate_sends': ['count==0'],
},
};
const BASE = __ENV.TARGET_BASE_URL;
const SECRET = __ENV.E2E_HOOK_SECRET;
export default function () {
// Índice global ao cenário. `__ITER` é por usuário virtual e, com 50 VUs, colide
// sistematicamente — o teste acusaria 14.700 "duplicatas" que ele mesmo criou.
const i = exec.scenario.iterationInTest;
const res = http.post(
`${BASE}/api/internal/test/enqueue-send`,
JSON.stringify({ subscriberIndex: i, devotionalDate: '2026-08-26' }),
{ headers: { 'Content-Type': 'application/json', 'x-e2e-secret': SECRET } },
);
enqueueLatency.add(res.timings.duration);
check(res, { 'aceito': (r) => r.status === 202 });
if (res.status === 409) duplicates.add(1);
sleep(0.01);
}Duas métricas distintas, dois limiares distintos. O http_req_duration acima mede apenas o
enfileiramento, que é o que a requisição HTTP faz. O tempo por job é uma métrica
própria, job_processing_ms, alimentada ao fim do cenário pela leitura da métrica de duração
de job exposta pelo worker, com limiar p(95)<900. Misturar as duas em um único limiar
mede a coisa errada e leva alguém a relaxar o limiar quando ele acusa. O critério de
aprovação exige, além disso, que a diferença entre o instante do último envio aceito e o do
primeiro fique abaixo de 1.200 s.
Critério de aprovação da bateria de carga: todos os thresholds verdes em duas execuções
consecutivas, com o relatório anexado ao pull request que altera o motor de envio, o
processamento de webhook ou o schema do banco.
24.10 Dados de teste #
24.10.1 Geração de assinantes sintéticos #
# 5.000 assinantes sintéticos: 70% FREE, 30% PAID, distribuição de engajamento realista
pnpm ops seed:synthetic --count=5000 --paid-ratio=0.30 --seed=20260825O gerador é determinístico: a mesma --seed produz exatamente o mesmo conjunto. Regras:
| Campo | Regra de geração |
|---|---|
phone_e164 |
Faixa reservada +5511990000000 a +5511999999999, atribuída sequencialmente. Nenhum número fora dessa faixa é gerado |
wa_id |
60% preenchido com o mesmo número sem +; 15% com a variante sem nono dígito; 25% nulo |
name |
Combinação de listas fixas de nomes e sobrenomes brasileiros comuns, indexadas pela sequência |
email |
assinante+{n}@exemplo.invalid — o TLD .invalid é reservado pela RFC 2606 e nunca resolve |
cpf |
Gerado com dígitos verificadores válidos a partir de uma base sequencial. Válido matematicamente, não emitido a ninguém |
tier / status |
Conforme --paid-ratio, com 5% em CANCELED com período futuro, 3% em EXPIRED, 2% com opt_out_at |
service_window_expires_at |
50% aberta (distribuída nas próximas 24 h), 35% fechada, 15% nula |
consecutive_window_misses |
Distribuição: 60% zero, 25% entre 1 e 2, 10% entre 3 e 6, 5% acima de 7 |
created_at |
Espalhado nos últimos 18 meses, para exercitar o acervo e a retenção |
24.10.2 Proibição de dados reais #
Regra absoluta, sem exceção e sem processo de aprovação:
- Nenhum dado pessoal de pessoa real entra em ambiente de desenvolvimento, teste, staging, fixture, gravação de contrato, log de CI, captura de tela ou anexo de issue. Isso inclui telefone, nome, e-mail, CPF, endereço e conteúdo de mensagem.
- Restauração de backup de produção em staging é proibida. Se for necessário reproduzir um problema com volume realista, usa-se o gerador sintético com a mesma cardinalidade. O procedimento de restauração da Seção 25.8.4 só existe para restaurar produção em produção.
- Investigação de caso individual é feita no ambiente de produção, por comando de
leitura do CLI de operação, com saída redigida e registro em
admin_audit_log. Nunca por cópia de dados para fora. - Números e faixas reservados: telefones sintéticos usam apenas
+5511990000000–+5511999999999; e-mails usam apenas@exemplo.invalid; a única exceção é o número de teste real da Meta, usado na bateria manual da Seção 24.11 e declarado na variável de ambienteWHATSAPP_ALLOWLISTdo ambiente de homologação. Números de teste não são chaves desettings: o catálogo desettingsé fechado na Seção 26.8.1 e não recebe entrada de QA. - O CI executa
pnpm test:fixtures:lint, que falha se qualquer arquivo sobtools/testing/contiver telefone brasileiro fora da faixa sintética, e-mail com TLD real, ou CPF que não venha do gerador determinístico.
24.11 Bateria manual obrigatória antes do lançamento #
Executada por pessoa, uma vez, com o resultado registrado em documents/ do repositório e
anexado ao pull request de lançamento. Nenhuma linha pode ficar em branco.
Recursos declarados para a bateria:
| Recurso | Valor | Onde fica configurado |
|---|---|---|
| Número de teste real (aparelho do operador) | Em E.164, na variável WHATSAPP_ALLOWLIST do ambiente de homologação |
Ambiente de staging |
| Número de teste fornecido pela Meta | O número de teste gratuito criado no aplicativo do WhatsApp Business Platform, associado à conta de desenvolvimento; também em WHATSAPP_ALLOWLIST |
Ambiente de staging |
| Conta sandbox da Asaas | Conta criada no ambiente sandbox, com chave de API própria em ASAAS_API_KEY do ambiente staging e URL base https://api-sandbox.asaas.com/v3 |
Segredos de staging |
| Cartão de teste da Asaas sandbox | O cartão de teste documentado pela Asaas para aprovação e o de recusa | Roteiro do teste |
O número de teste da Meta envia para no máximo 5 destinos cadastrados e não consome saldo, o que o torna adequado para os itens M-01 a M-12. Os itens M-13 a M-26 exigem, conforme o caso, a sandbox da Asaas, o ambiente de homologação ou o número de produção real, porque validam entrega, custo, qualidade e cadeia de alerta de verdade.
| # | Teste | Como | Critério de sucesso |
|---|---|---|---|
| M-01 | Recebimento do template diário | Disparar um envio individual usando o número de teste da Meta | O template chega no aparelho com header, corpo, footer e dois botões, todos legíveis, sem {{1}} cru |
| M-02 | Botão "Ler e ouvir agora" | Tocar no botão | O texto completo chega em até 10 s, com parágrafos preservados |
| M-03 | Áudio no aparelho | Continuar de M-02 com assinante PAID | O áudio chega como mensagem de voz (com forma de onda e player), não como arquivo anexo; toca do início ao fim; duração entre 3 e 6 min |
| M-04 | Áudio no iOS e no Android | Repetir M-03 em um aparelho de cada | Reprodução correta nos dois; sem erro de codec |
| M-05 | Botão "Depois" | Tocar em Depois |
Nenhuma mensagem adicional é enviada; o registro de SNOOZE aparece em inbound_messages |
| M-06 | Atalho de janela aberta | Responder qualquer coisa e disparar o envio do dia seguinte | Nenhum template é consumido; o pacote free-form chega direto |
| M-07 | Fallback de vídeo | Forçar consecutive_window_misses = 3 e disparar |
Chega o template com header de vídeo; o vídeo toca com áudio audível e a capa correta |
| M-08 | Opt-out | Responder SAIR |
Confirmação chega uma única vez; o envio seguinte não ocorre |
| M-09 | Reativação | Responder VOLTAR |
Mensagem de retorno chega; o envio seguinte ocorre |
| M-10 | OTP no WhatsApp | Solicitar login por OTP | Código de 6 dígitos chega em ≤ 30 s pelo template de autenticação; o botão de copiar código funciona |
| M-11 | Limite de OTP | Pedir 4 códigos em uma hora | O 4º é bloqueado com a mensagem correta |
| M-12 | Escrita de número não cadastrado | Enviar mensagem de um número desconhecido para o número do serviço | Resposta automática com instrução de cadastro; nenhum erro no log |
| M-13 | Checkout com cartão na sandbox | Assinar mensal com o cartão de teste de aprovação | Cobrança criada; webhook PAYMENT_CONFIRMED recebido; acesso liberado em ≤ 60 s |
| M-14 | Cartão recusado na sandbox | Repetir com o cartão de recusa | Mensagem de erro correta; nenhum acesso liberado |
| M-15 | PIX na sandbox | Assinar anual com PIX | QR Code renderiza e é legível por leitor de celular; após simular o pagamento na sandbox, o acesso libera em ≤ 60 s |
| M-16 | Inadimplência na sandbox | Marcar a cobrança como vencida na sandbox | Acesso revogado imediatamente; o painel exibe o aviso; o envio do dia seguinte é de tier FREE |
| M-17 | Cancelamento na sandbox | Cancelar pelo painel | Assinatura removida na Asaas; acesso mantido até o fim do período |
| M-18 | Envio real de produção com 5 pessoas | Com o número de produção aprovado, cadastrar 5 pessoas da equipe e rodar um lote real por 3 dias consecutivos | Taxa de entrega 100%; quality_rating permanece GREEN; custo por mensagem confere com a faixa esperada da Seção 17 |
| M-19 | Aprovação dos templates | Conferir no painel da Meta | Os oito templates — devocional_diario_v1, devocional_diario_video_v1, codigo_acesso_v1, boas_vindas_v1, lembrete_pagamento_v1, pagamento_confirmado_v1, acesso_encerrado_v1 e reativacao_v1 — com status registrado no banco igual ao exibido pela Meta, e categoria efetiva conferida. Ausência de qualquer um dos quatro últimos é documentada com o caminho alternativo ativo (Seção 28.9, item 5) |
| M-20 | Interruptor geral | Ligar o interruptor durante um lote de teste e depois desligar | Envios param em ≤ 5 s; nenhuma mensagem perdida; ao religar, o lote conclui |
| M-21 | Restauração de backup | Executar o ensaio da Seção 25.8.5 em máquina separada | Banco restaurado íntegro; contagens conferem; tempo dentro do previsto |
| M-22 | Acessibilidade do painel | Navegação por teclado e leitor de tela em /, /entrar, /assinar e /conta |
Todos os fluxos concluíveis; rótulos anunciados corretamente em português |
| M-23 | Cadeia de alerta ponta a ponta | Disparar um alerta sintético de severidade máxima em homologação e, em seguida, deixar de enviar o sinal de vida do lote por 15 minutos | A mensagem chega ao canal em até 60 s; o e-mail chega à lista de plantão em até 5 min; a ligação toca no número registrado em até 3 min e o responsável confirma o atendimento; e a ausência do sinal de vida gera alerta pelo serviço externo com o servidor desligado. Este último passo é executado com a máquina efetivamente parada, porque é o único cenário em que o monitor interno não pode ajudar. Alerta que vai para um canal criado no espaço de trabalho pessoal de quem configurou, ou para um número desativado, é alerta inexistente |
| M-24 | Supressão de alertas derivados | Derrubar o processo de trabalho em homologação | Os alertas derivados são suprimidos e apenas o alerta de origem chega ao canal |
| M-25 | Turnstile no formulário real | Abrir /cadastro e /contato em navegador real, com a política de conteúdo em modo de imposição, e completar um cadastro |
O widget anti-bot renderiza; o console do navegador fica sem nenhuma violação de política de conteúdo; o cadastro conclui. A verificação do lado do servidor alcança o destino externo sem bloqueio de egresso |
| M-26 | Bucket privado de verdade | Requisitar, sem assinatura, um objeto de áudio conhecido e um arquivo sob exports/ |
As duas requisições respondem 403. Nenhuma responde 200 nem 404 com corpo útil |
24.12 Definição de pronto e critérios de aceite de pull request #
24.12.1 Definição de pronto para uma tarefa #
Uma tarefa só é considerada pronta quando todos os itens abaixo são verdadeiros:
- O comportamento especificado está implementado, incluindo os casos de borda descritos na seção do documento que define a funcionalidade.
- Existem testes automatizados novos ou alterados cobrindo o comportamento, no nível correto da pirâmide (Seção 24.2).
pnpm test:allpassa localmente.- As metas de cobertura da Seção 24.2 são atendidas e a cobertura global não caiu.
- Erros novos estão catalogados na Seção 7 com
code, status HTTP e mensagem em português. - Textos visíveis ao usuário estão em português do Brasil e conferem com a Seção 10.
- Variáveis de ambiente novas estão registradas na Seção 26 e adicionadas ao
.env.example, com valor seguro por padrão. - Migrations, quando houver, são reversíveis ou seguem o procedimento de duas fases da Seção 25.11.
- Logs relevantes seguem o formato estruturado da Seção 23 e não contêm dado pessoal fora da política de redação.
- A documentação afetada no repositório (
README.mde comentários de contrato) está atualizada.
24.12.2 Critérios de aceite de um pull request #
Bloqueantes, verificados pelo CI:
| Verificação | Comando | Falha se |
|---|---|---|
| Formatação | pnpm format:check |
Qualquer arquivo fora do padrão |
| Lint | pnpm lint |
Qualquer erro ou aviso (--max-warnings=0) |
| Tipos | pnpm typecheck |
Qualquer erro de tipo em qualquer workspace |
| Unitários + cobertura | pnpm test:coverage |
Teste falhando ou meta de cobertura violada |
| Integração | pnpm test:integration |
Teste falhando |
| Contrato | pnpm test:contract |
Teste falhando ou gravação com mais de 150 dias |
| Build | pnpm build |
Build de web, worker ou ops falhando |
| Segredos | gitleaks detect |
Qualquer segredo detectado |
| Dependências | pnpm audit --audit-level=high e osv-scanner |
Vulnerabilidade high ou critical sem exceção declarada |
| E2E | pnpm test:e2e |
Qualquer cenário falhando (roda em main e em PRs que tocam apps/web) |
| Migration nova | verificação própria | Migration adicionada sem teste de integração correspondente |
| Módulo crítico | verificação própria | Alteração em qualquer arquivo listado na Seção 24.4 sem alteração no .test.ts correspondente |
Bloqueantes, verificados por pessoa:
- Uma aprovação de revisor humano. Pull request não é auto-aprovado.
- A descrição do pull request explica o que muda no comportamento observável, não só o que mudou no código.
- Nenhum
console.log,.only,.skip,@ts-ignoresem justificativa em comentário, nemanynovo sem comentário explicando por que o tipo não pode ser expresso. - Alterações no motor de envio, no processamento de webhook ou no schema do banco vêm com o relatório de carga da Seção 24.9 anexado.
- Alterações que afetam mensagens ao assinante vêm com captura de tela do WhatsApp real ou do simulador.
Limite de tamanho: pull request com mais de 800 linhas alteradas (excluindo lockfile, migrations geradas e gravações de contrato) recebe aviso automático pedindo divisão. Não é bloqueante, mas exige justificativa na descrição.
25. Infraestrutura, Deploy e CI/CD #
25.1 Topologia de referência #
Um único servidor virtual com Docker Compose atende o alvo de escala declarado na Seção 4.11. Não há Kubernetes, não há balanceador externo, não há serviço gerenciado de banco. A decisão é deliberada: o produto tem um pico previsível de 20 minutos por dia e o resto do tempo é quase ocioso. Complexidade operacional aqui só custaria dinheiro e tempo de resposta em incidente.
Internet
│
│ 443/tcp, 443/udp (HTTP/3), 80/tcp
▼
┌──────────────────────────────────────────────────────┐
│ VPS (Ubuntu LTS, Docker Engine, ufw) │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ rede: edge (bridge) │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ caddy │──────────▶ │ web │ │ │
│ │ │ :80/:443│ :3000 │ Next.js │ │ │
│ │ │ TLS auto│ │ (2 réplicas)│ │ │
│ │ └──────────┘ └──────┬───────┘ │ │
│ └──────────────────────────────────┼─────────────┘ │
│ │ │
│ ┌──────────────────────────────────┼─────────────┐ │
│ │ rede: backend (bridge, internal)│ │ │
│ │ │ │ │
│ │ ┌──────────────┐ ┌───────────▼──┐ │ │
│ │ │ worker │ │ postgres │ │ │
│ │ │ BullMQ + │──▶│ 17 │ │ │
│ │ │ Fastify :3001│ │ :5432 │ │ │
│ │ └──────┬───────┘ └──────┬───────┘ │ │
│ │ │ │ │ │
│ │ ▼ ▼ │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ redis │ │ pgbackrest │ │ │
│ │ │ 8 :6379 │ │ (auxiliar) │ │ │
│ │ └──────────────┘ └──────┬───────┘ │ │
│ └─────────────────────────────┼──────────────────┘ │
│ │ │
│ volumes: │ saída 443/tcp │
│ pg_data redis_data │ │
│ caddy_data caddy_config │ │
└────────────────────────────────┼─────────────────────┘
│
┌────────────────────┴─────────────────────┐
▼ ▼
┌───────────────────────┐ ┌──────────────────────────┐
│ Bucket de mídia │ │ Bucket de backup │
│ (S3-compatível, │ │ (S3-compatível, OUTRO │
│ região BR/SA) │ │ provedor/região) │
│ áudio OGG/MP3/MP4 │ │ pgBackRest + dumps │
└───────────────────────┘ └──────────────────────────┘
Saídas HTTPS do worker e do web:
graph.facebook.com api.asaas.com api.elevenlabs.io
texttospeech.googleapis.com api.resend.com <bucket de mídia>Contêineres em produção: caddy, web (2 réplicas), worker, postgres, redis,
pgbackrest. O ops não roda como serviço: é um binário dentro da imagem do worker,
invocado com docker compose exec worker ops <comando>.
Redes:
| Rede | Tipo | Quem participa | Observação |
|---|---|---|---|
edge |
bridge | caddy, web |
Única rede com portas publicadas no host |
backend |
bridge, internal: true |
web, worker, postgres, redis, pgbackrest |
Sem rota para a internet de entrada; a saída é feita pelo NAT do host |
Volumes:
| Volume | Conteúdo | Crescimento |
|---|---|---|
pg_data |
Diretório de dados do PostgreSQL | Ver Seção 25.2.4 |
redis_data |
AOF e RDB do Redis | < 1 GB |
caddy_data |
Certificados TLS e estado ACME | < 50 MB |
caddy_config |
Configuração autogerada do Caddy | < 10 MB |
pgbackrest_spool |
Fila assíncrona de WAL | < 2 GB |
25.2 Dimensionamento #
25.2.1 O que dimensiona o sistema #
O pico é o lote diário. Fora dele o sistema é quase ocioso. As grandezas relevantes:
- Envios no pico: para 10.000 assinantes (7.000 FREE, 3.000 PAID), um dia útil envia 3.000 mensagens; um domingo envia 3.000 + 7.000 = 10.000. O domingo é o pico real.
- Taxa de envio: limitada por configuração a 20 mensagens por segundo. 10.000 mensagens levam 500 s ≈ 8,3 min de chamadas à API, dentro da janela de 20 minutos da Seção 18.14.
- Concorrência de saída: 20 msg/s com latência média de 400 ms por chamada exige 8 requisições em voo. O worker usa concorrência 24 para ter folga com retries.
- Webhooks de entrada: cada mensagem enviada gera 2 a 3 webhooks de status. 10.000 envios geram até 30.000 webhooks, concentrados nos 15 minutos seguintes ao lote — aproximadamente 33 requisições por segundo de pico.
25.2.2 CPU #
| Componente | Consumo no pico | Base do cálculo |
|---|---|---|
worker |
~1,2 vCPU | 20 msg/s, cada envio ~25 ms de CPU (serializar, assinar, log, gravar); mais o consumo de processar 33 webhooks/s a ~8 ms cada |
web (2 réplicas) |
~0,9 vCPU | 33 webhooks/s a ~10 ms de CPU cada + tráfego web normal (pico de 40 req/s a ~15 ms) |
postgres |
~1,0 vCPU | ~250 escritas/s no pico (envios + status + logs), cada uma barata; mais o cálculo de métricas diário |
redis |
~0,2 vCPU | Fila com 10.000 jobs; operações O(1) |
caddy |
~0,2 vCPU | Terminação TLS de tráfego modesto |
| Sistema, Docker, backup | ~0,5 vCPU | |
| Total no pico | ~4,0 vCPU |
Recomendação: 4 vCPU para 10.000 assinantes, com o entendimento de que o pico dura menos de 15 minutos por dia e que picos curtos de 100% são aceitáveis. Para 50.000 assinantes o cálculo escala quase linearmente na parte de envio (50.000 mensagens no domingo a 20 msg/s levariam 42 min, acima da janela de 20 min), então a taxa sobe para 45 msg/s e o total vai para 8 vCPU.
25.2.3 Memória #
| Componente | 1.000 | 10.000 | 50.000 | Base |
|---|---|---|---|---|
postgres |
1,0 GB | 2,0 GB | 4,0 GB | shared_buffers = 25% da RAM da máquina; work_mem × conexões |
redis |
256 MB | 768 MB | 2,0 GB | 50.000 jobs × ~2 KB + limitadores e estado de disjuntor, com folga de 3× |
web × 2 |
1,0 GB | 1,4 GB | 2,0 GB | Next.js em produção estabiliza em ~500 MB por réplica com heap limitado |
worker |
512 MB | 1,0 GB | 1,5 GB | Concorrência 24, buffers de áudio de até 16 MB, --max-old-space-size ajustado |
caddy |
64 MB | 128 MB | 256 MB | |
pgbackrest |
128 MB | 256 MB | 512 MB | Compressão e upload |
| Sistema | 512 MB | 768 MB | 1,0 GB | |
| Folga (25%) | 0,9 GB | 1,7 GB | 2,9 GB | Evita OOM killer durante backup + lote simultâneos |
| Total | ~4,5 GB | ~8,0 GB | ~14,2 GB |
Recomendação: 4 GB para 1.000, 8 GB para 10.000, 16 GB para 50.000.
25.2.4 Disco #
Tabela dominante: message_logs. Estimativa por linha, incluindo índices: ~420 bytes.
| Perfil | Mensagens/dia | Linhas em 18 meses | message_logs |
Demais tabelas | Docker + logs | Total com folga |
|---|---|---|---|---|---|---|
| 1.000 | ~1.000 | ~547.000 | ~0,23 GB | ~0,3 GB | ~20 GB | 40 GB |
| 10.000 | ~10.000 (domingo) / ~3.000 (dia útil) | ~2,4 M | ~1,0 GB | ~1,5 GB | ~25 GB | 80 GB |
| 50.000 | ~50.000 / ~15.000 | ~12 M | ~5,0 GB | ~7 GB | ~35 GB | 160 GB |
Detalhamento da coluna "Docker + logs": imagens (web ~420 MB, worker ~380 MB, mais
Postgres, Redis, Caddy e as duas versões anteriores mantidas para rollback) ≈ 4 GB; logs
JSON rotacionados a 20 MB × 5 arquivos × 6 serviços ≈ 0,6 GB; volume de WAL local antes do
arquivamento ≈ 2 GB; espaço para dump lógico temporário ≈ 3 GB no perfil de 10.000. O
restante é folga do sistema operacional, que nunca deve passar de 80% de uso — o alerta de
disco da Seção 23 dispara em 75%.
Mídia não ocupa disco local: vai direto para o bucket. Volume de mídia: por devocional, 1 OGG (~1,4 MB), 1 MP3 (~4,5 MB) e 1 MP4 de fallback (~7 MB) ≈ 13 MB/dia ≈ 4,7 GB/ano, independente do número de assinantes.
25.2.5 Especificação recomendada #
| Assinantes | vCPU | RAM | Disco NVMe | Banda mensal estimada |
|---|---|---|---|---|
| 1.000 | 2 | 4 GB | 40 GB | ~200 GB |
| 10.000 | 4 | 8 GB | 80 GB | ~600 GB |
| 50.000 | 8 | 16 GB | 160 GB | ~2 TB |
Sistema operacional: Ubuntu Server LTS, 64 bits, com kernel padrão. Sistema de arquivos
ext4 com noatime. Swap de 2 GB configurado com vm.swappiness=10, apenas como rede de
segurança contra OOM durante backup.
25.3 docker-compose.yml de produção #
name: palavra-diaria
x-logging: &default-logging
driver: json-file
options:
max-size: "20m"
max-file: "5"
x-restart: &default-restart
restart: unless-stopped
services:
caddy:
<<: *default-restart
image: caddy:2-alpine
container_name: pd-caddy
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- edge
depends_on:
web:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:2019/config/"]
interval: 30s
timeout: 5s
retries: 3
logging: *default-logging
web:
<<: *default-restart
image: ${IMAGE_REGISTRY}/web:${IMAGE_TAG}
env_file:
- ./.env.production
environment:
NODE_ENV: production
PORT: "3000"
HOSTNAME: "0.0.0.0"
RUN_MIGRATIONS: "true"
NODE_OPTIONS: "--max-old-space-size=640"
networks:
- edge
- backend
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "/app/docker/healthcheck.js", "http://127.0.0.1:3000/api/internal/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 60s
deploy:
replicas: 2
resources:
limits:
cpus: "1.5"
memory: 768M
update_config:
order: start-first
stop_grace_period: 30s
logging: *default-logging
worker:
<<: *default-restart
image: ${IMAGE_REGISTRY}/worker:${IMAGE_TAG}
container_name: pd-worker
env_file:
- ./.env.production
environment:
NODE_ENV: production
WORKER_HTTP_PORT: "3001"
RUN_MIGRATIONS: "false"
NODE_OPTIONS: "--max-old-space-size=1024"
networks:
- backend
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
web:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "/app/docker/healthcheck.js", "http://127.0.0.1:3001/api/internal/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 40s
deploy:
resources:
limits:
cpus: "2.0"
memory: 1536M
# 120 s: tempo suficiente para o job em curso terminar sem duplicar envio.
stop_grace_period: 120s
stop_signal: SIGTERM
logging: *default-logging
postgres:
<<: *default-restart
image: postgres:17-alpine
container_name: pd-postgres
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_INITDB_ARGS: "--data-checksums --encoding=UTF8"
PGDATA: /var/lib/postgresql/data/pgdata
command:
- "postgres"
- "-c"
- "config_file=/etc/postgresql/postgresql.conf"
volumes:
- pg_data:/var/lib/postgresql/data
- ./postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro
- ./postgres/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro
- pgbackrest_spool:/var/spool/pgbackrest
- ./pgbackrest/pgbackrest.conf:/etc/pgbackrest/pgbackrest.conf:ro
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
shm_size: 512mb
deploy:
resources:
limits:
cpus: "2.0"
memory: 2560M
stop_grace_period: 60s
logging: *default-logging
redis:
<<: *default-restart
image: redis:8-alpine
container_name: pd-redis
command:
- "redis-server"
- "/usr/local/etc/redis/redis.conf"
- "--requirepass"
- "${REDIS_PASSWORD}"
volumes:
- redis_data:/data
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
networks:
- backend
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
cpus: "1.0"
memory: 1024M
logging: *default-logging
pgbackrest:
<<: *default-restart
image: ${IMAGE_REGISTRY}/pgbackrest:${IMAGE_TAG}
container_name: pd-pgbackrest
env_file:
- ./.env.production
volumes:
- pg_data:/var/lib/postgresql/data
- pgbackrest_spool:/var/spool/pgbackrest
- ./pgbackrest/pgbackrest.conf:/etc/pgbackrest/pgbackrest.conf:ro
networks:
- backend
depends_on:
postgres:
condition: service_healthy
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
logging: *default-logging
networks:
edge:
driver: bridge
backend:
driver: bridge
internal: true
volumes:
pg_data:
redis_data:
caddy_data:
caddy_config:
pgbackrest_spool:Decisões registradas neste arquivo:
- Nenhuma porta de banco ou Redis publicada no host. O acesso administrativo é feito por
túnel SSH combinado com
docker compose exec, nunca por exposição direta. A redebackendéinternal: true. webcomreplicas: 2eupdate_config.order: start-firstpermite troca sem queda: a nova réplica sobe e passa no health check antes de a antiga sair.stop_grace_period: 120snoworkeré o valor que garante que um job de envio em curso termine. UmSIGKILLno meio de um envio não duplicaria mensagem (a idempotência cobre), mas deixariadelivery_attemptsem estadoIN_FLIGHTque exigiria varredura.RUN_MIGRATIONS: "true"apenas noweb, conforme a decisão de aplicar migrations no start dowebcom lock. Oworkernunca aplica migration.shm_size: 512mbno Postgres evita erro de memória compartilhada em consultas com paralelismo, que a imagem padrão limita a 64 MB.
25.4 Dockerfile do web #
# syntax=docker/dockerfile:1.7
# ---------- base ----------
FROM node:24-alpine AS base
ENV PNPM_HOME="/pnpm" PATH="/pnpm:$PATH"
RUN corepack enable && apk add --no-cache libc6-compat tini
WORKDIR /app
# ---------- deps ----------
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
COPY apps/web/package.json ./apps/web/
COPY apps/worker/package.json ./apps/worker/
COPY apps/ops/package.json ./apps/ops/
COPY packages/db/package.json ./packages/db/
COPY packages/core/package.json ./packages/core/
COPY packages/integrations/package.json ./packages/integrations/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
# ---------- build ----------
FROM deps AS build
COPY . .
RUN pnpm --filter @palavra-diaria/db exec prisma generate
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm --filter @palavra-diaria/web build
# ---------- runtime ----------
FROM node:24-alpine AS runtime
RUN apk add --no-cache tini && \
addgroup -g 1001 -S nodejs && \
adduser -u 1001 -S nextjs -G nodejs
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
# Saída standalone do Next.js: apenas o necessário para executar.
COPY --from=build --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
# Migrations e cliente Prisma: o web aplica migrations no start.
COPY --from=build --chown=nextjs:nodejs /app/packages/db/prisma ./packages/db/prisma
COPY --from=build --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/prisma
COPY --chown=nextjs:nodejs docker/web-entrypoint.sh /app/docker/web-entrypoint.sh
COPY --chown=nextjs:nodejs docker/healthcheck.js /app/docker/healthcheck.js
RUN chmod +x /app/docker/web-entrypoint.sh
USER nextjs
EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--", "/app/docker/web-entrypoint.sh"]
CMD ["node", "apps/web/server.js"]25.5 Dockerfile do worker #
# syntax=docker/dockerfile:1.7
FROM node:24-alpine AS base
ENV PNPM_HOME="/pnpm" PATH="/pnpm:$PATH"
RUN corepack enable && apk add --no-cache libc6-compat tini
WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
COPY apps/worker/package.json ./apps/worker/
COPY apps/ops/package.json ./apps/ops/
COPY packages/db/package.json ./packages/db/
COPY packages/core/package.json ./packages/core/
COPY packages/integrations/package.json ./packages/integrations/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
FROM deps AS build
COPY . .
RUN pnpm --filter @palavra-diaria/db exec prisma generate
RUN pnpm --filter @palavra-diaria/worker build && \
pnpm --filter @palavra-diaria/ops build
# Descarta dependências de desenvolvimento antes de copiar para a imagem final.
RUN pnpm --filter @palavra-diaria/worker --prod deploy /tmp/worker-prod && \
pnpm --filter @palavra-diaria/ops --prod deploy /tmp/ops-prod
FROM node:24-alpine AS runtime
# ffmpeg é requisito do pipeline de áudio; é o único binário de SO necessário.
RUN apk add --no-cache tini ffmpeg curl && \
addgroup -g 1001 -S nodejs && \
adduser -u 1001 -S worker -G nodejs
WORKDIR /app
ENV NODE_ENV=production \
WORKER_HTTP_PORT=3001
COPY --from=build --chown=worker:nodejs /tmp/worker-prod ./
COPY --from=build --chown=worker:nodejs /tmp/ops-prod /opt/ops
COPY --from=build --chown=worker:nodejs /app/packages/db/prisma ./prisma
COPY --chown=worker:nodejs docker/worker-entrypoint.sh /app/docker/worker-entrypoint.sh
COPY --chown=worker:nodejs docker/healthcheck.js /app/docker/healthcheck.js
RUN chmod +x /app/docker/worker-entrypoint.sh && \
printf '#!/bin/sh\nexec node /opt/ops/dist/index.js "$@"\n' > /usr/local/bin/ops && \
chmod +x /usr/local/bin/ops
USER worker
EXPOSE 3001
ENTRYPOINT ["/sbin/tini", "--", "/app/docker/worker-entrypoint.sh"]
CMD ["node", "dist/index.js"]O binário ops é instalado em /usr/local/bin/ops dentro da imagem do worker. Todos os
runbooks da Seção 27 o invocam como docker compose exec worker ops <comando>.
25.6 Scripts de inicialização #
25.6.1 docker/web-entrypoint.sh #
#!/bin/sh
set -eu
log() { printf '{"level":"info","svc":"web-entrypoint","msg":"%s"}\n' "$1"; }
fail() { printf '{"level":"fatal","svc":"web-entrypoint","msg":"%s"}\n' "$1" >&2; exit 1; }
# Guarda dura: ganchos de teste nunca podem existir em produção.
if [ "${E2E_TEST_HOOKS:-false}" = "true" ] && [ "${NODE_ENV}" = "production" ] \
&& [ "${ALLOW_TEST_HOOKS_IN_PROD_BUILD:-false}" != "true" ]; then
fail "test hooks are forbidden in production"
fi
# Variáveis obrigatórias. Falhar no start é melhor do que falhar no primeiro pedido.
for v in DATABASE_URL REDIS_URL SESSION_JWT_PRIVATE_KEY ASAAS_API_KEY ASAAS_WEBHOOK_TOKEN \
META_APP_SECRET WHATSAPP_SYSTEM_USER_TOKEN WHATSAPP_VERIFY_TOKEN \
WHATSAPP_PHONE_NUMBER_ID ENCRYPTION_KEY PHONE_INDEX_KEY \
S3_BUCKET S3_ENDPOINT APP_URL PUBLIC_SITE_URL; do
eval "value=\${$v:-}"
[ -n "$value" ] || fail "missing required env var: $v"
done
if [ "${RUN_MIGRATIONS:-false}" = "true" ]; then
log "acquiring migration advisory lock"
# Lock de aplicação: garante que só uma réplica aplica migrations.
# A chave 8149021 é arbitrária, fixa e exclusiva deste sistema.
node -e '
const { Client } = require("pg");
const { execFileSync } = require("child_process");
(async () => {
const c = new Client({ connectionString: process.env.DATABASE_URL });
await c.connect();
const deadline = Date.now() + 120000;
for (;;) {
const { rows } = await c.query("SELECT pg_try_advisory_lock(8149021) AS ok");
if (rows[0].ok) break;
if (Date.now() > deadline) { console.error("migration lock timeout"); process.exit(1); }
await new Promise((r) => setTimeout(r, 2000));
}
try {
execFileSync("node", ["node_modules/prisma/build/index.js", "migrate", "deploy",
"--schema", "packages/db/prisma/schema.prisma"],
{ stdio: "inherit" });
} finally {
await c.query("SELECT pg_advisory_unlock(8149021)");
await c.end();
}
})().catch((e) => { console.error(e); process.exit(1); });
' || fail "migration failed"
log "migrations applied"
fi
log "starting web"
exec "$@"Os nomes acima são exatamente os nomes canônicos do registro de variáveis da Seção 26.3, que
é a dona. A chave privada de assinatura de sessão tem um nome, SESSION_JWT_PRIVATE_KEY;
SESSION_JWK_PRIVATE e JWT_PRIVATE_KEY são apelidos proibidos e não aparecem em lugar
nenhum. PHONE_INDEX_KEY é obrigatória desde o primeiro boot porque os índices cegos das
colunas cifradas existem desde a primeira migration (Seção 6.3).
25.6.2 docker/worker-entrypoint.sh #
#!/bin/sh
set -eu
fail() { printf '{"level":"fatal","svc":"worker-entrypoint","msg":"%s"}\n' "$1" >&2; exit 1; }
for v in DATABASE_URL REDIS_URL ASAAS_API_KEY WHATSAPP_SYSTEM_USER_TOKEN \
WHATSAPP_PHONE_NUMBER_ID ELEVENLABS_API_KEY ENCRYPTION_KEY PHONE_INDEX_KEY \
S3_BUCKET S3_ENDPOINT; do
eval "value=\${$v:-}"
[ -n "$value" ] || fail "missing required env var: $v"
done
command -v ffmpeg >/dev/null 2>&1 || fail "ffmpeg not found in image"
# O worker espera o schema estar na versão esperada. Ele não aplica migrations.
node dist/check-schema-version.js || fail "database schema is behind the expected version"
exec "$@"check-schema-version.js compara o último migration_name em _prisma_migrations com o
valor embutido na imagem no momento do build. Se o worker subir antes de o web aplicar a
migration, ele falha e reinicia até que a versão bata. Isso evita o pior caso de deploy:
worker novo escrevendo em schema antigo.
25.6.3 docker/healthcheck.js #
const url = process.argv[2];
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 4000);
fetch(url, { signal: ac.signal })
.then((r) => { clearTimeout(timer); process.exit(r.ok ? 0 : 1); })
.catch(() => { clearTimeout(timer); process.exit(1); });25.6.4 Comandos do CLI de operação #
O ops é a interface única de operação. Todo comando registra execução em job_runs com
ator, argumentos redigidos, resultado e duração.
| Comando | Efeito |
|---|---|
ops health [--strict] |
Verifica banco, Redis, Meta, Asaas, TTS e storage; devolve JSON e código de saída |
ops send:plan --date=<YYYY-MM-DD> [--dry-run] [--only-channel=<canal>] |
Planeja o lote de uma data |
ops send:resume --batch=<id> [--limit=N] |
Retoma um lote interrompido |
ops send:retry-failed --batch=<id> [--code=<erro>] |
Reenfileira apenas as falhas |
ops send:one --subscriber=<id> --date=<YYYY-MM-DD> [--force --reason=<texto>] |
Envia para um único assinante |
ops send:audio-catchup --date=<YYYY-MM-DD> |
Entrega o áudio pendente a quem está com a janela aberta |
ops send:abandon --batch=<id> --reason=<texto> |
Encerra um lote sem enviar o restante |
ops kill-switch:on --reason=<texto> / off [--no-resume] / status |
Interruptor geral (Seção 27.8) |
ops queue:stats [--queue=<nome>] [--all] |
Contagem por estado de cada fila |
ops queue:active --queue=<nome> --older-than=<segundos> |
Jobs ativos há muito tempo |
ops queue:repeatable --queue=<nome> |
Agendamentos repetíveis registrados |
ops queue:unstall --queue=<nome> |
Libera jobs travados |
ops queue:dead:list --queue=<nome> [--limit=N] [--group-by=code] |
Lista o trabalho morto: jobs no estado failed da fila com linha job_runs.status = 'DEAD'. Não existe fila .dlq separada (Seção 18.8) |
ops queue:dead:replay --queue=<nome> (--id=<jobId> | --code=<código> | --all) [--dry-run] [--force] |
Reprocessa trabalho morto a partir de job_runs |
ops queue:dead:purge --queue=<nome> --older-than=<dias> --reason=<texto> |
Descarta registros de trabalho morto antigos |
ops queue:recover [--all] |
Reenfileira o que ficou pendente no banco |
ops billing:reconcile [--since=<data>] [--subscription=<id>] [--fix] [--dry-run] [--verbose] |
Reconciliação Asaas × banco |
ops billing:replay-events (--since=<ISO> | --event-id=<id>) |
Reprocessa payment_events persistidos |
ops billing:pause-all --reason=<texto> |
Suspende cobranças recorrentes |
ops subscription:extend-all --days=N |
Estende current_period_end de todos os ativos |
ops subscription:force-activate --id=<id> --until=<data> --reason=<texto> |
Libera acesso manualmente, com auditoria |
ops subscriber:inspect --phone=<E.164> |
Estado completo de um assinante, com dados redigidos |
ops subscriber:entitlements --id=<id> |
Resultado de resolveEntitlements |
ops subscriber:suppress --code=<erro> --batch=<id> |
Marca falhas permanentes como suprimidas |
ops devotional:transition --id=<id> --to=<estado> / devotional:publish --id=<id> / devotional:reuse --source=<id> --for-date=<data> |
Operações editoriais de emergência |
ops tts:generate --devotional=<id> [--force] [--provider=primary|fallback] / ops tts:script --devotional=<id> --stats |
Áudio |
ops media:reupload --devotional=<id> |
Reenvia mídia à Meta e atualiza o identificador |
ops template:sync / ops template:submit --name=<novo> --from=<existente> |
Templates da Meta |
ops whatsapp:status / ops whatsapp:token-info |
Qualidade, limites e validade do token |
ops storage:check |
Testa list, put e get no bucket |
ops circuit:status / ops circuit:reset --name=<integração> |
Estado e reset de disjuntor |
ops backup:run --type=full|diff / ops backup:verify [--post-restore|--drill] / ops backup:restore --to=<ISO> |
Backup e restauração |
ops db:create-index-concurrently --name=<idx> --sql=<comando> |
Criação de índice fora da migration |
ops retention:run [--dry-run] |
Aplica a política de retenção |
ops metrics:recompute --date=<YYYY-MM-DD> |
Recalcula daily_metrics |
ops consent:export --sample=N --format=csv |
Evidência de opt-in para apelação |
ops config:set --key=<chave> --value=<valor> |
Ajusta configuração dinâmica em settings |
ops secrets:audit |
Confere permissões, presença e validade dos segredos |
ops deploy:mark --tag=<tag> --phase=start|end [--result=<status>] |
Marcação de deploy |
ops dr:preflight |
Confere os pré-requisitos de recuperação de desastre |
ops seed:synthetic --count=N --paid-ratio=R --seed=S / ops seed:e2e |
Dados sintéticos (apenas fora de produção) |
ops seed:synthetic e ops seed:e2e recusam executar se NODE_ENV=production, sem opção
de forçar.
25.7 Rede, domínios e firewall #
25.7.1 Domínios #
| Domínio | Destino | Uso |
|---|---|---|
palavradiaria.com.br |
web |
Landing page, páginas públicas, política de privacidade e termos |
www.palavradiaria.com.br |
redirecionamento 301 para o apex | Evita conteúdo duplicado |
app.palavradiaria.com.br |
web |
Painel do assinante e painel administrativo |
api.palavradiaria.com.br |
web |
Webhooks da Asaas e da Meta e integrações máquina a máquina |
Decisão: os três subdomínios apontam para o mesmo contêiner web. A separação existe para
isolar política de cookie, cabeçalhos de segurança e regras de limite de taxa, e para
permitir mover a API para outra máquina no futuro sem trocar URL de webhook.
O cookie de sessão usa o prefixo __Host-, que obriga Secure, Path=/ e proíbe o
atributo Domain. Consequência prática: a sessão emitida em app.palavradiaria.com.br não
é enviada para api.palavradiaria.com.br. Por isso as rotas de API consumidas pelo painel
são servidas sob app.palavradiaria.com.br/api/*, e api.palavradiaria.com.br é reservado
exclusivamente para webhooks de entrada e integrações que usam token próprio e não dependem
de cookie. Esta é uma decisão de arquitetura de domínio; o contrato das rotas é da Seção 7.
TLS: certificados emitidos e renovados automaticamente pelo Caddy via ACME. Renovação começa
30 dias antes do vencimento. HSTS com max-age=31536000, includeSubDomains e preload.
25.7.2 Caddyfile #
{
email ops@palavradiaria.com.br
admin 127.0.0.1:2019
servers {
protocols h1 h2 h3
trusted_proxies static private_ranges
}
log {
output stdout
format json
level INFO
}
}
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()"
Cross-Origin-Opener-Policy "same-origin"
-Server
-X-Powered-By
}
}
(common) {
import security_headers
encode zstd gzip
request_body {
max_size 5MB
}
log {
output stdout
format json
}
}
# ---------- Landing pública ----------
palavradiaria.com.br {
import common
reverse_proxy web:3000 {
lb_policy round_robin
health_uri /api/internal/health
health_interval 10s
health_timeout 3s
fail_duration 30s
transport http {
dial_timeout 3s
response_header_timeout 20s
}
}
}
www.palavradiaria.com.br {
import security_headers
redir https://palavradiaria.com.br{uri} permanent
}
# ---------- Painéis ----------
app.palavradiaria.com.br {
import common
# A politica de conteudo NAO e definida aqui. Ela carrega um nonce por
# requisicao e varia por rota, o que um cabecalho estatico nao consegue
# expressar. Quem a emite e a aplicacao, com os valores exatos da Secao 22.3.1.
# Duplicar a politica no proxy criaria uma segunda verdade que diverge na
# primeira mudanca e bloqueia silenciosamente o verificador anti-bot ou o
# player de audio.
# Prontidão só com token do monitor externo; a matriz de dependências,
# com latências e estado de disjuntor, nunca é pública.
@ready {
path /api/internal/ready
header Authorization Bearer*
}
handle @ready {
reverse_proxy web:3000
}
# Vivacidade é pública e devolve apenas {"status":"ok"}.
@health path /api/internal/health
handle @health {
rate_limit {
zone health {
key {remote_host}
events 60
window 1m
}
}
reverse_proxy web:3000
}
# Todo o restante de /api/internal/* nao existe para a internet.
@internal path /api/internal/*
handle @internal {
respond 404
}
handle {
reverse_proxy web:3000 {
lb_policy round_robin
health_uri /api/internal/health
health_interval 10s
}
}
}
# ---------- Webhooks e integração máquina a máquina ----------
api.palavradiaria.com.br {
import common
# Webhooks precisam do corpo cru para validação de assinatura.
# Nenhuma reescrita, nenhuma transformação de corpo.
@webhooks path /api/webhooks/*
handle @webhooks {
reverse_proxy web:3000 {
transport http {
dial_timeout 3s
response_header_timeout 5s
}
}
}
# Prontidão só com token do monitor externo; a matriz de dependências,
# com latências e estado de disjuntor, nunca é pública.
@ready {
path /api/internal/ready
header Authorization Bearer*
}
handle @ready {
reverse_proxy web:3000
}
# Vivacidade é pública e devolve apenas {"status":"ok"}.
@health path /api/internal/health
handle @health {
rate_limit {
zone health {
key {remote_host}
events 60
window 1m
}
}
reverse_proxy web:3000
}
# Todo o restante de /api/internal/* nao existe para a internet.
@internal path /api/internal/*
handle @internal {
respond 404
}
handle {
reverse_proxy web:3000 {
lb_policy round_robin
health_uri /api/internal/health
health_interval 10s
}
}
}Quatro decisões registradas no arquivo:
/api/internal/*devolve404na borda, sem chegar à aplicação, com duas exceções nomeadas. É defesa em profundidade: mesmo que os ganchos de teste da Seção 24.7.1 fossem habilitados por engano, não haveria caminho de rede até eles —/api/internal/test/*nunca é publicado, em nenhuma circunstância. A primeira exceção é/api/internal/health, público e limitado a 60 requisições por minuto por endereço, que devolve exclusivamente{"status":"ok"}— sem versão, sem nome de serviço e sem tempo de atividade, porque esses três campos revelam exatamente quando cada implantação ocorre. A segunda é/api/internal/ready, que só é roteado quando o monitor externo apresentaAuthorization: Bearer <METRICS_TOKEN>; sem o token, o caminho responde404como o restante. Motivo: a matriz de prontidão expõe o estado de degradação de cada provedor, e um atacante que a consulte de minuto em minuto sabe o instante exato em que uma campanha de mensagem falsa sobre falha de pagamento seria mais convincente.- Webhooks têm
response_header_timeoutde 5 s, contra os 20 s do resto. Asaas e Meta desistem antes disso; segurar a conexão só piora. request_body max_size 5MB, exatamente o valor deWEBHOOK_MAX_BODY_BYTES(5242880) declarado no registro da Seção 26.3 e no teto de corpo de webhook da Seção 7.14. O proxy é o teto externo e a aplicação é o teto interno, e o externo nunca é menor que o interno: se fosse, um corpo aceito pela aplicação seria recusado na borda por um componente que a Seção 12 não sabe que existe, e o provedor receberia413sem explicação. Nenhuma rota legítima recebe corpo maior; o upload de mídia editorial é feito por URL assinada direto para o bucket, sem passar pelo servidor. O webhook que exceder esse tamanho recebe413, medido antes da leitura do corpo — é a única resposta não-2xxpermitida em webhook autenticado (Seção 7.15.1). As rotas gerais continuam limitadas aMAX_BODY_BYTES(256 KB) e as editoriais aMAX_BODY_BYTES_ADMIN(1 MB), ambas aplicadas dentro da aplicação.- A política de segurança de conteúdo não é emitida pelo proxy. Ela carrega um nonce por requisição e varia por rota, e um cabeçalho estático não expressa nenhuma das duas coisas. Quem a emite é a aplicação, com os valores exatos da Seção 22.3.1, que é a dona. Os demais cabeçalhos de segurança, que são constantes, continuam no proxy.
25.7.3 Firewall #
# Regras aplicadas no host, uma única vez, na provisão.
ufw default deny incoming
ufw default allow outgoing
ufw allow from <IP_DE_OPERACAO_1> to any port 22 proto tcp comment 'SSH operacao 1'
ufw allow from <IP_DE_OPERACAO_2> to any port 22 proto tcp comment 'SSH operacao 2'
ufw allow 80/tcp comment 'HTTP (redirect + ACME)'
ufw allow 443/tcp comment 'HTTPS'
ufw allow 443/udp comment 'HTTP/3'
ufw --force enable| Porta | Protocolo | Origem | Motivo |
|---|---|---|---|
| 22 | tcp | Apenas IPs de operação declarados | Administração |
| 80 | tcp | Qualquer | Desafio ACME e redirecionamento para HTTPS |
| 443 | tcp | Qualquer | HTTPS |
| 443 | udp | Qualquer | HTTP/3 (QUIC) |
| 5432, 6379, 3000, 3001, 2019 | — | Nenhuma | Não publicadas no host |
Armadilha conhecida e sua correção obrigatória: o Docker escreve regras diretamente na
cadeia DOCKER-USER do iptables e contorna o ufw. Uma porta publicada com
ports: "5432:5432" fica acessível na internet mesmo com ufw deny. Duas proteções são
aplicadas em conjunto:
- Nenhum serviço além do
caddypublica portas — é o que o arquivo da Seção 25.3 faz. - Regra explícita na cadeia
DOCKER-USER, aplicada na provisão e verificada semanalmente pelo job de conformidade:
iptables -I DOCKER-USER -i eth0 ! -s 127.0.0.1 -p tcp --dport 5432 -j DROP
iptables -I DOCKER-USER -i eth0 ! -s 127.0.0.1 -p tcp --dport 6379 -j DROP
iptables -I DOCKER-USER -i eth0 ! -s 127.0.0.1 -p tcp --dport 3000 -j DROP
iptables -I DOCKER-USER -i eth0 ! -s 127.0.0.1 -p tcp --dport 3001 -j DROP
netfilter-persistent saveAcesso SSH: apenas chave, PasswordAuthentication no, PermitRootLogin no, usuário
deploy com comando restrito para o CI e usuário nominal para operação humana.
fail2ban ativo com a jail padrão de SSH.
Saída: irrestrita, porque o worker precisa alcançar Meta, Asaas, TTS, storage e e-mail, e todos esses serviços usam faixas de IP variáveis. Isso é registrado como decisão consciente; a alternativa (lista branca de destinos) geraria incidentes de entrega sem ganho real de segurança para este produto.
25.8 PostgreSQL em produção #
25.8.1 postgres/postgresql.conf #
Valores calculados para a máquina de 4 vCPU / 8 GB do perfil de 10.000 assinantes. Para
16 GB, dobrar shared_buffers, effective_cache_size e maintenance_work_mem.
listen_addresses = '*'
port = 5432
max_connections = 120
# --- Memoria ---
shared_buffers = 2GB # 25% da RAM da maquina
effective_cache_size = 5GB # estimativa do cache do SO + shared_buffers
work_mem = 12MB # por operacao de ordenacao
maintenance_work_mem = 512MB
huge_pages = try
# --- Escrita ---
wal_level = replica # exigido pelo arquivamento de WAL
max_wal_size = 4GB
min_wal_size = 1GB
checkpoint_completion_target = 0.9
wal_compression = zstd
synchronous_commit = on # dinheiro envolvido; nao relaxar
# --- Arquivamento (backup continuo) ---
archive_mode = on
archive_command = 'pgbackrest --stanza=palavra-diaria archive-push %p'
archive_timeout = 60s # limita a perda maxima a 60 s de WAL
# --- Planejador (disco NVMe) ---
random_page_cost = 1.1
effective_io_concurrency = 200
default_statistics_target = 200
# --- Paralelismo ---
max_worker_processes = 4
max_parallel_workers = 4
max_parallel_workers_per_gather = 2
max_parallel_maintenance_workers = 2
# --- Autovacuum: tabelas de log crescem rapido ---
autovacuum = on
autovacuum_max_workers = 3
autovacuum_naptime = 30s
autovacuum_vacuum_scale_factor = 0.05
autovacuum_analyze_scale_factor = 0.02
autovacuum_vacuum_cost_limit = 2000
# --- Observabilidade ---
log_destination = 'stderr'
logging_collector = off # o Docker coleta o stderr
log_min_duration_statement = 500ms
log_checkpoints = on
log_connections = off
log_disconnections = off
log_lock_waits = on
log_temp_files = 10MB
log_autovacuum_min_duration = 1s
log_line_prefix = '%m [%p] %q%u@%d %a '
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 5000
pg_stat_statements.track = top
# --- Seguranca de sessao ---
statement_timeout = 15s # nenhuma consulta de aplicacao passa disso
idle_in_transaction_session_timeout = 30s
lock_timeout = 5s
timezone = 'UTC'statement_timeout = 15s é limite global. Os jobs de manutenção que legitimamente demoram
mais (recálculo de métricas, retenção, VACUUM manual) elevam o valor na própria sessão com
SET LOCAL statement_timeout = '10min'. Isso força cada consulta lenta a ser uma decisão
explícita.
postgres/pg_hba.conf aceita apenas scram-sha-256 a partir da rede backend:
local all all scram-sha-256
host all all 172.16.0.0/12 scram-sha-256
host all all 127.0.0.1/32 scram-sha-25625.8.2 Pool de conexões #
Sem pgbouncer. O número de processos é conhecido e pequeno; um pool intermediário
adicionaria um ponto de falha e complicaria transações. A decisão é revista se o número de
réplicas de web passar de 4.
Distribuição de max_connections = 120:
| Consumidor | Conexões | Cálculo |
|---|---|---|
web réplica 1 |
15 | connection_limit=15 na URL do Prisma |
web réplica 2 |
15 | |
worker (pool principal) |
30 | Concorrência 24 + folga |
worker (pool de manutenção) |
5 | Jobs longos, isolados do pool principal |
ops (execuções manuais) |
10 | |
pgbackrest |
3 | |
| Reservado ao superusuário | 3 | superuser_reserved_connections |
| Folga | 39 | Absorve reinício com sobreposição de réplicas |
URL do Prisma em produção:
postgresql://app:<senha>@postgres:5432/palavra_diaria?schema=public&connection_limit=15&pool_timeout=10&connect_timeout=5&application_name=web-1application_name distinto por processo é obrigatório: é o que permite identificar em
pg_stat_activity qual componente está segurando conexões durante o runbook R-14.
O usuário da aplicação (app) não é superusuário e não é dono do schema. Ele tem
SELECT, INSERT, UPDATE, DELETE nas tabelas e USAGE nas sequências. O dono do
schema é o usuário migrator, usado exclusivamente pelo passo de migration. Um DROP TABLE
acidental por parte da aplicação é impossível por permissão, não por disciplina.
25.8.3 Backup #
Duas camadas independentes, com destinos diferentes.
Camada 1 — pgBackRest (recuperação a ponto no tempo).
# pgbackrest/pgbackrest.conf
[global]
repo1-type=s3
repo1-path=/palavra-diaria
repo1-s3-bucket=pd-backups
repo1-s3-endpoint=<endpoint-do-provedor-de-backup>
repo1-s3-region=<regiao>
repo1-s3-key=<lido de /run/secrets/backup_s3_key>
repo1-s3-key-secret=<lido de /run/secrets/backup_s3_secret>
repo1-s3-uri-style=path
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=<lido de /run/secrets/backup_cipher_pass>
repo1-retention-full=5
repo1-retention-full-type=count
repo1-retention-diff=6
repo1-bundle=y
repo1-block=y
compress-type=zst
compress-level=6
process-max=2
start-fast=y
archive-async=y
archive-push-queue-max=2GB
spool-path=/var/spool/pgbackrest
log-level-console=info
log-level-file=detail
[palavra-diaria]
pg1-path=/var/lib/postgresql/data/pgdata
pg1-host=postgres
pg1-port=5432
pg1-user=backupNenhum valor de credencial existe em arquivo versionado. O arquivo acima é um gabarito e
contém apenas os marcadores mostrados. O script de inicialização gera a configuração efetiva
em /etc/pgbackrest/pgbackrest.conf, modo 0400, dono root, substituindo cada marcador
pelo conteúdo do arquivo de segredo correspondente montado em /run/secrets/, conforme a
regra "repositório: nenhum segredo, nunca" da Seção 22.4.1. Duas exigências adicionais: a
credencial do bucket de backup tem permissão apenas de escrita e leitura, sem exclusão e
sem alteração de política, para que o comprometimento da aplicação não permita apagar os
backups; e a frase de cifra tem cópia em cofre offline, porque perdê-la torna todo backup
irrecuperável. O detector de segredos do pipeline recebe uma regra própria para os padrões
repo1-s3-key, repo1-s3-key-secret e repo1-cipher-pass, porque nenhum deles casa com as
regras genéricas do detector: sem essa regra, um valor colado por engano entra no repositório
sem que nada reprove o commit, e quem tiver leitura no repositório passa a ter a credencial
do bucket de backup e a frase que decifra os backups — ou seja, a base inteira, incluindo
tudo o que a criptografia de campo deveria proteger.
Cronograma (cron do host, chamando docker compose exec):
| Quando | Comando | O que faz |
|---|---|---|
| Domingo 02:00 | ops backup:run --type=full |
Backup completo |
| Segunda a sábado 02:00 | ops backup:run --type=diff |
Backup diferencial |
| Contínuo | archive_command do Postgres |
Envia cada segmento de WAL; archive_timeout=60s força um segmento por minuto mesmo sem escrita |
| Diariamente 02:40 | ops backup:verify |
Verificação do repositório e integridade do último backup |
Retenção de 35 dias, obtida por construção: repo1-retention-full=5 mantém 5 backups
completos semanais. O mais antigo tem 4 semanas (28 dias) e os diferenciais e WAL
associados a ele são preservados até o completo seguinte expirar, o que cobre a 5ª semana.
O intervalo garantido de recuperação a ponto no tempo é, portanto, de 35 dias.
Camada 2 — dump lógico diário.
# 03:10, cron do host
docker compose exec -T postgres \
pg_dump -U app -d palavra_diaria -Fc -Z0 --no-owner --no-privileges \
| zstd -T2 -9 \
| age -r "$BACKUP_AGE_RECIPIENT" \
> "/tmp/pd-$(date -u +%Y%m%dT%H%M%SZ).dump.zst.age"
rclone move /tmp/pd-*.dump.zst.age backup-remote:pd-backups/logical/ \
--immutable --s3-no-check-bucketO dump lógico é criptografado com age usando uma chave pública cuja chave privada
não está no servidor: ela é guardada no cofre de segredos da organização e em cópia
física offline. Um invasor com acesso total ao servidor pode apagar dados, mas não consegue
ler os backups lógicos que já saíram. A retenção do bucket lógico também é 35 dias, aplicada
por política de ciclo de vida do bucket, com bloqueio de objeto ativado para impedir
exclusão antecipada.
O bucket de backup fica em provedor diferente do bucket de mídia e do provedor do VPS. Isso é decisão explícita: um comprometimento de conta ou um incidente de provedor não pode levar dados e backups juntos.
25.8.4 Restauração #
Restauração completa para o ponto mais recente (usada em desastre):
# 1. Parar tudo que escreve.
docker compose stop web worker
# 2. Restaurar. O diretorio de dados e substituido.
docker compose run --rm pgbackrest \
pgbackrest --stanza=palavra-diaria --delta restore
# 3. Subir o Postgres. Ele reproduz o WAL ate o fim do arquivo.
docker compose up -d postgres
docker compose logs -f postgres | grep -m1 "database system is ready to accept connections"
# 4. Conferir integridade antes de liberar trafego.
docker compose exec -T worker ops backup:verify --post-restore
# 5. Religar a aplicacao.
docker compose up -d web workerRestauração a ponto no tempo (usada após erro humano, por exemplo um UPDATE sem
WHERE):
docker compose stop web worker
docker compose run --rm pgbackrest \
pgbackrest --stanza=palavra-diaria --delta \
--type=time --target="2026-08-25 04:55:00-03" \
--target-action=promote restore
docker compose up -d postgresTempos esperados, medidos no ensaio e revalidados a cada trimestre:
| Perfil | Tamanho do banco | Restauração do backup | Reprodução de WAL | Verificação | Total |
|---|---|---|---|---|---|
| 1.000 assinantes | ~0,8 GB | ~2 min | ~1 min | ~1 min | ~4 min |
| 10.000 assinantes | ~3 GB | ~7 min | ~3 min | ~2 min | ~12 min |
| 50.000 assinantes | ~14 GB | ~25 min | ~8 min | ~4 min | ~37 min |
Esses tempos são para restauração na mesma máquina, com o repositório de backup acessível. A recuperação em máquina nova soma o tempo de provisão, coberto no RTO da Seção 25.16.
25.8.5 Ensaio de restauração trimestral obrigatório #
A cada 90 dias, sem exceção, o procedimento completo é executado em uma máquina descartável. Nunca em produção, nunca com o repositório de produção montado como gravável.
# 1. Provisionar maquina temporaria e clonar o repositorio de deploy.
# 2. Restaurar o ultimo backup completo com credencial somente leitura.
pgbackrest --stanza=palavra-diaria --repo1-s3-key="$READONLY_KEY" \
--pg1-path=/tmp/restore-drill --delta restore
# 3. Subir um Postgres apontando para o diretorio restaurado e validar.
docker compose -f docker-compose.drill.yml up -d postgres-drill
docker compose -f docker-compose.drill.yml exec worker ops backup:verify --drillops backup:verify --drill executa e registra:
- Contagem de linhas das 10 tabelas principais, comparada com o valor esperado gravado no último backup.
SELECT max(created_at) FROM message_logs— precisa estar dentro da janela de RPO.- Verificação de integridade referencial em 20 amostras aleatórias de
subscriptionsepayments. prisma migrate status— o schema restaurado precisa estar na mesma versão da aplicação corrente.- Medição do tempo total, comparada com a tabela da Seção 25.8.4. Desvio acima de 50% abre incidente de capacidade.
O resultado é gravado em job_runs com job_name = 'backup.restore_drill'. Um alerta de
severidade alta é emitido quando now() - último sucesso > 100 dias. A máquina de ensaio é
destruída ao final; o comando de destruição faz parte do procedimento e é verificado no
mesmo registro.
25.9 Redis #
25.9.1 redis/redis.conf #
bind 0.0.0.0
protected-mode yes
port 6379
timeout 0
tcp-keepalive 300
# --- Persistencia ---
# AOF e a fonte primaria; RDB serve de snapshot rapido para reinicio.
appendonly yes
appendfilename "appendonly.aof"
appenddirname "appendonlydir"
appendfsync everysec
no-appendfsync-on-rewrite no
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
aof-use-rdb-preamble yes
save 900 1
save 300 10
save 60 10000
rdbcompression yes
rdbchecksum yes
stop-writes-on-bgsave-error yes
# --- Memoria ---
maxmemory 768mb
maxmemory-policy noeviction
maxmemory-samples 5
# --- Seguranca ---
rename-command FLUSHALL ""
rename-command FLUSHDB ""
rename-command CONFIG "CONFIG_b4f7a2"
# --- Latencia ---
latency-monitor-threshold 100
slowlog-log-slower-than 20000
slowlog-max-len 256maxmemory-policy noeviction é obrigatório e não negociável. BullMQ guarda o estado dos
jobs em Redis. Qualquer política de despejo descartaria jobs silenciosamente sob pressão de
memória, e o sintoma seria "algumas pessoas não receberam o devocional", sem erro em lugar
nenhum. Com noeviction, a escrita falha em voz alta e o alerta dispara. FLUSHALL e
FLUSHDB são desabilitados por renome para tornar impossível o comando destrutivo digitado
por engano em produção.
25.9.2 O que o Redis guarda #
| Chave / estrutura | Conteúdo | Criticidade se perdido |
|---|---|---|
| Filas BullMQ | Jobs pendentes, ativos, atrasados, falhos | Alta, mas recuperável (ver abaixo) |
ratelimit:otp:{phone} |
Contadores de envio de OTP | Baixa: o limite reinicia |
ratelimit:api:{ip} |
Limite de taxa das rotas públicas | Baixa |
circuit:{integração} |
Estado do disjuntor (Seção 27.3) | Baixa: reabre fechado |
killswitch:global |
Interruptor geral (Seção 27.8) | Média: ver nota |
test:clock:offset |
Deslocamento de relógio (fora de produção) | Irrelevante |
cache:devotional:{date} |
Cache de leitura do devocional do dia, TTL 1 h | Baixa |
lock:{recurso} |
Locks de curta duração | Baixa |
25.9.3 O que acontece se o Redis perder dados #
Perda total do Redis é um evento degradado, não catastrófico, porque nenhuma verdade de negócio vive nele. A recuperação:
- Jobs de envio.
delivery_attemptsno Postgres é a fonte da verdade sobre o que foi enviado. Após perder o Redis, a retomada do lote recria os jobs apenas para as tentativas que não estão em estado terminal. A chave única impede envio duplicado, então a operação é segura mesmo que o Redis tivesse jobs em voo. - Jobs de TTS. Recriados para os devocionais em
AUDIO_PENDING. - Eventos de pagamento.
payment_eventsestá no Postgres; o reprocessamento cobre os não processados. - Jobs repetíveis. Recriados automaticamente no start do
worker, que registra os agendamentos de forma idempotente. - Limites de taxa. Reiniciam do zero. Um assinante conseguiria pedir alguns OTPs a mais naquela hora. Aceitável.
- Interruptor geral. Aqui há um risco real: se o interruptor estivesse ligado e o Redis
fosse perdido, o sistema voltaria enviando. Por isso o estado do interruptor é gravado
também em
settingssob a chaveops.kill_switch, e o Redis é apenas o cache de leitura rápida. No start, o worker lêsettingse repopula o Redis. A decisão é registrada na Seção 27.8.
Após qualquer perda de Redis, o procedimento obrigatório é o runbook R-18 da Seção 27.6.
25.10 Storage de mídia #
| Item | Decisão |
|---|---|
| Serviço | Bucket S3-compatível, com região na América do Sul quando disponível |
| Nome | pd-media (produção), pd-media-staging (staging) |
| Acesso público | Bloqueado. Nenhum objeto é público, não há listagem anônima e não existe URL previsível. Toda leitura é por URL assinada com TTL de 15 minutos. A variável S3_PUBLIC_BASE_URL (Seção 26.3.7) aponta exclusivamente para o domínio de mídia que fica na frente do bucket e que exige assinatura; ela nunca é a URL direta do bucket |
| Criptografia | Em repouso, gerenciada pelo provedor; em trânsito, TLS obrigatório |
| Credenciais | Uma chave por ambiente, com política mínima: s3:PutObject, s3:GetObject, s3:DeleteObject, s3:ListBucket restritos ao próprio bucket |
| Versionamento | Ativado, com expiração de versões não correntes em 30 dias |
| CORS | Apenas https://app.palavradiaria.com.br, métodos GET e HEAD |
Estrutura de chaves (definida no pipeline de áudio da Seção 16):
devotionals/{YYYY}/{MM}/{devotionalId}/{voice}.ogg
devotionals/{YYYY}/{MM}/{devotionalId}/{voice}.mp3
devotionals/{YYYY}/{MM}/{devotionalId}/{voice}-fallback.mp4
devotionals/{YYYY}/{MM}/{devotionalId}/cover.jpg
tmp/{uploadId}/{arquivo}
exports/{subscriberId}/{ulid}.jsonPolítica de ciclo de vida:
| Prefixo | Regra | Motivo |
|---|---|---|
tmp/ |
Excluir 1 dia após a criação | Arquivos intermediários de transcodificação |
exports/ |
Excluir 7 dias após a criação | Exportações LGPD são baixadas de dentro do painel, sob sessão plena, por URL assinada de 15 minutos gerada no clique (Seção 22.7.4). O prefixo é isolado e nunca alcançável pelo domínio de mídia público |
devotionals/**/*.mp4 |
Mover para classe de acesso infrequente após 60 dias | O MP4 de fallback quase nunca é lido depois da semana do envio |
devotionals/**/*.ogg e *.mp3 |
Mover para classe de acesso infrequente após 365 dias; nunca excluir | O acervo completo é entitlement do plano pago |
| Uploads multiparte incompletos | Abortar após 7 dias | Evita cobrança por lixo invisível |
Backup do storage: sincronização diária às 03:40 para um segundo bucket, em provedor diferente, com o mesmo esquema de chaves.
rclone sync media-remote:pd-media backup-remote:pd-media-mirror \
--transfers 8 --checkers 16 \
--immutable \
--exclude "tmp/**" --exclude "exports/**" \
--log-level INFO --stats 1m--immutable faz o comando falhar em vez de sobrescrever um objeto que mudou, o que
transforma corrupção ou sobrescrita maliciosa em alerta em vez de propagação silenciosa. O
espelho tem retenção indefinida e bloqueio de exclusão.
Teste de fumaça pós-implantação, obrigatório e bloqueante. Depois de cada deploy, o
pipeline requisita um objeto conhecido do bucket sem assinatura e falha se a resposta não
for 403; repete a requisição pelo domínio de mídia, também sem assinatura, e exige 403;
e requisita um objeto sob exports/ pelo domínio de mídia, exigindo 403. Um bucket que
responde 200 a qualquer uma dessas três chamadas torna o acervo pago gratuito e permanente
para quem descobrir o padrão de chave, e expõe as exportações de portabilidade.
Perda do storage de mídia não perde conteúdo editorial: devotionals no Postgres tem todo o
texto, e o áudio é regenerável. O custo de regenerar 365 devocionais é da ordem de uma
diária de TTS, não um desastre.
25.11 Migrations em produção #
25.11.1 Quando rodam e com que lock #
Migrations são aplicadas por prisma migrate deploy no start do contêiner web, protegido
por um lock consultivo do PostgreSQL (pg_try_advisory_lock(8149021)), como mostra o script
da Seção 25.6.1. Com duas réplicas de web, apenas uma aplica; a outra espera até 120 s e
então segue, porque a migration já terá sido aplicada pela primeira. O worker verifica a
versão do schema e recusa subir se estiver atrás.
Ordem de subida no deploy, garantida pelas dependências do arquivo da Seção 25.3:
postgres e redis saudáveis → web (aplica migration, passa no health check) →
worker (valida a versão do schema).
25.11.2 Regras de compatibilidade #
Toda migration precisa ser compatível com a versão de aplicação anterior, porque durante
a troca com start-first as duas versões coexistem por alguns segundos. Consequências
obrigatórias:
- Coluna nova é sempre
NULLou temDEFAULT. NuncaNOT NULLsem default no mesmo passo. - Coluna renomeada é feita como adição + cópia + remoção, em releases diferentes.
- Índice em tabela grande é criado com
CREATE INDEX CONCURRENTLY, fora do fluxo automático (ver abaixo), porqueCONCURRENTLYnão roda dentro de transação. ALTER TABLE ... ADD CONSTRAINTde chave estrangeira usaNOT VALIDprimeiro eVALIDATE CONSTRAINTdepois, para não travar a tabela.- Nenhuma migration executa
UPDATEem tabela grande. Backfill é comando de operação, com lotes e pausas.
25.11.3 Migrations destrutivas em duas fases #
Remover uma coluna ou tabela é sempre feito em duas releases separadas por, no mínimo, uma semana de operação estável.
Exemplo concreto: remover subscribers.consecutive_window_misses, depois que a métrica
de dias sem interação migrar para daily_metrics (Seção 21). A coluna existe de verdade no
schema da Seção 6.3, o que torna o exemplo executável como está.
Fase 1 — release N (a coluna para de ser usada, mas continua existindo):
-- migration: 20260901T120000_deprecate_consecutive_window_misses
COMMENT ON COLUMN subscribers.consecutive_window_misses IS
'DEPRECATED em 2026-09-01. Remocao prevista para 2026-09-15. Nao escrever.';Nenhuma alteração estrutural. O código da release N deixa de ler e de escrever a coluna. Um gatilho temporário registra qualquer escrita remanescente, o que prova que nada mais a usa:
CREATE OR REPLACE FUNCTION warn_window_misses_write() RETURNS trigger AS $$
BEGIN
IF NEW.consecutive_window_misses IS DISTINCT FROM OLD.consecutive_window_misses THEN
RAISE WARNING 'consecutive_window_misses still being written for subscriber %', NEW.id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_warn_window_misses
BEFORE UPDATE ON subscribers
FOR EACH ROW EXECUTE FUNCTION warn_window_misses_write();Fase 2 — release N+1, no mínimo 7 dias depois, com zero avisos no log:
-- migration: 20260915T120000_drop_consecutive_window_misses
DROP TRIGGER IF EXISTS trg_warn_window_misses ON subscribers;
DROP FUNCTION IF EXISTS warn_window_misses_write();
ALTER TABLE subscribers DROP COLUMN consecutive_window_misses;Antes da fase 2, um dump lógico da tabela afetada é feito e guardado por 90 dias:
docker compose exec -T postgres \
pg_dump -U app -d palavra_diaria -Fc -t subscribers \
| age -r "$BACKUP_AGE_RECIPIENT" > pre-drop-subscribers-20260915.dump.ageÍndices concorrentes seguem o mesmo padrão de duas fases, mas por motivo diferente: a migration registra o índice como aplicado sem criá-lo, e a criação real é um comando de operação executado em janela de baixa carga.
docker compose exec worker ops db:create-index-concurrently \
--name=idx_message_logs_created_at \
--sql="CREATE INDEX CONCURRENTLY idx_message_logs_created_at ON message_logs (created_at DESC)"O comando monitora pg_stat_progress_create_index e aborta se passar de 30 minutos.
25.11.4 Verificação de privilégio pós-migration #
GRANT ... ON ALL TABLES concede privilégio apenas às tabelas existentes no momento do
comando. Toda tabela criada por uma migration posterior nasce sem privilégio para app_user,
e o sintoma — permission denied for table ... seguido de 500 — aparece horas depois do
deploy, longe da causa. Os ALTER DEFAULT PRIVILEGES da Seção 22.12.2 resolvem o caso
normal; esta verificação é a rede de proteção e roda antes de o tráfego ser trocado:
SELECT c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relkind = 'r'
AND NOT has_table_privilege('app_user', c.oid, 'SELECT');Qualquer linha devolvida aborta o deploy e dispara rollback da imagem. A mesma consulta é
repetida para INSERT, UPDATE e DELETE. O comando ops db:check executa essa
verificação e é chamado pelo passo de conferência da Seção 25.12.3.
25.11.5 Rollback de migration #
Prisma não gera rollback automático, e isso é tratado como característica, não como falta. A regra é: rollback de migration é rollback de dados, e rollback de dados é restauração.
| Situação | Ação |
|---|---|
| Migration aditiva (coluna nova, tabela nova, índice novo) causou problema | Rollback da imagem para a versão anterior. A estrutura extra é inerte. Nenhuma ação no banco |
| Migration alterou tipo ou restrição e a aplicação antiga não funciona | Rollback da imagem + migration de compensação escrita à mão e aplicada como nova migration. Nunca editar migration já aplicada |
| Migration destrutiva na fase 2 causou perda | Restauração a ponto no tempo para o instante imediatamente anterior (Seção 25.8.4), com perda declarada das escritas do intervalo. É por isso que a fase 2 é feita em janela de baixa escrita, às 03:00 |
| Migration falhou no meio | prisma migrate deploy é transacional por migration: ou aplica inteira ou nenhuma. O web não passa no health check e o deploy é abortado automaticamente pelo passo de verificação da Seção 25.12 |
Migration marcada como aplicada mas com falha parcial (situação possível quando a migration
contém comando não transacional) é resolvida com
prisma migrate resolve --rolled-back <nome> seguido de correção manual, sempre com o banco
em janela de manutenção.
25.12 Estratégia de deploy #
25.12.1 Princípios #
- A imagem é imutável e identificada pelo commit. Nada é construído no servidor.
- Uma versão por tag Git. Tag
v1.4.0produz imagensweb:v1.4.0eworker:v1.4.0, além deweb:sha-<commit curto>. Tags móveis nunca são usadas no arquivo de composição. - Nada é trocado antes de passar no health check. O tráfego só muda depois que a nova
réplica responde
200em/api/internal/healthe depois que a verificação de privilégio de tabela da Seção 25.11.4 devolve zero linhas. - Rollback é troca de tag. Sempre há pelo menos as duas versões anteriores presentes no registro e no disco do servidor.
- Deploy nunca acontece entre 05:00 e 07:00 de São Paulo. É a janela do lote diário. O
pipeline recusa deploy nesse intervalo, salvo com
force_deploy: trueexplícito.
25.12.2 Etiquetagem de imagem #
| Tag | Quando | Uso |
|---|---|---|
sha-a1b2c3d |
Todo commit em main |
Rastreabilidade e deploy em staging |
v1.4.0 |
Tag Git semântica | Deploy em produção |
PREVIOUS_TAG no arquivo .env.previous |
Gravada antes de cada deploy | Rollback imediato |
O IMAGE_TAG efetivo fica em /opt/palavra-diaria/.env.deploy no servidor, e o arquivo de
composição o lê. Trocar de versão é trocar uma linha desse arquivo e subir.
25.12.3 Procedimento de deploy #
#!/usr/bin/env bash
# deploy/deploy.sh — executado no servidor pelo CI, via SSH.
set -euo pipefail
NEW_TAG="$1"
cd /opt/palavra-diaria
# 1. Recusar a janela do lote diario, exceto com FORCE_DEPLOY=true.
HOUR="$(TZ=America/Sao_Paulo date +%H)"
if [ "${FORCE_DEPLOY:-false}" != "true" ] && [ "$HOUR" -ge 5 ] && [ "$HOUR" -lt 7 ]; then
echo "recusado: janela do lote diario (05:00-07:00 America/Sao_Paulo)" >&2
exit 2
fi
# 2. Guardar a versao atual para rollback.
CURRENT_TAG="$(grep -E '^IMAGE_TAG=' .env.deploy | cut -d= -f2)"
echo "PREVIOUS_TAG=${CURRENT_TAG}" > .env.previous
# 3. Baixar as imagens novas antes de qualquer troca.
IMAGE_TAG="$NEW_TAG" docker compose pull web worker pgbackrest
# 4. Verificacao de sanidade da imagem, fora do trafego.
docker run --rm --env-file ./.env.production \
--network palavra-diaria_backend \
"${IMAGE_REGISTRY}/worker:${NEW_TAG}" node dist/check-schema-version.js --dry-run
# 5. Trocar a tag e subir. start-first sobe a replica nova antes de derrubar a antiga.
sed -i "s/^IMAGE_TAG=.*/IMAGE_TAG=${NEW_TAG}/" .env.deploy
docker compose up -d --no-deps --wait --wait-timeout 180 web
# 6. Verificacao pos-troca do web.
for i in $(seq 1 30); do
if curl -fsS -m 5 https://app.palavradiaria.com.br/api/internal/health | grep -q '"status":"ok"'; then
break
fi
[ "$i" -eq 30 ] && { echo "health check falhou apos deploy" >&2; ./deploy/rollback.sh; exit 1; }
sleep 5
done
# 7. Trocar o worker apenas depois de o web estar saudavel (o schema ja esta migrado).
docker compose up -d --no-deps --wait --wait-timeout 180 worker
# 8. Verificacao de privilegio de tabela (Secao 25.11.4). Tabela nova sem GRANT
# derruba a aplicacao horas depois, longe da causa.
docker compose exec -T worker ops db:check --privileges || { ./deploy/rollback.sh; exit 1; }
# 9. Verificacao funcional profunda.
docker compose exec -T worker ops health --strict || { ./deploy/rollback.sh; exit 1; }
# 10. Limpeza: manter as tres ultimas versoes de imagem.
docker image prune -f --filter "until=168h" >/dev/null
echo "deploy concluido: ${CURRENT_TAG} -> ${NEW_TAG}"ops health --strict verifica, com código de saída diferente de zero em qualquer falha:
conexão com Postgres e latência de consulta simples abaixo de 50 ms; conexão com Redis;
GET /{PHONE_NUMBER_ID} na Meta devolvendo o número esperado; GET /v3/finance/balance na
Asaas devolvendo 200; HEAD em um objeto conhecido do bucket, e 403 no mesmo objeto sem
assinatura; presença dos oito templates com o status registrado; registro do job
repetível plan-daily-batch com o padrão cron e o fuso America/Sao_Paulo; versão do
schema igual à esperada; e ausência de job em estado active há mais de 10 minutos.
25.12.4 Rollback #
#!/usr/bin/env bash
# deploy/rollback.sh
set -euo pipefail
cd /opt/palavra-diaria
source .env.previous
echo "revertendo para ${PREVIOUS_TAG}"
sed -i "s/^IMAGE_TAG=.*/IMAGE_TAG=${PREVIOUS_TAG}/" .env.deploy
docker compose up -d --no-deps --wait --wait-timeout 180 web worker
docker compose exec -T worker ops health --strict
echo "rollback concluido para ${PREVIOUS_TAG}"Comando exato para rollback manual, digitado por uma pessoa em incidente:
ssh deploy@<servidor> '/opt/palavra-diaria/deploy/rollback.sh'Se a versão anterior não puder ser usada porque a migration nova é incompatível (situação rara, coberta pela regra de compatibilidade da Seção 25.11.2), o rollback de código é acompanhado de restauração a ponto no tempo. Essa combinação exige janela de manutenção declarada e segue o runbook R-16.
25.12.5 Janela de manutenção #
Deploy normal não tem janela: a troca com start-first é transparente. Janela declarada
é necessária apenas para: migration destrutiva de fase 2, restauração de banco, mudança de
versão maior do PostgreSQL e migração de servidor.
Janela padrão: terça-feira, 02:00 às 03:30 (America/Sao_Paulo). Escolhida por ser o horário de menor tráfego e por deixar mais de 2 horas de folga até o lote das 05:40. Aviso aos assinantes só é dado se a janela puder afetar o envio (ver Seção 27.7).
Durante a janela, o Caddy serve uma página estática de manutenção:
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile.maintenanceO Caddyfile.maintenance mantém /api/webhooks/* funcionando (Asaas e Meta não podem
receber erro, sob pena de pausa de fila) e devolve 503 com corpo HTML nas demais rotas.
Os webhooks continuam persistindo eventos; o processamento espera a fila religar.
25.13 Pipelines de CI/CD #
25.13.1 .github/workflows/ci.yml #
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: '24'
PNPM_VERSION: '10'
jobs:
setup:
name: Instalar dependências
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @palavra-diaria/db exec prisma generate
- name: Guardar dependências instaladas
uses: actions/cache/save@v4
with:
path: |
node_modules
apps/*/node_modules
packages/*/node_modules
packages/db/generated
key: deps-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
static:
name: Lint, formatação e tipos
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 12
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: '10' }
- uses: actions/setup-node@v4
with: { node-version: '24', cache: pnpm }
- uses: actions/cache/restore@v4
with:
path: |
node_modules
apps/*/node_modules
packages/*/node_modules
packages/db/generated
key: deps-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
fail-on-cache-miss: true
- run: pnpm format:check
- run: pnpm lint
- run: pnpm typecheck
unit:
name: Testes unitários e cobertura
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: '10' }
- uses: actions/setup-node@v4
with: { node-version: '24', cache: pnpm }
- uses: actions/cache/restore@v4
with:
path: |
node_modules
apps/*/node_modules
packages/*/node_modules
packages/db/generated
key: deps-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
fail-on-cache-miss: true
- run: pnpm test:coverage
- name: Comparar cobertura com a linha de base
run: node tools/ci/check-coverage-delta.js
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: coverage/
retention-days: 14
integration:
name: Testes de integração e contrato
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 25
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: palavra_diaria_test
options: >-
--health-cmd "pg_isready -U test"
--health-interval 10s
--health-timeout 5s
--health-retries 10
ports: ['5432:5432']
redis:
image: redis:8-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports: ['6379:6379']
env:
DATABASE_URL: postgresql://test:test@127.0.0.1:5432/palavra_diaria_test?schema=public
REDIS_URL: redis://127.0.0.1:6379
USE_EXTERNAL_SERVICES: 'false'
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: '10' }
- uses: actions/setup-node@v4
with: { node-version: '24', cache: pnpm }
- uses: actions/cache/restore@v4
with:
path: |
node_modules
apps/*/node_modules
packages/*/node_modules
packages/db/generated
key: deps-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
fail-on-cache-miss: true
- run: pnpm --filter @palavra-diaria/db exec prisma migrate deploy
- run: pnpm test:integration
- run: pnpm test:contract
- name: Verificar idade das gravações de contrato
run: node tools/ci/check-cassette-age.js --warn-days=100 --fail-days=150
build:
name: Build das imagens
needs: [static, unit]
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
run: echo "tag=sha-$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: Build e push do web
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/web/Dockerfile
push: ${{ github.event_name == 'push' }}
tags: ghcr.io/${{ github.repository }}/web:${{ steps.meta.outputs.tag }}
cache-from: type=gha,scope=web
cache-to: type=gha,mode=max,scope=web
provenance: true
sbom: true
- name: Build e push do worker
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/worker/Dockerfile
push: ${{ github.event_name == 'push' }}
tags: ghcr.io/${{ github.repository }}/worker:${{ steps.meta.outputs.tag }}
cache-from: type=gha,scope=worker
cache-to: type=gha,mode=max,scope=worker
provenance: true
sbom: true
e2e:
name: Testes E2E
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: palavra_diaria_e2e
options: >-
--health-cmd "pg_isready -U test" --health-interval 10s --health-retries 10
ports: ['5432:5432']
redis:
image: redis:8-alpine
options: --health-cmd "redis-cli ping" --health-interval 10s --health-retries 5
ports: ['6379:6379']
env:
DATABASE_URL: postgresql://test:test@127.0.0.1:5432/palavra_diaria_e2e?schema=public
REDIS_URL: redis://127.0.0.1:6379
E2E_TEST_HOOKS: 'true'
E2E_HOOK_SECRET: ci-e2e-secret
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: '10' }
- uses: actions/setup-node@v4
with: { node-version: '24', cache: pnpm }
- uses: actions/cache/restore@v4
with:
path: |
node_modules
apps/*/node_modules
packages/*/node_modules
packages/db/generated
key: deps-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
fail-on-cache-miss: true
- run: pnpm --filter @palavra-diaria/web exec playwright install --with-deps chromium
- run: pnpm --filter @palavra-diaria/db exec prisma migrate deploy
- run: pnpm --filter @palavra-diaria/web build
- run: pnpm test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: apps/web/playwright-report/
retention-days: 14
security:
name: Varredura de segurança
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: pnpm/action-setup@v4
with: { version: '10' }
- uses: actions/setup-node@v4
with: { node-version: '24', cache: pnpm }
- name: Segredos no histórico
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Vulnerabilidades de dependência
run: pnpm audit --audit-level=high --prod
- name: Varredura OSV
uses: google/osv-scanner-action@v1
with:
scan-args: |-
--lockfile=./pnpm-lock.yaml
--format=sarif
--output=osv.sarif
- uses: github/codeql-action/upload-sarif@v3
if: always()
with: { sarif_file: osv.sarif }
- name: Higiene de fixtures e gravações de contrato
run: |
node tools/ci/check-fixtures.js
node tools/ci/check-cassettes-redaction.js
gate:
name: Portão do pull request
if: always()
needs: [static, unit, integration, build, e2e, security]
runs-on: ubuntu-latest
steps:
- name: Falhar se qualquer job anterior falhou
run: |
echo '${{ toJSON(needs) }}' | node -e '
const needs = JSON.parse(require("fs").readFileSync(0, "utf8"));
const bad = Object.entries(needs).filter(([, v]) => v.result !== "success");
if (bad.length) {
console.error("jobs com falha:", bad.map(([k]) => k).join(", "));
process.exit(1);
}
console.log("todos os jobs passaram");
'O job gate é o único marcado como verificação obrigatória na proteção de branch. Isso
evita ter que atualizar a configuração do repositório toda vez que um job novo é adicionado.
25.13.2 .github/workflows/deploy.yml #
name: Deploy
on:
push:
tags: ['v*.*.*']
workflow_dispatch:
inputs:
image_tag:
description: 'Tag da imagem a implantar (ex.: v1.4.0 ou sha-a1b2c3d)'
required: true
environment:
description: 'Ambiente'
required: true
type: choice
options: [staging, production]
force_deploy:
description: 'Ignorar a janela protegida do lote diário'
required: false
type: boolean
default: false
concurrency:
group: deploy-${{ github.event.inputs.environment || 'production' }}
cancel-in-progress: false
jobs:
build-release:
name: Construir e publicar imagens da release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
file: ./apps/web/Dockerfile
push: true
tags: |
ghcr.io/${{ github.repository }}/web:${{ github.ref_name }}
ghcr.io/${{ github.repository }}/web:sha-${{ github.sha }}
cache-from: type=gha,scope=web
provenance: true
sbom: true
- uses: docker/build-push-action@v6
with:
context: .
file: ./apps/worker/Dockerfile
push: true
tags: |
ghcr.io/${{ github.repository }}/worker:${{ github.ref_name }}
ghcr.io/${{ github.repository }}/worker:sha-${{ github.sha }}
cache-from: type=gha,scope=worker
provenance: true
sbom: true
deploy-staging:
name: Implantar em staging
needs: [build-release]
if: always() && (needs.build-release.result == 'success' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
timeout-minutes: 20
environment:
name: staging
url: https://app.staging.palavradiaria.com.br
steps:
- uses: actions/checkout@v4
- name: Configurar chave SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Implantar
env:
TAG: ${{ github.event.inputs.image_tag || github.ref_name }}
run: |
ssh -i ~/.ssh/id_ed25519 "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"FORCE_DEPLOY=true /opt/palavra-diaria/deploy/deploy.sh ${TAG}"
- name: Verificação de fumaça
run: |
set -e
curl -fsS -m 10 https://app.staging.palavradiaria.com.br/api/internal/health | grep -q '"status":"ok"'
curl -fsS -m 10 https://staging.palavradiaria.com.br/ | grep -q 'Palavra Diária'
ssh -i ~/.ssh/id_ed25519 "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"cd /opt/palavra-diaria && docker compose exec -T worker ops health --strict"
deploy-production:
name: Implantar em produção
needs: [deploy-staging]
runs-on: ubuntu-latest
timeout-minutes: 25
# A aprovação manual vem da configuração do ambiente 'production':
# revisores obrigatórios + espera mínima de 5 minutos + branches permitidas.
environment:
name: production
url: https://palavradiaria.com.br
steps:
- uses: actions/checkout@v4
- name: Configurar chave SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Registrar início do deploy
run: |
ssh -i ~/.ssh/id_ed25519 "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"cd /opt/palavra-diaria && docker compose exec -T worker \
ops deploy:mark --tag=${{ github.event.inputs.image_tag || github.ref_name }} --phase=start"
- name: Implantar
env:
TAG: ${{ github.event.inputs.image_tag || github.ref_name }}
FORCE: ${{ github.event.inputs.force_deploy || 'false' }}
run: |
ssh -i ~/.ssh/id_ed25519 "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"FORCE_DEPLOY=${FORCE} /opt/palavra-diaria/deploy/deploy.sh ${TAG}"
- name: Verificação de fumaça em produção
run: |
set -e
curl -fsS -m 10 https://app.palavradiaria.com.br/api/internal/health | grep -q '"status":"ok"'
curl -fsS -m 10 https://api.palavradiaria.com.br/api/internal/health | grep -q '"status":"ok"'
curl -fsS -m 10 https://palavradiaria.com.br/ | grep -q 'Palavra Diária'
- name: Rollback automático em caso de falha
if: failure()
run: |
ssh -i ~/.ssh/id_ed25519 "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"/opt/palavra-diaria/deploy/rollback.sh"
- name: Registrar fim do deploy
if: always()
run: |
ssh -i ~/.ssh/id_ed25519 "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"cd /opt/palavra-diaria && docker compose exec -T worker \
ops deploy:mark --tag=${{ github.event.inputs.image_tag || github.ref_name }} --phase=end --result=${{ job.status }}"A aprovação manual não é um passo do YAML: é a configuração do ambiente production no
repositório, com revisores obrigatórios (no mínimo uma pessoa que não seja quem abriu o
deploy), espera mínima de 5 minutos e restrição de branch à main e a tags v*.
Isso é mais confiável do que uma etapa de aprovação escrita no arquivo, porque não pode ser
contornada editando o YAML no mesmo pull request.
25.13.3 Pipelines auxiliares #
| Arquivo | Gatilho | O que faz |
|---|---|---|
.github/workflows/nightly.yml |
Diário às 03:00 UTC | Roda a suíte completa contra main, incluindo E2E no perfil móvel; abre issue automática em caso de falha |
.github/workflows/deps.yml |
Semanal, segunda 06:00 UTC | Abre pull request com atualizações de dependência agrupadas por linha major; não atualiza major automaticamente |
.github/workflows/contract-age.yml |
Semanal | Verifica a idade das gravações de contrato e abre issue quando passa de 100 dias |
25.14 Gestão de segredos #
25.14.1 No CI #
| Onde | O quê | Regras |
|---|---|---|
| Segredos do repositório | GITHUB_TOKEN (automático) |
Escopo mínimo declarado por job em permissions: |
Segredos do ambiente staging |
DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY, DEPLOY_KNOWN_HOSTS |
Só acessíveis a jobs com environment: staging |
Segredos do ambiente production |
Os mesmos, com valores de produção | Só acessíveis após aprovação do ambiente |
Regras absolutas:
- Nenhum segredo de terceiros (Asaas, Meta, TTS, storage) existe no CI. O CI nunca fala com serviço externo real. As únicas credenciais do CI são as de deploy e as de registro de imagem.
- Segredos nunca são passados como argumento de linha de comando (visíveis em
ps). São passados por variável de ambiente ou por entrada padrão. - Nenhum
echode segredo. O mascaramento automático do CI é tratado como rede de segurança, não como permissão para imprimir. - Toda mudança em segredo de produção é registrada manualmente no registro de operação, com data, quem trocou e motivo.
- A chave de deploy é
ed25519, restrita no servidor porcommand="/opt/palavra-diaria/deploy/ssh-wrapper.sh",no-port-forwarding,no-agent-forwarding,no-ptynoauthorized_keys. O wrapper aceita apenasdeploy.sh,rollback.she um subconjunto declarado de comandosops, e nada mais.
25.14.2 No servidor #
Arquivo /opt/palavra-diaria/.env.production, dono root:docker, modo 0640. Não fica em
Git. É gerado a partir do cofre de segredos da organização e aplicado por procedimento
manual. O catálogo completo das variáveis está na Seção 26.
A cadência de rotação de cada segredo é a da Seção 26.7.3. Esta subseção declara apenas o procedimento no servidor; nenhum prazo é repetido aqui, para que exista um único número por segredo em todo o documento.
| Segredo | Procedimento no servidor |
|---|---|
POSTGRES_PASSWORD |
ALTER ROLE app WITH PASSWORD, atualizar arquivo, subir web e worker |
REDIS_PASSWORD |
Atualizar arquivo, reiniciar redis, web e worker na ordem |
SESSION_JWT_PRIVATE_KEY |
Rotação com sobreposição: a chave nova entra como assinante e a antiga fica como verificadora por 30 dias (SESSION_JWT_PREVIOUS_PUBLIC_KEY), depois é removida. Sem deslogar ninguém |
ENCRYPTION_KEY e PHONE_INDEX_KEY |
Rotação em duas fases da Seção 22.5.5: a chave nova entra como escritora, ENCRYPTION_KEY_PREVIOUS e PHONE_INDEX_KEY_PREVIOUS continuam aceitas em leitura até o recálculo terminar, e só então são removidas. São chaves distintas: comprometer uma não compromete a outra |
ASAAS_API_KEY |
Gerar nova no painel da Asaas, atualizar, reiniciar, revogar a antiga |
ASAAS_WEBHOOK_TOKEN |
Atualizar no painel da Asaas e no arquivo na mesma janela; eventos entre as duas trocas são recuperados pela reconciliação |
WHATSAPP_SYSTEM_USER_TOKEN |
Ver runbook R-07 |
META_APP_SECRET |
Rotação com janela dupla: META_APP_SECRET e META_APP_SECRET_PREVIOUS são aceitos simultaneamente na verificação do HMAC de webhook, com registro de qual validou; a anterior só é removida depois que 100% das validações usarem a nova. Sem essa janela, toda rotação rejeita webhooks legítimos entre a troca no painel do provedor e o reinício dos processos (Seção 17.7) |
ELEVENLABS_API_KEY |
Atualizar e reiniciar worker |
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY |
Criar chave nova, atualizar, validar, revogar a antiga |
BACKUP_ENCRYPTION_KEY e senha do repositório de backup |
Nunca rotacionar sem antes validar que backups antigos ainda são legíveis com a chave antiga guardada |
Chave privada age do backup lógico |
Nunca fica no servidor: guardada apenas no cofre e em cópia física offline |
Rotação fora de cadência é sempre permitida e obrigatória sob suspeita de vazamento; o procedimento é o mesmo da linha correspondente, e o prazo de 26.7.3 é o teto, nunca o piso.
Verificação automática semanal (ops secrets:audit): confere que o arquivo tem modo 0640,
que todas as variáveis obrigatórias existem, que nenhuma tem valor de exemplo, que o token
da Meta tem mais de 14 dias de validade restante, e que nenhuma chave aparece em log ou em
variável exportada por outro processo. Falha vira alerta.
25.15 Ambientes #
| Aspecto | local |
staging |
production |
|---|---|---|---|
| Onde roda | Máquina do desenvolvedor, Docker Compose | VPS menor dedicado, ou projeto Compose separado no mesmo host | VPS dedicado |
| Domínio | localhost:3000 |
staging.palavradiaria.com.br, app.staging..., api.staging... |
palavradiaria.com.br e subdomínios |
| TLS | Não (HTTP puro) | Sim, Caddy/ACME | Sim, Caddy/ACME |
| Banco | Postgres em contêiner, dados descartáveis | Postgres em contêiner, dados sintéticos | Postgres em contêiner, dados reais |
| Backup | Nenhum | Nenhum | pgBackRest + dump lógico (Seção 25.8.3) |
| Asaas | Servidor de simulação local | Sandbox (api-sandbox.asaas.com/v3) |
Produção (api.asaas.com/v3) |
| Servidor de simulação local | Número de teste da Meta, com lista de destinos permitidos | Número de produção aprovado | |
| TTS | Servidor de simulação local (áudio sintético fixo) | Provedor real, conta de desenvolvimento, cota limitada | Provedor real, conta de produção |
| Console (impresso no log) | Provedor real, domínio de staging, destinos restritos à equipe | Provedor real, domínio de produção | |
| Storage | MinIO local ou simulação em disco | Bucket pd-media-staging |
Bucket pd-media |
WHATSAPP_ALLOWLIST |
Definida (números da equipe) | Definida (números da equipe) | Vazia |
E2E_TEST_HOOKS |
true |
false |
false, com guarda que impede o start |
| Lote diário | Manual | Automático às 05:40, sobre dados sintéticos | Automático às 05:40 |
Réplicas de web |
1 | 1 | 2 |
| Nível de log | debug |
info |
info |
| Origem dos dados | Seed de desenvolvimento | ops seed:synthetic |
Cadastro real |
| Restauração de backup de produção | Proibida | Proibida | Permitida (é o próprio ambiente) |
| Quem faz deploy | O próprio desenvolvedor | Automático, ao publicar tag | Automático, após aprovação manual |
Não existe ambiente de QA separado. A bateria manual da Seção 24.11 é executada em staging para os itens de sandbox e em produção para os itens que exigem número e cobrança reais.
25.16 Plano de continuidade #
25.16.1 Objetivos declarados #
| Objetivo | Valor | Como é sustentado |
|---|---|---|
| RPO (perda máxima de dados aceitável) | 5 minutos | archive_timeout = 60s força o envio de um segmento de WAL por minuto; o envio assíncrono e o upload somam, no pior caso, mais 2 a 3 minutos. O valor declarado tem folga sobre o comportamento medido |
| RTO (tempo máximo até voltar a operar) | 90 minutos | Provisão de VPS novo (~10 min) + instalação e clone (~15 min) + restauração e reprodução de WAL (~12 a 37 min conforme o porte) + verificação (~5 min) + propagação de DNS (~10 min com TTL de 300 s) + folga |
| Disponibilidade alvo | 99,5% mensal | Conforme a meta declarada na Seção 2.6. Equivale a cerca de 3,6 h de indisponibilidade por mês |
O RTO de 90 minutos é aceitável para este produto por uma razão específica: o envio é diário
e concentrado em 20 minutos. Uma queda às 14:00 não afeta nenhum assinante além do painel
web. Uma queda às 05:30 é o único cenário verdadeiramente crítico, e para ele existe uma
mitigação própria: o lote pode ser disparado com atraso de até 4 horas sem que a experiência
mude de forma relevante, porque o planejamento é idempotente por data e o motor respeita a
chave send:{subscriberId}:{devotionalDate}.
25.16.2 Pré-requisitos permanentes #
Estes itens precisam existir antes do desastre, e sua existência é verificada
mensalmente por ops dr:preflight:
- Repositório de deploy em Git, com o arquivo de composição,
Caddyfile, configurações e scripts versionados. - Imagens das três últimas versões publicadas no registro, acessíveis com credencial que não depende do servidor caído.
.env.productionguardado no cofre de segredos da organização, fora do servidor.- Chave privada
agedo backup lógico no cofre e em cópia física offline. - Credencial de leitura do repositório de backup guardada separadamente da credencial de escrita usada pelo servidor.
- DNS com TTL de 300 segundos nos registros A/AAAA dos quatro domínios, permanentemente.
- Contato e procedimento de suporte do provedor de VPS documentados.
- Lista de contatos da Meta e da Asaas, com o identificador da conta.
25.16.3 Procedimento de recuperação total #
Cenário: o servidor foi perdido por completo, sem possibilidade de recuperação do disco.
# --- Passo 1: provisionar (alvo: 10 min) ---
# Criar VPS com a mesma especificacao (Secao 25.2.5), Ubuntu LTS, na mesma regiao.
# Anotar o IP novo.
# --- Passo 2: preparar o host (alvo: 10 min) ---
ssh root@<novo-ip>
apt-get update && apt-get install -y ca-certificates curl git ufw fail2ban
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
# Firewall conforme a Secao 25.7.3.
# --- Passo 3: restaurar a configuracao (alvo: 5 min) ---
git clone <repositorio-de-deploy> /opt/palavra-diaria
cd /opt/palavra-diaria
# Trazer .env.production do cofre. Nunca por canal nao criptografado.
install -m 0640 -o root -g docker /caminho/do/cofre/.env.production ./.env.production
echo "IMAGE_TAG=<ultima-tag-boa-conhecida>" > .env.deploy
# --- Passo 4: subir a infraestrutura sem trafego (alvo: 5 min) ---
docker compose up -d postgres redis
docker compose exec -T postgres pg_isready -U app
# --- Passo 5: restaurar o banco (alvo: 12 a 37 min) ---
docker compose stop postgres
docker compose run --rm pgbackrest \
pgbackrest --stanza=palavra-diaria --delta restore
docker compose up -d postgres
docker compose logs -f postgres | grep -m1 "ready to accept connections"
# --- Passo 6: garantir que nada dispare sozinho ---
docker compose up -d worker
docker compose exec -T worker ops kill-switch:on --reason="recuperacao de desastre em andamento"
# --- Passo 7: validar antes de abrir (alvo: 5 min) ---
docker compose exec -T worker ops backup:verify --post-restore
docker compose exec -T worker ops health --strict
# --- Passo 8: abrir o trafego (alvo: 5 min) ---
docker compose up -d web caddy
curl -fsS -m 10 http://127.0.0.1/api/internal/health --resolve palavradiaria.com.br:80:127.0.0.1
# --- Passo 9: apontar o DNS (alvo: 10 min de propagacao com TTL 300) ---
# Atualizar os registros A/AAAA dos quatro dominios para o IP novo.
# --- Passo 10: reconciliar e religar ---
docker compose exec -T worker ops billing:reconcile --since=<data-do-backup> --fix
docker compose exec -T worker ops billing:replay-events --since=<timestamp-do-backup>
docker compose exec -T worker ops template:sync
docker compose exec -T worker ops kill-switch:off
docker compose exec -T worker ops send:plan --date=<hoje> --dry-run # conferir antes
docker compose exec -T worker ops send:plan --date=<hoje> # se o lote do dia nao saiuO passo 10 é o que fecha a lacuna do RPO: eventos de pagamento que a Asaas entregou nos minutos perdidos são recuperados pela reconciliação, porque a Asaas é a fonte da verdade para cobrança. Mensagens de WhatsApp recebidas no intervalo perdido não são recuperáveis — a Meta não reentrega webhooks antigos. Consequência prática: alguns assinantes podem ter tocado no botão do devocional e não receber o pacote completo. O tratamento está no runbook R-17.
25.16.4 Cenários parciais #
| Cenário | RTO | Ação |
|---|---|---|
| Volume de dados corrompido, servidor íntegro | ~20 min | Restauração local (Seção 25.8.4), sem reprovisionar |
Erro humano em dados (UPDATE sem WHERE) |
~25 min | Restauração a ponto no tempo para o instante anterior |
| Redis perdido | ~10 min | Runbook R-18 |
| Bucket de mídia perdido | ~4 h | Restaurar do espelho; áudio faltante é regerado |
| Provedor de VPS com incidente regional | Depende do provedor | Se passar de 60 min, executar a recuperação total em outro provedor |
| Conta do WhatsApp banida | Indeterminado | Runbook R-05; o produto continua funcionando no painel web enquanto isso |
25.17 Custo mensal estimado #
Premissas de cálculo, declaradas aqui para que os números sejam auditáveis:
- Distribuição de tier conforme a Seção 2.6: 70% FREE, 30% PAID.
- FREE recebe 1 template por semana: 4,33 templates por mês.
- PAID recebe template apenas quando a janela de atendimento está fechada. Adota-se 50% dos dias com janela aberta (comportamento esperado do desenho de entrega), logo 15 templates por mês por assinante PAID. Mensagens free-form dentro da janela não têm custo por mensagem.
- Preço por template na faixa
UTILITY, referência de planejamento: R$ 0,04. A faixaMARKETINGcusta cerca de 8 vezes mais; a linha "cenário MARKETING" mostra o impacto. As faixas canônicas de custo por categoria estão na Seção 17. - Asaas, referência de planejamento: cartão 2,99% + R$ 0,49 por transação; PIX R$ 1,99 por cobrança recebida. Divisão de meio de pagamento: 70% cartão, 30% PIX.
- TTS: o áudio é gerado uma vez por devocional, nunca por assinante. 30 devocionais por mês, cerca de 2.500 caracteres cada, cerca de 75.000 caracteres por mês. O custo é constante em qualquer escala.
- Câmbio de referência: US$ 1,00 = R$ 5,40. Valores arredondados.
Cenário A — 1.000 assinantes (700 FREE, 300 PAID)
| Item | Cálculo | Custo mensal |
|---|---|---|
| Templates FREE | 700 × 4,33 = 3.031 msg × R$ 0,04 | R$ 121 |
| Templates PAID | 300 × 15 = 4.500 msg × R$ 0,04 | R$ 180 |
| Mensagens free-form (texto + áudio) | Sem custo dentro da janela | R$ 0 |
| Asaas — cartão | 210 × (R$ 19,90 × 2,99% + R$ 0,49) | R$ 228 |
| Asaas — PIX | 90 × R$ 1,99 | R$ 180 |
| TTS | 75.000 caracteres, plano de entrada | R$ 120 |
| VPS (2 vCPU / 4 GB / 40 GB) | R$ 130 | |
| Storage de mídia + espelho | ~10 GB acumulados + tráfego | R$ 20 |
| Storage de backup | ~15 GB com 35 dias de retenção | R$ 15 |
| E-mail transacional | ~2.000 mensagens, faixa gratuita | R$ 0 |
| Domínio (rateio mensal) | R$ 5 | |
| Monitoramento externo de disponibilidade | R$ 30 | |
| Total | R$ 1.029 | |
| Receita bruta | 300 × R$ 19,90 | R$ 5.970 |
| Custo por assinante ativo | R$ 1.029 / 1.000 | R$ 1,03 |
| Cenário MARKETING | templates a R$ 0,34 | R$ 3.310 (+R$ 2.281) |
Cenário B — 10.000 assinantes (7.000 FREE, 3.000 PAID)
| Item | Cálculo | Custo mensal |
|---|---|---|
| Templates FREE | 7.000 × 4,33 = 30.310 msg × R$ 0,04 | R$ 1.213 |
| Templates PAID | 3.000 × 15 = 45.000 msg × R$ 0,04 | R$ 1.800 |
| Asaas — cartão | 2.100 × (R$ 19,90 × 2,99% + R$ 0,49) | R$ 2.279 |
| Asaas — PIX | 900 × R$ 1,99 | R$ 1.791 |
| TTS | 75.000 caracteres | R$ 120 |
| VPS (4 vCPU / 8 GB / 80 GB) | R$ 260 | |
| Storage de mídia + espelho | ~60 GB acumulados + tráfego | R$ 60 |
| Storage de backup | ~60 GB com 35 dias | R$ 45 |
| E-mail transacional | ~20.000 mensagens | R$ 110 |
| Domínio (rateio mensal) | R$ 5 | |
| Monitoramento externo | R$ 60 | |
| Total | R$ 7.743 | |
| Receita bruta | 3.000 × R$ 19,90 | R$ 59.700 |
| Custo por assinante ativo | R$ 7.743 / 10.000 | R$ 0,77 |
| Cenário MARKETING | templates a R$ 0,34 | R$ 30.335 (+R$ 22.592) |
Cenário C — 50.000 assinantes (35.000 FREE, 15.000 PAID)
| Item | Cálculo | Custo mensal |
|---|---|---|
| Templates FREE | 35.000 × 4,33 = 151.550 msg × R$ 0,04 | R$ 6.062 |
| Templates PAID | 15.000 × 15 = 225.000 msg × R$ 0,04 | R$ 9.000 |
| Asaas — cartão | 10.500 × (R$ 19,90 × 2,99% + R$ 0,49) | R$ 11.393 |
| Asaas — PIX | 4.500 × R$ 1,99 | R$ 8.955 |
| TTS | 75.000 caracteres | R$ 120 |
| VPS (8 vCPU / 16 GB / 160 GB) | R$ 700 | |
| Storage de mídia + espelho | ~200 GB acumulados + tráfego | R$ 180 |
| Storage de backup | ~250 GB com 35 dias | R$ 160 |
| E-mail transacional | ~100.000 mensagens | R$ 430 |
| Domínio (rateio mensal) | R$ 5 | |
| Monitoramento externo | R$ 60 | |
| Total | R$ 37.065 | |
| Receita bruta | 15.000 × R$ 19,90 | R$ 298.500 |
| Custo por assinante ativo | R$ 37.065 / 50.000 | R$ 0,74 |
| Cenário MARKETING | templates a R$ 0,34 | R$ 165.010 (+R$ 127.945) |
Três conclusões operacionais que decorrem da tabela e que orientam decisões de produto:
- A categoria do template é a variável de custo mais importante do produto. A diferença
entre
UTILITYeMARKETINGno cenário C é de mais de R$ 127 mil por mês. Manter o template classificado comoUTILITYe monitorar a categoria efetiva devolvida pela API não é detalhe de implementação: é a diferença entre margem alta e prejuízo. - A taxa de abertura da janela é a segunda variável. Cada ponto percentual a mais de janelas abertas elimina templates. Se a abertura subisse de 50% para 70% no cenário C, o custo de templates PAID cairia de R$ 9.000 para R$ 5.400.
- Infraestrutura é irrelevante perto de mensagem e cobrança. No cenário C, VPS, storage e monitoramento somam menos de 3% do custo total. Otimizar servidor para economizar dinheiro é esforço mal aplicado; otimizar categoria de template e abertura de janela é onde o dinheiro está.
26. Registro de Configuração e Variáveis de Ambiente #
Esta seção é a dona do registro de configuração. Nenhuma outra seção lista variáveis de ambiente de forma completa: todas referenciam aqui.
26.1 Princípios #
- Toda variável é declarada e validada. Não existe
process.env.Xespalhado pelo código. O acesso é sempre por um objeto tipado, produzido por um único módulo. - O boot falha rápido e com mensagem clara. Variável obrigatória ausente ou inválida derruba o processo antes de aceitar a primeira requisição, com a lista completa do que está errado — nunca uma variável por vez.
- Segredo nunca aparece em log, erro, resposta ou artefato de build. A lista de redação é explícita e testada.
- Sem valor padrão para segredo. Um padrão de desenvolvimento que vaza para produção
é a origem clássica do incidente. Segredo obrigatório não tem
default. - Padrão seguro. Onde há dúvida, o padrão é o comportamento mais restritivo.
26.1.1 Nome canônico de segredo #
Cada segredo tem exatamente um nome, e ele é definido nesta seção. Um mesmo segredo com dois ou três nomes ao longo do documento não é uma inconsistência cosmética: é um contêiner que não sobe. O guarda de inicialização procura um nome, o registro de configuração declara outro, e o operador acrescenta a variável faltante para destravar — passando a existir duas chaves no ambiente sem que ninguém saiba qual assina.
Nomes canônicos, com os apelidos proibidos entre parênteses:
| Nome canônico | Apelidos proibidos |
|---|---|
SESSION_JWT_PRIVATE_KEY / SESSION_JWT_PUBLIC_KEY |
SESSION_JWK_PRIVATE, JWT_PRIVATE_KEY, JWT_PUBLIC_KEY |
WHATSAPP_SYSTEM_USER_TOKEN |
WHATSAPP_TOKEN, WHATSAPP_ACCESS_TOKEN, META_WHATSAPP_TOKEN |
WHATSAPP_VERIFY_TOKEN |
HUB_VERIFY_TOKEN, META_VERIFY_TOKEN |
ENCRYPTION_KEY e ENCRYPTION_KEY_PREVIOUS |
ENCRYPTION_KEY_V<n> e qualquer família versionada |
PHONE_INDEX_KEY e PHONE_INDEX_KEY_PREVIOUS |
TAX_ID_ENCRYPTION_KEY e qualquer chave por coluna |
META_APP_SECRET e META_APP_SECRET_PREVIOUS |
— |
ELEVENLABS_API_KEY |
TTS_API_KEY |
BUILD_SHA |
APP_VERSION |
APP_URL |
APP_BASE_URL |
PUBLIC_SITE_URL |
NEXT_PUBLIC_SITE_URL |
OPS_WEBHOOK_URL |
ALERT_WEBHOOK_URL |
ASAAS_API_BASE_URL |
ASAAS_BASE_URL |
S3_BUCKET e S3_ENDPOINT |
MEDIA_BUCKET, MEDIA_ENDPOINT, S3_ENDPOINT_HOST |
WEBHOOK_MAX_BODY_BYTES |
ASAAS_WEBHOOK_MAX_BODY_BYTES |
O par de chaves de sessão usa o prefixo SESSION_JWT_ em toda a família, incluindo
SESSION_JWT_KEY_ID, SESSION_JWT_PREVIOUS_PUBLIC_KEY e SESSION_JWT_PREVIOUS_KEY_ID. O guarda
de boot de cada contêiner valida exatamente esses nomes, e nenhum outro.
Um teste do pipeline extrai toda ocorrência de env.<NOME>, process.env.<NOME> e ${<NOME>}
do código, dos arquivos de composição e dos scripts de inicialização, e falha se algum nome não
constar do registro de 26.3. É o único mecanismo capaz de pegar essa classe de erro antes do
primeiro deploy de produção, porque nenhum teste unitário a alcança.
26.2 Variável de ambiente versus setting de runtime #
A fronteira é decidida por três perguntas:
| Pergunta | Sim → | Não → |
|---|---|---|
| É segredo ou credencial? | Variável de ambiente | continue |
| Mudar exige reiniciar o processo ou redeploy? | Variável de ambiente | continue |
| Um administrador não técnico precisa mudar sem chamar alguém? | Setting de runtime | Variável de ambiente |
| Critério | Variável de ambiente | Setting de runtime (settings) |
|---|---|---|
| Onde vive | Arquivo .env / gestor de segredos |
Tabela settings |
| Quem altera | Operador com acesso ao servidor | Administrador pelo painel |
| Quando vale | No próximo start do processo | Em até 60 segundos, sem reinício |
| Validação | Schema Zod no boot (26.5) | CHECK no banco + Zod na borda da API |
| Auditoria | Histórico do gestor de segredos | admin_audit_log |
| Pode ser segredo | Sim | Sim, com is_secret = true, mas evitado |
| Exemplos | DATABASE_URL, WHATSAPP_SYSTEM_USER_TOKEN, SESSION_JWT_PRIVATE_KEY |
send.rate_per_second, resend.paid_daily_limit, privacy.dpo_email |
Casos limítrofes decididos aqui, para não sobrar dúvida:
| Configuração | Decisão | Por quê |
|---|---|---|
| Taxa de envio ao WhatsApp | Ambos. WHATSAPP_SEND_RATE_PER_SECOND é o teto absoluto; send.rate_per_second é o valor operacional, limitado pelo teto |
O operador precisa reduzir a taxa durante um incidente sem redeploy, mas não pode elevá-la acima do que a plataforma aceita |
| Hora do envio diário | Setting (send.daily_hour_local) |
Decisão de produto, sem impacto de segurança |
| Dia do envio gratuito | Setting (send.free_tier_weekday) |
Idem |
| Preço dos planos | Nem um nem outro: linhas em plans |
Preço tem histórico e vínculo com assinaturas existentes |
| Modo manutenção | Setting (ops.maintenance_mode) |
Precisa ser ligado em segundos, sem deploy |
| E-mail do encarregado de dados | Setting (privacy.dpo_email) |
Muda com a pessoa, não com a infraestrutura |
| Limite de reenvio por tier | Setting (resend.free_daily_limit, resend.paid_daily_limit) |
Ajuste de produto |
Quando um valor existe nos dois lugares, o setting define o valor operacional e a
variável de ambiente define o limite máximo permitido. O código aplica
Math.min(setting, envCeiling).
26.3 Registro completo de variáveis #
Legenda de App: W = apps/web, K = apps/worker, O = apps/ops.
Legenda de Obrig.: Sim = o boot falha sem ela; Não = tem padrão ou é opcional.
26.3.1 Núcleo da aplicação #
| Nome | Tipo | Obrig. | Padrão | Exemplo | App | Segredo | Descrição / se faltar |
|---|---|---|---|---|---|---|---|
NODE_ENV |
enum | Sim | — | production |
W K O | Não | development, test ou production. Ausente: boot falha; muitas bibliotecas mudam de comportamento e adivinhar é perigoso. |
APP_ENV |
enum | Sim | — | production |
W K O | Não | local, staging ou production. Separado de NODE_ENV porque staging roda com NODE_ENV=production. Ausente: boot falha. |
APP_NAME |
string | Não | palavra-diaria |
palavra-diaria |
W K O | Não | Identificador em logs e métricas. |
APP_URL |
url | Sim | — | https://app.palavradiaria.com.br |
W K O | Não | URL base dos painéis. Usada em magic link, e-mails e no claim iss. Ausente: boot falha; link quebrado é pior que não subir. |
PUBLIC_SITE_URL |
url | Sim | — | https://palavradiaria.com.br |
W | Não | URL da landing. Usada em redirecionamentos e e-mails. |
PORT |
int | Não | 3000 |
3000 |
W | Não | Porta HTTP do app web. |
WORKER_PORT |
int | Não | 3001 |
3001 |
K | Não | Porta do servidor interno de saúde e métricas do worker. |
TZ |
string | Não | UTC |
UTC |
W K O | Não | Fuso do processo. Sempre UTC. A conversão para horário de Brasília é feita em código, nunca pelo fuso do processo. |
BUILD_SHA |
string | Não | unknown |
a1b2c3d |
W K O | Não | SHA curto do commit. Ecoado em X-Api-Build e em /api/internal/version. Nome canônico único: APP_VERSION é apelido proibido (26.1.1). |
NEXT_PUBLIC_GA_ID |
string | Não | — (vazia) | G-XXXXXXXXXX |
W | Não | Identificador de medição de terceiro. Vazia por padrão e vazia em produção no lançamento: a analítica do produto é auto-hospedada e sem cookie (Seção 21). Preenchê-la liga um script de terceiro e passa a exigir aviso de cookies. |
NEXT_PUBLIC_META_PIXEL_ID |
string | Não | — (vazia) | 123456789012345 |
W | Não | Pixel de anúncios. Mesma regra da anterior: vazia por padrão, e só é preenchida junto com a base legal e o aviso correspondentes (Seção 22.7.1). |
26.3.2 Banco de dados #
| Nome | Tipo | Obrig. | Padrão | Exemplo | App | Segredo | Descrição / se faltar |
|---|---|---|---|---|---|---|---|
DATABASE_URL |
url | Sim | — | postgresql://app:senha@postgres:5432/palavra_diaria?schema=public |
W K O | Sim | Conexão principal. Ausente: boot falha imediatamente. |
SHADOW_DATABASE_URL |
url | Não | — | postgresql://app:senha@postgres:5432/palavra_diaria_shadow |
O | Sim | Banco sombra do fluxo de migrations. Só em local. Ausente em desenvolvimento: prisma migrate dev falha com mensagem própria. |
DATABASE_POOL_MAX |
int | Não | 10 |
20 |
W K | Não | Conexões máximas por processo. Web e worker somados precisam caber em max_connections. |
DATABASE_POOL_MIN |
int | Não | 2 |
2 |
W K | Não | Conexões mantidas abertas. |
DATABASE_CONNECT_TIMEOUT_MS |
int | Não | 5000 |
5000 |
W K O | Não | Tempo máximo para obter conexão. Estouro: DATABASE_UNAVAILABLE. |
DATABASE_STATEMENT_TIMEOUT_MS |
int | Não | 8000 |
8000 |
W K | Não | statement_timeout da sessão. Impede que uma consulta ruim trave o pool. |
DATABASE_SSL |
bool | Não | false |
true |
W K O | Não | Exige TLS na conexão. true obrigatório quando o banco não está na mesma rede privada. |
DATABASE_LOG_QUERIES |
bool | Não | false |
false |
W K | Não | Registra consultas com parâmetros. Nunca true em produção: parâmetros contêm dado pessoal. |
26.3.3 Redis e filas #
| Nome | Tipo | Obrig. | Padrão | Exemplo | App | Segredo | Descrição / se faltar |
|---|---|---|---|---|---|---|---|
REDIS_URL |
url | Sim | — | redis://:senha@redis:6379/0 |
W K O | Sim | Conexão do Redis. Ausente: boot falha. |
REDIS_TLS |
bool | Não | false |
false |
W K O | Não | Conexão por TLS. |
REDIS_COMMAND_TIMEOUT_MS |
int | Não | 1000 |
1000 |
W K | Não | Timeout por comando. Estouro no web degrada conforme a Seção 7.12.4. |
REDIS_MAX_RETRIES |
int | Não | 3 |
3 |
W K | Não | Tentativas por comando antes de desistir. |
BULLMQ_PREFIX |
string | Não | pd |
pd |
W K O | Não | Prefixo das chaves das filas. Separa ambientes que dividem um mesmo Redis. |
BULLMQ_CONCURRENCY |
int | Não | 10 |
25 |
K | Não | Jobs simultâneos por worker. Valor alto sem aumentar DATABASE_POOL_MAX esgota o pool. |
BULLMQ_MAX_STALLED_COUNT |
int | Não | 2 |
2 |
K | Não | Vezes que um job pode ficar travado antes de ir para a fila de falhas. |
BULLMQ_JOB_RETENTION_COMPLETED |
int | Não | 1000 |
1000 |
K | Não | Jobs concluídos mantidos no Redis. |
BULLMQ_JOB_RETENTION_FAILED |
int | Não | 5000 |
5000 |
K | Não | Jobs falhos mantidos, para diagnóstico. |
26.3.4 WhatsApp Business Cloud API #
| Nome | Tipo | Obrig. | Padrão | Exemplo | App | Segredo | Descrição / se faltar |
|---|---|---|---|---|---|---|---|
WHATSAPP_API_BASE_URL |
url | Não | derivado de WHATSAPP_GRAPH_VERSION |
https://graph.facebook.com/v26.0 |
W K O | Não | Base da API. A linha de versão está na Seção 4. |
WHATSAPP_GRAPH_VERSION |
string | Não | v26.0 |
v26.0 |
W K O | Não | Versão da Graph API, injetada na URL base. Nenhum caminho de código embute a versão (Seção 17.1): subir de versão é trocar esta variável, não editar arquivo. |
WHATSAPP_PHONE_NUMBER_ID |
string | Sim | — | 109876543210987 |
W K O | Não | Número remetente. Também compõe a chave de unicidade de mídia (Seção 6.27). Ausente: boot falha. |
WHATSAPP_BUSINESS_ACCOUNT_ID |
string | Sim | — | 210987654321098 |
W K O | Não | Conta de negócio. Usada para gerenciar templates. |
WHATSAPP_SYSTEM_USER_TOKEN |
string | Sim | — | EAAG... |
W K O | Sim | Token de acesso. Ausente: boot falha. Rotação em 26.7.3. |
META_APP_ID |
string | Sim | — | 1234567890123456 |
W | Não | Aplicativo da plataforma. |
META_APP_SECRET |
string | Sim | — | a1b2c3d4... |
W | Sim | Segredo usado no HMAC do webhook de entrada. Ausente: boot falha; sem ele nenhum webhook pode ser verificado. |
META_APP_SECRET_PREVIOUS |
string | Não | — | — | W | Sim | Segredo anterior, aceito simultaneamente durante a rotação. A verificação aceita qualquer um dos dois e registra qual validou; sem essa janela dupla, toda rotação rejeita webhooks legítimos entre a troca no painel do provedor e o reinício dos processos. |
WHATSAPP_VERIFY_TOKEN |
string | Sim | — | pd_verify_9f2c... |
W | Sim | Token do handshake hub.verify_token. Ausente: boot falha. |
WHATSAPP_SEND_RATE_PER_SECOND |
int | Não | 20 |
20 |
K | Não | Teto de mensagens por segundo. O valor operacional é o setting send.rate_per_second, limitado por este. |
WHATSAPP_TIMEOUT_MS |
int | Não | 15000 |
15000 |
K | Não | Timeout por chamada de envio. |
WHATSAPP_MAX_RETRIES |
int | Não | 3 |
3 |
K | Não | Tentativas por mensagem antes de marcar a tentativa como falha. |
WHATSAPP_TEMPLATE_DAILY |
string | Não | devocional_diario_v1 |
devocional_diario_v1 |
K | Não | Nome do template de convite diário. |
WHATSAPP_TEMPLATE_VIDEO |
string | Não | devocional_diario_video_v1 |
devocional_diario_video_v1 |
K | Não | Template de fallback com cabeçalho de vídeo. |
WHATSAPP_TEMPLATE_OTP |
string | Não | codigo_acesso_v1 |
codigo_acesso_v1 |
W | Não | Template de código de acesso. |
WHATSAPP_TEMPLATE_WELCOME |
string | Não | boas_vindas_v1 |
boas_vindas_v1 |
W K | Não | Template de confirmação de inscrição. |
WHATSAPP_TEMPLATE_LANGUAGE |
string | Não | pt_BR |
pt_BR |
W K | Não | Idioma dos templates. |
WHATSAPP_MEDIA_TTL_DAYS |
int | Não | 30 |
30 |
K | Não | Validade do identificador de mídia. Reupload é disparado antes de vencer. |
26.3.5 Provedor de pagamento #
| Nome | Tipo | Obrig. | Padrão | Exemplo | App | Segredo | Descrição / se faltar |
|---|---|---|---|---|---|---|---|
ASAAS_ENVIRONMENT |
enum | Sim | — | production |
W K O | Não | sandbox ou production. Ausente: boot falha; adivinhar significaria cobrar de verdade em staging. |
ASAAS_API_BASE_URL |
url | Não | derivado de ASAAS_ENVIRONMENT |
https://api.asaas.com/v3 |
W K O | Não | Base da API. sandbox resolve para https://api-sandbox.asaas.com/v3. |
ASAAS_API_KEY |
string | Sim | — | $aact_... |
W K O | Sim | Chave enviada no header access_token. Ausente: boot falha. |
ASAAS_WEBHOOK_TOKEN |
string | Sim | — | pd_asaas_7d1e... |
W | Sim | Token comparado em tempo constante no webhook. Ausente: boot falha. |
ASAAS_WEBHOOK_TOKEN_PREVIOUS |
string | Não | — | — | W | Sim | Token anterior, aceito simultaneamente por até 24 h durante a rotação (26.7.3). Sem essa janela, todo evento que chega entre a troca no painel do provedor e o reinício dos processos é rejeitado. |
ASAAS_RATE_LIMIT_RPS |
int | Não | 8 |
8 |
W K O | Não | Teto local de requisições por segundo à API do provedor, aplicado por token bucket antes de a chamada sair (Seção 12.2). Existe para o produto nunca ser quem estoura o limite do provedor. |
ASAAS_TIMEOUT_MS |
int | Não | 10000 |
10000 |
W K | Não | Timeout por chamada. Estouro: PAYMENT_PROVIDER_UNAVAILABLE. |
ASAAS_MAX_RETRIES |
int | Não | 2 |
2 |
W K | Não | Tentativas em erro transitório. Nunca retenta POST de criação sem chave de idempotência. |
ASAAS_WEBHOOK_URL |
url | Não | ${APP_URL}/api/webhooks/asaas |
— | O | Não | URL registrada no provedor. Usada pelo comando de configuração. |
26.3.6 Síntese de voz e mídia #
| Nome | Tipo | Obrig. | Padrão | Exemplo | App | Segredo | Descrição / se faltar |
|---|---|---|---|---|---|---|---|
TTS_PRIMARY_PROVIDER |
enum | Não | ELEVENLABS |
ELEVENLABS |
K | Não | ELEVENLABS ou GOOGLE_TTS. |
ELEVENLABS_API_KEY |
string | Condicional | — | sk_a1b2... |
K | Sim | Obrigatória quando o primário é ELEVENLABS. Ausente nesse caso: boot falha. |
ELEVENLABS_BASE_URL |
url | Não | https://api.elevenlabs.io/v1 |
— | K | Não | Base da API de voz. |
TTS_VOICE_ID |
string | Sim | — | 21m00Tcm4TlvDq8ikWAM |
K | Não | Voz em português do Brasil. Ausente: boot falha; áudio com voz errada é pior que áudio nenhum. |
TTS_MODEL_ID |
string | Não | eleven_multilingual_v2 |
— | K | Não | Modelo de síntese multilíngue. |
TTS_STABILITY |
float | Não | 0.45 |
0.45 |
K | Não | 0 a 1. Menor gera mais variação expressiva. |
TTS_SIMILARITY_BOOST |
float | Não | 0.75 |
0.75 |
K | Não | 0 a 1. Aderência ao timbre da voz de referência. |
TTS_OUTPUT_FORMAT |
string | Não | mp3_44100_128 |
— | K | Não | Formato bruto pedido ao provedor, antes da transcodificação. |
TTS_TIMEOUT_MS |
int | Não | 120000 |
120000 |
K | Não | Timeout da síntese. Texto de 5 minutos leva dezenas de segundos. |
TTS_MAX_ATTEMPTS_BEFORE_FALLBACK |
int | Não | 3 |
3 |
K | Não | Falhas no primário antes de cair para o secundário. |
GOOGLE_TTS_CREDENTIALS_JSON |
json | Condicional | — | {"type":"service_account",...} |
K | Sim | Credencial da conta de serviço, como JSON em uma linha. Obrigatória quando o fallback está habilitado. |
GOOGLE_TTS_VOICE_NAME |
string | Não | pt-BR-Neural2-C |
— | K | Não | Voz do provedor secundário. |
TTS_FALLBACK_VOICE_ID |
string | Não | — | pt-BR-Neural2-B |
K | Não | Voz alternativa usada quando a voz configurada é recusada pelo provedor (Seção 16.9). Vazia: o fallback usa GOOGLE_TTS_VOICE_NAME. |
GOOGLE_TTS_LANGUAGE_CODE |
string | Não | pt-BR |
pt-BR |
K | Não | Idioma do provedor secundário. |
FFMPEG_PATH |
string | Não | ffmpeg |
/usr/bin/ffmpeg |
K | Não | Binário de transcodificação. Ausente do sistema: o boot do worker falha na verificação de dependências. |
AUDIO_OGG_BITRATE_KBPS |
int | Não | 32 |
32 |
K | Não | Bitrate do OGG/Opus enviado ao WhatsApp. |
AUDIO_OGG_FALLBACK_BITRATE_KBPS |
int | Não | 24 |
24 |
K | Não | Bitrate de reencode quando o arquivo passa do limite de aviso. |
AUDIO_MP3_BITRATE_KBPS |
int | Não | 128 |
128 |
K | Não | Bitrate do MP3 do player web. |
AUDIO_MAX_BYTES |
int | Não | 16777216 |
16777216 |
K | Não | Limite duro de mídia. Acima disso a geração falha localmente. |
AUDIO_WARN_BYTES |
int | Não | 12582912 |
12582912 |
K | Não | Limiar de aviso que dispara o reencode em bitrate menor. |
26.3.7 Storage de objetos #
| Nome | Tipo | Obrig. | Padrão | Exemplo | App | Segredo | Descrição / se faltar |
|---|---|---|---|---|---|---|---|
S3_ENDPOINT |
url | Sim | — | https://abc123.r2.cloudflarestorage.com |
W K O | Não | Endpoint compatível com S3. Ausente: boot falha. |
S3_REGION |
string | Não | auto |
auto |
W K O | Não | Região. Provedores compatíveis costumam aceitar auto. |
S3_BUCKET |
string | Sim | — | palavra-diaria-media |
W K O | Não | Bucket privado de mídia. Ausente: boot falha. |
S3_ACCESS_KEY_ID |
string | Sim | — | AKIA... |
W K O | Sim | Chave de acesso. |
S3_SECRET_ACCESS_KEY |
string | Sim | — | — | W K O | Sim | Segredo de acesso. Ausente: boot falha. |
S3_FORCE_PATH_STYLE |
bool | Não | true |
true |
W K O | Não | Necessário na maioria dos provedores compatíveis. |
S3_SIGNED_URL_TTL_SECONDS |
int | Não | 900 |
900 |
W | Não | Validade da URL assinada do player web. 15 minutos. |
S3_PUBLIC_BASE_URL |
url | Não | — | https://media.palavradiaria.com.br |
W | Não | Uso restrito. Aponta exclusivamente para o domínio de mídia que fica na frente do bucket e que exige URL assinada; nunca é a URL direta do bucket. O bucket permanece privado, sem acesso anônimo e sem listagem (Seção 25.10 e 22.2.7). Um teste de fumaça pós-implantação requisita um objeto conhecido sem assinatura e falha se a resposta não for 403. As exportações de portabilidade (22.7.4) ficam no prefixo exports/, jamais alcançável por esta variável. Ausente: usa URL assinada direta. |
S3_ARCHIVE_BUCKET |
string | Não | igual a S3_BUCKET |
palavra-diaria-archive |
O | Não | Destino do arquivamento de partições (Seção 6.33.3). |
26.3.8 E-mail transacional #
| Nome | Tipo | Obrig. | Padrão | Exemplo | App | Segredo | Descrição / se faltar |
|---|---|---|---|---|---|---|---|
EMAIL_ENABLED |
bool | Não | true |
true |
W K | Não | false desliga todo envio e apenas registra em log. Útil em desenvolvimento. |
RESEND_API_KEY |
string | Condicional | — | re_a1b2... |
W K | Sim | Obrigatória quando EMAIL_ENABLED=true. Ausente nesse caso: boot falha. |
EMAIL_FROM |
Condicional | — | Palavra Diária <ola@palavradiaria.com.br> |
W K | Não | Remetente. Domínio precisa estar verificado no provedor. | |
EMAIL_REPLY_TO |
Não | igual a EMAIL_FROM |
suporte@palavradiaria.com.br |
W K | Não | Endereço de resposta. | |
EMAIL_ALERTS_TO |
Sim | — | alertas@palavradiaria.com.br |
W K O | Não | Destino dos alertas operacionais. Ausente: boot falha; alerta que não chega a ninguém é pior que nenhum alerta. | |
EMAIL_MAX_RETRIES |
int | Não | 3 |
3 |
K | Não | Tentativas por e-mail. |
26.3.9 Sessão, criptografia e segredos de aplicação #
| Nome | Tipo | Obrig. | Padrão | Exemplo | App | Segredo | Descrição / se faltar |
|---|---|---|---|---|---|---|---|
SESSION_JWT_PRIVATE_KEY |
pem | Sim | — | -----BEGIN PRIVATE KEY-----\n... |
W | Sim | Chave privada Ed25519, formato PKCS#8. Só o app web assina. Ausente: boot falha. |
SESSION_JWT_PUBLIC_KEY |
pem | Sim | — | -----BEGIN PUBLIC KEY-----\n... |
W K | Não | Chave pública SPKI correspondente. Usada para verificar. |
SESSION_JWT_KEY_ID |
string | Não | k1 |
k1 |
W K | Não | Valor do cabeçalho kid. Muda a cada rotação. |
SESSION_JWT_PREVIOUS_PUBLIC_KEY |
pem | Não | — | — | W K | Não | Chave pública anterior, aceita durante a janela de rotação (26.7.3). |
SESSION_JWT_PREVIOUS_KEY_ID |
string | Não | — | k0 |
W K | Não | kid da chave anterior. |
ACCESS_TOKEN_TTL_SECONDS |
int | Não | 900 |
900 |
W | Não | Validade do token de acesso. 15 minutos. |
SESSION_TTL_SUBSCRIBER_DAYS |
int | Não | 30 |
30 |
W | Não | Validade da sessão do assinante. |
SESSION_TTL_ADMIN_HOURS |
int | Não | 12 |
12 |
W | Não | Validade da sessão do administrador. |
IMPERSONATION_TTL_MINUTES |
int | Não | 30 |
30 |
W | Não | Validade da sessão de suporte. |
OTP_PEPPER |
string | Sim | — | 32 bytes em hex | W | Sim | Pepper do hash de OTP (Seção 8.2.2). Mínimo 32 caracteres. Ausente: boot falha. |
ENCRYPTION_KEY |
string | Sim | — | 32 bytes em base64 | W K O | Sim | Chave AES-256-GCM para telefone, wa_id, e-mail, CPF e segredo TOTP (Seção 22.5.3). Exatamente 32 bytes decodificados. Ausente: boot falha. |
ENCRYPTION_KEY_PREVIOUS |
string | Não | — | — | W K O | Sim | Chave anterior, aceita apenas para decifrar durante a rotação (26.7.4). |
PHONE_INDEX_KEY |
string | Sim | — | 32 bytes em base64 | W K O | Sim | Chave HMAC do índice cego de telefone, wa_id, CPF e e-mail (Seção 22.5.4). Distinta de ENCRYPTION_KEY: comprometer uma não compromete a outra — a de índice permite enumerar, a de cifra permite decifrar. Ausente: boot falha. |
PHONE_INDEX_KEY_PREVIOUS |
string | Não | — | — | W K O | Sim | Chave anterior, aceita apenas em leitura durante a fase 1 da rotação de 22.5.5. |
CSRF_SECRET |
string | Sim | — | 32 bytes em hex | W | Sim | Chave do token CSRF double-submit, emitido no cookie __Host-csrf. Ausente: boot falha. |
COOKIE_DOMAIN |
string | Não | — | — | W | Não | Deixado vazio de propósito: o prefixo __Host- proíbe Domain. Existe só para diagnóstico. |
TRUST_PROXY |
bool | Não | true |
true |
W | Não | Confia em X-Forwarded-For do proxy reverso. false em execução sem proxy. Errado: o IP registrado vira o do proxy e o rate limit por IP deixa de funcionar. |
ALLOWED_ORIGINS |
csv | Sim | — | https://app.palavradiaria.com.br,https://palavradiaria.com.br |
W | Não | Origens aceitas em CORS e na verificação anti-CSRF. Ausente: boot falha. Não confundir com a constante de código EGRESS_ALLOWED_HOSTS (Seção 22.2.6), que é a lista de destinos de saída. |
TURNSTILE_SECRET_KEY |
string | Condicional | — | 0x4AAA... |
W | Sim | Chave de verificação do desafio anti-bot no servidor (Seção 22.6.4). Obrigatória quando APP_ENV=production; ausente nesse caso, o boot falha, porque um formulário público sem verificação vira porta de cadastro em massa. |
BACKUP_ENCRYPTION_KEY |
string | Condicional | — | chave pública age |
O | Sim | Chave de cifra dos backups lógicos (Seção 25.8.3). O backup nunca sai do host em texto claro. Obrigatória quando APP_ENV=production. Nunca descartar a chave antiga enquanto existir backup cifrado com ela. |
26.3.10 Observabilidade #
| Nome | Tipo | Obrig. | Padrão | Exemplo | App | Segredo | Descrição / se faltar |
|---|---|---|---|---|---|---|---|
LOG_LEVEL |
enum | Não | info |
info |
W K O | Não | trace, debug, info, warn, error ou fatal. |
LOG_FORMAT |
enum | Não | json |
json |
W K O | Não | json em staging e produção; pretty em desenvolvimento. |
LOG_REDACT_EXTRA |
csv | Não | — | req.body.cpfCnpj |
W K O | Não | Caminhos adicionais de redação, somados à lista fixa de 26.7.5. |
SENTRY_DSN |
url | Não | — | https://abc@o1.ingest.sentry.io/1 |
W K | Sim | Coletor de exceções. Ausente: o rastreamento fica só no log local. |
SENTRY_TRACES_SAMPLE_RATE |
float | Não | 0.1 |
0.1 |
W K | Não | Amostragem de rastros. 0 a 1. |
METRICS_ENABLED |
bool | Não | true |
true |
W K | Não | Expõe /api/internal/metrics e o endpoint equivalente no worker. |
METRICS_TOKEN |
string | Condicional | — | 32 bytes em hex | W K | Sim | Token do endpoint de métricas. Obrigatório quando METRICS_ENABLED=true e APP_ENV não é local. |
OPS_WEBHOOK_URL |
url | Não | — | https://hooks.exemplo.com/pd |
W K | Sim | Destino dos alertas operacionais por webhook (Seção 7.15.4). |
OPS_WEBHOOK_SECRET |
string | Condicional | — | 32 bytes em hex | W K | Sim | Chave do HMAC do webhook de saída. Obrigatória quando OPS_WEBHOOK_URL está definida. |
HEALTHCHECK_TIMEOUT_MS |
int | Não | 3000 |
3000 |
W K | Não | Timeout de cada verificação de dependência em /api/internal/ready. |
26.3.11 Agendamento #
| Nome | Tipo | Obrig. | Padrão | Exemplo | App | Segredo | Descrição / se faltar |
|---|---|---|---|---|---|---|---|
SCHEDULER_ENABLED |
bool | Não | true |
true |
K | Não | false desliga todos os jobs repetíveis. Usado em réplicas que só processam fila. |
SCHEDULER_TIMEZONE |
string | Não | America/Sao_Paulo |
— | K | Não | Fuso dos jobs repetíveis. Registrado no agendador da fila. |
DAILY_SEND_HOUR |
int | Não | 6 |
6 |
K | Não | Teto/piso da hora de envio. O valor operacional é o setting send.daily_hour_local. |
DAILY_PLAN_OFFSET_MINUTES |
int | Não | 20 |
20 |
K | Não | Antecedência do planejamento. Com padrão 6 e 20, o planejamento roda às 05:40. |
FREE_TIER_SEND_WEEKDAY |
int | Não | 0 |
0 |
K | Não | Dia do envio gratuito. 0 = domingo. Teto do setting send.free_tier_weekday. |
RECONCILE_HOUR |
int | Não | 4 |
4 |
K | Não | Hora da reconciliação de cobranças. |
RETENTION_HOUR |
int | Não | 3 |
3 |
K | Não | Hora do expurgo por retenção. |
PARTITION_HOUR |
int | Não | 3 |
3 |
K | Não | Hora da criação de partições. |
METRICS_ROLLUP_HOUR |
int | Não | 3 |
3 |
K | Não | Hora da consolidação de daily_metrics. O job roda às 03:10 sobre o dia D−1 já fechado (Seção 21.5); o deslocamento de 10 minutos é fixo no agendador. Não existe consolidação às 23:00: ela mediria o dia antes de o dia acabar. |
JOB_STALE_MINUTES |
int | Não | 30 |
30 |
K | Não | Minutos em RUNNING antes de um job ser considerado travado e alertado. |
26.3.12 Rate limiting #
| Nome | Tipo | Obrig. | Padrão | Exemplo | App | Segredo | Descrição / se faltar |
|---|---|---|---|---|---|---|---|
RATE_LIMIT_ENABLED |
bool | Não | true |
true |
W | Não | false apenas em desenvolvimento e em testes de carga internos. |
RATE_LIMIT_GLOBAL_IP_CAPACITY |
int | Não | 600 |
600 |
W | Não | Rajada global por IP. |
RATE_LIMIT_GLOBAL_IP_REFILL_PER_MINUTE |
int | Não | 300 |
300 |
W | Não | Recarga global por IP. |
OTP_MAX_PER_HOUR_PER_PHONE |
int | Não | 3 |
3 |
W | Não | Envios de código por número por hora. |
OTP_MAX_PER_HOUR_PER_IP |
int | Não | 10 |
10 |
W | Não | Envios por IP por hora. Barreira contra varredura de números. |
OTP_RESEND_COOLDOWN_SECONDS |
int | Não | 60 |
60 |
W | Não | Intervalo mínimo entre envios para o mesmo número. |
OTP_TTL_SECONDS |
int | Não | 600 |
600 |
W | Não | Validade do código do WhatsApp. |
OTP_MAX_ATTEMPTS |
int | Não | 5 |
5 |
W | Não | Tentativas por código. |
MAGIC_LINK_TTL_SECONDS |
int | Não | 1200 |
1200 |
W | Não | Validade do link por e-mail. |
ADMIN_LOGIN_MAX_PER_HOUR_PER_IP |
int | Não | 10 |
10 |
W | Não | Tentativas de login administrativo por IP. |
IDEMPOTENCY_TTL_SECONDS |
int | Não | 86400 |
86400 |
W | Não | Validade da chave de idempotência (Seção 7.7.2). |
26.3.13 Limites de negócio e de protocolo #
| Nome | Tipo | Obrig. | Padrão | Exemplo | App | Segredo | Descrição / se faltar |
|---|---|---|---|---|---|---|---|
FREE_ARCHIVE_DAYS |
int | Não | 7 |
7 |
W | Não | Teto do acervo visível no plano gratuito. Limita o setting archive.free_days. |
RESEND_LIMIT_FREE |
int | Não | 1 |
1 |
W | Não | Teto de reenvios diários no plano gratuito. |
RESEND_LIMIT_PAID |
int | Não | 3 |
3 |
W | Não | Teto de reenvios diários no plano pago. |
WINDOW_MISS_THRESHOLD |
int | Não | 3 |
3 |
K | Não | Dias sem abrir a janela antes de usar o template de vídeo. |
SERVICE_WINDOW_HOURS |
int | Não | 24 |
24 |
W K | Não | Duração da janela de atendimento. Fixado pela plataforma; existe para testes. |
TEASER_MAX_LENGTH |
int | Não | 300 |
300 |
W | Não | Teto do resumo enviado no template. |
FREEFORM_TEXT_MAX_LENGTH |
int | Não | 4096 |
4096 |
K | Não | Limite da mensagem de texto livre. |
MAX_BODY_BYTES |
int | Não | 262144 |
262144 |
W | Não | Corpo máximo em rotas gerais. 256 KB. |
MAX_BODY_BYTES_ADMIN |
int | Não | 1048576 |
1048576 |
W | Não | Corpo máximo em rotas editoriais. 1 MB. |
WEBHOOK_MAX_BODY_BYTES |
int | Não | 5242880 |
5242880 |
W | Não | Corpo máximo de webhook. 5 MB. |
HTTP_HANDLER_TIMEOUT_MS |
int | Não | 25000 |
25000 |
W | Não | Timeout total do handler HTTP. |
SEND_BATCH_TIMEOUT_MINUTES |
int | Não | 40 |
40 |
K | Não | Tempo máximo de um lote de envio antes de ser marcado como falho. Dobro da meta de 20 minutos. |
26.3.14 Bootstrap e operação #
| Nome | Tipo | Obrig. | Padrão | Exemplo | App | Segredo | Descrição / se faltar |
|---|---|---|---|---|---|---|---|
BOOTSTRAP_ADMIN_EMAIL |
Condicional | — | owner@palavradiaria.com.br |
O | Não | Conta inicial. Obrigatória só na primeira execução do seed. | |
BOOTSTRAP_ADMIN_PASSWORD |
string | Condicional | — | — | O | Sim | Senha inicial, com no mínimo 24 caracteres. Lida dentro do caminho que cria o primeiro proprietário, nunca no topo do módulo, e só depois da verificação de que nenhum OWNER existe — ver a regra completa logo abaixo da tabela. |
SEED_DEV_DATA |
bool | Não | false |
false |
O | Não | Habilita os seeds de desenvolvimento. Ignorada quando NODE_ENV=production. |
MAINTENANCE_MODE |
bool | Não | false |
false |
W | Não | Interruptor de emergência por ambiente. O modo normal é o setting ops.maintenance_mode; esta variável existe para o caso de o banco estar inacessível. |
MAINTENANCE_BYPASS_TOKEN |
string | Condicional | — | 32 bytes em hex | W | Sim | Valor exigido no cabeçalho X-Maintenance-Bypass para atravessar a página de manutenção e validar o sistema antes de reabrir o tráfego (Seção 9.15.3). Obrigatória quando MAINTENANCE_MODE=true. |
E2E_TEST_HOOKS |
bool | Não | false |
false |
W | Não | Habilita as rotas /api/internal/test/* da Seção 24.7.1. O boot do web falha quando esta variável é true com NODE_ENV=production, a menos que ALLOW_TEST_HOOKS_IN_PROD_BUILD=true. Está no registro justamente por governar uma guarda de produção. |
E2E_HOOK_SECRET |
string | Condicional | — | 32 bytes em hex | W | Sim | Valor exigido no cabeçalho x-e2e-secret das rotas de teste. Obrigatória quando E2E_TEST_HOOKS=true. |
ALLOW_TEST_HOOKS_IN_PROD_BUILD |
bool | Não | false |
false |
W | Não | Única forma de rodar os ganchos de teste com NODE_ENV=production, usada apenas na imagem de CI que executa os testes E2E contra um build de produção. Nunca definida no servidor de produção, e o proxy devolve 404 para todo /api/internal/test/* em qualquer ambiente (Seção 25.7.2). |
OPS_CLI_TOKEN |
string | Condicional | — | 32 bytes em hex | O | Sim | Autoriza comandos destrutivos da linha de comando de operação. Obrigatório quando APP_ENV=production. |
BACKUP_S3_BUCKET |
string | Não | — | palavra-diaria-backups |
O | Não | Destino dos backups lógicos. |
BACKUP_RETENTION_DAYS |
int | Não | 35 |
35 |
O | Não | Retenção dos backups. |
Regra da senha inicial, imposta pelo boot e não por checklist. BOOTSTRAP_ADMIN_PASSWORD só
é lida dentro do caminho que cria o primeiro proprietário, depois de confirmar que nenhum OWNER
existe. Sua ausência com proprietário já criado é o estado normal e não produz erro — avaliar
requireEnv no topo do módulo faria todo deploy posterior falhar assim que a variável fosse
removida, e o operador a reporia com a senha original para destravar, deixando-a lá para sempre.
O valor precisa ter no mínimo 24 caracteres e é rejeitado pelo schema se constar da lista de
senhas comuns ou for igual ao exemplo publicado. A conta criada nasce com
must_change_password = true e totp_enrolled_at nulo, e a sessão emitida no primeiro acesso é
restrita às rotas de troca de senha e de enrolamento de segundo fator. O processo recusa
iniciar se APP_ENV = 'production', existir ao menos um OWNER com TOTP enrolado, e
BOOTSTRAP_ADMIN_PASSWORD continuar definida: a remoção deixa de ser um item de checklist e
passa a ser imposta pelo boot.
Total: 165 variáveis, sendo 29 obrigatórias sem padrão, 13 condicionalmente obrigatórias (dependem do valor de outra variável, conforme as regras cruzadas de 26.5) e 32 marcadas como segredo.
Não existe variável ASAAS_WEBHOOK_MAX_BODY_BYTES: o teto de corpo de webhook tem um nome,
WEBHOOK_MAX_BODY_BYTES (26.3.13), e o proxy da Seção 25.7.2 usa exatamente esse valor. Também
não existe TAX_ID_ENCRYPTION_KEY: o CPF é cifrado com ENCRYPTION_KEY e indexado com
PHONE_INDEX_KEY, como toda outra coluna de dado pessoal (Seção 22.5.3).
26.4 .env.example #
Arquivo .env.example na raiz do repositório. Segredos aparecem apenas como placeholder
descritivo — nunca com valor real, nem de desenvolvimento.
# ============================================================================
# Palavra Diária — exemplo de configuração
# Copie para .env e preencha. Nunca faça commit de .env.
# Gere segredos com: openssl rand -hex 32 | openssl rand -base64 32
# ============================================================================
# ---------------------------------------------------------------- núcleo
NODE_ENV=development
APP_ENV=local
APP_NAME=palavra-diaria
APP_URL=http://localhost:3000
PUBLIC_SITE_URL=http://localhost:3000
PORT=3000
WORKER_PORT=3001
TZ=UTC
BUILD_SHA=local
NEXT_PUBLIC_GA_ID=
NEXT_PUBLIC_META_PIXEL_ID=
# ---------------------------------------------------------------- banco
DATABASE_URL=postgresql://palavra:palavra@localhost:5432/palavra_diaria?schema=public
SHADOW_DATABASE_URL=postgresql://palavra:palavra@localhost:5432/palavra_diaria_shadow
DATABASE_POOL_MAX=10
DATABASE_POOL_MIN=2
DATABASE_CONNECT_TIMEOUT_MS=5000
DATABASE_STATEMENT_TIMEOUT_MS=8000
DATABASE_SSL=false
DATABASE_LOG_QUERIES=false
# ---------------------------------------------------------------- redis e filas
REDIS_URL=redis://localhost:6379/0
REDIS_TLS=false
REDIS_COMMAND_TIMEOUT_MS=1000
REDIS_MAX_RETRIES=3
BULLMQ_PREFIX=pd
BULLMQ_CONCURRENCY=10
BULLMQ_MAX_STALLED_COUNT=2
BULLMQ_JOB_RETENTION_COMPLETED=1000
BULLMQ_JOB_RETENTION_FAILED=5000
# ---------------------------------------------------------------- whatsapp
WHATSAPP_GRAPH_VERSION=v26.0
WHATSAPP_API_BASE_URL=https://graph.facebook.com/v26.0
WHATSAPP_PHONE_NUMBER_ID=SUBSTITUA_PELO_ID_DO_NUMERO
WHATSAPP_BUSINESS_ACCOUNT_ID=SUBSTITUA_PELO_ID_DA_CONTA
WHATSAPP_SYSTEM_USER_TOKEN=SEGREDO_TOKEN_DE_ACESSO_DA_PLATAFORMA
META_APP_ID=SUBSTITUA_PELO_ID_DO_APP
META_APP_SECRET=SEGREDO_DO_APP
META_APP_SECRET_PREVIOUS=
WHATSAPP_VERIFY_TOKEN=SEGREDO_GERE_COM_openssl_rand_hex_32
WHATSAPP_SEND_RATE_PER_SECOND=20
WHATSAPP_TIMEOUT_MS=15000
WHATSAPP_MAX_RETRIES=3
WHATSAPP_TEMPLATE_DAILY=devocional_diario_v1
WHATSAPP_TEMPLATE_VIDEO=devocional_diario_video_v1
WHATSAPP_TEMPLATE_OTP=codigo_acesso_v1
WHATSAPP_TEMPLATE_WELCOME=boas_vindas_v1
WHATSAPP_TEMPLATE_LANGUAGE=pt_BR
WHATSAPP_MEDIA_TTL_DAYS=30
# ---------------------------------------------------------------- pagamentos
ASAAS_ENVIRONMENT=sandbox
ASAAS_API_BASE_URL=https://api-sandbox.asaas.com/v3
ASAAS_API_KEY=SEGREDO_CHAVE_DA_API_DE_PAGAMENTOS
ASAAS_WEBHOOK_TOKEN=SEGREDO_GERE_COM_openssl_rand_hex_32
ASAAS_WEBHOOK_TOKEN_PREVIOUS=
ASAAS_TIMEOUT_MS=10000
ASAAS_MAX_RETRIES=2
ASAAS_RATE_LIMIT_RPS=8
# ---------------------------------------------------------------- voz e mídia
TTS_PRIMARY_PROVIDER=ELEVENLABS
ELEVENLABS_API_KEY=SEGREDO_CHAVE_DO_PROVEDOR_DE_VOZ
ELEVENLABS_BASE_URL=https://api.elevenlabs.io/v1
TTS_VOICE_ID=SUBSTITUA_PELO_ID_DA_VOZ_PT_BR
TTS_MODEL_ID=eleven_multilingual_v2
TTS_STABILITY=0.45
TTS_SIMILARITY_BOOST=0.75
TTS_OUTPUT_FORMAT=mp3_44100_128
TTS_TIMEOUT_MS=120000
TTS_MAX_ATTEMPTS_BEFORE_FALLBACK=3
GOOGLE_TTS_CREDENTIALS_JSON=
GOOGLE_TTS_VOICE_NAME=pt-BR-Neural2-C
TTS_FALLBACK_VOICE_ID=
GOOGLE_TTS_LANGUAGE_CODE=pt-BR
FFMPEG_PATH=ffmpeg
AUDIO_OGG_BITRATE_KBPS=32
AUDIO_OGG_FALLBACK_BITRATE_KBPS=24
AUDIO_MP3_BITRATE_KBPS=128
AUDIO_MAX_BYTES=16777216
AUDIO_WARN_BYTES=12582912
# ---------------------------------------------------------------- storage
S3_ENDPOINT=http://localhost:9000
S3_REGION=auto
S3_BUCKET=palavra-diaria-media
S3_ACCESS_KEY_ID=SEGREDO_CHAVE_DE_ACESSO
S3_SECRET_ACCESS_KEY=SEGREDO_CHAVE_SECRETA
S3_FORCE_PATH_STYLE=true
S3_SIGNED_URL_TTL_SECONDS=900
S3_PUBLIC_BASE_URL=
S3_ARCHIVE_BUCKET=palavra-diaria-archive
# ---------------------------------------------------------------- e-mail
EMAIL_ENABLED=false
RESEND_API_KEY=SEGREDO_CHAVE_DO_PROVEDOR_DE_EMAIL
EMAIL_FROM=Palavra Diária <ola@palavradiaria.com.br>
EMAIL_REPLY_TO=suporte@palavradiaria.com.br
EMAIL_ALERTS_TO=alertas@palavradiaria.com.br
EMAIL_MAX_RETRIES=3
# ---------------------------------------------------------------- sessão e cripto
# Gere o par Ed25519 com:
# openssl genpkey -algorithm ed25519 -out jwt.key
# openssl pkey -in jwt.key -pubout -out jwt.pub
# Use \n literal para representar quebras de linha em uma única linha do .env
SESSION_JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nSEGREDO\n-----END PRIVATE KEY-----\n"
SESSION_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nCONTEUDO\n-----END PUBLIC KEY-----\n"
SESSION_JWT_KEY_ID=k1
SESSION_JWT_PREVIOUS_PUBLIC_KEY=
SESSION_JWT_PREVIOUS_KEY_ID=
ACCESS_TOKEN_TTL_SECONDS=900
SESSION_TTL_SUBSCRIBER_DAYS=30
SESSION_TTL_ADMIN_HOURS=12
IMPERSONATION_TTL_MINUTES=30
OTP_PEPPER=SEGREDO_GERE_COM_openssl_rand_hex_32
ENCRYPTION_KEY=SEGREDO_GERE_COM_openssl_rand_base64_32
ENCRYPTION_KEY_PREVIOUS=
PHONE_INDEX_KEY=SEGREDO_GERE_COM_openssl_rand_base64_32
PHONE_INDEX_KEY_PREVIOUS=
CSRF_SECRET=SEGREDO_GERE_COM_openssl_rand_hex_32
COOKIE_DOMAIN=
TRUST_PROXY=false
ALLOWED_ORIGINS=http://localhost:3000
TURNSTILE_SECRET_KEY=SEGREDO_CHAVE_DE_VERIFICACAO_ANTI_BOT
# ---------------------------------------------------------------- observabilidade
LOG_LEVEL=debug
LOG_FORMAT=pretty
LOG_REDACT_EXTRA=
SENTRY_DSN=
SENTRY_TRACES_SAMPLE_RATE=0.1
METRICS_ENABLED=true
METRICS_TOKEN=
OPS_WEBHOOK_URL=
OPS_WEBHOOK_SECRET=
HEALTHCHECK_TIMEOUT_MS=3000
# ---------------------------------------------------------------- agendamento
SCHEDULER_ENABLED=true
SCHEDULER_TIMEZONE=America/Sao_Paulo
DAILY_SEND_HOUR=6
DAILY_PLAN_OFFSET_MINUTES=20
FREE_TIER_SEND_WEEKDAY=0
RECONCILE_HOUR=4
RETENTION_HOUR=3
PARTITION_HOUR=3
METRICS_ROLLUP_HOUR=3
JOB_STALE_MINUTES=30
# ---------------------------------------------------------------- rate limiting
RATE_LIMIT_ENABLED=true
RATE_LIMIT_GLOBAL_IP_CAPACITY=600
RATE_LIMIT_GLOBAL_IP_REFILL_PER_MINUTE=300
OTP_MAX_PER_HOUR_PER_PHONE=3
OTP_MAX_PER_HOUR_PER_IP=10
OTP_RESEND_COOLDOWN_SECONDS=60
OTP_TTL_SECONDS=600
OTP_MAX_ATTEMPTS=5
MAGIC_LINK_TTL_SECONDS=1200
ADMIN_LOGIN_MAX_PER_HOUR_PER_IP=10
IDEMPOTENCY_TTL_SECONDS=86400
# ---------------------------------------------------------------- limites de negócio
FREE_ARCHIVE_DAYS=7
RESEND_LIMIT_FREE=1
RESEND_LIMIT_PAID=3
WINDOW_MISS_THRESHOLD=3
SERVICE_WINDOW_HOURS=24
TEASER_MAX_LENGTH=300
FREEFORM_TEXT_MAX_LENGTH=4096
MAX_BODY_BYTES=262144
MAX_BODY_BYTES_ADMIN=1048576
WEBHOOK_MAX_BODY_BYTES=5242880
HTTP_HANDLER_TIMEOUT_MS=25000
SEND_BATCH_TIMEOUT_MINUTES=40
# ---------------------------------------------------------------- bootstrap
BOOTSTRAP_ADMIN_EMAIL=owner@palavradiaria.com.br
BOOTSTRAP_ADMIN_PASSWORD=SEGREDO_TROQUE_NO_PRIMEIRO_LOGIN
SEED_DEV_DATA=true
MAINTENANCE_MODE=false
MAINTENANCE_BYPASS_TOKEN=
OPS_CLI_TOKEN=
BACKUP_S3_BUCKET=
BACKUP_RETENTION_DAYS=35
BACKUP_ENCRYPTION_KEY=SEGREDO_CHAVE_PUBLICA_age_DO_BACKUP
# ------------------------------------------------------- ganchos de teste
# Ligados apenas em execucao de teste. Nunca no servidor de producao.
E2E_TEST_HOOKS=false
E2E_HOOK_SECRET=
ALLOW_TEST_HOOKS_IN_PROD_BUILD=false26.5 Schema Zod de validação de ambiente #
Arquivo packages/core/src/env.ts. É a única porta de entrada para configuração.
process.env é proibido em qualquer outro arquivo, e uma regra de lint reforça isso.
// packages/core/src/env.ts
import { z } from 'zod';
// ------------------------------------------------------------ auxiliares
const bool = z
.enum(['true', 'false', '1', '0'])
.transform((v) => v === 'true' || v === '1');
const int = (min: number, max: number) =>
z.coerce.number().int().min(min).max(max);
const float01 = z.coerce.number().min(0).max(1);
const csv = z
.string()
.min(1)
.transform((v) => v.split(',').map((s) => s.trim()).filter(Boolean));
const pem = (kind: 'PRIVATE' | 'PUBLIC') =>
z.string().transform((v) => v.replace(/\\n/g, '\n')).refine(
(v) => v.includes(`-----BEGIN ${kind} KEY-----`),
{ message: `deve ser uma chave ${kind} no formato PEM` },
);
const secret = (minLength: number) =>
z.string().min(minLength, `precisa de ao menos ${minLength} caracteres`);
const base64Key32 = z.string().refine(
(v) => { try { return Buffer.from(v, 'base64').length === 32; } catch { return false; } },
{ message: 'deve ser 32 bytes em base64 (openssl rand -base64 32)' },
);
// ------------------------------------------------------------ schema
export const envSchema = z
.object({
// núcleo
NODE_ENV: z.enum(['development', 'test', 'production']),
APP_ENV: z.enum(['local', 'staging', 'production']),
APP_NAME: z.string().default('palavra-diaria'),
APP_URL: z.string().url(),
PUBLIC_SITE_URL: z.string().url(),
PORT: int(1, 65535).default(3000),
WORKER_PORT: int(1, 65535).default(3001),
TZ: z.string().default('UTC'),
BUILD_SHA: z.string().default('unknown'),
NEXT_PUBLIC_GA_ID: z.string().optional().or(z.literal('')),
NEXT_PUBLIC_META_PIXEL_ID: z.string().optional().or(z.literal('')),
// banco
DATABASE_URL: z.string().url().startsWith('postgresql://'),
SHADOW_DATABASE_URL: z.string().url().optional(),
DATABASE_POOL_MAX: int(1, 200).default(10),
DATABASE_POOL_MIN: int(0, 50).default(2),
DATABASE_CONNECT_TIMEOUT_MS: int(100, 60_000).default(5_000),
DATABASE_STATEMENT_TIMEOUT_MS: int(100, 120_000).default(8_000),
DATABASE_SSL: bool.default('false'),
DATABASE_LOG_QUERIES: bool.default('false'),
// redis e filas
REDIS_URL: z.string().url(),
REDIS_TLS: bool.default('false'),
REDIS_COMMAND_TIMEOUT_MS: int(50, 30_000).default(1_000),
REDIS_MAX_RETRIES: int(0, 20).default(3),
BULLMQ_PREFIX: z.string().min(1).max(20).default('pd'),
BULLMQ_CONCURRENCY: int(1, 200).default(10),
BULLMQ_MAX_STALLED_COUNT: int(0, 10).default(2),
BULLMQ_JOB_RETENTION_COMPLETED: int(0, 100_000).default(1_000),
BULLMQ_JOB_RETENTION_FAILED: int(0, 100_000).default(5_000),
// whatsapp
WHATSAPP_GRAPH_VERSION: z.string().regex(/^v[0-9]+\.[0-9]+$/).default('v26.0'),
WHATSAPP_API_BASE_URL: z.string().url().default('https://graph.facebook.com/v26.0'),
WHATSAPP_PHONE_NUMBER_ID: z.string().regex(/^[0-9]{10,20}$/),
WHATSAPP_BUSINESS_ACCOUNT_ID: z.string().regex(/^[0-9]{10,20}$/),
WHATSAPP_SYSTEM_USER_TOKEN: secret(40),
META_APP_ID: z.string().regex(/^[0-9]{10,20}$/),
META_APP_SECRET: secret(20),
META_APP_SECRET_PREVIOUS: secret(20).optional().or(z.literal('')),
WHATSAPP_VERIFY_TOKEN: secret(20),
WHATSAPP_SEND_RATE_PER_SECOND: int(1, 80).default(20),
WHATSAPP_TIMEOUT_MS: int(1_000, 120_000).default(15_000),
WHATSAPP_MAX_RETRIES: int(0, 10).default(3),
WHATSAPP_TEMPLATE_DAILY: z.string().default('devocional_diario_v1'),
WHATSAPP_TEMPLATE_VIDEO: z.string().default('devocional_diario_video_v1'),
WHATSAPP_TEMPLATE_OTP: z.string().default('codigo_acesso_v1'),
WHATSAPP_TEMPLATE_WELCOME: z.string().default('boas_vindas_v1'),
WHATSAPP_TEMPLATE_LANGUAGE: z.string().default('pt_BR'),
WHATSAPP_MEDIA_TTL_DAYS: int(1, 30).default(30),
// pagamentos
ASAAS_ENVIRONMENT: z.enum(['sandbox', 'production']),
ASAAS_API_BASE_URL: z.string().url().optional(),
ASAAS_API_KEY: secret(20),
ASAAS_WEBHOOK_TOKEN: secret(20),
ASAAS_WEBHOOK_TOKEN_PREVIOUS: secret(20).optional().or(z.literal('')),
ASAAS_TIMEOUT_MS: int(1_000, 60_000).default(10_000),
ASAAS_MAX_RETRIES: int(0, 5).default(2),
ASAAS_RATE_LIMIT_RPS: int(1, 100).default(8),
// voz e mídia
TTS_PRIMARY_PROVIDER: z.enum(['ELEVENLABS', 'GOOGLE_TTS']).default('ELEVENLABS'),
ELEVENLABS_API_KEY: secret(20).optional(),
ELEVENLABS_BASE_URL: z.string().url().default('https://api.elevenlabs.io/v1'),
TTS_VOICE_ID: z.string().min(3),
TTS_MODEL_ID: z.string().default('eleven_multilingual_v2'),
TTS_STABILITY: float01.default(0.45),
TTS_SIMILARITY_BOOST: float01.default(0.75),
TTS_OUTPUT_FORMAT: z.string().default('mp3_44100_128'),
TTS_TIMEOUT_MS: int(5_000, 600_000).default(120_000),
TTS_MAX_ATTEMPTS_BEFORE_FALLBACK: int(1, 10).default(3),
GOOGLE_TTS_CREDENTIALS_JSON: z
.string()
.refine((v) => { try { JSON.parse(v); return true; } catch { return false; } },
{ message: 'deve ser um JSON válido em uma única linha' })
.optional(),
GOOGLE_TTS_VOICE_NAME: z.string().default('pt-BR-Neural2-C'),
TTS_FALLBACK_VOICE_ID: z.string().optional().or(z.literal('')),
GOOGLE_TTS_LANGUAGE_CODE: z.string().default('pt-BR'),
FFMPEG_PATH: z.string().default('ffmpeg'),
AUDIO_OGG_BITRATE_KBPS: int(8, 128).default(32),
AUDIO_OGG_FALLBACK_BITRATE_KBPS: int(8, 128).default(24),
AUDIO_MP3_BITRATE_KBPS: int(32, 320).default(128),
AUDIO_MAX_BYTES: int(1, 16_777_216).default(16_777_216),
AUDIO_WARN_BYTES: int(1, 16_777_216).default(12_582_912),
// storage
S3_ENDPOINT: z.string().url(),
S3_REGION: z.string().default('auto'),
S3_BUCKET: z.string().min(3),
S3_ACCESS_KEY_ID: secret(8),
S3_SECRET_ACCESS_KEY: secret(16),
S3_FORCE_PATH_STYLE: bool.default('true'),
S3_SIGNED_URL_TTL_SECONDS: int(60, 604_800).default(900),
S3_PUBLIC_BASE_URL: z.string().url().optional().or(z.literal('')),
S3_ARCHIVE_BUCKET: z.string().optional(),
// e-mail
EMAIL_ENABLED: bool.default('true'),
RESEND_API_KEY: secret(20).optional(),
EMAIL_FROM: z.string().min(5).optional(),
EMAIL_REPLY_TO: z.string().email().optional(),
EMAIL_ALERTS_TO: z.string().email(),
EMAIL_MAX_RETRIES: int(0, 10).default(3),
// sessão e cripto
SESSION_JWT_PRIVATE_KEY: pem('PRIVATE'),
SESSION_JWT_PUBLIC_KEY: pem('PUBLIC'),
SESSION_JWT_KEY_ID: z.string().min(1).max(16).default('k1'),
SESSION_JWT_PREVIOUS_PUBLIC_KEY: pem('PUBLIC').optional().or(z.literal('')),
SESSION_JWT_PREVIOUS_KEY_ID: z.string().max(16).optional(),
ACCESS_TOKEN_TTL_SECONDS: int(60, 3_600).default(900),
SESSION_TTL_SUBSCRIBER_DAYS: int(1, 365).default(30),
SESSION_TTL_ADMIN_HOURS: int(1, 168).default(12),
IMPERSONATION_TTL_MINUTES: int(5, 240).default(30),
OTP_PEPPER: secret(32),
ENCRYPTION_KEY: base64Key32,
ENCRYPTION_KEY_PREVIOUS: base64Key32.optional().or(z.literal('')),
PHONE_INDEX_KEY: base64Key32,
PHONE_INDEX_KEY_PREVIOUS: base64Key32.optional().or(z.literal('')),
CSRF_SECRET: secret(32),
COOKIE_DOMAIN: z.string().optional(),
TRUST_PROXY: bool.default('true'),
ALLOWED_ORIGINS: csv,
TURNSTILE_SECRET_KEY: secret(20).optional(),
// observabilidade
LOG_LEVEL: z.enum(['trace','debug','info','warn','error','fatal']).default('info'),
LOG_FORMAT: z.enum(['json', 'pretty']).default('json'),
LOG_REDACT_EXTRA: csv.optional(),
SENTRY_DSN: z.string().url().optional().or(z.literal('')),
SENTRY_TRACES_SAMPLE_RATE: float01.default(0.1),
METRICS_ENABLED: bool.default('true'),
METRICS_TOKEN: secret(32).optional(),
OPS_WEBHOOK_URL: z.string().url().optional().or(z.literal('')),
OPS_WEBHOOK_SECRET: secret(32).optional(),
HEALTHCHECK_TIMEOUT_MS: int(100, 30_000).default(3_000),
// agendamento
SCHEDULER_ENABLED: bool.default('true'),
SCHEDULER_TIMEZONE: z.string().default('America/Sao_Paulo'),
DAILY_SEND_HOUR: int(0, 23).default(6),
DAILY_PLAN_OFFSET_MINUTES: int(5, 120).default(20),
FREE_TIER_SEND_WEEKDAY: int(0, 6).default(0),
RECONCILE_HOUR: int(0, 23).default(4),
RETENTION_HOUR: int(0, 23).default(3),
PARTITION_HOUR: int(0, 23).default(3),
METRICS_ROLLUP_HOUR: int(0, 23).default(3),
JOB_STALE_MINUTES: int(1, 1_440).default(30),
// rate limiting
RATE_LIMIT_ENABLED: bool.default('true'),
RATE_LIMIT_GLOBAL_IP_CAPACITY: int(1, 100_000).default(600),
RATE_LIMIT_GLOBAL_IP_REFILL_PER_MINUTE: int(1, 100_000).default(300),
OTP_MAX_PER_HOUR_PER_PHONE: int(1, 20).default(3),
OTP_MAX_PER_HOUR_PER_IP: int(1, 200).default(10),
OTP_RESEND_COOLDOWN_SECONDS: int(10, 600).default(60),
OTP_TTL_SECONDS: int(60, 1_800).default(600),
OTP_MAX_ATTEMPTS: int(1, 10).default(5),
MAGIC_LINK_TTL_SECONDS: int(300, 3_600).default(1_200),
ADMIN_LOGIN_MAX_PER_HOUR_PER_IP: int(1, 100).default(10),
IDEMPOTENCY_TTL_SECONDS: int(300, 604_800).default(86_400),
// limites de negócio
FREE_ARCHIVE_DAYS: int(1, 365).default(7),
RESEND_LIMIT_FREE: int(0, 10).default(1),
RESEND_LIMIT_PAID: int(0, 20).default(3),
WINDOW_MISS_THRESHOLD: int(1, 30).default(3),
SERVICE_WINDOW_HOURS: int(1, 24).default(24),
TEASER_MAX_LENGTH: int(50, 300).default(300),
FREEFORM_TEXT_MAX_LENGTH: int(500, 4_096).default(4_096),
MAX_BODY_BYTES: int(1_024, 10_485_760).default(262_144),
MAX_BODY_BYTES_ADMIN: int(1_024, 10_485_760).default(1_048_576),
WEBHOOK_MAX_BODY_BYTES: int(1_024, 10_485_760).default(5_242_880),
HTTP_HANDLER_TIMEOUT_MS: int(1_000, 120_000).default(25_000),
SEND_BATCH_TIMEOUT_MINUTES: int(5, 240).default(40),
// bootstrap
BOOTSTRAP_ADMIN_EMAIL: z.string().email().optional(),
BOOTSTRAP_ADMIN_PASSWORD: z
.string()
.min(24, 'precisa de ao menos 24 caracteres')
.refine((v) => !COMMON_PASSWORDS.has(v.toLowerCase()) && v !== PUBLISHED_EXAMPLE_PASSWORD,
{ message: 'senha comum ou igual ao exemplo publicado' })
.optional(),
SEED_DEV_DATA: bool.default('false'),
MAINTENANCE_MODE: bool.default('false'),
MAINTENANCE_BYPASS_TOKEN: secret(32).optional(),
OPS_CLI_TOKEN: secret(32).optional(),
BACKUP_S3_BUCKET: z.string().optional(),
BACKUP_RETENTION_DAYS: int(1, 365).default(35),
BACKUP_ENCRYPTION_KEY: secret(20).optional(),
// ganchos de teste
E2E_TEST_HOOKS: bool.default('false'),
E2E_HOOK_SECRET: secret(20).optional(),
ALLOW_TEST_HOOKS_IN_PROD_BUILD: bool.default('false'),
})
// ---------------------------------------------------- regras entre campos
.superRefine((e, ctx) => {
const issue = (path: string, message: string) =>
ctx.addIssue({ code: 'custom', path: [path], message });
if (e.TTS_PRIMARY_PROVIDER === 'ELEVENLABS' && !e.ELEVENLABS_API_KEY) {
issue('ELEVENLABS_API_KEY', 'obrigatória quando TTS_PRIMARY_PROVIDER=ELEVENLABS');
}
if (e.EMAIL_ENABLED && !e.RESEND_API_KEY) {
issue('RESEND_API_KEY', 'obrigatória quando EMAIL_ENABLED=true');
}
if (e.EMAIL_ENABLED && !e.EMAIL_FROM) {
issue('EMAIL_FROM', 'obrigatória quando EMAIL_ENABLED=true');
}
if (e.OPS_WEBHOOK_URL && !e.OPS_WEBHOOK_SECRET) {
issue('OPS_WEBHOOK_SECRET', 'obrigatória quando OPS_WEBHOOK_URL está definida');
}
if (e.MAINTENANCE_MODE && !e.MAINTENANCE_BYPASS_TOKEN) {
issue('MAINTENANCE_BYPASS_TOKEN', 'obrigatório quando MAINTENANCE_MODE=true');
}
if (e.E2E_TEST_HOOKS && !e.E2E_HOOK_SECRET) {
issue('E2E_HOOK_SECRET', 'obrigatório quando E2E_TEST_HOOKS=true');
}
if (e.E2E_TEST_HOOKS && e.NODE_ENV === 'production' && !e.ALLOW_TEST_HOOKS_IN_PROD_BUILD) {
issue('E2E_TEST_HOOKS', 'proibidos com NODE_ENV=production');
}
if (e.DATABASE_POOL_MIN > e.DATABASE_POOL_MAX) {
issue('DATABASE_POOL_MIN', 'não pode ser maior que DATABASE_POOL_MAX');
}
if (e.AUDIO_WARN_BYTES > e.AUDIO_MAX_BYTES) {
issue('AUDIO_WARN_BYTES', 'não pode ser maior que AUDIO_MAX_BYTES');
}
if (e.AUDIO_OGG_FALLBACK_BITRATE_KBPS > e.AUDIO_OGG_BITRATE_KBPS) {
issue('AUDIO_OGG_FALLBACK_BITRATE_KBPS', 'deve ser menor ou igual ao bitrate normal');
}
// regras específicas de produção
if (e.APP_ENV === 'production') {
if (e.ASAAS_ENVIRONMENT !== 'production') {
issue('ASAAS_ENVIRONMENT', 'precisa ser "production" quando APP_ENV=production');
}
if (!e.APP_URL.startsWith('https://')) {
issue('APP_URL', 'precisa usar https em produção');
}
if (e.ALLOWED_ORIGINS.some((o) => !o.startsWith('https://'))) {
issue('ALLOWED_ORIGINS', 'todas as origens precisam usar https em produção');
}
if (e.DATABASE_LOG_QUERIES) {
issue('DATABASE_LOG_QUERIES', 'proibido em produção: consultas contêm dado pessoal');
}
if (e.SEED_DEV_DATA) {
issue('SEED_DEV_DATA', 'proibido em produção');
}
if (!e.RATE_LIMIT_ENABLED) {
issue('RATE_LIMIT_ENABLED', 'não pode ser desligado em produção');
}
if (e.METRICS_ENABLED && !e.METRICS_TOKEN) {
issue('METRICS_TOKEN', 'obrigatório em produção quando METRICS_ENABLED=true');
}
if (!e.OPS_CLI_TOKEN) {
issue('OPS_CLI_TOKEN', 'obrigatório em produção');
}
if (!e.TURNSTILE_SECRET_KEY) {
issue('TURNSTILE_SECRET_KEY', 'obrigatória em produção: formulário público sem'
+ ' verificação anti-bot vira porta de cadastro em massa');
}
if (!e.BACKUP_ENCRYPTION_KEY) {
issue('BACKUP_ENCRYPTION_KEY', 'obrigatória em produção: backup nunca sai do host'
+ ' em texto claro');
}
if (e.E2E_TEST_HOOKS || e.ALLOW_TEST_HOOKS_IN_PROD_BUILD) {
issue('E2E_TEST_HOOKS', 'ganchos de teste são proibidos em produção');
}
if (!e.DATABASE_SSL && !e.DATABASE_URL.includes('@postgres:')) {
issue('DATABASE_SSL', 'exigido quando o banco não está na rede privada do compose');
}
if (e.LOG_FORMAT !== 'json') {
issue('LOG_FORMAT', 'precisa ser "json" em produção para a coleta de logs');
}
if (e.ENCRYPTION_KEY_PREVIOUS && e.ENCRYPTION_KEY_PREVIOUS === e.ENCRYPTION_KEY) {
issue('ENCRYPTION_KEY_PREVIOUS', 'não pode ser igual à chave corrente');
}
if (e.PHONE_INDEX_KEY === e.ENCRYPTION_KEY) {
issue('PHONE_INDEX_KEY',
'precisa ser distinta de ENCRYPTION_KEY: chaves iguais unem os dois riscos');
}
}
if (e.APP_ENV !== 'production' && e.ASAAS_ENVIRONMENT === 'production') {
issue('ASAAS_ENVIRONMENT',
'usar o ambiente de produção de pagamentos fora de produção cobraria de verdade');
}
});
export type Env = z.infer<typeof envSchema>;
// ------------------------------------------------------------ carga
let cached: Env | null = null;
export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
if (cached) return cached;
const parsed = envSchema.safeParse(source);
if (!parsed.success) {
const lines = parsed.error.issues.map((i) => {
const name = i.path.join('.') || '(raiz)';
return ` • ${name}: ${i.message}`;
});
// eslint-disable-next-line no-console -- o logger ainda não existe neste ponto
console.error(
[
'',
'════════════════════════════════════════════════════════════════',
' CONFIGURAÇÃO INVÁLIDA — o processo não pode iniciar',
'════════════════════════════════════════════════════════════════',
'',
`Foram encontrados ${parsed.error.issues.length} problema(s):`,
'',
...lines,
'',
'Consulte .env.example para o formato esperado de cada variável.',
'Segredos nunca são exibidos nesta mensagem.',
'════════════════════════════════════════════════════════════════',
'',
].join('\n'),
);
process.exit(1);
}
cached = parsed.data;
return cached;
}
export const env = loadEnv();Exemplo de saída real quando faltam variáveis:
════════════════════════════════════════════════════════════════
CONFIGURAÇÃO INVÁLIDA — o processo não pode iniciar
════════════════════════════════════════════════════════════════
Foram encontrados 4 problema(s):
• DATABASE_URL: Required
• OTP_PEPPER: precisa de ao menos 32 caracteres
• ENCRYPTION_KEY: deve ser 32 bytes em base64 (openssl rand -base64 32)
• ASAAS_ENVIRONMENT: precisa ser "production" quando APP_ENV=production
Consulte .env.example para o formato esperado de cada variável.
Segredos nunca são exibidos nesta mensagem.
════════════════════════════════════════════════════════════════Três propriedades garantidas por esse desenho:
- Todos os problemas de uma vez.
safeParseacumula; ninguém corrige uma variável, reinicia, e descobre a seguinte. - Nenhum valor é impresso. A mensagem cita o nome e a regra violada, nunca o valor recebido. Imprimir "recebido: sk_live_abc123" vazaria o segredo no log de boot.
- Falha antes de servir.
loadEnv()é chamado no topo do módulo de instrumentação, antes do servidor abrir a porta. Um processo mal configurado nunca chega a atender.
26.6 Diferenças entre ambientes #
| Variável | local |
staging |
production |
|---|---|---|---|
NODE_ENV |
development |
production |
production |
APP_ENV |
local |
staging |
production |
APP_URL |
http://localhost:3000 |
https://staging.palavradiaria.com.br |
https://app.palavradiaria.com.br |
DATABASE_SSL |
false |
false (rede privada) |
false (rede privada do compose) |
DATABASE_LOG_QUERIES |
true |
false |
false (proibido pelo schema) |
DATABASE_POOL_MAX |
5 |
10 |
20 |
BULLMQ_CONCURRENCY |
5 |
10 |
25 |
ASAAS_ENVIRONMENT |
sandbox |
sandbox |
production |
WHATSAPP_PHONE_NUMBER_ID |
número de teste | número de teste | número de produção |
WHATSAPP_SEND_RATE_PER_SECOND |
2 |
5 |
20 |
EMAIL_ENABLED |
false |
true (só para a equipe) |
true |
TTS_PRIMARY_PROVIDER |
ELEVENLABS |
ELEVENLABS |
ELEVENLABS |
S3_ENDPOINT |
MinIO local | bucket de staging | bucket de produção |
S3_BUCKET |
palavra-diaria-media |
palavra-diaria-media-staging |
palavra-diaria-media |
LOG_LEVEL |
debug |
debug |
info |
LOG_FORMAT |
pretty |
json |
json |
SENTRY_DSN |
vazio | preenchido | preenchido |
SENTRY_TRACES_SAMPLE_RATE |
0 |
1.0 |
0.1 |
RATE_LIMIT_ENABLED |
false |
true |
true (obrigatório) |
TRUST_PROXY |
false |
true |
true |
SCHEDULER_ENABLED |
false |
true |
true |
DAILY_SEND_HOUR |
qualquer, para teste | 6 |
6 |
SEED_DEV_DATA |
true |
true |
false (proibido) |
MAINTENANCE_MODE |
false |
false |
false |
METRICS_TOKEN |
vazio | preenchido | preenchido (obrigatório) |
OPS_CLI_TOKEN |
vazio | preenchido | preenchido (obrigatório) |
BACKUP_S3_BUCKET |
vazio | preenchido | preenchido |
BACKUP_ENCRYPTION_KEY |
vazio | preenchido | preenchido (obrigatório) |
TURNSTILE_SECRET_KEY |
chave de teste do provedor | preenchido | preenchido (obrigatório) |
E2E_TEST_HOOKS |
true |
true |
false (proibido) |
ALLOW_TEST_HOOKS_IN_PROD_BUILD |
false |
false |
false (proibido) |
NEXT_PUBLIC_GA_ID / NEXT_PUBLIC_META_PIXEL_ID |
vazios | vazios | vazios |
Duas decisões importantes na tabela:
SCHEDULER_ENABLED=falseem desenvolvimento. Sem isso, cada desenvolvedor teria um agendador tentando enviar mensagens. O disparo em desenvolvimento é manual, pela linha de comando de operação.ASAAS_ENVIRONMENT=sandboxem staging. Staging usa dados sintéticos. Cobrar de verdade em staging é o tipo de erro que só se comete uma vez, e o schema de 26.5 impede a combinaçãoAPP_ENV≠productioncomASAAS_ENVIRONMENT=production.
Staging usa SENTRY_TRACES_SAMPLE_RATE=1.0 de propósito: o volume é baixo e o objetivo é
enxergar tudo. Produção usa 0,1 porque 100% de rastros em 10.000 assinantes é caro sem
ganho proporcional.
26.7 Política de segredos #
26.7.1 Onde ficam #
| Ambiente | Onde | Acesso |
|---|---|---|
local |
Arquivo .env na máquina do desenvolvedor, fora do controle de versão |
O próprio desenvolvedor |
staging |
Arquivo /etc/palavra-diaria/staging.env no servidor, modo 0600, dono root |
Operadores com acesso ao servidor |
production |
Arquivo /etc/palavra-diaria/production.env no servidor, modo 0600, dono root |
Somente quem tem OWNER e acesso ao servidor |
| CI | Segredos do repositório, escopo por ambiente | Quem administra o repositório |
Regras não negociáveis:
.enve*.envestão no.gitignore. Um gancho de pré-commit recusa qualquer arquivo que case com.env*e não seja.env.example.- Segredo nunca entra em imagem de contêiner. É injetado em tempo de execução por
env_filenodocker-compose.yml. - Segredo nunca entra em argumento de build (
ARG/--build-arg): argumentos ficam no histórico de camadas da imagem. - Segredo nunca aparece em pull request, issue, mensagem de chat ou captura de tela.
- Segredo nunca é enviado por e-mail ou mensagem. A transferência é por canal cifrado de uso único.
26.7.2 Quem tem acesso #
| Papel | Acesso |
|---|---|
| Desenvolvedor | Apenas os segredos de local, que apontam para serviços de teste |
| Operador | staging completo; production somente leitura, para diagnóstico |
OWNER do produto |
production completo, incluindo rotação |
| Agente executor de build | Nenhum. Recebe a configuração já montada no ambiente de execução |
O acesso a production é individual e nominal. Não existe credencial compartilhada de
servidor: cada pessoa tem chave SSH própria, e a revogação é a remoção da chave.
26.7.3 Como rotacionar cada segredo #
Esta subseção é a dona única da cadência de rotação. Nenhuma outra seção declara prazo de
rotação de segredo: as Seções 22.4.2 e 25.14.2 trazem o inventário e o procedimento e apontam
para cá. As três âncoras são 90 dias para a chave de assinatura de sessão, 180 dias para
as senhas de banco e de Redis e 365 dias para as chaves de cifra. O prazo é o teto, nunca o
piso: rotação antecipada é sempre permitida e é obrigatória sob suspeita de vazamento. O alerta
secret_rotation_due (Seção 23.8) usa exatamente os prazos desta tabela, lidos da chave
ops.secret_rotated_at de settings.
| Segredo | Frequência | Procedimento | Interrupção |
|---|---|---|---|
DATABASE_URL (senha) |
180 dias, ou em incidente | Cria o novo usuário no Postgres com as mesmas permissões, troca a variável, reinicia web e worker, remove o usuário antigo depois de 24 h | Alguns segundos no reinício |
REDIS_URL (senha) |
180 dias | CONFIG SET requirepass, troca a variável, reinicia |
Alguns segundos; sessões sobrevivem, chaves de rate limit são recriadas |
WHATSAPP_SYSTEM_USER_TOKEN |
60 dias | Gera token novo no painel da plataforma, troca a variável, reinicia o worker, revoga o antigo | Nenhuma se o token novo entrar antes de o antigo ser revogado |
META_APP_SECRET |
Só em incidente | Janela dupla obrigatória: copia o valor atual para META_APP_SECRET_PREVIOUS, gera o novo segredo no painel do provedor, coloca-o em META_APP_SECRET, reinicia o web. A verificação aceita as duas assinaturas e registra qual validou; só depois de 100% das validações usarem a nova é que a anterior é removida |
Nenhuma. Sem a janela dupla, toda rotação rejeita webhooks legítimos entre a troca no painel e o reinício — e webhook rejeitado leva o provedor a desativar a assinatura |
WHATSAPP_VERIFY_TOKEN |
365 dias | Troca a variável, reinicia, refaz o handshake no painel | Nenhuma; o token só é usado no handshake |
ASAAS_API_KEY |
365 dias | Gera nova chave no provedor, troca, reinicia, revoga a antiga | Nenhuma com sobreposição |
ASAAS_WEBHOOK_TOKEN |
365 dias | Troca no provedor e na variável na mesma janela, reinicia | Webhooks falham entre as duas trocas. Fazer em menos de 2 minutos e reprocessar o que falhar |
ELEVENLABS_API_KEY |
365 dias | Gera nova, troca, reinicia o worker, revoga a antiga | Nenhuma |
GOOGLE_TTS_CREDENTIALS_JSON |
365 dias | Nova chave da conta de serviço, troca, reinicia, apaga a antiga | Nenhuma |
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY |
365 dias | Novo par de chaves, troca, reinicia, revoga o antigo | Nenhuma |
RESEND_API_KEY |
365 dias | Nova chave, troca, reinicia, revoga | Nenhuma |
SESSION_JWT_PRIVATE_KEY / SESSION_JWT_PUBLIC_KEY |
90 dias | Ver 26.7.4 | Nenhuma, com a janela de sobreposição |
OTP_PEPPER |
Só em incidente | Troca e reinicia. Todos os OTPs pendentes são invalidados | Quem estava com código em mãos precisa pedir outro |
ENCRYPTION_KEY |
365 dias | Ver 26.7.4 | Nenhuma, com a chave anterior configurada |
PHONE_INDEX_KEY |
365 dias, na mesma janela de ENCRYPTION_KEY |
Ver 26.7.4 e o procedimento de três fases de 22.5.5 | Nenhuma; durante a fase de leitura dupla cada busca compara dois HMACs |
CSRF_SECRET |
365 dias | Troca e reinicia | Tokens CSRF em páginas abertas ficam inválidos; a próxima ação falha uma vez e o front recarrega |
METRICS_TOKEN |
365 dias | Troca, reinicia, atualiza o coletor | Coleta de métricas interrompida até atualizar o coletor |
OPS_WEBHOOK_SECRET |
365 dias | Troca dos dois lados na mesma janela | Alertas falham entre as trocas; o log continua |
OPS_CLI_TOKEN |
90 dias, ou na saída de alguém da equipe | Troca e reinicia | Nenhuma |
TURNSTILE_SECRET_KEY |
365 dias | Gera par novo no painel do provedor, troca a variável, reinicia o web | Nenhuma; o desafio anterior continua válido pelo tempo de vida do token já emitido |
BACKUP_ENCRYPTION_KEY |
365 dias | Validar antes que os backups antigos ainda são legíveis com a chave anterior guardada; só então gerar a nova, trocar a variável e passar a cifrar com ela. A chave antiga nunca é descartada enquanto existir backup cifrado com ela | Nenhuma na operação; a restauração de um backup antigo exige a chave da época |
BOOTSTRAP_ADMIN_PASSWORD |
Uso único | Removida do ambiente após o primeiro login. O boot recusa iniciar em produção se ela persistir com um OWNER já enrolado em TOTP (26.3.14) |
— |
26.7.4 Rotação com sobreposição #
Três segredos exigem janela de sobreposição, porque há dados antigos que precisam continuar sendo lidos.
Chave de assinatura de sessão:
# 1. gera o novo par
openssl genpkey -algorithm ed25519 -out jwt-k2.key
openssl pkey -in jwt-k2.key -pubout -out jwt-k2.pub
# 2. move a atual para "anterior" e coloca a nova como corrente
SESSION_JWT_PREVIOUS_PUBLIC_KEY="<conteúdo de jwt-k1.pub>"
SESSION_JWT_PREVIOUS_KEY_ID=k1
SESSION_JWT_PRIVATE_KEY="<conteúdo de jwt-k2.key>"
SESSION_JWT_PUBLIC_KEY="<conteúdo de jwt-k2.pub>"
SESSION_JWT_KEY_ID=k2
# 3. reinicia web e worker
# tokens novos são assinados com k2; tokens antigos ainda verificam com k1
# 4. depois de 20 minutos (TTL do access token de 15 min + folga),
# remove SESSION_JWT_PREVIOUS_PUBLIC_KEY e SESSION_JWT_PREVIOUS_KEY_ID e reiniciaNenhum assinante é desconectado. O refresh token não depende da chave, então mesmo um token de acesso rejeitado é renovado sem novo login.
Chave de criptografia de dados:
# 1. mantém a atual como anterior e define a nova
ENCRYPTION_KEY_PREVIOUS="<chave atual em base64>"
ENCRYPTION_KEY="$(openssl rand -base64 32)"
# 2. reinicia: escrita usa a nova, leitura tenta a nova e cai para a anterior
# 3. recifra os dados existentes em lote
pnpm ops crypto:rewrap --batch-size 500
# 4. confirma que nada resta com a chave antiga
pnpm ops crypto:verify # precisa reportar 0 registros com a chave anterior
# 5. remove ENCRYPTION_KEY_PREVIOUS e reiniciaPular o passo 4 e remover a chave anterior torna telefone, wa_id, e-mail, CPF e segredo TOTP
irrecuperáveis. O comando crypto:verify existe exatamente para impedir esse erro, e o
passo 5 é bloqueado por checagem de boot: se ainda houver linha que só decifra com a chave
anterior e ENCRYPTION_KEY_PREVIOUS estiver ausente, o processo falha ao iniciar com mensagem
explícita. Note que a verificação não pode ser feita por comparação de prefixo no envelope: o
prefixo v<n> marca o formato do envelope, não a chave que o produziu (Seção 22.5.3).
Chave do índice cego:
# 1. mantém a atual como anterior e define a nova
PHONE_INDEX_KEY_PREVIOUS="<chave atual em base64>"
PHONE_INDEX_KEY="$(openssl rand -base64 32)"
# 2. reinicia: toda busca calcula os dois HMACs e consulta com IN (novo, antigo);
# toda escrita usa apenas a chave nova
# 3. recalcula os índices cegos existentes em lote
pnpm ops crypto:reindex --batch-size 500
# 4. confirma que nenhuma linha depende da chave anterior
pnpm ops crypto:reindex --verify # precisa reportar 0 linhas pendentes
# 5. remove PHONE_INDEX_KEY_PREVIOUS e reiniciaA rotação do índice não tem prefixo possível, porque o valor precisa ser comparável por igualdade direta no índice — é por isso que ela usa leitura dupla em vez de marcador. Se o backfill for interrompido, o sistema permanece funcional na fase 1 indefinidamente; a única perda é o custo de uma comparação a mais por busca. O procedimento completo, com as três fases e a justificativa, está em 22.5.5.
26.7.5 Proibição de logar segredos #
Redação obrigatória no logger, aplicada em todos os três apps:
// packages/core/src/logging/redact.ts
export const REDACT_PATHS = [
// cabeçalhos
'req.headers.authorization',
'req.headers.cookie',
'req.headers["x-hub-signature-256"]',
'req.headers["asaas-access-token"]',
'req.headers["idempotency-key"]',
'res.headers["set-cookie"]',
// corpos
'req.body.password',
'req.body.currentPassword',
'req.body.newPassword',
'req.body.code',
'req.body.token',
'req.body.creditCardToken',
'req.body.creditCard',
'req.body.cpfCnpj',
'*.creditCard',
'*.creditCardToken',
'*.cvv',
'*.password',
'*.passwordHash',
'*.totpSecret',
'*.totpSecretEncrypted',
'*.refreshToken',
'*.refreshTokenHash',
'*.codeHash',
'*.apiKey',
'*.accessToken',
'*.secret',
'*.pepper',
// URL assinada é credencial, não endereço: quem tem a URL tem o arquivo (Seção 23.1.4).
'url',
'mediaUrl',
'signedUrl',
'downloadUrl',
'audioUrl',
'videoUrl',
'presignedUrl',
'invoiceUrl',
'*.url',
] as const;
export const loggerOptions = {
redact: { paths: [...REDACT_PATHS, ...(env.LOG_REDACT_EXTRA ?? [])], censor: '[REDACTED]' },
level: env.LOG_LEVEL,
};Quatro reforços além da lista:
- Nunca
JSON.stringify(env). O objeto de configuração não tem serialização segura. Ele expõetoJSON()que devolve apenas os nomes das chaves definidas, nunca os valores. /api/internal/versiondevolveAPP_ENV,BUILD_SHAe a lista de nomes de variáveis presentes — jamais valores.- Teste automatizado de vazamento. A suíte executa um cenário completo de login,
checkout, webhook e geração de URL assinada de mídia, capturando toda a saída de log, e falha
se encontrar qualquer valor das variáveis marcadas como segredo, o código de acesso gerado, o
valor de token de sessão ou de refresh, ou qualquer string contendo
X-Amz-Signatureou o host de mídia da plataforma. Roda também com o assinante incluído na lista de depuração de 23.1.5, para cobrir o níveldebug. É o único teste que garante a lista de redação na prática, porque um caminho novo de log pode escapar da lista sem ninguém notar — e umlogger.debugacrescentado para depurar entrega de código sobrevive ao commit. É bloqueador de pull request. - Erro de provedor externo é sanitizado. Bibliotecas de cliente HTTP costumam anexar
os cabeçalhos da requisição ao objeto de erro. O invólucro de cada integração remove
config.headerserequestantes de propagar.
26.7.6 O que fazer em caso de vazamento #
| Passo | Prazo | Ação |
|---|---|---|
| 1 | Imediato | Rotacione o segredo pelo procedimento de 26.7.3. Rotacionar primeiro, investigar depois |
| 2 | Imediato | Revogue a credencial antiga no provedor. Trocar sem revogar não resolve nada |
| 3 | 1 hora | Registre o incidente: qual segredo, onde vazou, desde quando esteve exposto, quem teve acesso |
| 4 | 4 horas | Avalie o alcance. ENCRYPTION_KEY exposta com cópia do banco significa CPF e segredo TOTP comprometidos |
| 5 | 24 horas | Se houver suspeita de acesso a dado pessoal, avalie a notificação à autoridade e aos titulares conforme a Seção 22 |
| 6 | 48 horas | Corrija a causa: remova do histórico do repositório, aperte permissões, feche o canal que vazou |
| 7 | 1 semana | Revise se o mesmo caminho pode vazar outro segredo |
Ações adicionais por segredo específico:
| Segredo vazado | Ação obrigatória além da rotação |
|---|---|
SESSION_JWT_PRIVATE_KEY |
Revogue todas as sessões: UPDATE sessions SET revoked_at = now(), revoked_reason = 'admin_revoke' WHERE revoked_at IS NULL. Quem tinha a chave podia forjar qualquer sessão |
ENCRYPTION_KEY |
Recifre tudo com chave nova. Force o reenrolamento de TOTP de todos os administradores |
PHONE_INDEX_KEY |
Reindexe todos os índices cegos com chave nova (26.7.4). Quem tinha a chave podia confirmar se um telefone específico está na base — o que, neste produto, revela convicção religiosa. Avalie a comunicação de incidente da Seção 22.7.8 |
OTP_PEPPER |
Invalide todos os OTPs pendentes |
ASAAS_API_KEY |
Concilie todas as cobranças dos últimos 30 dias contra o provedor, procurando movimentação não originada pelo sistema |
WHATSAPP_SYSTEM_USER_TOKEN |
Verifique o log de mensagens enviadas procurando disparo não originado pelo sistema. Notifique a plataforma |
META_APP_SECRET |
Reprocesse os webhooks recebidos desde o vazamento, procurando evento forjado. webhook_deliveries tem o corpo cru para a perícia |
S3_SECRET_ACCESS_KEY |
Audite os objetos do bucket procurando acesso ou remoção não esperada |
26.8 Settings de runtime #
A tabela settings está definida na Seção 6.23 (colunas, índices e constraints) e os seeds
estão em 6.32.4. Aqui fica o registro do que cada chave significa.
26.8.1 Chaves canônicas #
Formato obrigatório: grupo.chave, tudo em minúsculas, ponto como único separador. Nenhuma
chave em maiúsculas — nome em maiúsculas é nome de variável de ambiente, e confundir os dois faz
o administrador procurar no painel um valor que só existe no .env. Nenhuma chave sem grupo.
Esta tabela é a lista completa e fechada. Toda chave citada em qualquer seção do documento existe aqui e no seed obrigatório da Seção 6.32.4 — uma chave lida em código e ausente do seed devolve nulo em produção, e o comportamento que ela deveria controlar simplesmente não acontece, sem erro.
| Chave | Tipo | Padrão | Faixa | Papel para editar | Efeito |
|---|---|---|---|---|---|
send.daily_hour_local |
INT |
6 |
0–23 | ADMIN |
Hora local do envio diário. Muda o registro do job repetível no próximo ciclo. |
send.plan_offset_minutes |
INT |
20 |
5–120 | ADMIN |
Antecedência do planejamento em relação ao envio. |
send.free_tier_weekday |
INT |
0 |
0–6 | ADMIN |
Dia do envio gratuito. 0 = domingo. |
send.rate_per_second |
INT |
20 |
1–80 | ADMIN |
Taxa operacional de envio. Limitada por WHATSAPP_SEND_RATE_PER_SECOND. |
send.max_attempts |
INT |
5 |
1–10 | ADMIN |
Tentativas por etapa de entrega. |
send.window_miss_threshold |
INT |
3 |
1–14 | ADMIN |
Dias sem abrir a janela antes do template de vídeo. |
send.optout_keywords |
JSON |
["SAIR","PARAR","PARE","CANCELAR","STOP","DESCADASTRAR","REMOVER"] |
— | ADMIN |
Palavras de saída reconhecidas. Sete valores, em maiúsculas, conforme a normalização da Seção 20.3.2. |
send.optin_keywords |
JSON |
["VOLTAR","RETORNAR","QUERO VOLTAR","REATIVAR"] |
— | ADMIN |
Palavras de retorno reconhecidas. |
content.teaser_max_length |
INT |
300 |
50–300 | EDITOR |
Teto do resumo enviado no template. |
content.bible_version_default |
STRING |
"ALMEIDA_1911" |
— | EDITOR |
Versão bíblica padrão de novos devocionais. A sigla ARC é proibida (22.10.3). |
content.allowed_bible_versions |
JSON |
["ALMEIDA_1911","BIBLIA_LIVRE"] |
— | OWNER |
Lista fechada de versões aceitas pelo editor. |
content.bible_source_provenance |
JSON |
[] |
— | OWNER |
Origem verificável de cada texto bíblico importado: nome da fonte, URL, data e responsável (22.10.3). |
content.editorial_lock_minutes |
INT |
10 |
1–60 | ADMIN |
Duração do bloqueio de edição concorrente. |
resend.free_daily_limit |
INT |
1 |
0–10 | ADMIN |
Reenvios manuais por dia no plano gratuito. Limitado por RESEND_LIMIT_FREE. |
resend.paid_daily_limit |
INT |
3 |
0–20 | ADMIN |
Reenvios manuais por dia no plano pago. |
archive.free_days |
INT |
7 |
1–90 | ADMIN |
Dias de acervo visíveis no plano gratuito. |
landing.announcement_enabled |
BOOL |
false |
— | ADMIN |
Liga a faixa de aviso no topo da página pública (Seção 9.3.1). Desligada por padrão: uma faixa esquecida no ar é pior do que faixa nenhuma. |
billing.reminder_days |
JSON |
[3,1,0] |
— | ADMIN |
Dias antes do vencimento em que o lembrete é enviado. |
billing.reconcile_batch_size |
INT |
200 |
10–2000 | ADMIN |
Assinaturas conferidas por execução da reconciliação. |
billing.usd_brl_reference_rate |
INT |
5400 |
1000–20000 | OWNER |
Câmbio de referência em milésimos de real por dólar (5400 = R$ 5,40/US$). Converte todo custo de mídia e de mensagem cotado em dólar para centavos de real (Seções 16.14, 17.12 e 21.2). Inteiro, nunca decimal: câmbio em float reintroduz erro de arredondamento em cálculo monetário. |
whatsapp.price_per_message_micros |
JSON |
{"marketing":0,"utility":0,"authentication":0,"service":0} |
— | OWNER |
Preço por categoria de conversa, em micros de dólar por mensagem, conforme a tabela vigente do provedor (Seção 17.12). Alimenta message_logs.cost_micros e as fórmulas de custo da Seção 21. Zero significa "ainda não informado" e faz o painel exibir o custo como indisponível, nunca como zero. |
whatsapp.no_reply_numbers |
JSON |
[] |
— | ADMIN |
Números que nunca recebem resposta automática, usados pela proteção anti-laço da Seção 19.13. |
tts.cost_per_1k_chars_micros |
INT |
0 |
0–100000000 | OWNER |
Custo de síntese de voz por mil caracteres, em micros de BRL (Seção 16.4). Alimenta audio_assets.cost_micros e as fórmulas de custo da Seção 21. Zero significa "ainda não informado" e faz o painel exibir o custo como indisponível, nunca como zero. O sufixo é _micros, nunca _micro_usd: a convenção de unidades da Seção 7.10 vale também para chaves de configuração. |
support.email |
STRING |
"suporte@palavradiaria.com.br" |
— | ADMIN |
E-mail de suporte exibido ao assinante. |
support.whatsapp_reply_text |
STRING |
texto padrão | — | EDITOR |
Resposta automática a mensagens sem intenção reconhecida. |
privacy.dpo_email |
STRING |
"privacidade@palavradiaria.com.br" |
— | OWNER |
E-mail do encarregado de dados. Publicado na política de privacidade. Endereço único em todo o documento (Seção 1.20). |
privacy.policy_version |
STRING |
"2026-08-01" |
— | OWNER |
Versão vigente do texto de consentimento. Gravada em cada evento de consentimento. |
privacy.deletion_grace_days |
INT |
30 |
1–90 | OWNER |
Dias entre o pedido de exclusão e a anonimização. |
privacy.message_log_retention_months |
INT |
18 |
6–60 | OWNER |
Retenção de message_logs e inbound_messages. Fonte única do prazo de 22.8; nenhuma seção repete o número em código. |
privacy.legal_entity |
JSON |
{"razaoSocial":null,"cnpj":null,"endereco":null} |
— | OWNER |
Identificação do controlador nos Termos e na Política. |
security.blocked_phone_prefixes |
JSON |
[] |
— | ADMIN |
Faixas de números virtuais e de SMS descartável (22.6.4). |
security.blocked_email_domains |
JSON |
[] |
— | ADMIN |
Domínios de e-mail descartável (22.6.4). |
security.mobile_carrier_ranges |
JSON |
[] |
— | ADMIN |
Faixas de IP de operadora móvel brasileira, usadas para não punir CGNAT na pontuação de risco (22.6.3). |
ops.kill_switch |
JSON |
{"enabled": false, "reason": null, "activatedBy": null, "activatedAt": null} |
— | OWNER |
Interruptor geral de envios. Enquanto enabled for verdadeiro, nenhuma mensagem sai. É a chave citada pelo procedimento de emergência da Seção 27.8, e não existe nenhum outro nome para ela. |
ops.maintenance_mode |
BOOL |
false |
— | OWNER |
Bloqueia rotas de escrita e exibe aviso. Leitura continua funcionando. |
ops.alert_email |
STRING |
"alertas@palavradiaria.com.br" |
— | ADMIN |
Destino dos alertas operacionais. |
ops.cost_alert_threshold_cents |
INT |
50000 |
0–10000000 | ADMIN |
Custo diário acima do qual um alerta é disparado. R$ 500,00 por padrão. Comparado em reais por divisão explícita por 100 (Seção 23.8, alerta 40). |
ops.infra_cost_monthly_cents |
INT |
0 |
0–100000000 | OWNER |
Custo fixo mensal de infraestrutura, em centavos, usado nas fórmulas de custo da Seção 21.2. |
ops.cost_per_subscriber_target_cents |
INT |
0 |
0–1000000 | OWNER |
Meta de custo por assinante, em centavos (Seção 21.8). |
ops.marketing_spend_monthly_cents |
INT |
0 |
0–100000000 | OWNER |
Gasto mensal de marketing, em centavos. É a entrada do custo de aquisição da Seção 21.2.4; sem ela o indicador não pode ser calculado. |
ops.debug_subscriber_ids |
JSON |
[] |
máximo 20 itens | ADMIN |
Assinantes com nível de log elevado por 24 h (23.1.5). |
ops.debug_paths |
JSON |
[] |
— | ADMIN |
Rotas com nível de log elevado por 24 h (23.1.5). |
ops.secret_rotated_at |
JSON |
{} |
— | OWNER |
Data da última rotação de cada segredo, por nome canônico. Alimenta o alerta secret_rotation_due (22.4.2). Mantida pelo sistema; edição manual é excepcional. |
ops.last_access_review_at |
STRING |
null |
— | OWNER |
Conclusão da revisão trimestral de acessos (22.12.3). Mantida pelo sistema. |
ops.reencrypt_cursor |
STRING |
null |
— | OWNER |
Último id processado pelo job de re-criptografia (22.5.5). Mantida pelo sistema; existe para tornar o job retomável. |
São 45 chaves. Quatro decisões registradas sobre esta tabela:
- O interruptor geral chama-se
ops.kill_switche éJSON, nãoBOOL. O tipo JSON existe porque desligar os envios sem registrar quem desligou, quando e por quê transforma o controle de emergência em mistério na manhã seguinte. - As três últimas chaves de
opssão estado do sistema, não configuração de produto. Elas vivem emsettingsporque precisam sobreviver a reinício e ser legíveis pelo painel de operação, e ficam comeditable_by = 'OWNER'justamente para que ninguém as ajuste por engano. - Custos ficam em centavos, com sufixo
_cents. Guardar custo em reais como decimal reintroduziria float em cálculo monetário, que é proibido em todo o documento. - Preço unitário fica em micros, com sufixo
_micros, e câmbio fica em milésimos. Preço por mensagem e por caractere é pequeno demais para caber em centavos sem perder precisão na soma de centenas de milhares de linhas; o câmbio precisa de três casas. Nenhuma chave usa sufixo_brle nenhuma guarda valor monetário como decimal.
26.8.2 Regras de edição #
PUT /api/admin/settings/{key}valida contravalue_type,min_value,max_valueeallowed_valuesantes de gravar. Falha devolveVALIDATION_ERRORcomdetails.- O papel exigido é o da coluna
editable_by, não o da rota. Papel insuficiente devolveINSUFFICIENT_ROLE. - Toda alteração grava
settings.updateemadmin_audit_log, combeforeeafter, e exigereasonde no mínimo 10 caracteres (CHECK em 6.9.3). - Setting com teto em variável de ambiente é validado contra o teto no momento da gravação.
Tentar
send.rate_per_second = 50comWHATSAPP_SEND_RATE_PER_SECOND=20devolveVALIDATION_ERRORcom{"field":"value","issue":"out_of_range"}. settingsusa versionamento otimista (6.1.4). Edição concorrente devolveOPTIMISTIC_LOCK_FAILED.- "Restaurar padrão" grava
default_valueemvaluee é auditado como qualquer edição.
26.8.3 Cache #
Leitura em memória, com TTL de 60 segundos e invalidação ativa: o UPDATE publica no canal
Redis settings:invalidate com a chave alterada, e todos os processos limpam a entrada. A
janela de inconsistência é de milissegundos no caminho normal e de 60 segundos se o Redis
estiver fora.
Nenhum setting é lido dentro de laço quente. O motor de envio lê todos os settings
relevantes uma vez no início do lote e usa os mesmos valores até o fim. Mudar
send.rate_per_second no meio de um lote afeta o lote seguinte, não o corrente — e isso é
deliberado: mudar a taxa no meio da execução tornaria o comportamento imprevisível.
26.9 Feature flags #
Estrutura da tabela em 6.24, seeds em 6.32.5, avaliação determinística em 6.24.3. As chaves de
feature_flags não usam prefixo de grupo — essa é a convenção da tabela e a diferença em
relação a settings é deliberada: uma flag é um interruptor temporário com data de morte, não
uma configuração organizada por domínio. Toda chave desta tabela existe no seed de 6.32.5.
| Chave | Padrão | Tier alvo | Efeito quando ligada | Efeito quando desligada | Quando desligar |
|---|---|---|---|---|---|
video_fallback_enabled |
ligada, 100% | PAID |
Assinante pago que não abre a janela há 3 dias recebe o template com cabeçalho de vídeo no 4º dia | O assinante continua recebendo só o template de convite; o áudio não chega até ele abrir a janela | Se o custo do template de vídeo subir acima do aceitável, ou se a taxa de aprovação do template cair |
window_shortcut_enabled |
ligada, 100% | todos | Quando a janela de 24 h já está aberta, o sistema pula o template e envia o pacote free-form direto | Todo envio começa pelo template, mesmo com janela aberta. Custo maior, mais atrito | Se aparecer inconsistência na leitura de service_window_expires_at |
tts_google_fallback |
ligada, 100% | todos | Após 3 falhas no provedor primário de voz, o secundário assume | Falha no primário significa devocional sem áudio no dia | Se a qualidade do secundário for reprovada editorialmente |
web_player_enabled |
ligada, 100% | PAID |
O painel exibe o player de áudio com URL assinada | O painel mostra só o texto; o áudio chega apenas pelo WhatsApp | Se o custo de saída do storage ficar alto demais |
pix_checkout_enabled |
ligada, 100% | todos | PIX aparece como forma de pagamento | Só cartão de crédito | Durante instabilidade do PIX no provedor |
card_checkout_enabled |
ligada, 100% | todos | Cartão aparece como forma de pagamento | Só PIX | Durante instabilidade de cartão no provedor. Nunca desligue as duas juntas: o painel bloqueia a segunda desativação |
email_fallback_login |
ligada, 100% | todos | Assinante com e-mail verificado pode entrar por magic link | Só OTP no WhatsApp | Durante instabilidade do provedor de e-mail |
admin_impersonation |
ligada, 100% | todos | Suporte pode acessar o painel como o assinante, em modo leitura | A ação some do painel e a rota devolve FEATURE_DISABLED |
Durante auditoria de privacidade, ou se houver suspeita de uso indevido |
metrics_dashboard |
ligada, 100% | todos | Painel de métricas visível para papéis administrativos | O menu some e a rota devolve FEATURE_DISABLED |
Se a agregação estiver com dados incorretos |
snooze_button_enabled |
ligada, 100% | todos | O template de convite inclui o botão "Depois" | O template sai só com "Ler e ouvir agora" | Se o botão reduzir a taxa de abertura da janela. Exige resubmissão do template à plataforma |
reengagement_campaign |
desligada, 0% | todos | Quem não abre a janela há 14 dias recebe uma mensagem de reengajamento | Nada é enviado | Padrão desligada: exige template aprovado e avaliação de custo antes de ligar |
audio_speed_control |
desligada, 0% | PAID |
O player web ganha controle de velocidade | Player com velocidade fixa | Padrão desligada: funcionalidade pronta, aguardando validação com assinantes |
audio_human_review |
desligada, 0% | todos | O devocional fica em AUDIO_PENDING com aviso no painel até um EDITOR aprovar o áudio |
AUDIO_PENDING → AUDIO_READY é automático assim que o pipeline conclui |
Padrão desligada: só ligar quando houver troca de voz ou de provedor em avaliação |
26.9.1 Regras de uso #
- Toda flag tem data de morte. Uma flag existe para reduzir risco de uma mudança, não para ser configuração permanente. Flag em 100% e estável por 60 dias vira comportamento fixo e a flag é removida por migration.
- Nenhuma flag altera regra fiscal ou de conformidade. Não existe flag que desligue o registro de consentimento, a auditoria ou a validação de assinatura de webhook.
- Flag desligada devolve
FEATURE_DISABLED(403) na rota correspondente, e a interface esconde o elemento. As duas coisas: esconder sem bloquear a rota é falso controle. - Rollout percentual usa hash estável de
key + subscriberId(6.24.3). Aumentar o percentual só adiciona assinantes; nunca remove quem já estava dentro. - Alteração de flag é auditada com
feature_flag.updateemadmin_audit_log, com o valor anterior e o novo. - Flags que dependem de template aprovado —
snooze_button_enabledereengagement_campaign— verificamwhatsapp_templates.status = 'APPROVED'antes de permitir a ativação. Sem isso, ligar a flag produziria falhas132xxxem massa no envio seguinte.
26.10 Checklist de configuração inicial #
Executado uma vez por ambiente, na ordem. Cada passo é verificável.
# 1. copie o exemplo e preencha
cp .env.example .env
# 2. gere os segredos de aplicação
echo "OTP_PEPPER=$(openssl rand -hex 32)" >> .env
echo "CSRF_SECRET=$(openssl rand -hex 32)" >> .env
echo "ENCRYPTION_KEY=$(openssl rand -base64 32)" >> .env
echo "PHONE_INDEX_KEY=$(openssl rand -base64 32)" >> .env
echo "METRICS_TOKEN=$(openssl rand -hex 32)" >> .env
echo "OPS_CLI_TOKEN=$(openssl rand -hex 32)" >> .env
echo "MAINTENANCE_BYPASS_TOKEN=$(openssl rand -hex 32)" >> .env
# TURNSTILE_SECRET_KEY vem do painel do provedor anti-bot; BACKUP_ENCRYPTION_KEY
# é a chave publica `age` gerada com `age-keygen`, cuja parte privada fica no cofre.
# ENCRYPTION_KEY e PHONE_INDEX_KEY são geradas separadamente e precisam ser
# diferentes entre si: o schema de 26.5 recusa o boot se forem iguais.
# 3. gere o par de chaves de sessão
openssl genpkey -algorithm ed25519 -out /tmp/jwt.key
openssl pkey -in /tmp/jwt.key -pubout -out /tmp/jwt.pub
# copie o conteúdo para SESSION_JWT_PRIVATE_KEY e SESSION_JWT_PUBLIC_KEY, com \n literal
shred -u /tmp/jwt.key /tmp/jwt.pub
# 4. valide a configuração ANTES de subir qualquer serviço
pnpm --filter @palavra-diaria/core env:check
# saída esperada: "configuração válida — 165 variáveis, 29 obrigatórias, 0 problemas"
# 5. suba a infraestrutura
docker compose up -d postgres redis
# 6. aplique as migrations
pnpm --filter @palavra-diaria/db migrate:deploy
# 7. confirme os objetos que não estão no schema declarativo (Seção 6.31.2)
pnpm --filter @palavra-diaria/db verify
# saída esperada: "todos os índices parciais, CHECKs e partições presentes"
# 8. carregue os dados de referência
pnpm --filter @palavra-diaria/db seed
# 9. verifique as credenciais externas sem enviar nada
pnpm ops check:integrations
# verifica: banco, redis, storage, plataforma de mensagens, pagamentos, voz, e-mail
# 10. suba a aplicação
docker compose up -d web worker caddy
# 11. confirme a prontidão
curl -fsS https://app.palavradiaria.com.br/api/internal/ready | jq
# 12. faça o primeiro login administrativo, troque a senha e enrole o TOTP
# a sessão do primeiro acesso é restrita a essas duas ações
# 13. REMOVA BOOTSTRAP_ADMIN_PASSWORD do .env e reinicie
# em produção isto não é opcional: com um OWNER já enrolado em TOTP,
# o boot RECUSA iniciar enquanto a variável existir (26.3.14)O passo 13 é imposto pelo boot, e não confiado ao operador, porque o modo de falha do desenho
anterior era silencioso e permanente: removida a variável, o próximo deploy que rodasse o seed
falharia; o operador a reporia com a senha original para destravar; e ela ficaria lá para sempre,
agora com uma conta OWNER ativa correspondente.
O passo 4 antes do passo 5 é deliberado: descobrir configuração inválida depois de subir
banco e filas custa tempo e produz estado parcial. env:check roda o mesmo schema de 26.5
sem abrir conexão nenhuma.
O passo 9 é o que separa "configurado" de "funcionando": ele autentica em cada provedor externo com uma chamada de leitura, sem enviar mensagem, sem criar cobrança e sem gerar áudio. Falha ali aponta exatamente qual credencial está errada, com o nome da variável.
27. Tratamento de Erros, Resiliência e Runbooks Operacionais #
27.1 Taxonomia de erros #
Todo erro do sistema é classificado em exatamente uma das seis classes abaixo, no momento em
que é criado ou capturado. A classe determina retry, log, alerta e resposta ao usuário. Uma
exceção sem classe é tratada como INTEGRATION e gera alerta, porque erro não classificado
é bug de classificação.
// packages/core/src/errors.ts
export type ErrorClass =
| 'TRANSIENT' // falha temporária, a mesma operação tende a funcionar depois
| 'PERMANENT' // a mesma operação nunca vai funcionar; repetir é desperdício
| 'CONTRACT' // a forma da requisição ou da resposta não bate com o esperado
| 'DATA' // os dados persistidos estão inválidos ou inconsistentes
| 'INTEGRATION' // o serviço externo respondeu de forma inesperada ou não respondeu
| 'BUSINESS'; // o sistema funcionou e a regra de negócio recusou a operação
export class AppError extends Error {
constructor(
readonly code: string, // SCREAMING_SNAKE_CASE, catalogado na Seção 7
readonly errorClass: ErrorClass,
message: string,
readonly context: Record<string, unknown> = {},
readonly cause?: unknown,
) { super(message); }
}| Classe | Definição | Retry | Nível de log | Alerta | Exposta ao usuário |
|---|---|---|---|---|---|
TRANSIENT |
Timeout de rede, 429, 502, 503, 504, deadlock do Postgres, conexão recusada |
Sim, conforme a Seção 27.2 | warn na tentativa, error no esgotamento |
Só ao esgotar as tentativas, ou se a taxa passar do limiar | Mensagem genérica de "tente novamente em instantes" |
PERMANENT |
400, 404, 422 de serviço externo; número que não existe no WhatsApp (131026); áudio maior que o limite |
Não | error |
Sim, se afetar entrega ao assinante | Mensagem específica quando cabível |
CONTRACT |
Resposta externa que não passa no schema Zod; campo obrigatório ausente; enum desconhecido; corpo que não é JSON | Não | error com o corpo bruto truncado em 2 KB |
Sim, sempre. Contrato quebrado é sinal de mudança no serviço externo | Mensagem genérica |
DATA |
Estado impossível no banco: tier=PAID sem assinatura; phone_e164 que não normaliza; devocional publicado sem áudio |
Não | error com os identificadores envolvidos |
Sim, sempre. Indica bug de escrita | Mensagem genérica |
INTEGRATION |
Serviço externo indisponível, disjuntor aberto, credencial inválida, mudança de comportamento não classificável | Depende: token inválido não; indisponibilidade sim | error |
Sim | Mensagem genérica ou degradação silenciosa |
BUSINESS |
Assinante já cadastrado; limite de reenvio atingido; tentativa de acessar recurso de outro tier | Nunca | info |
Não. Não é falha do sistema | Sim, mensagem específica e acionável |
Exemplos concretos de classificação, um por classe:
| Situação real | Classe | code |
O que acontece |
|---|---|---|---|
A Meta devolve 503 ao enviar o template das 06:00 |
TRANSIENT |
WHATSAPP_UNAVAILABLE |
Retry com backoff; esgotadas as tentativas da fila, o job fica em failed e uma linha DEAD é gravada em job_runs |
A Meta devolve 131026 (não é possível entregar) |
PERMANENT |
WHATSAPP_UNDELIVERABLE |
Ao terceiro dia consecutivo, subscribers.blocked_at e blocked_reason são gravados e o status passa a BLOCKED; assinante sai dos lotes; alerta agregado diário |
A Asaas passa a devolver value como string em vez de número |
CONTRACT |
ASAAS_RESPONSE_SCHEMA_MISMATCH |
Nenhum retry; alerta imediato; o evento fica pendente em payment_events para reprocessamento após a correção |
Um assinante tem tier=PAID e assinatura nula |
DATA |
INCONSISTENT_SUBSCRIBER_STATE |
Alerta imediato; a reconciliação tenta corrigir; o assinante é tratado como FREE até lá |
| O token de acesso da Meta expirou | INTEGRATION |
WHATSAPP_TOKEN_INVALID |
Sem retry (repetir não resolve); alerta crítico; disjuntor abre; runbook R-07 |
| Assinante FREE clica em reenviar pela segunda vez no mesmo dia | BUSINESS |
RESEND_LIMIT_REACHED |
409 com mensagem clara; nenhum alerta; contabilizado como métrica de produto |
Todo AppError é logado no formato estruturado da Seção 23, sempre com requestId ou
jobId, code, errorClass, attempt, e context já redigido pela política da Seção 22.
A mensagem devolvida ao usuário segue o envelope de erro da Seção 7. Nenhum rastro de pilha
chega ao cliente.
27.2 Política canônica de retry #
27.2.1 Fórmula #
Backoff exponencial com jitter total. Sem jitter, milhares de jobs que falharam juntos voltam juntos e derrubam o serviço que estava se recuperando.
// packages/core/src/retry.ts
export function computeBackoffMs(attempt: number, opts: {
baseMs: number; // atraso da primeira repetição
factor: number; // multiplicador
maxMs: number; // teto
random: () => number; // injetável para teste determinístico
}): number {
const uncapped = opts.baseMs * Math.pow(opts.factor, attempt - 1);
const capped = Math.min(uncapped, opts.maxMs);
// Jitter total: sorteio uniforme em [0, capped].
return Math.floor(opts.random() * capped);
}Quando o serviço externo devolve Retry-After, esse valor vence o cálculo, respeitado o
teto. Isso é obrigatório para a Meta (130429) e para a Asaas (429).
27.2.2 Tabela de tentativas por tipo de operação #
O número de tentativas de cada fila é o da tabela da Seção 18.8, que é a dona do catálogo de filas. Esta tabela fixa a curva do atraso e o destino ao esgotar; onde as duas falarem da mesma fila, os valores são os mesmos de propósito.
| Operação | Tentativas | baseMs |
factor |
maxMs |
Janela total aproximada | Destino ao esgotar |
|---|---|---|---|---|---|---|
Envio de mensagem WhatsApp, fila send.dispatch (template ou free-form) |
4 | 2.000 | 3 | 300.000 | ~1,5 min | Estado failed de send.dispatch + linha DEAD em job_runs |
Entrega do pacote por interação, fila send.followup |
3 | 2.000 | 3 | 300.000 | ~30 s | Estado failed de send.followup + linha DEAD em job_runs |
Envio com erro 130429 (rate limit) |
Não consome tentativa: o job é adiado com moveToDelayed pelo Retry-After, com teto de 600.000 ms |
— | — | 600.000 | até ~10 min por adiamento | Reentra na fila; o teto de 6 h da Seção 27.3.1 vale aqui também |
Envio com erro 131049/131050 |
1 nova tentativa no mesmo dia; depois adia para o dia seguinte | 3.600.000 | 1 | 3.600.000 | 1 h | Não é falha: entra na lista do dia seguinte |
Upload de mídia para a Meta, fila media.upload |
5 | 3.000 | 3 | 120.000 | ~4 min | Estado failed de media.upload + linha DEAD em job_runs |
Geração de TTS (provedor primário), dentro de tts.generate |
3 | 5.000 | 3 | 60.000 | ~1,5 min | Aciona o provedor de fallback |
Geração de TTS (provedor de fallback), dentro de tts.generate |
3 | 5.000 | 3 | 60.000 | ~1,5 min | Estado failed de tts.generate + linha DEAD em job_runs + alerta crítico |
| Transcodificação com ffmpeg | 2 | 1.000 | 2 | 5.000 | ~5 s | Falha o job tts.generate, que segue a política da fila |
| Upload para o bucket de mídia | 5 | 1.000 | 3 | 60.000 | ~2 min | Falha o job media.upload, que segue a política da fila |
Chamada de leitura à Asaas (GET) |
4 | 1.000 | 3 | 30.000 | ~50 s | Falha do job; a reconciliação corrige depois |
Chamada de escrita à Asaas (POST/DELETE) |
Ver Seção 27.2.4 | — | — | — | — | — |
Processamento de evento de pagamento, fila billing.webhook |
8 | 5.000 | 2 | 300.000 | ~20 min | Estado failed de billing.webhook + linha DEAD em job_runs + alerta crítico |
Escrita no Postgres com deadlock (40P01) ou falha de serialização (40001) |
3 | 50 | 4 | 2.000 | ~2,5 s | Falha do job, que segue a política do job |
| Conexão com o Postgres recusada | 10 | 500 | 2 | 15.000 | ~1,5 min | Processo encerra e o Docker reinicia |
| Conexão com o Redis recusada | Contínuo, com teto | 500 | 2 | 30.000 | contínuo | Nunca desiste; o cliente reconecta |
Envio de e-mail transacional, fila email.send |
3 | 5.000 | 3 | 120.000 | ~1 min | Estado failed de email.send + linha DEAD em job_runs; e-mail nunca é crítico |
Exemplo numérico do envio de WhatsApp com baseMs=2000, factor=3, maxMs=300000 e
jitter total: os atrasos máximos por tentativa são 2 s, 6 s, 18 s e 54 s; com jitter, cada um
é sorteado no intervalo [0, máximo]. O tempo esperado até a 4ª tentativa é de
aproximadamente 40 segundos e o pior caso é de aproximadamente 80 segundos, o que cabe
folgadamente na janela de 20 minutos do lote.
27.2.3 Configuração no BullMQ #
// apps/worker/src/jobs/send-dispatch.ts
export const sendDispatchQueue = new Queue('send.dispatch', {
connection,
defaultJobOptions: {
attempts: 4, // Seção 18.8 é a dona deste número
backoff: { type: 'jitteredExponential' },
removeOnComplete: { age: 86_400, count: 10_000 },
removeOnFail: { age: 2_592_000 }, // 30 dias: falhas ficam para inspeção manual
},
});
export const sendDispatchWorker = new Worker(
'send.dispatch',
processSendDispatch,
{
connection,
concurrency: 12,
limiter: { max: await settings.getInt('send.rate_per_second'), duration: 1_000 },
settings: {
backoffStrategy: (attemptsMade, _type, err) => {
const retryAfter = (err as AppError)?.context?.retryAfterMs;
if (typeof retryAfter === 'number') return Math.min(retryAfter, 600_000);
return computeBackoffMs(attemptsMade, {
baseMs: 2_000, factor: 3, maxMs: 300_000, random: Math.random,
});
},
},
// Job considerado travado após 60 s sem renovar o bloqueio.
lockDuration: 60_000,
stalledInterval: 30_000,
maxStalledCount: 2,
},
);Erros PERMANENT, CONTRACT, DATA e BUSINESS abortam o retry imediatamente,
mesmo que ainda haja tentativas disponíveis:
async function processSendDispatch(job: Job<SendPayload>) {
try {
return await sendMessage(job.data);
} catch (err) {
if (err instanceof AppError && err.errorClass !== 'TRANSIENT' && err.errorClass !== 'INTEGRATION') {
throw new UnrecoverableError(`${err.code}: ${err.message}`);
}
throw err;
}
}27.2.4 Onde o retry é PROIBIDO #
Estas operações não têm retry automático porque não são idempotentes e repetir pode cobrar duas vezes, criar duas assinaturas ou enviar duas mensagens. A regra é absoluta.
| Operação | Por que não é idempotente | O que se faz no lugar |
|---|---|---|
POST /v3/customers na Asaas |
Cria um cliente novo a cada chamada; a Asaas não deduplica por CPF | Antes de criar, buscar por GET /v3/customers?cpfCnpj=<cpf>. Se existir, reusar. O par busca-e-cria é envolvido em um lock por CPF com TTL de 30 s. Em timeout, a operação falha para o usuário com PAYMENT_PROVIDER_TIMEOUT e a próxima tentativa dele encontra o cliente já criado |
POST /v3/subscriptions na Asaas |
Criaria duas assinaturas para o mesmo assinante | externalReference recebe o ULID da nossa subscriptions.id, gerado antes da chamada e persistido em PENDING_PAYMENT. Em timeout, um job de conciliação busca GET /v3/subscriptions?externalReference=<ulid> depois de 60 s e adota o que encontrar. Nunca se recria às cegas |
POST /v3/payments (cobrança avulsa de PIX) |
Geraria duas cobranças e o assinante poderia pagar as duas | Mesmo mecanismo de externalReference + conciliação |
DELETE /v3/subscriptions/{id} |
Idempotente na prática (a segunda chamada devolve 404), mas o 404 seria interpretado como falha |
Retry permitido, com 404 tratado como sucesso |
| Cobrança no cartão tokenizado | Cobraria duas vezes | Nunca é feita por nós diretamente. A recorrência é da Asaas |
POST /{PHONE_NUMBER_ID}/messages na Meta após resposta bem-sucedida |
Enviaria a mesma mensagem duas vezes ao assinante | A idempotência é garantida por delivery_attempts com índice único em send:{subscriberId}:{devotionalDate} e pela gravação do wamid em message_logs. O retry só ocorre quando não houve resposta ou quando a resposta foi um erro classificado como TRANSIENT. Se a resposta chegou com o identificador da mensagem, o job é marcado como concluído mesmo que a gravação subsequente falhe — nesse caso, um job de reparo lê o log e completa o registro |
| Confirmação de opt-out ao assinante | Enviaria duas mensagens de "você foi removido" | Guardada por opt_out_confirmation_sent_at, verificada dentro da mesma transação que grava opt_out_at |
| Envio de OTP | Enviaria dois códigos e confundiria o assinante | Limite de 3 por hora, verificado em transação; falha de envio devolve erro ao usuário, que decide pedir de novo |
POST /{PHONE_NUMBER_ID}/media (upload) |
Criaria um identificador de mídia novo, desperdiçando cota | O identificador é gravado em media_uploads com o hash do arquivo. Antes de subir, consulta-se o hash. Retry só acontece se o upload não completou |
| E-mail de recibo de pagamento | Enviaria dois recibos | Guardado pelo jobId determinístico receipt:{paymentId} na fila email.send, que o BullMQ deduplica; o provedor de e-mail também recebe uma chave de idempotência quando suportada. settings é tabela de configuração e nunca é usada como armazenamento de chave de idempotência |
| Anonimização por pedido de eliminação (LGPD) | Segunda execução falharia por não encontrar os dados | A operação é idempotente por construção (usa UPDATE ... WHERE deleted_at IS NULL), então o retry é permitido; está listada aqui apenas para registrar que foi analisada |
Regra de escrita para quem implementa: qualquer chamada de rede que crie ou cobre algo precisa de uma chave de idempotência decidida por nós, gerada antes da chamada e persistida. Se não for possível gerar essa chave, a operação não pode ter retry automático e precisa de conciliação posterior.
27.3 Disjuntores #
Um disjuntor por integração, implementado em packages/integrations/src/circuit.ts, com
estado em Redis sob circuit:{nome} para ser compartilhado entre web e worker.
Estados: CLOSED (normal), OPEN (recusa imediatamente, sem tentar) e HALF_OPEN (deixa
passar um número limitado de sondagens).
| Integração | Limiar de abertura | Janela de contagem | Tempo em OPEN |
Sondagens em HALF_OPEN |
Para fechar |
|---|---|---|---|---|---|
whatsapp |
20 falhas TRANSIENT/INTEGRATION ou taxa de falha > 50% com no mínimo 30 chamadas |
60 s deslizantes | 60 s | 3 | 3 sucessos consecutivos |
asaas |
10 falhas ou taxa > 50% com no mínimo 20 chamadas | 60 s | 120 s | 2 | 2 sucessos consecutivos |
tts-primary |
5 falhas consecutivas | 300 s | 900 s | 1 | 1 sucesso |
tts-fallback |
5 falhas consecutivas | 300 s | 900 s | 1 | 1 sucesso |
storage |
10 falhas ou taxa > 40% com no mínimo 20 chamadas | 60 s | 60 s | 3 | 2 sucessos consecutivos |
email |
10 falhas | 300 s | 300 s | 1 | 1 sucesso |
Qualquer falha durante HALF_OPEN reabre o disjuntor imediatamente e dobra o tempo em
OPEN, até o teto de 15 minutos. Isso evita o padrão de "abre, fecha, abre" que castiga um
serviço que ainda não se recuperou.
Erros de classe BUSINESS e PERMANENT não contam para o disjuntor: um número que não
existe no WhatsApp não é sinal de que a Meta está fora do ar. Apenas TRANSIENT,
INTEGRATION e timeouts contam.
27.3.1 O que acontece com a fila enquanto o disjuntor está aberto #
Esta é a decisão mais importante da seção, porque o comportamento ingênuo (falhar os jobs)
consumiria todas as tentativas em segundos e mandaria o lote inteiro para o estado failed.
Regra: quando o disjuntor está OPEN, o job não falha — ele é adiado.
async function guardedCall<T>(name: CircuitName, fn: () => Promise<T>, job?: Job): Promise<T> {
const state = await circuit.state(name);
if (state.status === 'OPEN') {
const waitMs = state.reopensInMs + jitter(0, 5_000);
if (job) {
// Adia sem consumir tentativa. O job volta para a fila 'delayed'.
await job.moveToDelayed(Date.now() + waitMs, job.token);
throw new DelayedError();
}
throw new AppError('CIRCUIT_OPEN', 'INTEGRATION', `Integração ${name} indisponível.`, { name });
}
return fn();
}job.moveToDelayed combinado com DelayedError é o mecanismo do BullMQ que devolve o job
para o estado delayed sem incrementar o contador de tentativas. Consequências, por
fila:
| Fila | Comportamento com disjuntor aberto |
|---|---|
send.dispatch e send.followup |
Jobs vão para delayed com o tempo restante de abertura + jitter de até 5 s. O lote pausa e retoma sozinho. Se a abertura durar mais de 30 minutos, o lote é marcado como DELAYED_BY_CIRCUIT e um alerta de severidade alta é emitido |
billing.webhook |
Jobs vão para delayed. Os eventos já estão persistidos em payment_events, então nada se perde. O impacto é atraso na liberação de acesso |
tts.generate |
Com tts-primary aberto, o job não é adiado: ele vai direto ao provedor de fallback. Só é adiado se os dois disjuntores estiverem abertos |
media.upload |
Adiado. O envio que depende da mídia é adiado junto, por dependência de job |
email.send |
Adiado por até 30 minutos; depois é descartado com log. E-mail não vale a pena segurar |
Um job adiado por disjuntor tem um teto absoluto: se o adiamento acumulado passar de
6 horas, o job falha com CIRCUIT_OPEN_TIMEOUT, fica em failed e grava a linha DEAD
em job_runs. Um devocional entregue 6 horas atrasado já perdeu o propósito.
27.3.2 Volta ao normal e visibilidade #
A transição OPEN → HALF_OPEN é automática, por expiração da chave Redis. Não existe
temporizador em memória: se o worker reiniciar, o estado sobrevive.
# Estado de todos os disjuntores.
docker compose exec worker ops circuit:status{
"whatsapp": { "status": "CLOSED", "failures60s": 0, "successRate": 1.0 },
"asaas": { "status": "OPEN", "openedAt": "2026-08-25T09:12:03.000Z",
"reopensInMs": 43000, "consecutiveOpens": 2, "lastError": "ASAAS_TIMEOUT" },
"tts-primary": { "status": "HALF_OPEN", "probesRemaining": 1 },
"tts-fallback": { "status": "CLOSED" },
"storage": { "status": "CLOSED" },
"email": { "status": "CLOSED" }
}Reset manual, usado apenas quando se sabe que a causa foi corrigida (por exemplo, após trocar um token):
docker compose exec worker ops circuit:reset --name=asaasO reset é registrado em admin_audit_log com o operador. Toda transição de estado emite
métrica e log estruturado, e a abertura de qualquer disjuntor gera alerta conforme a
Seção 23.
27.4 Degradação graciosa #
Uma subseção por dependência, com a decisão explícita do comportamento. A pergunta que cada uma responde é: o que o produto ainda consegue fazer?
27.4.1 A Meta (WhatsApp) está fora #
É a dependência mais crítica: sem ela não há entrega do produto.
| Capacidade | Estado durante a indisponibilidade |
|---|---|
| Envio do devocional diário | Parado. Jobs adiados pelo disjuntor, retomam sozinhos |
| Recebimento de mensagens | Parado. A Meta reentrega webhooks por até 7 dias, então nada se perde de forma permanente |
| Login por OTP no WhatsApp | Parado. Fallback automático: se o assinante tem e-mail verificado, o sistema envia um link mágico por e-mail sem que ele precise pedir, e a tela informa a troca de canal |
| Cadastro novo | Permitido até a etapa de confirmação. O assinante é criado com opt_in_confirmed_at = NULL e recebe a confirmação quando o canal voltar. A tela diz: "Recebemos seu cadastro. Vamos mandar a confirmação no WhatsApp assim que possível." |
| Checkout e cobrança | Funcionam normalmente. Pagamento não depende da Meta |
| Painel web (leitura do devocional e do áudio) | Funciona normalmente. É o plano B de entrega |
| Painel administrativo | Funciona, exceto o botão de envio de teste, que fica desabilitado com o motivo visível |
Decisão: o produto não troca de canal automaticamente. Não existe entrega por
e-mail nem por SMS, conforme o escopo declarado na Seção 2. Se a indisponibilidade passar de
3 horas dentro da janela de envio, o sistema envia um e-mail (apenas para quem tem
e-mail verificado) com o link do devocional no painel web. Esse e-mail é de aviso
operacional, não é o canal do produto, e o texto deixa isso claro. O envio é feito uma única
vez por incidente, garantido pelo jobId determinístico outage-notice:{data} na fila
email.send — o BullMQ descarta o segundo job com o mesmo jobId.
Se a indisponibilidade cruzar a meia-noite, o lote do dia é abandonado. Não se acumula devocional. Um assinante que perdeu o dia 26 recebe o dia 27 normalmente, e o dia 26 fica disponível no acervo do painel. Enviar dois devocionais no dia seguinte seria pior para a experiência e para a reputação do número.
27.4.2 A Asaas está fora #
| Capacidade | Estado durante a indisponibilidade |
|---|---|
| Entrega do devocional | Funciona normalmente. Nenhuma dependência da Asaas no caminho de envio |
| Entitlements de quem já é pagante | Funcionam normalmente. São lidos do nosso banco |
| Checkout novo | Indisponível. A página exibe: "Não é possível concluir a assinatura agora. Tente de novo em alguns minutos." Nenhuma assinatura é criada em estado ambíguo |
| Recebimento de webhooks | Não chegam. Ficam na fila de reentrega da Asaas |
| Liberação de acesso após pagamento | Atrasada. Corrigida pela reconciliação das 04:00 ou por reconciliação manual |
| Revogação por inadimplência | Atrasada. Um assinante inadimplente pode receber alguns devocionais a mais. Decisão: aceitável. O custo de um dia de entrega indevida é da ordem de centavos; recusar acesso por falta de informação seria pior |
| Cancelamento pelo assinante | Aceito no nosso lado (status = CANCELED, tier mantido até o fim do período) e enfileirado para propagar à Asaas quando voltar. O assinante recebe confirmação normal. Se a propagação falhar por mais de 24 h, alerta crítico — o risco de cobrar quem cancelou é inaceitável |
Decisão: o cancelamento é sempre aceito localmente primeiro. É a única operação de cobrança em que assumimos o compromisso antes de a Asaas confirmar, porque o dano de recusar um cancelamento é maior do que o de um cancelamento propagado com atraso.
27.4.3 O provedor de TTS está fora #
| Situação | Comportamento |
|---|---|
| Primário fora, fallback disponível | Troca automática após 3 falhas. audio_assets.provider registra qual foi usado. Nenhum impacto visível ao assinante. Alerta de severidade baixa |
Os dois fora, com antecedência (o áudio é gerado quando o devocional atinge READY, normalmente com dias de folga) |
O devocional permanece em AUDIO_PENDING e o audio_assets da tentativa recebe status = 'FAILED'; não existe estado AUDIO_FAILED (Seção 16.8). Um job tenta a cada 30 minutos. Alerta de severidade média. Se às 05:00 do dia do envio o áudio ainda não existir, escala para o runbook R-11 — 05:00 é o primeiro dos três marcos de prontidão da Seção 18.4 |
| Os dois fora, no dia do envio, sem áudio | O texto é enviado normalmente, para todos os tiers. O assinante PAID recebe o texto completo e, no lugar do áudio, uma mensagem curta: "O áudio de hoje está sendo preparado e chega em breve." Um job de recuperação envia o áudio assim que ele existir, desde que ainda esteja dentro da janela de atendimento de 24 h. Se a janela fechar antes, o áudio fica disponível no painel web e não é enviado |
Decisão: o áudio nunca bloqueia o texto. Devocional sem áudio é um produto degradado;
devocional não entregue é um produto quebrado. delivery_attempts.audio_status registra
MISSING e a métrica de entrega de áudio é acompanhada separadamente da de texto.
27.4.4 O storage de mídia está fora #
| Capacidade | Estado |
|---|---|
| Envio de texto | Funciona normalmente |
| Envio de áudio pela Meta | Funciona, desde que o identificador de mídia na Meta ainda seja válido. O áudio já está na Meta; o nosso bucket não participa do envio. Esta é uma propriedade importante do desenho: fazer o upload uma vez por devocional desacopla o envio do nosso storage |
| Geração de áudio novo | Bloqueada. O arquivo não pode ser persistido. Job adiado pelo disjuntor |
| Player de áudio no painel web | Indisponível. O painel mostra: "O áudio está temporariamente indisponível. O texto está aqui embaixo." O texto continua visível |
| Upload de imagem pelo painel administrativo | Bloqueado, com mensagem clara |
| Exportação LGPD | Bloqueada. O pedido fica pendente e é processado quando o storage voltar; o prazo legal é de 15 dias, então algumas horas não comprometem |
Decisão: o painel web nunca falha por causa do storage. Toda página que exibe áudio trata a URL assinada como opcional e degrada para "somente texto" sem erro visível.
27.4.5 O Redis está fora #
O caso mais delicado, porque as filas param.
| Capacidade | Estado |
|---|---|
| Landing page e páginas públicas | Funcionam. Não dependem do Redis |
| Login | Funciona. Sessões estão no Postgres; o Redis é só cache. O limite de taxa de OTP passa a ser verificado direto no Postgres, com uma consulta de contagem em otp_codes — mais lenta, mas correta |
| Painel do assinante | Funciona, sem o cache do devocional do dia. Latência maior |
| Checkout | Funciona. O lock de criação de cliente na Asaas degrada para um lock consultivo do Postgres sobre o hash do CPF |
| Recebimento de webhooks | Funciona. O handler persiste o evento em payment_events ou inbound_messages e responde 200. O enfileiramento falha, e a falha é registrada com queued_at IS NULL |
| Processamento assíncrono | Parado. Nada é processado |
| Envio do devocional | Parado |
Decisão: o web não derruba o processo se o Redis cair. A rota pública
GET /api/internal/health continua devolvendo 200 com o corpo mínimo {"status":"ok"}, e
não 503; o estado degradado do Redis aparece apenas em GET /api/internal/ready, que não
é publicado pelo proxy e exige o METRICS_TOKEN do monitor externo (Seção 23.6).
Devolver 503 faria o proxy tirar a réplica do balanceamento e derrubaria o site inteiro
por causa de um cache — e, pior, faria a Asaas e a Meta receberem erro nos webhooks, o que
levaria à pausa da fila de webhooks da Asaas. Manter o web de pé é mais importante do que
sinalizar o problema pelo código HTTP; o problema é sinalizado pelo alerta.
O worker, ao contrário, não tem função útil sem Redis. Ele reporta 503 na sonda
interna do contêiner (/api/internal/health na porta local, que não é publicada pelo proxy), para de
aceitar jobs e fica em reconexão. O cliente Redis é configurado com
maxRetriesPerRequest: null e enableOfflineQueue: false no worker, para que as operações
falhem rápido em vez de acumular na memória.
Quando o Redis volta, o job queue.recover (registrado no start do worker) varre
payment_events e inbound_messages com queued_at IS NULL e enfileira o que ficou para
trás. É o que fecha a lacuna do período sem Redis. O procedimento completo é o runbook R-18.
27.4.6 O PostgreSQL está fora #
Registrado por completude: sem Postgres não há produto.
| Capacidade | Estado |
|---|---|
| Tudo, exceto páginas estáticas | Parado |
| Landing page | Serve a versão estática gerada no build; o formulário de cadastro fica desabilitado com aviso |
| Webhooks | Respondem 503. Asaas e Meta reentregam. A Asaas pausa a fila após falhas consecutivas — por isso a indisponibilidade do Postgres tem prioridade máxima de resposta, e o runbook R-14 existe |
O web e o worker fazem no máximo 10 tentativas de reconexão e então encerram, deixando o
Docker reiniciá-los. Isso evita processos vivos e inúteis segurando memória.
27.5 Trabalho morto: estado failed e job_runs #
27.5.1 Onde fica #
Não existe fila morta separada no BullMQ. Um job que esgota as tentativas fica no estado
failed da própria fila e grava uma linha em job_runs com status = 'DEAD', job_name,
queue, bull_job_id, attempt, error_code, error, payload redigido e
finished_at. job_runs é a dead-letter do sistema (decisão da Seção 18.8). Nenhum
nome com sufixo .dlq existe neste produto, nem em comando, nem em alerta, nem em código.
Manter o job no estado failed preserva o payload, o histórico de tentativas e a mensagem
de erro final, e permite reprocessar sem reconstruir nada; a linha em job_runs garante que
a evidência sobreviva a uma perda de Redis e permite consulta por SQL, que é muito mais
prático em incidente do que navegar por chaves.
Retenção do estado failed, por fila (removeOnFail), com os nomes canônicos da
Seção 18.8:
| Fila | Retenção do estado failed |
|---|---|
send.dispatch |
30 dias |
send.followup |
30 dias |
whatsapp.inbound |
30 dias |
billing.webhook |
30 dias |
tts.generate |
14 dias |
media.upload |
14 dias |
email.send |
7 dias |
Demais filas (send.plan, billing.reconcile, billing.lifecycle, metrics.rollup, maintenance.cleanup, messaging.pause) |
7 dias |
A retenção da linha em job_runs é independente e maior: ela segue a política de
retenção de dados operacionais da Seção 22.8. É por isso que a consulta por SQL abaixo
continua respondendo depois de o Redis já ter descartado o job.
27.5.2 Como é inspecionada #
# Resumo de todas as filas.
docker compose exec worker ops queue:stats{
"send.dispatch": { "waiting": 0, "active": 0, "delayed": 12, "completed": 2984, "failed": 7 },
"billing.webhook": { "waiting": 0, "active": 0, "delayed": 0, "completed": 141, "failed": 0 },
"tts.generate": { "waiting": 0, "active": 1, "delayed": 0, "completed": 30, "failed": 0 }
}# Detalhe dos jobs mortos, agrupados por causa.
docker compose exec worker ops queue:dead:list --queue=send.dispatch --limit=50 --group-by=code{
"deadCount": 7,
"groups": [
{ "code": "WHATSAPP_UNDELIVERABLE", "count": 5, "sampleJobIds": ["s_01K3Q...", "s_01K3R..."] },
{ "code": "CIRCUIT_OPEN_TIMEOUT", "count": 2, "sampleJobIds": ["s_01K3S..."] }
],
"oldest": "2026-08-25T09:04:11.000Z"
}Consulta equivalente por SQL, útil quando o Redis está indisponível — e é ela que responde
depois que o estado failed já expirou:
SELECT queue,
error_code,
count(*) AS total,
min(finished_at) AS mais_antigo,
max(finished_at) AS mais_recente
FROM job_runs
WHERE status = 'DEAD'
AND finished_at > now() - interval '7 days'
GROUP BY queue, error_code
ORDER BY total DESC;27.5.3 Como é reprocessado #
# Um job específico.
docker compose exec worker ops queue:dead:replay --queue=send.dispatch --id=s_01K3Q9X
# Todos os jobs de uma causa, depois de a causa ter sido corrigida.
docker compose exec worker ops queue:dead:replay --queue=send.dispatch --code=CIRCUIT_OPEN_TIMEOUT
# Simulação: mostra o que seria reprocessado, sem reprocessar.
docker compose exec worker ops queue:dead:replay --queue=billing.webhook --all --dry-runRegras de reprocessamento, aplicadas pelo próprio comando:
- O contador de tentativas é zerado, mas o histórico anterior é preservado nas linhas de
job_runs. Um job reprocessado três vezes é visível como tal. - A idempotência continua valendo. Reprocessar um envio cuja chave
send:{subscriberId}:{devotionalDate}já está marcada como enviada termina comoALREADY_SENTsem chamar a Meta. Reprocessar em massa é seguro. - A revalidação do disparo vale no reprocessamento. Um item represado por horas é reavaliado contra o estado atual do assinante (Seção 18.5.1): quem saiu, foi bloqueado, foi excluído ou perdeu o acesso pago nesse intervalo não recebe, ou recebe rebaixado. Reprocessar nunca ressuscita uma permissão que já foi revogada.
- Envios com mais de 24 horas não são reprocessados por padrão. O comando recusa e
exige
--force, porque entregar o devocional de anteontem é pior do que não entregar. Os jobs são descartados com registro. - Jobs
PERMANENTnão são reprocessáveis sem--force. Reenviar para um número que não existe apenas consome cota e degrada a qualidade do número. - Todo reprocessamento é registrado em
admin_audit_logcom o operador, o filtro usado e a contagem.
# Descarte controlado, com registro.
docker compose exec worker ops queue:dead:purge --queue=send.dispatch --older-than=30 --reason="expurgo mensal"27.5.4 Alertas de trabalho morto #
Todas as condições são medidas sobre job_runs, contando linhas com status = 'DEAD' na
fila indicada. Não há alerta sobre "fila morta" porque não há fila morta.
| Condição | Severidade | Ação esperada |
|---|---|---|
Qualquer linha DEAD com queue = 'billing.webhook' |
Crítica, imediata | Dinheiro envolvido. Runbook R-10 |
Mais de 10 linhas DEAD com queue = 'send.dispatch' em 1 hora |
Alta | Runbook R-03 |
Mais de 50 linhas DEAD com queue = 'send.dispatch' em 1 hora |
Crítica | Runbook R-03 e avaliar o interruptor geral |
Qualquer linha DEAD com queue = 'tts.generate' |
Média | Runbook R-11 |
Linha DEAD com mais de 24 h sem tratamento, em qualquer fila |
Média | Triagem obrigatória |
Contagem de linhas DEAD crescendo por 3 janelas de 15 minutos consecutivas |
Alta | Indica causa sistêmica, não caso isolado |
O alerta traz sempre a agregação por error_code, não a lista de jobs. O que a pessoa de
plantão precisa saber às 06:10 é "50 falhas, todas 131049", não cinquenta identificadores.
27.6 Runbooks operacionais #
Formato fixo: sintoma, impacto, diagnóstico, correção, verificação, prevenção. Todos os
comandos partem do diretório de deploy no servidor (cd /opt/palavra-diaria).
R-01 — O lote diário não disparou #
Sintoma. Às 06:05 nenhum envio foi registrado. O painel de métricas mostra zero
mensagens no dia. Nenhum alerta de falha, apenas silêncio. O alerta send.batch_not_started
dispara (06:05 com send_batches.started_at nulo, Seção 18.12).
Impacto. Todos os assinantes do dia ficam sem devocional. Em domingo, atinge a base inteira. É o incidente de maior impacto do produto.
Diagnóstico.
cd /opt/palavra-diaria
# 1. O worker está de pé e saudável?
docker compose ps worker
docker compose exec -T worker node /app/docker/healthcheck.js http://127.0.0.1:3001/api/internal/health; echo "saida=$?"
# 2. O job repetível está registrado com o fuso correto?
docker compose exec -T worker ops queue:repeatable --queue=send.plan
# Esperado: { "name": "plan-daily-batch", "pattern": "40 5 * * *", "tz": "America/Sao_Paulo",
# "next": "2026-08-26T08:40:00.000Z" }
# O fuso faz parte da asserção: sem tz, o padrão dispara 02:40 em Brasília (Seção 18.8).
# 3. O relógio do host está correto?
timedatectl status
docker compose exec -T worker date -u
# 4. Houve tentativa de planejamento?
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT id, job_name, queue, status, started_at, finished_at, error_code
FROM job_runs
WHERE queue = 'send.plan'
ORDER BY started_at DESC LIMIT 5;"
# 5. O interruptor geral está ligado?
docker compose exec -T worker ops kill-switch:status
# 6. Existe conteúdo publicado para hoje?
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT id, title, status, scheduled_for FROM devotionals
WHERE scheduled_for = current_date AND deleted_at IS NULL;"
# 7. O Redis responde?
docker compose exec -T redis redis-cli -a "$REDIS_PASSWORD" pingCausas em ordem de frequência: interruptor geral esquecido ligado; worker parado ou em reinício contínuo; Redis indisponível quando o agendador deveria disparar; devocional do dia não publicado (então o lote roda e não planeja nada); job repetível perdido após uma limpeza manual do Redis.
Correção.
# Se o interruptor estava ligado:
docker compose exec -T worker ops kill-switch:off
# Se o worker estava parado:
docker compose up -d worker
docker compose logs --tail=200 worker
# Se o job repetível sumiu (recriado de forma idempotente no start):
docker compose restart worker
docker compose exec -T worker ops queue:repeatable --queue=send.plan
# Se não havia conteúdo: ver R-12 antes de continuar.
# Disparo manual, sempre com simulação antes:
docker compose exec -T worker ops send:plan --date=$(TZ=America/Sao_Paulo date +%F) --dry-run
# Conferir as contagens por canal e por tier. Se estiverem corretas:
docker compose exec -T worker ops send:plan --date=$(TZ=America/Sao_Paulo date +%F)Regra de horário. Se já passou das 09:00 local, disparar mesmo assim: o devocional do dia entregue às 09:30 ainda é útil. Se já passou das 12:00, não disparar. Registrar o dia como perdido, publicar o devocional no painel web e seguir o procedimento de comunicação da Seção 27.7. Devocional de manhã entregue à noite gera opt-out.
Verificação.
docker compose exec -T worker ops queue:stats
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT status, count(*) FROM delivery_attempts
WHERE devotional_date = current_date GROUP BY status ORDER BY 2 DESC;"Esperado: SENT crescendo, PENDING diminuindo, FAILED próximo de zero. Confirmar em um
aparelho real que a mensagem chegou.
Prevenção. O alerta send.batch_not_started das 06:05 já existe e foi o que disparou;
ele é a prevenção contra a descoberta tardia. Somam-se: alerta separado "interruptor geral
ligado há mais de 30 minutos"; verificação ops health --strict no deploy, que confere o
registro do job repetível com o fuso; e o alerta de conteúdo ausente às 05:00 do
runbook R-12.
R-02 — O lote parou no meio #
Sintoma. O painel mostra 1.240 de 3.000 enviados e o número não sobe há 10 minutos. Nenhuma falha em massa registrada.
Impacto. Parte da base não recebe. Proporcional ao ponto de parada.
Diagnóstico.
# 1. Estado das filas: há jobs presos em 'active' ou parados em 'delayed'?
docker compose exec -T worker ops queue:stats --queue=send.dispatch
# 2. Algum disjuntor aberto?
docker compose exec -T worker ops circuit:status
# 3. O worker está vivo e consumindo CPU?
docker compose ps worker
docker stats --no-stream pd-worker
# 4. Reinícios recentes? (falta de memória aparece como exit code 137)
docker inspect pd-worker --format '{{.RestartCount}} {{.State.ExitCode}} {{.State.OOMKilled}}'
# 5. Erros nos últimos minutos, agrupados por código.
docker compose logs --since=15m worker \
| grep '"level":"error"' \
| node -e 'let m={};require("readline").createInterface({input:process.stdin})
.on("line",l=>{try{const o=JSON.parse(l);m[o.code||"?"]=(m[o.code||"?"]||0)+1}catch{}})
.on("close",()=>console.log(JSON.stringify(m,null,2)))'
# 6. Estado do lote.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT id, status, planned_count, sent_count, failed_count, started_at, updated_at
FROM send_batches WHERE devotional_date = current_date;"Causas: disjuntor aberto adiando tudo; worker reiniciado por falta de memória; jobs travados por processamento longo; Postgres sem conexões; interruptor acionado no meio.
Correção.
# Disjuntor aberto por causa já resolvida:
docker compose exec -T worker ops circuit:reset --name=whatsapp
# Jobs travados:
docker compose exec -T worker ops queue:unstall --queue=send.dispatch
# Worker morto por falta de memória: subir o limite e reiniciar.
# Editar .env.production: NODE_OPTIONS=--max-old-space-size=1280
docker compose up -d worker
# Retomar o lote. É idempotente: reenfileira só o que não está em estado terminal.
docker compose exec -T worker ops send:resume --batch=<id-do-lote>Verificação. ops queue:stats com waiting e delayed caindo até zero;
sent_count + failed_count = planned_count em send_batches; ops queue:dead:list sem
crescimento; confirmação em aparelho real.
Prevenção. Alerta "lote sem progresso por 5 minutos com waiting > 0". Limite de
memória do worker com folga de 50% sobre o pico medido. maxStalledCount: 2 para que um
job travado seja detectado e reprocessado sozinho. Métrica de taxa de envio por minuto
exposta no painel, com faixa esperada visível.
R-03 — Taxa de falha de envio alta, ou lote com falha em massa #
Sintoma. Um destes alertas (Seção 18.12): send.failure_rate_high (acima de 5%),
send.batch_failure_rate (acima de 10% durante o disparo, a partir de 300 tentativas),
send.batch_error_dominant (um único error_code acima de 60% das falhas) ou
send.batch_halted_by_guard (o motor pausou o lote sozinho ao passar de 20% nas primeiras
200 tentativas). Muitas linhas DEAD novas em job_runs.
Impacto. De alguns assinantes sem devocional a risco de rebaixamento da qualidade do número, que é dano de longo prazo. Alvo de decisão: 5 minutos.
Diagnóstico.
# 0. Trinta segundos: a foto do lote, com os cinco codigos mais frequentes.
docker compose exec -T worker ops send:status --date=$(TZ=America/Sao_Paulo date +%F)
# Devolve planejados, enviados, falhos, adiados, taxa e os 5 error_code dominantes.
# 1. Distribuição das falhas por código. É a informação que decide tudo.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT error_code, count(*) AS total,
round(100.0*count(*)/sum(count(*)) OVER (), 1) AS pct
FROM delivery_attempts
WHERE devotional_date = current_date AND status = 'FAILED'
GROUP BY error_code ORDER BY total DESC;"
# 2. É concentrado em uma rota ou tier?
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT route, tier_at_send, status, count(*) FROM delivery_attempts
WHERE devotional_date = current_date GROUP BY 1,2,3 ORDER BY 1,2,3;"
# 3. Qualidade do número e limite de mensagens.
docker compose exec -T worker ops whatsapp:statusTabela de decisão por código dominante:
| Código dominante | Significado | Ação |
|---|---|---|
131047 |
Fora da janela de 24 h | O plano classificou errado. O motor reclassifica para template automaticamente. Se persistir, investigar service_window_expires_at desatualizado por webhooks de entrada não processados |
131026 |
Não é possível entregar | Números inválidos ou sem WhatsApp. Ao terceiro dia consecutivo, blocked_at e blocked_reason são gravados. Se o volume for alto e súbito, suspeitar de importação de base ruim |
131049 / 131050 |
Limitação por saúde do ecossistema | Não é falha de sistema. Adiar para o dia seguinte. Se recorrente, revisar categoria e conteúdo do template |
130429, 80007, 613, 4 |
Estrangulamento | Reduzir send.rate_per_second e retomar. Não parar o lote |
190, 133010, 33, 131031 |
Erro de configuração ou de conta | Parar o lote. Não retentar: cada tentativa queima cota e reputação sem chance de sucesso. Ir para R-07 (190) ou R-05 (133010, 131031) |
131047 / 131049 em conjunto |
Janela e categoria | O lote está correto; o problema é de rota. Investigar service_window_expires_at desatualizado por webhooks de entrada não processados (Seção 17.8) |
132000–132016 |
Problema de template | Ir para R-06 |
133xxx (demais) |
Problema de conta ou número | Ir para R-04 ou R-05 |
| Misto, sem dominante | Instabilidade da Meta | Deixar o disjuntor trabalhar; monitorar |
Correção.
# Estrangulamento: reduzir a taxa pela metade e retomar.
docker compose exec -T worker ops config:set --key=send.rate_per_second --value=10
docker compose restart worker # o limitador lê o valor na subida
docker compose exec -T worker ops send:resume --batch=<id>
# Erro sistêmico: PARAR o lote. Interrompe o consumo da fila e PRESERVA os
# itens pendentes; não os cancela.
docker compose exec -T worker ops send:halt --date=$(TZ=America/Sao_Paulo date +%F) --reason="190"
# Corrigida a causa, retomar apenas PENDING e RETRYING.
docker compose exec -T worker ops send:resume --date=$(TZ=America/Sao_Paulo date +%F)
# Falhas transitórias já resolvidas: reprocessar só elas.
docker compose exec -T worker ops queue:dead:replay --queue=send.dispatch --code=WHATSAPP_UNAVAILABLE
# Falhas permanentes: bloquear o destino, não reenviar.
docker compose exec -T worker ops subscriber:block --code=131026 --batch=<id>Se o lote foi pausado sozinho por send.batch_halted_by_guard, ele já está parado: o passo
seguinte é diagnosticar e decidir, não religar às cegas. A retomada é sempre humana.
Se a taxa de falha passar de 25% e o problema não estiver contido no lote — por exemplo, envio para quem não deu opt-in —, acionar o interruptor geral (Seção 27.8) antes de continuar diagnosticando. Um lote falhando em massa castiga a qualidade do número, e qualidade perdida leva semanas para recuperar.
Regra de parada. Se o devocional não puder sair até 10:00 local, ele não sai naquele dia: enviar devocional matinal à tarde tem custo de reputação maior do que não enviar. Registrar a decisão e seguir a regra de comunicação da Seção 27.7.
Verificação. Taxa de falha do lote abaixo de 2%; quality_rating ainda em GREEN;
contagem de linhas DEAD em job_runs sem crescimento por 15 minutos.
Prevenção. Alertas escalonados de 5% (alta), 10% (crítica, durante o disparo) e código
dominante acima de 60%, mais a contenção automática em 20% (Seção 18.10) — é ela que impede
que 8.000 itens queimem 32.000 chamadas enquanto ninguém está acordado. Verificação de
service_window_expires_at na simulação do plano. Higiene de base: nenhum número entra sem
passar por normalizePhoneBR e por confirmação de opt-in no WhatsApp, o que praticamente
elimina 131026. Todo lote com mais de 5% de falha gera relatório pós-incidente (Seção
27.7.4), com a decisão explícita sobre reenvio.
R-04 — Número do WhatsApp com qualidade vermelha #
Sintoma. Webhook phone_number_quality_update com RED, ou o painel da Meta exibindo
qualidade baixa. Alerta imediato.
Impacto. Limite de mensagens reduzido, entrega estrangulada e, se não corrigido, o número pode ser restringido ou banido. É o caminho para o R-05.
Diagnóstico.
docker compose exec -T worker ops whatsapp:status{
"phoneNumberId": "1099...",
"displayPhoneNumber": "+55 11 ...",
"qualityRating": "RED",
"messagingLimit": "TIER_1K",
"throughputLevel": "STANDARD",
"nameStatus": "APPROVED"
}# Bloqueios e opt-outs recentes: o sinal mais direto de insatisfação.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT date_trunc('day', opt_out_at) AS dia, count(*) FROM subscribers
WHERE opt_out_at > now() - interval '14 days'
GROUP BY 1 ORDER BY 1;"
# Taxa de leitura por dia: queda indica conteúdo indesejado.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT date_trunc('day', created_at) AS dia,
count(*) FILTER (WHERE status = 'read') * 100.0 / nullif(count(*),0) AS pct_lida
FROM message_logs WHERE created_at > now() - interval '14 days'
GROUP BY 1 ORDER BY 1;"
# Houve envio para quem não tinha opt-in confirmado?
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT count(*) FROM delivery_attempts da
JOIN subscribers s ON s.id = da.subscriber_id
WHERE da.created_at > now() - interval '7 days'
AND (s.opt_in_confirmed_at IS NULL OR s.opt_out_at IS NOT NULL);"O último comando precisa devolver zero. Qualquer valor diferente é bug grave e é a causa mais provável da queda de qualidade.
Correção.
- Reduzir o volume imediatamente:
docker compose exec -T worker ops config:set --key=send.rate_per_second --value=5
docker compose exec -T worker ops config:set --key=send.enabled_tiers --value=PAID
docker compose restart workerEnviar apenas para PAID por alguns dias. São os assinantes com maior engajamento e menor probabilidade de bloquear.
- Se houve envio sem opt-in, parar tudo com o interruptor geral, corrigir o bug e só então religar.
- Revisar o texto do template: garantir que o rodapé "Responda SAIR para cancelar" está presente e legível, e que o teaser não parece propaganda.
- Facilitar a saída: a resposta ao opt-out precisa ser imediata e sem atrito. Dificultar a saída aumenta o bloqueio, que é o que destrói a qualidade.
- Não trocar de número. Trocar leva o problema junto e queima um ativo. A qualidade se recupera em 7 a 14 dias de comportamento saudável.
Verificação. qualityRating volta a YELLOW e depois a GREEN em até 14 dias;
opt-outs diários voltam à média histórica; taxa de leitura acima de 40%.
Prevenção. Alerta em qualquer transição para YELLOW, não só RED. Painel com
opt-outs diários e taxa de leitura visíveis. Opt-in duplo obrigatório, que já é regra do
produto. Métrica de bloqueios acompanhada semanalmente.
R-05 — Número banido ou conta restrita #
Sintoma. Erros 133xxx em todos os envios; o painel da Meta mostra o número como
restrito ou desabilitado; nenhuma mensagem sai.
Impacto. Máximo. O canal do produto está fora. Duração indeterminada, de horas a permanente.
Diagnóstico.
docker compose exec -T worker ops whatsapp:status
docker compose logs --since=1h worker | grep -oE '"metaCode":[0-9]+' | sort | uniq -c | sort -rnConsultar o painel de negócios da Meta para o motivo exato e se há apelação disponível.
Correção.
# 1. Parar tudo. Continuar tentando piora o registro da conta.
docker compose exec -T worker ops kill-switch:on --reason="numero restrito pela Meta"- Abrir apelação, com a evidência de opt-in: exportar
consent_eventsde uma amostra de assinantes, que contém texto de consentimento versionado, IP, data e canal. É exatamente a evidência que a Meta pede, e a existência dessa tabela é o que torna a apelação viável.
docker compose exec -T worker ops consent:export --sample=200 --format=csv > /tmp/consent-evidence.csv- Comunicar aos assinantes pelo painel web e por e-mail (ver Seção 27.7). Este é um dos poucos casos em que se comunica proativamente.
- Publicar o devocional diário no painel web normalmente. O produto degrada para "somente web" e continua existindo.
- Não cobrar durante a interrupção. Se passar de 3 dias, suspender as cobranças
recorrentes na Asaas e estender
current_period_endde todos os assinantes ativos pelo número de dias parados:
docker compose exec -T worker ops billing:pause-all --reason="canal indisponivel"
docker compose exec -T worker ops subscription:extend-all --days=<dias-parados>- Se um número secundário verificado existir, migrar
WHATSAPP_PHONE_NUMBER_IDe ressubmeter os templates. Isso exige que os oito templates já tenham sido aprovados no segundo número, o que é parte da prevenção.
Verificação. Envio de teste para o número do operador; qualityRating legível;
ops health --strict passando na verificação de templates.
Prevenção. Um segundo número verificado na mesma conta de negócios, com os oito
templates da Seção 17.5 aprovados, mantido em espera. Opt-in duplo com registro imutável. Monitoramento de
qualidade com alerta em YELLOW. Nunca importar base comprada ou de terceiros — não existe
caminho de cadastro que não passe pela confirmação no WhatsApp.
R-06 — Template rejeitado ou pausado pela Meta #
Sintoma. Erros 132000 a 132015 no envio; webhook message_template_status_update
com REJECTED, PAUSED ou DISABLED.
Impacto. Se for devocional_diario_v1, nenhum assinante com janela fechada recebe. Se
for codigo_acesso_v1, ninguém consegue entrar no painel.
Diagnóstico.
docker compose exec -T worker ops template:sync
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT name, language, category, status, quality_score, rejected_reason, updated_at
FROM whatsapp_templates ORDER BY name;"# O parâmetro enviado violava as regras de formatação?
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT id, length(teaser) AS tam,
teaser ~ E'[\\n\\r\\t]' AS tem_quebra,
teaser LIKE '% %' AS tem_4_espacos
FROM devotionals WHERE scheduled_for >= current_date - 7;"As colunas tem_quebra e tem_4_espacos precisam ser todas false. Verdadeiro significa
que a sanitização do teaser falhou e é a causa direta do erro de template.
Causas: parâmetro com quebra de linha, tabulação ou 4 espaços; teaser acima do limite; template pausado por qualidade baixa (muitos bloqueios após recebê-lo); reclassificação de categoria; template editado no painel da Meta sem ressincronizar.
Correção.
# Pausa por qualidade: a Meta reabilita sozinha após um período.
# Enquanto isso, usar o template alternativo já aprovado.
docker compose exec -T worker ops config:set --key=send.daily_template_name --value=devocional_diario_v2
# Rejeição por conteúdo: corrigir e ressubmeter.
docker compose exec -T worker ops template:submit --name=devocional_diario_v3 --from=devocional_diario_v1Se o problema foi o parâmetro, a correção é no teaser, e o teste da Seção 24.4.5 precisa ganhar um caso novo com a string exata que falhou.
Se nenhum template diário estiver utilizável, os assinantes com janela aberta continuam recebendo normalmente pelo caminho free-form. Rodar o lote apenas para eles:
docker compose exec -T worker ops send:plan --date=$(TZ=America/Sao_Paulo date +%F) --only-route=WINDOW_DIRECTVerificação. whatsapp_templates.status = 'APPROVED'; envio de teste chegando; sem
erros de template por 30 minutos.
Prevenção. Manter sempre dois templates diários aprovados (v1 e v2) com conteúdo
equivalente, para troca imediata. ops template:sync roda a cada hora e alerta em qualquer
mudança de status. O teaser é validado por isTemplateParamSafe no momento do salvamento no
painel administrativo, e não apenas no envio — falhar cedo, no editor, e não às 06:00.
R-07 — Token de acesso da Meta expirado #
Sintoma. Todos os envios falham com 190. ops health --strict falha na verificação da
Meta.
Impacto. Nenhuma mensagem sai e nenhum webhook é validado. Equivalente à Meta estar fora.
Diagnóstico.
docker compose logs --since=30m worker | grep -c '"metaCode":190'
# Validade do token corrente. Um token de usuário do sistema sem expiração
# devolve expiresAt nulo.
docker compose exec -T worker ops whatsapp:token-info{ "isValid": false, "expiresAt": "2026-08-25T03:00:00.000Z",
"scopes": ["whatsapp_business_messaging"], "type": "SYSTEM_USER" }Correção.
- Gerar um token novo no painel de negócios da Meta para o usuário do sistema, com as
permissões
whatsapp_business_messagingewhatsapp_business_management, sem expiração. - Atualizar o segredo no servidor:
sudo sed -i 's|^WHATSAPP_SYSTEM_USER_TOKEN=.*|WHATSAPP_SYSTEM_USER_TOKEN=<novo-token>|' \
/opt/palavra-diaria/.env.production
docker compose up -d web worker
docker compose exec -T worker ops circuit:reset --name=whatsapp
docker compose exec -T worker ops whatsapp:token-info- Reprocessar o que falhou por essa causa:
docker compose exec -T worker ops queue:dead:replay --queue=send.dispatch --code=WHATSAPP_TOKEN_INVALID- Revogar o token antigo no painel da Meta.
- Atualizar o cofre de segredos da organização.
Verificação. ops health --strict passa; envio de teste chega; nenhum 190 por 15
minutos.
Prevenção. Usar token de usuário do sistema sem expiração, nunca token de usuário
comum. ops secrets:audit semanal alerta se o token tiver validade definida e faltarem
menos de 14 dias. ops health a cada 15 minutos detecta a invalidação em minutos, não no
lote da manhã seguinte.
R-08 — Webhook da Asaas pausado por falhas #
Sintoma. Nenhum evento novo em payment_events há horas; o painel da Asaas mostra a fila
de webhooks interrompida; assinantes que pagaram não têm acesso liberado.
Impacto. Alto e silencioso. Pagamentos confirmados não viram acesso; inadimplências não viram revogação. Piora sozinho com o tempo.
Diagnóstico.
# 1. Último evento recebido.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT max(received_at) AS ultimo, now() - max(received_at) AS ha_quanto_tempo
FROM payment_events;"
# 2. O endpoint responde corretamente e rápido?
curl -i -m 5 -X POST https://api.palavradiaria.com.br/api/webhooks/asaas \
-H 'Content-Type: application/json' \
-H "asaas-access-token: $ASAAS_WEBHOOK_TOKEN" \
-d '{"event":"PING","payment":null}'
# Esperado: HTTP/2 200, em menos de 2 s.
# 3. Erros no handler.
docker compose logs --since=6h web | grep '"route":"/api/webhooks/asaas"' | grep '"level":"error"' | tail -50
# 4. Latência do handler nas últimas horas.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT date_trunc('hour', received_at) AS hora, count(*),
round(avg(handler_duration_ms)) AS media_ms,
max(handler_duration_ms) AS max_ms
FROM payment_events WHERE received_at > now() - interval '12 hours'
GROUP BY 1 ORDER BY 1;"max_ms acima de 2.000 é a causa provável: a Asaas interpreta lentidão como falha e pausa a
fila após uma sequência de erros. O handler precisa apenas persistir e responder; qualquer
processamento síncrono nele é bug.
Correção.
- Corrigir a causa (endpoint fora do ar, TLS vencido, handler lento, token divergente).
- Reativar a fila no painel da Asaas. A Asaas reentrega os eventos pendentes.
- Fechar a lacuna com reconciliação, que não depende de webhook:
docker compose exec -T worker ops billing:reconcile --since=$(date -u -d '3 days ago' +%F) --fix- Reprocessar eventos persistidos mas não processados:
docker compose exec -T worker ops billing:replay-events --since=$(date -u -d '3 days ago' +%FT%TZ)Verificação. Eventos novos chegando; ops billing:reconcile --dry-run sem divergências;
amostra de 5 assinantes que pagaram no período com tier = PAID.
Prevenção. O handler já é mínimo por desenho: valida o token em tempo constante,
persiste em payment_events, enfileira e responde 200. Alerta "nenhum evento da Asaas em
6 horas" (a base gera eventos com regularidade suficiente para que o silêncio seja sinal).
Alerta de p99 do handler acima de 1,5 s. Reconciliação diária às 04:00 é a rede de segurança
permanente.
R-09 — Divergência de assinatura entre a Asaas e o banco #
Sintoma. A reconciliação diária reporta divergências, ou um assinante reclama de estar pagando sem ter acesso, ou de ter acesso sem pagar.
Impacto. Financeiro e de confiança. Cobrar quem não tem acesso é o pior dos dois.
Diagnóstico.
docker compose exec -T worker ops billing:reconcile --dry-run --verbose{
"checked": 3011,
"divergences": [
{ "subscriptionId": "01K3Q...", "asaasId": "sub_9v8x", "field": "status",
"ours": "ACTIVE", "theirs": "INACTIVE", "since": "2026-08-22" },
{ "subscriptionId": "01K3R...", "asaasId": "sub_7t6r", "field": "nextDueDate",
"ours": "2026-09-20", "theirs": "2026-09-22" }
]
}# Caso individual, com o histórico completo.
docker compose exec -T worker ops subscriber:inspect --phone=+5511990000123Causas: webhook perdido durante indisponibilidade; evento processado fora de ordem; alteração manual feita no painel da Asaas; falha parcial de transação.
Correção. A regra canônica é: a Asaas é a fonte da verdade para cobrança.
# Aplicar as correções propostas.
docker compose exec -T worker ops billing:reconcile --fix
# Caso individual.
docker compose exec -T worker ops billing:reconcile --subscription=01K3Q --fixCada correção grava subscription_events com origem RECONCILIATION, preservando o rastro
de que a mudança não veio de webhook.
Duas exceções em que não se aplica a correção automática:
- Nós dizemos
ACTIVE, a Asaas dizINACTIVE, e o assinante pagou. Antes de revogar, conferirGET /v3/payments?subscription=<id>por um pagamento confirmado. Se existir, o erro é da Asaas ou de um cancelamento acidental; abrir chamado e manter o acesso. Revogar acesso de quem pagou é o pior desfecho possível. - Divergência de valor. Nunca corrigida automaticamente. Sempre revisão humana, porque pode indicar alteração de preço aplicada parcialmente.
Verificação. ops billing:reconcile --dry-run com zero divergências; amostra de 5 casos
corrigidos conferidos manualmente; subscription_events com o registro correspondente.
Prevenção. Reconciliação diária às 04:00, sempre. Alerta quando as divergências passam
de 0,5% da base. Proibição operacional de alterar assinaturas direto no painel da Asaas
(toda alteração passa pelo nosso painel, que propaga). Processamento de eventos ordenado por
dateCreated, não por ordem de chegada.
R-10 — Pagamento confirmado sem liberação de acesso #
Sintoma. Um assinante mostra o comprovante e continua vendo "Plano gratuito".
Impacto. Individual, mas de alto atrito. Se for sistêmico, é o pior tipo de incidente.
Diagnóstico.
# 1. Estado do assinante.
docker compose exec -T worker ops subscriber:inspect --phone=+5511990000123{
"subscriberId": "01K3Q", "tier": "FREE",
"subscription": { "id": "01K3R", "status": "PENDING_PAYMENT",
"asaasId": "sub_9v8x", "currentPeriodEnd": null },
"lastPaymentEvent": { "event": "PAYMENT_CONFIRMED", "receivedAt": "2026-08-25T11:02:00Z",
"processedAt": null, "error": "ASAAS_RESPONSE_SCHEMA_MISMATCH" }
}processedAt nulo com erro registrado é o diagnóstico completo: o evento chegou e o
processamento falhou.
# 2. Eventos pendentes no sistema todo — isto responde se é caso isolado ou sistêmico.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT event_type, error_code, count(*) FROM payment_events
WHERE processed_at IS NULL AND received_at > now() - interval '24 hours'
GROUP BY 1,2 ORDER BY 3 DESC;"
# 3. Fila morta de cobrança.
docker compose exec -T worker ops queue:dead:list --queue=billing.webhook
# 4. O evento chegou a existir?
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT id, event_type, received_at, processed_at, error_code
FROM payment_events WHERE asaas_subscription_id = 'sub_9v8x'
ORDER BY received_at;"Ramos: evento nunca chegou (ir para R-08); evento chegou e falhou (reprocessar); evento
processado e a transição foi recusada (bug na máquina de estados, ver Seção 24.4.4);
externalReference divergente, impedindo o casamento.
Correção.
# Reprocessar o evento específico.
docker compose exec -T worker ops billing:replay-events --event-id=<id>
# Se o evento não existe mas o pagamento é real, reconciliar direto da Asaas.
docker compose exec -T worker ops billing:reconcile --subscription=01K3R --fix
# Último recurso, com registro em auditoria e justificativa obrigatória:
docker compose exec -T worker ops subscription:force-activate \
--id=01K3R --until=2026-09-25 --reason="pagamento confirmado, evento perdido, chamado 123"subscription:force-activate exige --reason, grava em admin_audit_log e emite alerta
informativo. Existe para não deixar um assinante pagante esperando enquanto se investiga a
causa raiz.
Comunicação com o assinante, sempre no mesmo dia: "Identificamos o seu pagamento e liberamos o acesso. Desculpe pelo transtorno." Sem explicação técnica.
Verificação. ops subscriber:entitlements --id=<id> devolve tier: PAID; o assinante
confirma o acesso; o envio seguinte inclui áudio; payment_events.processed_at preenchido.
Prevenção. Alerta crítico para qualquer linha job_runs.status = 'DEAD' com
queue = 'billing.webhook'. Alerta para
payment_events com processed_at IS NULL há mais de 15 minutos. Reconciliação diária.
Botão "Verificar meu pagamento" no painel, que dispara a reconciliação daquela assinatura sob
demanda e resolve o caso sem contato com suporte.
R-11 — O áudio do dia não foi gerado #
Sintoma. Alerta "devocional de hoje sem áudio" às 05:00, ou o devocional preso em
AUDIO_PENDING.
Impacto. Assinantes pagos recebem texto sem áudio. É a diferença do plano que eles compraram.
Diagnóstico.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT d.id, d.title, d.status, d.scheduled_for,
a.provider, a.status AS audio_status, a.duration_seconds, a.ogg_key,
m.whatsapp_media_id, m.expires_at, a.created_at
FROM devotionals d
LEFT JOIN audio_assets a ON a.devotional_id = d.id AND a.invalidated_at IS NULL
LEFT JOIN media_uploads m ON m.audio_asset_id = a.id AND m.superseded_at IS NULL
WHERE d.scheduled_for BETWEEN current_date AND current_date + 3
ORDER BY d.scheduled_for;"
docker compose exec -T worker ops queue:stats --queue=tts.generate
docker compose exec -T worker ops queue:dead:list --queue=tts.generate
docker compose exec -T worker ops circuit:status
docker compose logs --since=12h worker | grep '"job":"tts.generate"' | grep '"level":"error"' | tail -30Causas: os dois provedores indisponíveis; cota do provedor esgotada; roteiro de narração
acima do máximo absoluto de 20.000 caracteres (NARRATION_SCRIPT_TOO_LARGE — o aviso
SCRIPT_TOO_LONG dos 9.000 caracteres não falha o job, apenas divide em partes);
ffmpeg falhando; storage fora; devocional que nunca chegou a READY, então o job nunca foi
enfileirado.
Correção.
# Regerar, escolhendo o provedor explicitamente se o primário está com problema.
docker compose exec -T worker ops tts:generate --devotional=<id> --force --provider=fallback
# Roteiro longo demais: encurtar a reflexão no painel administrativo e regerar.
# Verificar o tamanho antes. Os três limites da Seção 16.3.7 são distintos:
# warnAbove 9000 -> aviso SCRIPT_TOO_LONG, o job segue com divisão em partes
# hardLimit 20000 -> NARRATION_SCRIPT_TOO_LARGE, o job falha
# chunkSize 5000 -> tamanho de cada requisição ao provedor, não limite do roteiro
docker compose exec -T worker ops tts:script --devotional=<id> --stats
# { "characters": 21430, "estimatedDurationSec": 1509,
# "warnAbove": 9000, "hardLimit": 20000, "chunkSize": 5000 }
# Áudio existe no bucket mas o identificador de mídia na Meta expirou:
docker compose exec -T worker ops media:reupload --devotional=<id>Se o áudio não puder ser gerado a tempo, o texto sai mesmo assim, conforme a Seção 27.4.3. Confirmar que o motor está configurado para isso e disparar o job de recuperação para entregar o áudio depois:
docker compose exec -T worker ops send:audio-catchup --date=$(TZ=America/Sao_Paulo date +%F)O comando envia o áudio apenas para quem ainda está com a janela de atendimento aberta. Quem já fechou a janela recebe o áudio no painel web, e o motivo é registrado.
Verificação. audio_assets com ogg_key, mp3_key, status = 'READY' e
duration_seconds entre 180 e 360, e media_uploads.whatsapp_media_id preenchido com
expires_at a mais de 48 h; ouvir o áudio pelo painel administrativo antes de liberar;
devotionals.status = 'PUBLISHED'.
Prevenção. O áudio é gerado quando o devocional atinge READY, normalmente com dias de
antecedência — a folga é a prevenção principal. Alerta às 05:00 para qualquer devocional dos
próximos 3 dias sem áudio, que é o primeiro dos três marcos de prontidão da Seção 18.4.
Validação de tamanho do roteiro no momento de salvar no painel, não na geração.
Monitoramento de cota do provedor com alerta em 80% de uso.
R-12 — Conteúdo do dia ausente às 05:00 #
Sintoma. Alerta "nenhum devocional publicado para hoje", disparado às 05:00.
Impacto. Sem conteúdo não há envio. Se não for resolvido em 40 minutos, o lote das 05:40 não terá o que enviar.
Diagnóstico.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT scheduled_for, id, title, status FROM devotionals
WHERE scheduled_for BETWEEN current_date AND current_date + 14
AND deleted_at IS NULL
ORDER BY scheduled_for;"Verificar se falta o dia (linha ausente) ou se existe em estado não publicável (DRAFT,
READY, AUDIO_PENDING).
Correção, em ordem de preferência.
# 1. Existe e está pronto, faltou publicar. Melhor caso.
docker compose exec -T worker ops devotional:publish --id=<id>
# 2. Existe em DRAFT com conteúdo completo: revisar rápido, marcar READY, gerar áudio,
# publicar. Cabe em 20 minutos.
docker compose exec -T worker ops devotional:transition --id=<id> --to=READY
docker compose exec -T worker ops tts:generate --devotional=<id>
docker compose exec -T worker ops devotional:publish --id=<id>
# 3. Não existe: reaproveitar um devocional do acervo com mais de 180 dias.
docker compose exec -T worker ops devotional:reuse --source=<id-antigo> --for-date=$(TZ=America/Sao_Paulo date +%F)devotional:reuse clona o conteúdo, gera um id novo, reaproveita o áudio existente se
ainda estiver no bucket (evitando o custo e o tempo de TTS) e marca
devotional_revisions.origin = 'REUSE'. A escolha de 180 dias é deliberada: mais de meio ano
é tempo suficiente para que a repetição não seja percebida, e assinantes com menos de 180
dias de casa nunca viram o conteúdo.
Se nem isso for possível, não enviar. Um devocional vazio ou improvisado é pior do que a ausência.
Verificação. SELECT status FROM devotionals WHERE scheduled_for = current_date devolve
PUBLISHED; ops send:plan --dry-run mostra as contagens esperadas.
Prevenção. Alerta escalonado: informativo com 7 dias de antecedência se houver menos de 7 dias de conteúdo publicado na fila; alta com 3 dias; crítica às 05:00 do próprio dia. O painel administrativo mostra permanentemente "conteúdo publicado até DD/MM (N dias de folga)" no topo do calendário editorial. Meta operacional declarada: manter no mínimo 14 dias de conteúdo publicado à frente.
R-13 — Fila travada com job preso #
Sintoma. ops queue:stats mostra jobs em active que não avançam. waiting acumula. O
worker parece vivo mas não produz.
Impacto. Depende da fila. Em send.dispatch, o lote para.
Diagnóstico.
docker compose exec -T worker ops queue:stats --all
# Jobs ativos há mais tempo do que o razoável.
docker compose exec -T worker ops queue:active --queue=send.dispatch --older-than=120[
{ "id": "s_01K3Q...", "name": "send", "processedOn": "2026-08-25T09:02:11.000Z",
"ageSeconds": 843, "attemptsMade": 1, "lockExpiresIn": -120 }
]lockExpiresIn negativo significa que o bloqueio expirou e o job está tecnicamente órfão.
# O processo do worker está travado ou trabalhando?
docker stats --no-stream pd-worker
docker compose exec -T worker node -e "console.log(process.memoryUsage())"
# Alguma consulta segurando bloqueio no banco?
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT pid, state, wait_event_type, wait_event, now()-query_start AS duracao,
left(query, 120) AS consulta
FROM pg_stat_activity
WHERE state <> 'idle' AND now()-query_start > interval '30 seconds'
ORDER BY duracao DESC;"
# Bloqueios em cadeia.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT blocked.pid AS bloqueado, blocking.pid AS bloqueador,
left(blocked.query,80) AS q_bloqueada, left(blocking.query,80) AS q_bloqueadora
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;"Causas: chamada HTTP sem timeout (a mais comum); bloqueio no banco; laço infinito em processamento de áudio; worker com CPU saturada.
Correção.
# 1. Liberar os jobs órfãos. O BullMQ os devolve para 'waiting'.
docker compose exec -T worker ops queue:unstall --queue=send.dispatch
# 2. Se houver consulta travando o banco, encerrar apenas ela.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT pg_cancel_backend(<pid>);"
# Se pg_cancel_backend não resolver em 30 s:
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT pg_terminate_backend(<pid>);"
# 3. Reiniciar o worker. O stop_grace_period de 120 s deixa o job em curso terminar.
docker compose restart worker
# 4. Retomar o lote.
docker compose exec -T worker ops send:resume --batch=<id>Nunca usar docker kill no worker. O encerramento forçado deixa delivery_attempts em
SENDING com locked_at velho e exige a varredura de reparo da Seção 18.15.
Verificação. active volta a valores normais e rotativos; waiting cai; sem jobs com
lockExpiresIn negativo; taxa de envio por minuto de volta à faixa esperada.
Prevenção. Timeout obrigatório em toda chamada HTTP de saída (AbortSignal.timeout),
imposto por regra de lint que proíbe fetch sem signal. lockDuration: 60_000 e
maxStalledCount: 2 fazem o BullMQ recuperar sozinho a maioria dos casos. Alerta "job ativo
há mais de 5 minutos". statement_timeout = 15s no Postgres impede consulta eterna.
R-14 — Banco sem conexões disponíveis #
Sintoma. Erros too many clients already ou Timed out fetching a new connection from the connection pool. O site fica lento e depois devolve erro.
Impacto. Total enquanto durar. Webhooks começam a falhar, o que aciona o risco do R-08.
Diagnóstico.
# Quem está consumindo conexões.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT application_name, state, count(*)
FROM pg_stat_activity WHERE datname = 'palavra_diaria'
GROUP BY 1,2 ORDER BY 3 DESC;"
# Limite configurado e uso atual.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT setting::int AS max_connections FROM pg_settings WHERE name='max_connections';"
# Conexões ociosas dentro de transação: o vilão clássico.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT pid, application_name, now()-state_change AS ocioso_ha, left(query,100)
FROM pg_stat_activity
WHERE state = 'idle in transaction' ORDER BY 2 DESC;"idle in transaction com muitos minutos indica transação aberta e esquecida — quase sempre
uma chamada de rede dentro de prisma.$transaction, o que é proibido por convenção de
código.
Correção.
# 1. Encerrar conexões ociosas em transação com mais de 5 minutos.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE state = 'idle in transaction' AND now()-state_change > interval '5 minutes';"
# 2. Alívio imediato: reduzir a concorrência do worker. WORKER_CONCURRENCY é variável
# de ambiente (Seção 26), não chave de settings: edita-se o arquivo e reinicia.
sudo sed -i 's|^WORKER_CONCURRENCY=.*|WORKER_CONCURRENCY=8|' /opt/palavra-diaria/.env.production
docker compose up -d worker
# 3. Se for pico legítimo, elevar o teto (exige reinício do Postgres).
# Editar postgres/postgresql.conf: max_connections = 160
docker compose restart postgres
docker compose restart web workerElevar max_connections só é seguro com memória disponível: cada conexão custa
aproximadamente 10 MB. De 120 para 160 são ~400 MB adicionais, que precisam caber no limite
do contêiner.
Verificação. Contagem de conexões abaixo de 70% do teto; nenhuma idle in transaction
com mais de 1 minuto; p95 da API de volta abaixo de 400 ms.
Prevenção. idle_in_transaction_session_timeout = 30s no Postgres, que mata o problema
na origem. connection_limit explícito por processo, com a soma sempre abaixo de
max_connections. Proibição, imposta em revisão de código, de qualquer chamada de rede
dentro de transação. Alerta em 70% de uso do teto. application_name distinto por processo,
que é o que torna o diagnóstico imediato.
R-15 — Disco cheio #
Sintoma. Alerta de disco acima de 75%; ou o Postgres recusando escrita com
No space left on device; ou o Docker falhando ao baixar imagem.
Impacto. Se o disco encher de verdade, o Postgres para e o sistema inteiro cai. É um incidente que dá muitos avisos antes de acontecer.
Diagnóstico.
df -h /
docker system df -v
# Tamanho dos volumes.
du -sh /var/lib/docker/volumes/* 2>/dev/null | sort -rh | head -10
# Maiores tabelas e índices.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT relname, pg_size_pretty(pg_total_relation_size(c.oid)) AS total,
pg_size_pretty(pg_relation_size(c.oid)) AS tabela
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'
ORDER BY pg_total_relation_size(c.oid) DESC LIMIT 15;"
# O WAL está acumulando? Indica arquivamento quebrado.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT count(*) AS segmentos, pg_size_pretty(sum(size)) AS total
FROM pg_ls_waldir();"
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT * FROM pg_stat_archiver;"
# Logs de contêiner fora de controle.
du -sh /var/lib/docker/containers/*/*-json.log | sort -rh | headpg_stat_archiver com failed_count alto e last_failed_time recente é a causa mais
perigosa: se o arquivamento de WAL falha, o Postgres acumula segmentos até encher o disco.
Correção.
# 1. Ganho rápido e seguro: lixo do Docker.
docker image prune -af --filter "until=168h"
docker builder prune -af
docker container prune -f
# 2. Se o arquivamento de WAL falhou, é a prioridade absoluta.
docker compose exec -T pgbackrest pgbackrest --stanza=palavra-diaria check
docker compose exec -T pgbackrest pgbackrest --stanza=palavra-diaria archive-push-async
# Corrigir credencial ou conectividade do repositório antes de qualquer outra coisa.
# 3. Retenção de dados: apagar o que já passou do prazo.
docker compose exec -T worker ops retention:run --dry-run
docker compose exec -T worker ops retention:run
# 4. Recuperar espaço de tabelas inchadas (bloqueia a tabela; usar em janela).
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"VACUUM (FULL, ANALYZE) message_logs;"
# 5. Truncar logs de contêiner (último recurso, perde histórico).
truncate -s 0 /var/lib/docker/containers/*/*-json.logNunca apagar arquivos de pg_wal manualmente. Isso corrompe o banco de forma
irreversível. Se o WAL for o problema, a solução é fazer o arquivamento voltar a funcionar.
Verificação. df -h / abaixo de 70%; pg_stat_archiver.failed_count estável;
ops backup:verify passando; escrita no banco normal.
Prevenção. Alerta em 75% e crítico em 85%, com margem suficiente para agir. Rotação de
log já configurada (20 MB × 5 arquivos por serviço). docker image prune semanal
automatizado. Job de retenção diário. Alerta específico para
pg_stat_archiver.failed_count crescente, que é o sinal precoce do pior caso. Painel com a
projeção de crescimento de disco a 90 dias.
R-16 — Restauração de backup #
Sintoma. Perda ou corrupção de dados: erro humano, falha de disco, ou reconstrução após desastre.
Impacto. Máximo durante a execução. O sistema fica fora enquanto dura.
Diagnóstico. Antes de restaurar, três perguntas precisam de resposta escrita:
- Qual é o instante-alvo? Determinar o momento exato imediatamente anterior ao dano.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT * FROM admin_audit_log ORDER BY created_at DESC LIMIT 20;"
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT job_name, status, started_at, finished_at FROM job_runs
ORDER BY started_at DESC LIMIT 20;"- Restauração completa ou parcial? Se apenas uma tabela foi afetada, restaurar em um banco temporário e copiar a tabela é muito menos destrutivo.
- O backup está íntegro?
docker compose exec -T pgbackrest pgbackrest --stanza=palavra-diaria info
docker compose exec -T pgbackrest pgbackrest --stanza=palavra-diaria checkCorreção — restauração parcial (preferida sempre que possível).
# 1. Restaurar em diretório separado, sem tocar em produção.
docker compose run --rm pgbackrest \
pgbackrest --stanza=palavra-diaria --pg1-path=/tmp/restore-parcial \
--type=time --target="2026-08-25 04:55:00-03" restore
# 2. Subir uma instância temporária apontando para esse diretório.
docker compose -f docker-compose.restore.yml up -d postgres-restore
# 3. Extrair apenas o necessário.
docker compose -f docker-compose.restore.yml exec -T postgres-restore \
pg_dump -U app -d palavra_diaria -t subscribers --data-only -Fc > /tmp/subscribers.dump
# 4. Conferir antes de aplicar.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"CREATE SCHEMA IF NOT EXISTS restore_tmp;"
pg_restore -d palavra_diaria --schema=restore_tmp /tmp/subscribers.dump
# Comparar restore_tmp.subscribers com public.subscribers e aplicar só as linhas corretas.Correção — restauração completa. Seguir a Seção 25.8.4, com o interruptor geral ligado antes de subir a aplicação e desligado só depois da verificação:
docker compose stop web worker
docker compose run --rm pgbackrest \
pgbackrest --stanza=palavra-diaria --delta \
--type=time --target="2026-08-25 04:55:00-03" --target-action=promote restore
docker compose up -d postgres
docker compose up -d worker
docker compose exec -T worker ops kill-switch:on --reason="pos-restauracao, verificacao pendente"
docker compose exec -T worker ops backup:verify --post-restore
docker compose exec -T worker ops billing:reconcile --since=<data-do-alvo> --fix
docker compose exec -T worker ops health --strict
docker compose up -d web
docker compose exec -T worker ops kill-switch:offO interruptor ligado durante a verificação é obrigatório: sem ele, um lote poderia disparar com dados parciais e enviar mensagens erradas.
Verificação. Contagens das tabelas principais compatíveis com o esperado;
prisma migrate status sem migrations pendentes; ops health --strict passando;
ops billing:reconcile --dry-run sem divergências; amostra de 10 assinantes conferida.
Prevenção. Ensaio trimestral obrigatório (Seção 25.8.5), que é o que transforma este
runbook de teoria em procedimento praticado. Retenção de 35 dias. Backup em provedor
separado. Duas camadas independentes de backup. Permissões que impedem a aplicação de
executar DROP TABLE.
R-17 — Assinante relata que não recebeu #
Sintoma. Contato pelo suporte: "não recebi o devocional de hoje".
Impacto. Individual, salvo se for a ponta de um problema sistêmico. A primeira pergunta a responder é justamente qual dos dois é.
Diagnóstico.
# 1. É sistêmico? Sempre verificar antes de investigar o caso individual.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT status, count(*) FROM delivery_attempts
WHERE devotional_date = current_date GROUP BY 1;"
# 2. Estado do assinante. A busca é pelo índice cego: o comando normaliza o número,
# calcula phone_hmac e consulta por ele — a coluna phone_e164 é cifrada (Seção 6).
docker compose exec -T worker ops subscriber:inspect --phone=+5511990000123{
"subscriberId": "01K3Q...",
"phoneE164": "+55119****0123",
"waId": "5511990000123",
"tier": "PAID",
"status": "ACTIVE_PAID",
"optInConfirmedAt": "2026-03-02T14:00:00Z",
"optOutAt": null,
"blockedAt": null,
"serviceWindowExpiresAt": "2026-08-24T22:10:00Z",
"consecutiveWindowMisses": 4,
"today": {
"planned": true, "route": "TEMPLATE_VIDEO",
"attemptStatus": "SENT", "wamid": "wamid.HBg...",
"tierAtSend": "PAID", "tierAtSendEffective": "PAID", "downgradedAt": null,
"messageStatus": "delivered", "deliveredAt": "2026-08-25T09:00:14Z"
}
}Árvore de decisão:
| O que se vê | Conclusão | Ação |
|---|---|---|
optInConfirmedAt: null |
Nunca confirmou o opt-in | Reenviar a mensagem de boas-vindas e orientar a responder SIM |
optOutAt preenchido |
Saiu, talvez sem perceber | Explicar e oferecer VOLTAR |
blockedAt preenchido |
Número marcado como não entregável, ou bloqueio administrativo | Confirmar o número, corrigir se necessário, desbloquear pelo painel (a transição de saída de BLOCKED está na Seção 11.8) |
planned: false |
Não entrou no lote | Verificar o motivo em skipped: tier FREE em dia que não é domingo é o caso mais comum e não é erro |
attemptStatus: SKIPPED_OPTED_OUT, SKIPPED_INELIGIBLE ou SKIPPED_PAUSED |
O estado mudou entre o planejamento e o disparo | Não é falha: é a revalidação do disparo funcionando (Seção 18.5.1). Explicar o estado atual |
downgradedAt preenchido |
Perdeu o acesso pago entre 05:40 e o disparo | Recebeu o texto sem áudio, corretamente. Se o pagamento existir, ir para R-10 |
attemptStatus: FAILED |
Envio falhou | Verificar error_code e aplicar R-03 |
messageStatus: sent, sem delivered |
Aparelho desligado ou sem rede | Aguardar; a Meta reentrega por até 30 dias |
messageStatus: delivered |
Foi entregue. Está no aparelho | Orientar: verificar arquivadas, silenciadas, ou se o número foi bloqueado |
messageStatus: failed com 131049 |
Adiado por saúde do ecossistema | Explicar que chega no dia seguinte; não é falha |
| Tudo correto e mesmo assim não chegou | O assinante bloqueou o número | Orientar a desbloquear |
# Histórico completo das mensagens do assinante nos últimos 7 dias.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT created_at, direction, message_key, status, wamid, error_code
FROM message_logs WHERE subscriber_id = '01K3Q...'
AND created_at > now() - interval '7 days' ORDER BY created_at DESC;"Correção.
# Reenvio manual, respeitando o limite do tier (Seção 13).
docker compose exec -T worker ops send:one --subscriber=01K3Q... --date=$(TZ=America/Sao_Paulo date +%F)
# Reenvio ignorando o limite, com justificativa registrada.
docker compose exec -T worker ops send:one --subscriber=01K3Q... \
--date=$(TZ=America/Sao_Paulo date +%F) --force --reason="chamado #456, falha comprovada"Se delivered estiver confirmado, não reenviar. Reenviar o que já foi entregue produz
mensagem duplicada, irrita e degrada a qualidade do número. Orientar o assinante a procurar
a conversa.
Verificação. message_logs com delivered para o novo envio; confirmação do assinante.
Prevenção. O painel web mostra o devocional do dia sempre, com botão de reenviar — resolve a maioria dos casos sem contato com suporte. A resposta padrão do suporte já inclui as três orientações mais comuns (arquivadas, silenciadas, bloqueio). Métrica de reclamações por dia: mais de 5 em um dia é sinal sistêmico e escala para R-03.
R-18 — Redis indisponível ou com perda de dados #
Sintoma. worker reportando 503; erros de conexão nos logs; ops queue:stats
falhando; ou o Redis de pé mas com todas as filas vazias.
Impacto. Processamento assíncrono parado. O site continua de pé (Seção 27.4.5).
Diagnóstico.
docker compose ps redis
docker compose exec -T redis redis-cli -a "$REDIS_PASSWORD" ping
docker compose exec -T redis redis-cli -a "$REDIS_PASSWORD" info memory | grep -E 'used_memory_human|maxmemory_human'
docker compose exec -T redis redis-cli -a "$REDIS_PASSWORD" info persistence | grep -E 'aof_enabled|aof_last_write_status|rdb_last_bgsave_status|loading'
docker compose logs --tail=100 redis
docker inspect pd-redis --format '{{.State.OOMKilled}} {{.RestartCount}}'Causas: contêiner morto por falta de memória; maxmemory atingido com noeviction
(escritas recusadas, que é o comportamento desejado); AOF corrompido impedindo o start;
volume perdido.
Correção.
# 1. Memória cheia: elevar o limite e reiniciar.
# Editar redis/redis.conf: maxmemory 1024mb; e o limite do contêiner no compose.
docker compose up -d redis
# 2. AOF corrompido impedindo o start:
docker compose run --rm --entrypoint redis-check-aof redis \
--fix /data/appendonlydir/appendonly.aof.1.incr.aof
docker compose up -d redis
# 3. Perda total de dados. Repopular a partir do Postgres, que é a fonte da verdade.
docker compose restart worker # recria os jobs repetíveis, de forma idempotente
docker compose exec -T worker ops queue:recover --allops queue:recover --all executa, nesta ordem:
- Reenfileira
payment_eventscomprocessed_at IS NULL. - Reenfileira
inbound_messagescomprocessed_at IS NULL. - Reenfileira
devotionalsemAUDIO_PENDING. - Para o lote do dia corrente, reenfileira
delivery_attemptsque não estão em estado terminal — a chave única impede duplicata. - Repopula
killswitch:globala partir desettings, que é a proteção descrita na Seção 25.9.3.
# 4. Conferir e retomar.
docker compose exec -T worker ops queue:stats --all
docker compose exec -T worker ops send:resume --batch=<id-do-lote-do-dia>Verificação. PING respondendo PONG; worker com health 200; filas com movimento;
ops kill-switch:status refletindo o valor de settings; nenhum envio duplicado
(SELECT count(*), count(DISTINCT idempotency_key) FROM delivery_attempts WHERE devotional_date = current_date com os dois valores iguais).
Prevenção. maxmemory-policy noeviction, que transforma pressão de memória em erro
visível. Alerta em 80% de maxmemory. AOF com appendfsync everysec. Limite de memória do
contêiner com folga de 30%. Nenhuma verdade de negócio no Redis — é o desenho que torna a
perda recuperável.
R-19 — Storage de mídia indisponível #
Sintoma. Erros ao gerar áudio; player do painel devolvendo erro; URLs assinadas falhando.
Impacto. Contido. O envio de áudio pela Meta continua funcionando com os identificadores de mídia já existentes (Seção 27.4.4).
Diagnóstico.
docker compose exec -T worker ops storage:check{ "endpoint": "...", "bucket": "pd-media", "canList": false, "canPut": false,
"canGet": false, "lastError": "503 Service Unavailable", "latencyMs": 5001 }docker compose exec -T worker ops circuit:status | grep -A3 storage
docker compose logs --since=1h worker | grep '"integration":"storage"' | grep error | tail -20Distinguir três causas: indisponibilidade do provedor (verificar a página de status);
credencial expirada ou revogada (erro 403 em vez de 503); e bucket cheio ou com política
alterada.
Correção.
# Credencial: atualizar e reiniciar.
sudo sed -i 's|^MEDIA_ACCESS_KEY=.*|MEDIA_ACCESS_KEY=<nova>|' /opt/palavra-diaria/.env.production
docker compose up -d worker web
docker compose exec -T worker ops circuit:reset --name=storage
# Indisponibilidade do provedor: esperar. Os jobs estão adiados, não perdidos.
docker compose exec -T worker ops queue:stats --queue=tts.generate
# Se passar de 6 horas e houver devocional próximo sem áudio, usar o espelho
# como origem temporária de leitura (somente leitura, sem escrita).
docker compose exec -T worker ops config:set --key=MEDIA_READ_ENDPOINT --value=<endpoint-do-espelho>Se o storage voltar depois de o lote ter saído sem áudio, executar o job de recuperação do R-11.
Verificação. ops storage:check com os três testes verdadeiros; player do painel
funcionando; tts.generate drenando.
Prevenção. Espelho diário em provedor diferente (Seção 25.10), que é o que torna a leitura alternativa possível. Alerta de latência do storage acima de 2 s. Rotação anual de credencial com validação antes de revogar a antiga. Desacoplamento do envio: o áudio já está na Meta antes do disparo, então uma queda do storage às 06:00 não afeta a entrega do dia.
R-20 — Certificado TLS não renovado #
Sintoma. Navegador exibindo aviso de certificado; webhooks da Asaas e da Meta falhando em massa com erro de TLS; monitoramento externo acusando expiração.
Impacto. Alto e abrupto. Os dois provedores de webhook recusam conexão com certificado inválido, o que leva à pausa da fila da Asaas (R-08) e à perda de status da Meta.
Diagnóstico.
# Validade dos certificados dos quatro domínios.
for d in palavradiaria.com.br www.palavradiaria.com.br app.palavradiaria.com.br api.palavradiaria.com.br; do
echo -n "$d: "
echo | openssl s_client -servername "$d" -connect "$d":443 2>/dev/null \
| openssl x509 -noout -enddate
done
docker compose logs --since=48h caddy | grep -iE 'certificate|acme|error' | tail -40
# A porta 80 está acessível? O desafio HTTP-01 depende dela.
curl -sI -m 5 http://palavradiaria.com.br/.well-known/acme-challenge/test | head -1
# O DNS aponta para este servidor?
dig +short A palavradiaria.com.br
curl -s -m 5 https://api.ipify.orgCausas: porta 80 bloqueada por mudança de firewall; DNS apontando para outro lugar; limite de
emissão do emissor atingido; volume caddy_data perdido, apagando as chaves de conta.
Correção.
# 1. Porta 80 fechada:
sudo ufw allow 80/tcp
sudo ufw status numbered
# 2. Forçar nova tentativa de emissão.
docker compose restart caddy
docker compose logs -f caddy | grep -i acme
# 3. Limite do emissor atingido: alternar o emissor no Caddyfile
# (bloco `issuer acme` para ZeroSSL) e recarregar.
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
# 4. Volume perdido: o Caddy recria a conta e emite tudo de novo.
# Aguardar. Emissão dos quatro domínios leva menos de 2 minutos.Enquanto o certificado está inválido, avisar Asaas e Meta não é possível — o que se faz é resolver rápido e, depois, recuperar os eventos perdidos:
docker compose exec -T worker ops billing:reconcile --since=<data> --fixStatus de mensagem perdidos da Meta não são recuperáveis. message_logs fica com o último
status conhecido, o que é aceitável: o registro de sent já prova o envio.
Verificação. openssl s_client mostrando validade futura para os quatro domínios;
webhook de teste da Asaas respondendo 200; monitoramento externo verde.
Prevenção. Monitoramento externo com alerta a 21 dias da expiração — o Caddy renova
a 30 dias, então 21 dias significa que a renovação automática falhou duas vezes. Verificação
semanal de que a porta 80 está aberta, no mesmo job que confere as regras de firewall.
Volume caddy_data incluído no inventário de itens críticos. Registros DNS com TTL de 300 s
e alerta se o A deixar de apontar para o servidor.
27.7 Comunicação de incidente #
27.7.1 Quem avisa #
| Papel | Responsabilidade |
|---|---|
| Quem está de plantão | Declara o incidente, executa o runbook, atualiza a página de status |
OWNER |
Único que autoriza comunicação em massa aos assinantes e decisões de crédito ou extensão de assinatura |
| Suporte | Responde contatos individuais com o texto padrão; não improvisa explicação técnica |
Ninguém mais fala em nome do produto durante um incidente. Explicação técnica não vai para o assinante em nenhuma hipótese.
27.7.1.1 Canais de comunicação, em ordem #
A dependência circular precisa estar explicitada, porque o canal que costuma cair é o canal de mensagens. A ordem é:
- Página pública de estado em
/status, servida por infraestrutura independente da aplicação e do canal de mensagens, com o texto atualizado pelo responsável do incidente. O endereço consta do rodapé de todas as páginas e da mensagem de boas-vindas, para que o assinante saiba onde olhar antes de precisar. - E-mail, para quem tiver endereço verificado — pela fila
email.send, que não depende da plataforma de mensagens. - Canal de mensagens, somente se ele for o canal saudável. Comunicar a queda do canal pelo próprio canal é impossível e não pode ser o plano.
- Aviso dentro do painel do assinante, que continua acessível mesmo com o canal de mensagens indisponível.
Incidente que afete a entrega por mais de 24 h aciona também a suspensão de cobrança descrita na Seção 13. O e-mail é opcional no cadastro: por isso a página de estado é o primeiro item da lista, e não o segundo.
27.7.2 Quando se comunica e quando não #
| Situação | Comunicar? | Canal |
|---|---|---|
| Lote atrasado, entregue até 09:00 | Não | — |
| Lote atrasado, entregue entre 09:00 e 12:00 | Não proativamente; aviso no painel web | Painel |
| Dia perdido por completo | Sim | Painel + e-mail para quem tem e-mail verificado |
| Falha parcial afetando menos de 5% da base | Não | Correção silenciosa; resposta individual se houver contato |
| Falha parcial afetando mais de 20% da base | Sim | Painel + e-mail aos afetados |
| Áudio ausente em um dia | Não | O texto foi entregue; o áudio está no painel |
| Áudio ausente por 2 dias seguidos | Sim, só para PAID | |
| Canal do WhatsApp fora por mais de 6 horas | Sim | Painel + e-mail |
| Número banido | Sim, imediatamente | Painel + e-mail + aviso permanente no topo do painel |
| Checkout indisponível | Aviso na própria página | Página de assinatura |
| Vazamento de dados pessoais | Sim, obrigatório | E-mail a todos os afetados + comunicação à ANPD, conforme a Seção 22 |
| Manutenção programada sem impacto no envio | Não | — |
| Manutenção programada com impacto no envio | Sim, com 48 h de antecedência | Painel + e-mail |
| Cobrança indevida | Sim, individualmente, com estorno já feito | E-mail + WhatsApp |
Princípio: comunicar quando o assinante percebeu ou vai perceber. Avisar sobre um problema que ninguém notou cria a impressão de instabilidade sem benefício.
27.7.3 Textos padrão #
Dia perdido — e-mail:
Assunto: Sobre o devocional de hoje
Olá, {primeiro_nome}.
Hoje o devocional não foi enviado no horário de sempre por uma falha técnica do nosso lado. Já resolvemos.
O devocional de hoje está disponível no seu painel: {link}
Amanhã, às 6h, tudo volta ao normal.
Desculpe pelo transtorno.
Equipe Palavra Diária
Canal do WhatsApp indisponível — aviso no painel:
As mensagens no WhatsApp estão temporariamente indisponíveis. Estamos trabalhando para normalizar. Enquanto isso, o devocional de hoje está aqui no painel, com texto e áudio. Assim que o WhatsApp voltar, você recebe normalmente.
Número banido — e-mail:
Assunto: O envio pelo WhatsApp está suspenso temporariamente
Olá, {primeiro_nome}.
O nosso número de WhatsApp está com o envio suspenso e estamos resolvendo isso junto ao WhatsApp. Não sabemos ainda quando será liberado.
Enquanto isso:
- O devocional continua sendo publicado todos os dias no seu painel, com texto e áudio: {link}
- Sua assinatura está pausada. Você não será cobrado enquanto o envio estiver suspenso, e os dias parados serão somados ao seu plano.
Avisamos assim que voltar.
Equipe Palavra Diária
Falha parcial — resposta individual do suporte:
Olá, {primeiro_nome}. Verificamos aqui: o devocional de hoje não chegou até você por uma falha no envio. Já reenviamos — deve chegar em instantes. Se não chegar, é só responder esta mensagem.
Cobrança indevida — e-mail:
Assunto: Estorno da cobrança de {data}
Olá, {primeiro_nome}.
Identificamos uma cobrança indevida de {valor} feita em {data}. O estorno já foi solicitado e o valor volta para você em até {prazo} pelo mesmo meio de pagamento.
Sua assinatura continua ativa e nada muda para você.
Desculpe pelo erro.
Equipe Palavra Diária
Manutenção programada com impacto — painel e e-mail:
Manutenção programada: {data}, das {hora_inicio} às {hora_fim}. Nesse período o painel ficará fora do ar. O devocional do dia será enviado normalmente às 6h.
Regras de redação, obrigatórias em qualquer texto de incidente:
- Dizer o que aconteceu do ponto de vista do assinante, nunca do sistema. "O devocional não foi enviado", não "houve falha no processamento da fila".
- Dizer o que já foi feito. No passado, não no futuro.
- Dizer o que ele pode fazer agora, com link.
- Dizer quando volta ao normal, se souber. Se não souber, dizer que não sabe.
- Pedir desculpa uma vez. Não repetir.
- Nunca citar fornecedor, código de erro ou detalhe técnico.
- Nunca prometer o que não se controla.
27.7.4 Registro de incidente #
Todo incidente de severidade alta ou crítica gera um registro em docs/incidentes/ do
repositório, criado em até 48 horas, com: linha do tempo em horários locais, impacto medido
em assinantes afetados, causa raiz, o que funcionou na detecção, o que falhou, e as ações
corretivas com responsável e prazo. Sem busca por culpado — o objetivo é a ação corretiva.
Duas seções são obrigatórias em todo registro que envolva não entrega, e o registro não é aprovado sem elas:
## Recuperação da entrega
As mensagens não entregues foram reenviadas? Sim | Não | Parcial.
Quando. Quantas. Se não, a justificativa explícita, aprovada por Produto.
## Comunicação
Os assinantes afetados foram avisados? Sim | Não.
Canal e texto utilizado (Seção 27.7.3). Havendo exposição de dado pessoal,
referência ao rito da Seção 22.7.8 e à decisão sobre comunicação à Autoridade
e aos titulares.Sem essa exigência, o desfecho comum é o pior possível: o incidente é resolvido às 09:40, o relatório é escrito no prazo, e ninguém decidiu se as 8.000 pessoas recebem o devocional atrasado — a fila já foi limpa na mitigação, e elas simplesmente não receberam nada e não foram avisadas. Em uma base religiosa diária, esse silêncio é o que gera cancelamento.
Uma ação corretiva obrigatória em todo registro: qual alerta teria detectado isso antes? Se a resposta for "nenhum", criar o alerta é parte do fechamento.
27.8 Interruptor geral #
27.8.1 O que é #
Um mecanismo único que interrompe todo envio de mensagem para assinantes de forma imediata, sem derrubar a aplicação, sem perder jobs e sem exigir deploy. Existe para o momento em que se percebe que o sistema está fazendo algo errado e cada segundo de funcionamento piora o dano: envio para quem não deu opt-in, conteúdo errado publicado, loop de reenvio, qualidade do número despencando.
Estado guardado em dois lugares: settings sob a chave ops.kill_switch (verdade
durável) e Redis sob killswitch:global (leitura rápida). O Redis é cache; na inicialização
e a cada 60 segundos o worker reconcilia a partir de settings. Isso garante que uma perda
de Redis não religue os envios sozinha (Seção 25.9.3).
27.8.2 Como ligar #
cd /opt/palavra-diaria
docker compose exec -T worker ops kill-switch:on --reason="envio para nao-optantes detectado"Saída:
{
"killSwitch": "ON",
"reason": "envio para nao-optantes detectado",
"activatedBy": "ops-cli",
"activatedAt": "2026-08-25T09:07:22.481Z",
"effect": {
"queuesHalted": ["send.dispatch", "send.followup", "media.upload"],
"jobsInFlight": 6,
"jobsMovedToHalted": 1834
}
}Caminho alternativo, para quando o CLI não estiver disponível (worker fora do ar, por exemplo):
# Direto no banco. Vale imediatamente na próxima leitura de reconciliação.
# Todas as colunas NOT NULL de settings (Seção 6.23) vão no INSERT: sem elas o
# comando aborta justamente no pior momento, em ambiente recém-provisionado.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"INSERT INTO settings (key, value, value_type, default_value, description, group_name, is_secret, editable_by)
VALUES ('ops.kill_switch',
'{\"enabled\":true,\"reason\":\"emergencia manual\"}'::jsonb,
'JSON',
'{\"enabled\":false,\"reason\":null}'::jsonb,
'Interruptor geral de envio. Quando ligado, nenhuma mensagem sai.',
'ops', false, 'OWNER')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now();"
# Efeito imediato no Redis, sem depender do worker.
docker compose exec -T redis redis-cli -a "$REDIS_PASSWORD" SET killswitch:global 1A linha ops.kill_switch é criada pelo seed obrigatório da Seção 6.32.4; este INSERT traz
todas as colunas apenas para funcionar também em ambiente recém-provisionado, em que o seed
possa não ter rodado. A chave tem exatamente este nome, no formato grupo.chave do catálogo
da Seção 26.8.1 — não existe send.pause_all.
Último recurso, quando nada mais responde: docker compose stop worker. Isso para os
envios, mas deixa jobs em active com bloqueio pendente, o que exige ops queue:unstall
depois. É pior do que o interruptor, e por isso é o último recurso.
27.8.3 O efeito exato #
| Componente | Comportamento com o interruptor ligado |
|---|---|
Filas send.dispatch, send.followup e media.upload |
O worker lê o interruptor imediatamente antes de cada chamada ao provedor e devolve o job para delayed com moveToDelayed, sem consumir tentativa — o mesmo mecanismo da Seção 27.3.1. Jobs em waiting e delayed permanecem; nada é descartado |
| Jobs em curso | Terminam a chamada atual (não se aborta uma requisição já enviada, para não gerar ambiguidade sobre o envio) e não pegam o próximo |
| Jobs pendentes do lote | Marcados como HALTED em delivery_attempts; a chave de idempotência é preservada |
| Job de planejamento das 05:40 | Executa e planeja normalmente, mas não enfileira. O plano fica pronto para quando religar |
| Webhooks de entrada | Continuam funcionando. Eventos são persistidos. Asaas e Meta não recebem erro |
| Processamento de pagamento | Continua. Liberar acesso não envia mensagem ao assinante enquanto o interruptor está ligado; a mensagem de boas-vindas fica pendente |
| Mensagens de OTP e de confirmação de opt-out | Continuam sendo enviadas. São exceções explícitas: bloquear o OTP impediria as pessoas de entrar no painel, e bloquear a confirmação de opt-out seria uma falha de conformidade |
| Painel web e painel administrativo | Funcionam normalmente, com faixa vermelha no topo do painel administrativo indicando o interruptor ligado, o motivo e há quanto tempo |
| Geração de TTS | Continua. Preparar conteúdo não faz mal |
Como as duas exceções passam. O interruptor não é implementado pausando filas do
BullMQ, e não existe uma fila whatsapp-critical — as filas do sistema são exatamente
as treze da Seção 18.8. A verificação acontece dentro do worker, no instante anterior à
chamada ao provedor, e recusa todo envio cuja message_key (Seção 19.2) não seja otp.code
ou optout.confirmed. Essas duas passam; todo o resto é adiado. A decisão é deliberada:
pausar a fila inteira bloquearia também as duas mensagens que jamais podem parar, e criar
uma fila só para elas duplicaria o catálogo de filas para resolver um problema de guarda,
não de roteamento. Nenhuma mensagem de conteúdo é exceção.
27.8.4 Como religar #
# 1. Conferir o que está represado antes de soltar.
docker compose exec -T worker ops kill-switch:status
docker compose exec -T worker ops queue:stats --all{
"killSwitch": "ON",
"reason": "envio para nao-optantes detectado",
"activatedAt": "2026-08-25T09:07:22.481Z",
"durationMinutes": 47,
"halted": { "send.dispatch": 1834, "send.followup": 41, "media.upload": 12 }
}# 2. Simular o que sairia ao religar. Passo obrigatório.
docker compose exec -T worker ops send:plan --date=$(TZ=America/Sao_Paulo date +%F) --dry-run
# 3. Religar.
docker compose exec -T worker ops kill-switch:off --reason="causa corrigida, chamado #789"
# 4. Retomar em ritmo reduzido, para observar antes de abrir tudo.
docker compose exec -T worker ops config:set --key=send.rate_per_second --value=5
docker compose restart worker
docker compose exec -T worker ops send:resume --batch=<id> --limit=50
# 5. Verificar as 50 primeiras.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
"SELECT status, error_code, count(*) FROM delivery_attempts
WHERE devotional_date = current_date GROUP BY 1,2 ORDER BY 3 DESC;"
# 6. Se estiver correto, liberar o restante na taxa normal.
docker compose exec -T worker ops config:set --key=send.rate_per_second --value=20
docker compose restart worker
docker compose exec -T worker ops send:resume --batch=<id>Regra de horário no religamento: se já passou das 12:00 local, não retomar o lote do
dia. Religar o interruptor sem retomar o lote é feito com --no-resume:
docker compose exec -T worker ops kill-switch:off --reason="..." --no-resume
docker compose exec -T worker ops send:abandon --batch=<id> --reason="fora da janela util"Os assinantes leem o devocional do dia no painel web. O envio volta ao normal no dia seguinte.
27.8.5 Regras de uso #
- Ligar é sempre seguro. Nenhum dado se perde, nenhuma cobrança é afetada, nenhum webhook falha. Na dúvida, ligar e diagnosticar com calma.
--reasoné obrigatório. O comando recusa executar sem motivo, e o texto vai paraadmin_audit_loge para o alerta.- Toda ativação gera alerta de severidade alta, mesmo quando feita de propósito. Isso garante que ninguém deixe o interruptor ligado sem que o resto perceba.
- Alerta de lembrete a cada 30 minutos enquanto estiver ligado, com o motivo e a duração. É a proteção contra o cenário do R-01, em que o lote não dispara porque o interruptor ficou esquecido.
- Religar exige a simulação do passo 2. Não é opcional. É o que evita religar e mandar 3.000 mensagens erradas de uma vez.
- Ligar e religar são registrados em
admin_audit_logcom operador, motivo, horário e duração.
28. Plano de Execução e Milestones #
Esta seção sequencia todo o trabalho de construção do sistema para um único agente de IA executor trabalhando sozinho, do repositório vazio ao lançamento em produção. O plano é organizado em 14 milestones (M0 a M13) mais uma trilha externa (X e Y) que corre em paralelo e depende de terceiros.
O plano não usa datas. Usa pontos de esforço relativo (escala Fibonacci: 1, 2, 3, 5, 8, 13, 21), porque a velocidade de um agente executor varia muito com o ambiente e com o tempo de espera de terceiros. Datas aparecem apenas onde são impostas por terceiros (aprovação de template pela Meta, verificação de negócio), e ali são tratadas como risco, não como cronograma.
28.1 Princípios que governam o sequenciamento #
- O risco externo é atacado primeiro, mesmo antes do código que o consome. Verificação de negócio na Meta e aprovação de templates levam dias e não são aceleráveis. A trilha externa começa no primeiro dia útil do projeto, em paralelo com M0.
- Nada é construído sobre um schema instável. O modelo de dados (Seção 6) é congelado em M1 e só muda por migration aditiva depois disso.
- Cada milestone termina em um estado executável e verificável por comando. Não existe milestone que só "prepara terreno" sem produzir evidência automatizável.
- O caminho crítico do valor é entregar o devocional. Cobrança é uma camada sobre um produto que já funciona, não o contrário.
- Vertical antes de horizontal. Prefere-se uma fatia fina completa (schema → serviço → rota → tela → teste) a uma camada inteira sem consumidor.
- Toda integração externa nasce com simulador local. Nenhum milestone pode depender de um serviço externo estar disponível para ser concluído (ver Seção 24 para o simulador).
28.2 Visão geral dos milestones #
| ID | Título | Objetivo em uma frase | Depende de | Esforço |
|---|---|---|---|---|
| M0 | Fundação do repositório e ferramentas | Monorepo instalável, tipado, lintado e conteinerizado, com CI verde em um commit vazio de funcionalidade. | — | 5 |
| M1 | Banco e schema | Todas as tabelas da Seção 6 criadas por migration, já com criptografia de campo e índices cegos, seeds e client tipado. | M0 | 13 |
| M2 | Autenticação e sessões | Assinante entra por OTP e administrador entra por senha mais TOTP, com sessões revogáveis. | M1 | 8 |
| M3 | Cadastro, opt-in e onboarding | Um número de telefone real vira assinante FREE com consentimento registrado e opt-in confirmado. | M2 | 8 |
| M4 | Painel administrativo e conteúdo editorial | Editor humano cria, revisa, agenda e publica devocionais com versionamento e calendário. | M2 | 13 |
| M5 | Pipeline de áudio | Devocional aprovado vira OGG/Opus, MP3 e MP4, armazenados e prontos para envio. | M4 | 13 |
| M6 | Integração WhatsApp e motor de envio | O lote diário sai às 06:00 e entrega texto e áudio dentro das regras da plataforma. | M3, M5, X4 | 21 |
| M7 | Pagamentos Asaas e ciclo de assinatura | Assinante compra, é cobrado por ciclo e perde o acesso imediatamente ao falhar o pagamento. | M6, Y1 | 13 |
| M8 | Painel do assinante | Assinante gerencia conta, acervo, assinatura, preferências e dados sozinho. | M7 | 8 |
| M9 | Landing page e página de vendas | Visitante entende o produto, ouve uma amostra e chega ao checkout. | M8 | 8 |
| M10 | Métricas, observabilidade e alertas | Operador enxerga entrega, receita, custo e falhas, e é avisado antes do assinante reclamar. | M7 | 8 |
| M11 | Endurecimento de segurança e LGPD | Todos os controles da Seção 22 exceto a criptografia de campo e os índices cegos, entregues em M1, implementados e verificáveis. | M10 | 5 |
| M12 | Testes E2E e carga | A suíte completa da Seção 24 passa e o alvo de escala é comprovado em teste. | M11 | 8 |
| M13 | Preparação de produção e lançamento | Sistema em produção, com backup restaurável, rollback testado e go-live aprovado. | M12 | 5 |
Total: 136 pontos de construção, mais a trilha externa (esforço humano baixo, tempo de espera alto). M1 sobe de 8 para 13 e M11 cai de 8 para 5 porque a criptografia de campo e os índices cegos são decisão de schema, não de endurecimento, e por isso nascem na migration inicial.
Trilha externa, executada por um humano com poder de assinar contrato e comprovar identidade da empresa:
| ID | Título | Objetivo | Depende de | Espera típica |
|---|---|---|---|---|
| X0 | Conta Meta Business | Business Manager criado, com domínio verificado. | — | horas |
| X1 | Verificação de negócio | Empresa verificada na Meta com documentos oficiais. | X0 | 2 a 15 dias |
| X2 | WABA e número | WhatsApp Business Account criada, número dedicado registrado e nome de exibição aprovado. | X1 | 1 a 5 dias |
| X3 | Submissão de templates | Os oito templates da Seção 17 submetidos nas categorias corretas. | X2 | minutos |
| X4 | Aprovação dos templates | Templates com status APPROVED na Meta. |
X3 | 1 minuto a 3 dias por template |
| Y0 | Conta Asaas sandbox | Chave de sandbox emitida e webhook apontado para o ambiente de desenvolvimento. | — | horas |
| Y1 | Conta Asaas produção | Conta aprovada, chave de produção emitida, webhook de produção configurado. | Y0 | 1 a 5 dias |
| Z0 | Contas de apoio | Provedor de TTS, storage S3-compatível, e-mail transacional, DNS e servidor. | — | horas |
28.3 Diagrama de dependências #
TRILHA EXTERNA (humano, inicia no dia 1, não bloqueia M0..M5)
X0 conta Meta ──► X1 verificação ──► X2 WABA + número ──► X3 submissão ──► X4 aprovação
│
Y0 Asaas sandbox ──────────────────────────► Y1 Asaas produção │
│ │ │
Z0 TTS + storage + e-mail + DNS + servidor │ │
│ │ │
▼ ▼ ▼
TRILHA DE CONSTRUÇÃO (agente executor)
┌──────┐
│ M0 │ fundação do repositório
└───┬──┘
│
┌───▼──┐
│ M1 │ banco e schema ◄── congela o modelo de dados,
└───┬──┘ já com criptografia de campo
│ e índices cegos (Seção 22.5)
┌─────────┴─────────┐
┌───▼──┐ ┌───▼──┐
│ M2 │ auth │ M4 │ admin + editorial
└───┬──┘ └───┬──┘
│ │
┌───▼──┐ ┌───▼──┐
│ M3 │ cadastro │ M5 │ pipeline de áudio
└───┬──┘ └───┬──┘
│ │
└─────────┬─────────┘
│ (X4 exigido para o caminho real;
┌───▼──┐ simulador cobre o desenvolvimento)
│ M6 │ WhatsApp + motor de envio
└───┬──┘
┌─────────┴─────────┐
┌───▼──┐ ┌───▼───┐
│ M7 │ pagamentos │ M10 │ métricas e alertas
└───┬──┘ └───┬───┘
│ │
┌───▼──┐ │
│ M8 │ painel │
└───┬──┘ │
│ │
┌───▼──┐ │
│ M9 │ landing │
└───┬──┘ │
└─────────┬─────────┘
┌───▼───┐
│ M11 │ segurança e LGPD
└───┬───┘
┌───▼───┐
│ M12 │ E2E e carga
└───┬───┘
┌───▼───┐
│ M13 │ produção e lançamento
└───────┘28.4 Milestones detalhados #
28.4.1 M0 — Fundação do repositório e ferramentas #
Objetivo. Ter um monorepo instalável, tipado, lintado, formatado, conteinerizado e com integração contínua verde antes de escrever qualquer regra de negócio.
Pré-requisitos. Nenhum milestone anterior. Requer apenas o runtime, o gerenciador de pacotes e o Docker instalados nas linhas de versão declaradas na Seção 4.
Tarefas.
- Criar o repositório Git com branch padrão
main,.gitignore,.gitattributeseLICENSE. - Inicializar o workspace do gerenciador de pacotes com
pnpm-workspace.yamlcobrindoapps/*epackages/*. - Criar a árvore de diretórios exata da Seção 4:
apps/web,apps/worker,apps/ops,packages/db,packages/core,packages/integrations. - Configurar TypeScript com um
tsconfig.base.jsonna raiz em modo estrito etsconfig.jsonpor pacote com project references. - Configurar ESLint e Prettier exatamente como especificado na Seção 5, incluindo a regra que proíbe comentários
TODOeFIXMEno código versionado. - Instalar e configurar o executor de testes unitários da Seção 4, com um teste trivial em
packages/coresó para provar que a suíte roda. - Criar
packages/core/src/env.tscom o schema de validação de ambiente da Seção 26, ainda com poucas variáveis, e fazerapps/webeapps/workerfalharem o boot quando o ambiente for inválido. - Criar
docker-compose.ymlde desenvolvimento com os serviços de banco e de fila, volumes nomeados e healthchecks. - Criar
.env.examplecompleto conforme a Seção 26 e umscripts/bootstrap.shque copia o exemplo, sobe os contêineres e espera os healthchecks. - Criar
README.mdcom pré-requisitos, comandos de desenvolvimento e a estrutura do monorepo. - Criar
DECISIONS.mdvazio, com o cabeçalho e o formato de registro definido na Seção 30.4. - Criar o workflow de integração contínua descrito na Seção 25 com os jobs de lint, typecheck, teste e build.
- Configurar o padrão de commits e o hook de verificação de mensagem conforme a Seção 5.
Artefatos. pnpm-workspace.yaml, tsconfig.base.json, eslint.config.js, prettier.config.js, docker-compose.yml, .env.example, README.md, DECISIONS.md, scripts/bootstrap.sh, .github/workflows/ci.yml, árvore vazia de apps/ e packages/.
Critérios de saída.
pnpm install --frozen-lockfile # instala sem erro
pnpm -r typecheck # zero erro de tipo em todos os pacotes
pnpm -r lint # zero aviso, zero erro
pnpm -r test # suíte trivial passa
pnpm -r build # build de todos os pacotes conclui
docker compose up -d && docker compose ps --format json | grep -c healthy # todos healthy
bash scripts/bootstrap.sh # termina com código 0 em máquina limpaA execução do workflow de CI no commit correspondente deve terminar com sucesso.
Não se faz neste milestone. Nenhuma tabela, nenhuma rota de API, nenhuma tela, nenhuma integração externa, nenhum job de fila. Nada de Dockerfile de produção — ele pertence a M13.
28.4.2 M1 — Banco e schema #
Objetivo. Materializar integralmente o modelo de dados da Seção 6, com migrations reversíveis, seeds determinísticos, criptografia de campo já embutida e um client tipado consumível pelos três aplicativos.
Pré-requisitos. M0 concluído.
Tarefas.
- Escrever
packages/db/prisma/schema.prismacom todos os models, enums, mapeamentos, índices e relações exatamente como a Seção 6 define. Nenhuma tabela fora da lista fechada daquela seção. - Gerar a migration inicial e conferir o SQL produzido contra o DDL de referência da Seção 6, corrigindo divergências de tipo, nullability, default e
ON DELETE. - Adicionar as constraints
CHECKque o gerador não emite, em migration manual complementar. - Implementar o particionamento mensal de
message_logse a função de criação automática de partição futura, conforme a Seção 6. - Criar
packages/db/src/client.tscom o singleton do client, incluindo logging de query lenta ligado ao logger da Seção 23. - Implementar o gerador de identificadores ULID em
packages/core/src/id.tse os prefixos textuais de API definidos na Seção 6. - Escrever os seeds obrigatórios: planos, administrador inicial, definições dos oito templates do WhatsApp com os
componentsidênticos aos da Seção 17.5, as 45 chaves desettingsno formatogrupo.chavee as feature flags, com os valores concretos da Seção 6.32. - Implementar a criptografia de campo em envelope e os índices cegos determinísticos da Seção 22.5 já na migration inicial, e não em um marco posterior:
subscribers.phone_e164/phone_hmac,subscribers.wa_id/wa_id_hmac,subscribers.email/email_hmacesubscriber_profiles.cpf/cpf_hmac, com os módulospackages/core/src/crypto/field-encryption.tsepackages/core/src/crypto/blind-index.ts. As colunas cifradas não recebemCHECKde formato de conteúdo — o banco não vê o valor; a validação de E.164 fica no Zod da borda (Seção 5.6). - Escrever o repositório de assinantes de modo que toda busca por telefone,
wa_id, CPF ou e-mail passe pela coluna de índice cego. Nenhuma consulta compara a coluna cifrada. Uma regra de lint reprovaWHERE phone_e164 =,WHERE wa_id =e equivalentes. - Escrever factories e fixtures de teste em
packages/db/src/testing/conforme a Seção 24. - Escrever os testes de integração que sobem o banco em contêiner, aplicam as migrations, rodam os seeds e verificam índices e constraints, incluindo
I-37eI-41da Seção 24.5.3. - Implementar em
apps/opsos comandosdb:seed,db:resetedb:check, este último cobrindo também a verificação de privilégio de tabela da Seção 25.11.4. - Documentar em
packages/db/README.mda ordem das migrations e o procedimento de migration destrutiva em duas fases descrito na Seção 25.
Artefatos. packages/db/prisma/schema.prisma, packages/db/prisma/migrations/**, packages/db/src/client.ts, packages/db/src/seed.ts, packages/db/src/testing/**, packages/core/src/id.ts, packages/core/src/crypto/field-encryption.ts, packages/core/src/crypto/blind-index.ts, comandos de apps/ops.
Critérios de saída.
pnpm --filter @app/db migrate:deploy # aplica todas as migrations do zero
pnpm --filter @app/db seed # idempotente: rodar duas vezes não duplica
pnpm --filter @app/db test # testes de schema passam
pnpm --filter @app/db test:encryption # grava e relê um assinante
pnpm --filter @app/ops start db:check # confere as 28 tabelas, índices e FKs
pnpm --filter @app/db exec prisma migrate diff --from-migrations --to-schema-datamodel --exit-codeO comando test:encryption é bloqueante e verificável sem interpretação: ele grava um assinante com telefone conhecido, lê a linha crua no banco e falha se phone_e164 contiver o telefone em claro; falha se phone_hmac não tiver 64 caracteres hexadecimais; e falha se a busca por phone_hmac não encontrar a linha. O último comando deve sair com código 0, provando que não há deriva entre schema e migrations.
Não se faz neste milestone. Nenhuma lógica de negócio, nenhuma rota, nenhum job. Não se escreve resolveEntitlements ainda — apenas as colunas que ele lê. A criptografia entra aqui só como schema e repositório: os controles de cabeçalho, política de conteúdo, retenção e direitos do titular continuam sendo de M11.
28.4.3 M2 — Autenticação e sessões #
Objetivo. Permitir que um assinante entre com código de acesso e que um administrador entre com senha e segundo fator, com sessões emitidas, rotacionadas e revogáveis.
Pré-requisitos. M1 concluído. O envio real do código por WhatsApp ainda não existe: em M2 o código é entregue pelo simulador local descrito na Seção 24 e devolvido exclusivamente pela rota interna de teste GET /api/internal/test/otp, protegida pelo perfil de compilação da Seção 24.7.1. O código nunca é registrado em log — a proibição da regra 12 da Seção 30.3 vale também em desenvolvimento, porque um logger.debug acrescentado para depurar sobrevive ao commit e o teste secrets-never-logged.test.ts da Seção 24.4.2.1 reprova o build.
Tarefas.
- Implementar o envelope de resposta, o catálogo de erros e o
requestIdda Seção 7 emapps/web/src/lib/http/. - Implementar o middleware de rate limiting por token bucket em Redis, com os headers de resposta especificados na Seção 7.
- Implementar a normalização de telefone brasileiro em
packages/core/src/phone.ts, com a busca em cascata da Seção 11, e cobrir com a tabela extensa de casos da Seção 24. - Implementar geração, hash, verificação, expiração e invalidação de códigos de acesso conforme a Seção 8, persistindo em
otp_codes. - Implementar a emissão e verificação do token de sessão com assinatura EdDSA, o cookie de sessão com as flags da Seção 8, e a persistência em
sessions. - Implementar rotação de sessão, revogação individual e "sair de todos os dispositivos".
- Implementar o login administrativo: verificação de senha com o algoritmo e os parâmetros de custo da Seção 8, segundo fator obrigatório, códigos de recuperação, bloqueio após tentativas e desbloqueio.
- Implementar as guardas de autorização por papel e a verificação de propriedade de recurso, com a distinção entre 403 e 404 definida na Seção 8.
- Implementar o fluxo de impersonação de assinante pelo administrador, com registro obrigatório em
admin_audit_log. - Implementar as rotas de autenticação listadas no inventário da Seção 7.
- Implementar o fallback de acesso por link mágico enviado por e-mail, para assinantes com e-mail verificado.
- Escrever os testes de integração de todos os caminhos de sucesso e de todos os erros catalogados.
Artefatos. apps/web/src/lib/http/**, apps/web/src/lib/auth/**, packages/core/src/phone.ts, packages/core/src/otp.ts, rotas em apps/web/src/app/api/auth/**, testes correspondentes.
Critérios de saída.
pnpm --filter @app/web test:integration -- auth
curl -s -X POST localhost:3000/api/auth/otp/request -d '{"phone":"11987654321"}' | jq '.data.expiresAt'
curl -s -X POST localhost:3000/api/auth/otp/verify -d '{"phone":"...","code":"000000"}' | jq '.error.code'A verificação com código inválido deve retornar OTP_INVALID com status 401; código expirado devolve OTP_EXPIRED com status 410, e nunca 401, para que o cliente possa habilitar o botão de reenvio sem ambiguidade. Seis tentativas seguidas devem retornar OTP_ATTEMPTS_EXCEEDED. Quatro solicitações na mesma hora devem retornar o erro de limite com status 429 e os headers de limite preenchidos, com resposta idêntica para número cadastrado e não cadastrado.
Não se faz neste milestone. Nenhuma tela de login com acabamento visual — apenas o formulário funcional mínimo. Nenhuma mensagem real de WhatsApp. Nenhum cadastro novo: M2 autentica quem já existe no banco por seed.
28.4.4 M3 — Cadastro, opt-in e onboarding #
Objetivo. Transformar um visitante em assinante FREE com consentimento registrado, telefone verificado e opt-in confirmado, sem o qual nenhum devocional pode ser enviado.
Pré-requisitos. M2 concluído.
Tarefas.
- Implementar o formulário de cadastro com as validações campo a campo e as mensagens em português definidas na Seção 11.
- Implementar a rota de criação de assinante, com a rejeição de DDI diferente de 55 e as mensagens de erro correspondentes.
- Implementar o registro imutável de consentimento em
consent_events, com o texto versionado, IP, agente de usuário, canal e timestamp. - Implementar a máquina de estados do assinante da Seção 11, com a tabela de transições permitidas aplicada no serviço, não no controlador.
- Implementar o tratamento dos casos de borda da Seção 11: número já ativo, número com opt-out, número excluído, código expirado, abandono no meio do fluxo, corrida entre duas abas.
- Implementar a etapa de confirmação ativa de opt-in, com o registro de
opt_in_confirmed_at, ainda acionada pelo simulador local. - Implementar a regra de
welcome_backfill: quem confirma depois do horário de envio recebe o devocional do dia uma única vez. - Implementar
resolveEntitlementsempackages/core/src/entitlements.tscomo fonte única da verdade, conforme a Seção 13, e proibir qualquer recálculo local por regra de lint. - Implementar as telas do fluxo de cadastro com os textos de interface da Seção 10.
- Escrever os testes unitários de
resolveEntitlementse os testes de integração do fluxo completo.
Artefatos. apps/web/src/app/(public)/cadastro/**, apps/web/src/app/api/signups/**, packages/core/src/entitlements.ts, packages/core/src/subscriber-state.ts, testes.
Critérios de saída.
pnpm --filter @app/web test:integration -- signup
pnpm --filter @app/core test -- entitlements
psql "$DATABASE_URL" -c "select count(*) from consent_events where subscriber_id = :id" -- >= 1Um cadastro completo pelo simulador deve terminar com subscribers.tier = 'FREE', opt_in_confirmed_at preenchido e exatamente um registro em consent_events. Repetir o mesmo telefone deve retornar SUBSCRIBER_ALREADY_EXISTS com status 409, sem criar segundo registro.
Não se faz neste milestone. Nenhum envio de devocional. Nenhum checkout. A landing ainda não existe: o formulário é acessado por rota direta.
28.4.5 M4 — Painel administrativo e conteúdo editorial #
Objetivo. Dar ao editor humano a ferramenta completa para escrever, versionar, agendar e publicar devocionais dentro dos limites reais da plataforma de mensagens.
Pré-requisitos. M2 concluído. Não depende de M3.
Tarefas.
- Implementar o layout do painel administrativo, a navegação e as guardas de papel conforme a Seção 15.
- Implementar o editor de devocional com todos os campos da Seção 15 e os contadores de caracteres com os limites reais.
- Implementar a geração determinística do teaser a partir do texto, com truncamento e sanitização que garantem ausência de quebra de linha, de tabulação e de quatro ou mais espaços consecutivos, e no máximo 300 caracteres.
- Implementar a pré-visualização fiel de como a mensagem aparece no aplicativo de mensagens, incluindo cabeçalho, corpo, rodapé e botões.
- Implementar a máquina de estados editorial com as guardas da Seção 15, incluindo a que impede publicar sem áudio pronto quando existirem assinantes pagos.
- Implementar o versionamento por revisão em
devotional_revisions, com comparação e restauração. - Implementar o calendário editorial mensal, com reagendamento, duplicação, indicação de dias vazios e alerta de menos de sete dias de conteúdo agendado.
- Implementar a importação em lote por arquivo CSV, com validação linha a linha e relatório de erros.
- Implementar a gestão de assinantes: busca, ficha completa, histórico de mensagens e de pagamentos, e as ações administrativas da Seção 15, todas com auditoria obrigatória.
- Implementar a tela de auditoria com filtros e exportação.
- Implementar a marcação de devocional de reserva (
evergreen) usada pela regra de prontidão da Seção 18. - Escrever os testes de integração das transições editoriais e os testes unitários da sanitização do teaser.
Artefatos. apps/web/src/app/admin/**, apps/web/src/app/api/admin/**, packages/core/src/teaser.ts, packages/core/src/devotional-state.ts, testes.
Critérios de saída.
pnpm --filter @app/web test:integration -- admin
pnpm --filter @app/core test -- teaser
pnpm --filter @app/web test:e2e -- admin-publish # cenário: criar, revisar, agendar, publicarO teste de propriedade do teaser deve provar, sobre entradas aleatórias, que a saída nunca contém \n, \t nem quatro espaços seguidos, e nunca excede 300 caracteres. Tentar publicar um devocional sem áudio com assinantes pagos existentes deve retornar DEVOTIONAL_AUDIO_REQUIRED com status 409.
Não se faz neste milestone. Nenhuma geração de áudio de verdade — o campo de áudio fica visível e vazio. Nenhum envio. Nenhuma métrica.
28.4.6 M5 — Pipeline de áudio #
Objetivo. Converter um devocional aprovado em áudio narrado de qualidade, nos formatos exigidos, armazenado com segurança e pronto para envio.
Pré-requisitos. M4 concluído. Requer Z0 para credenciais reais de síntese de voz e de armazenamento; sem elas, o pipeline roda contra o simulador local.
Tarefas.
- Implementar
buildNarrationScript()empackages/core/src/narration.ts, com a remoção de marcação, a leitura por extenso das referências bíblicas e as regras de expansão da Seção 16. - Implementar a interface
TtsProviderempackages/integrations/src/tts/e o adaptador do provedor primário com os parâmetros de voz exatos da Seção 16. - Implementar o adaptador do provedor de fallback e a regra de acionamento após três falhas, registrando o provedor efetivamente usado.
- Implementar a transcodificação com os comandos exatos da Seção 16, produzindo o formato de mensagem de voz e o formato do player web, com normalização de volume e verificação de duração.
- Implementar a regra de tamanho: alvo prático de duração, limite duro de mídia, reencode automático acima do limiar intermediário e o comportamento se ainda assim exceder.
- Implementar a geração do arquivo de vídeo usado no caminho de fallback do quarto dia sem interação.
- Implementar o cliente de armazenamento com a convenção de chaves da Seção 16, bucket privado e URLs assinadas de curta duração para o player web.
- Implementar o job
tts.generatecom concorrência, tentativas, backoff e timeout conforme a Seção 18. Não existe fila de mensagens mortas: job que esgota as tentativas fica no estadofailedda própria fila e gravajob_runscomstatus = 'DEAD'. - Implementar a invalidação e regeneração de áudio quando o texto muda depois de o áudio ficar pronto, com retorno do devocional ao estado pendente de áudio.
- Implementar as verificações automáticas de qualidade: duração mínima e máxima, detecção de áudio silencioso e detecção de truncamento.
- Implementar o player de áudio do painel administrativo para revisão antes da publicação.
- Escrever os testes do roteiro de narração, incluindo a tabela de expansão de abreviações e números.
Artefatos. packages/core/src/narration.ts, packages/integrations/src/tts/**, packages/integrations/src/storage/**, apps/worker/src/jobs/tts-generate.ts, apps/worker/src/media/ffmpeg.ts, testes.
Critérios de saída.
pnpm --filter @app/core test -- narration
pnpm --filter @app/worker test:integration -- tts
pnpm --filter @app/ops start audio:generate --devotional <id>
ffprobe -v error -show_entries stream=codec_name,channels,sample_rate -of json <arquivo.ogg>O ffprobe deve reportar o codec, a contagem de canais e a taxa de amostragem exigidos pela Seção 16. O arquivo gerado deve ficar abaixo do limite duro de mídia. Derrubar o provedor primário no simulador deve resultar em audio_assets.provider gravado com o fallback, sem intervenção manual.
Não se faz neste milestone. Nenhum upload para a plataforma de mensagens — isso pertence a M6. Nenhuma narração humana. Nenhuma geração de texto por inteligência artificial.
28.4.7 M6 — Integração WhatsApp e motor de envio #
Objetivo. Entregar o devocional do dia a todos os destinatários elegíveis, dentro das regras da plataforma, de forma idempotente, observável e reprocessável.
Pré-requisitos. M3 e M5 concluídos. Para o caminho real, exige X4 (templates aprovados). Enquanto X4 não chega, todo o milestone é desenvolvido e testado contra o simulador local.
Tarefas.
- Implementar a camada
WhatsAppProviderempackages/integrations/src/whatsapp/com os métodos de envio de template, texto livre, áudio, vídeo e resposta interativa. - Implementar o upload de mídia com a regra dura de uma única vez por devocional, o registro do identificador de mídia, o controle de validade e o reupload automático quando expirado.
- Implementar a verificação do webhook de entrada e a validação da assinatura do corpo cru em tempo constante, conforme a Seção 17.
- Implementar a persistência bruta do evento de entrada e o processamento assíncrono em fila, com resposta imediata ao emissor.
- Implementar a abertura da janela de atendimento a partir de qualquer mensagem recebida, atualizando o campo de expiração da janela e disparando a entrega completa em segundos.
- Implementar o job de planejamento do lote com a montagem da coorte, as exclusões e a idempotência por chave, gravando em
send_batchesedelivery_attempts. - Implementar a regra de prontidão do conteúdo com o alerta ao operador e o mecanismo de reserva completo definido na Seção 18.
- Implementar o job de disparo com o limitador de taxa, as tentativas, o backoff e a classificação de erro por código da plataforma, e a revalidação obrigatória do assinante imediatamente antes de cada chamada ao provedor: relê
deleted_at,blocked_at,opt_out_atetierpor chave primária e aplica a regra da Seção 18.5.tier_at_sendé registro histórico, nunca autoridade; sem essa releitura, o congelamento das 05:40 concede na prática um dia de carência a todo inadimplente. - Implementar o atalho de janela aberta, que pula o convite por template e entrega o pacote completo direto.
- Implementar a fila de entrega pendente, o prazo de expiração no mesmo dia e o contador de dias sem interação.
- Implementar o fallback por template com cabeçalho de vídeo no quarto dia sem interação.
- Implementar o catálogo de mensagens e o roteador de palavras-chave de entrada da Seção 19, incluindo saída, retorno, pausa e reenvio.
- Implementar a persistência dos estados de mensagem em
message_logscom timestamps próprios. - Implementar a tela de envios do painel administrativo: progresso ao vivo, contagem por status, falhas com motivo, reprocessar e cancelar lote.
- Implementar os comandos de backfill e reprocessamento em
apps/ops. - Escrever os testes do motor de envio com relógio controlado, sem enviar nenhuma mensagem real.
Artefatos. packages/integrations/src/whatsapp/**, apps/worker/src/jobs/send-plan.ts, send-dispatch.ts, send-followup.ts, apps/web/src/app/api/webhooks/whatsapp/route.ts, apps/web/src/app/admin/envios/**, packages/core/src/messages/**, testes.
Critérios de saída.
pnpm --filter @app/worker test:integration -- send-engine
pnpm --filter @app/ops start send:plan --date 2026-09-06 --dry-run
pnpm --filter @app/ops start send:replay --batch <id> --dry-run
psql "$DATABASE_URL" -c "select count(*) from delivery_attempts where subscriber_id=:s and devotional_date=:d"A última consulta deve retornar exatamente 1 mesmo depois de reexecutar o planejamento três vezes. O teste de lote sintético com 3.000 destinatários deve concluir dentro da janela de capacidade declarada na Seção 18.14, do ponto de vista de tempo de API simulado. Uma mensagem de entrada com assinatura inválida deve ser rejeitada com status 401 e não gerar registro em inbound_messages.
Não se faz neste milestone. Nenhuma cobrança. Nenhum assinante pago real — o tier PAID é exercitado por concessão manual de acesso pelo administrador, recurso já disponível desde M4.
28.4.8 M7 — Pagamentos Asaas e ciclo de assinatura #
Objetivo. Vender a assinatura, cobrar por ciclo, refletir cada evento de pagamento no acesso do assinante e revogar o acesso pago imediatamente quando o pagamento falha.
Pré-requisitos. M6 concluído e Y0 disponível. O caminho de produção exige Y1.
Tarefas.
- Implementar o cliente tipado do provedor de pagamento em
packages/integrations/src/asaas/, com autenticação por header, timeouts, política de tentativas com backoff e jitter, e disjuntor conforme a Seção 27. - Implementar a validação de CPF com o algoritmo de dígitos verificadores e as mensagens de erro da Seção 12.
- Implementar a criação e a sincronização do cliente no provedor, mapeando para as nossas tabelas conforme a Seção 12.
- Implementar o checkout com cartão, com tokenização, sem persistir nem registrar em log qualquer dado sensível do cartão.
- Implementar o checkout com PIX, com código copia-e-cola, imagem do código e expiração.
- Implementar o endpoint de webhook do provedor com comparação em tempo constante do token, persistência bruta em
payment_events, deduplicação por identificador de evento e resposta rápida. - Implementar o processador assíncrono e idempotente dos doze eventos obrigatórios listados na Seção 12, com o efeito exato de cada um sobre o estado da assinatura e sobre o tier.
- Implementar a regra dura de revogação imediata: os eventos de falha rebaixam o assinante na mesma transação, sem carência.
- Implementar o cancelamento voluntário com manutenção do acesso até o fim do período já pago, distinto da revogação por inadimplência.
- Implementar os lembretes de cobrança antes do vencimento, com os textos e canais da Seção 19.
- Implementar a reconciliação diária, o relatório de divergências e a regra de que o provedor de pagamento vence em caso de conflito.
- Implementar a prevenção de duas assinaturas ativas para o mesmo assinante e o procedimento de correção se acontecer.
- Implementar reembolso e contestação com efeito imediato no acesso e comunicação ao assinante.
- Escrever os testes de contrato contra o simulador, cobrindo evento duplicado, evento fora de ordem e pagamento confirmado após cancelamento.
Artefatos. packages/integrations/src/asaas/**, apps/web/src/app/api/webhooks/asaas/route.ts, apps/web/src/app/(public)/checkout/**, apps/worker/src/jobs/billing-webhook.ts, billing-reconcile.ts, packages/core/src/subscription-state.ts, testes.
Critérios de saída.
pnpm --filter @app/web test:integration -- billing
pnpm --filter @app/worker test:integration -- reconcile
pnpm --filter @app/ops start billing:simulate --event PAYMENT_OVERDUE --subscription <id>
psql "$DATABASE_URL" -c "select s.status, sub.tier from subscriptions s join subscribers sub on ... "Após simular o evento de inadimplência, a consulta deve mostrar a assinatura expirada e o tier rebaixado, no mesmo instante, sem job intermediário. Reenviar o mesmo evento cinco vezes deve produzir exatamente um registro processado e nenhuma mudança adicional de estado. O tempo de resposta do endpoint de webhook deve ficar abaixo do limite declarado na Seção 9.12, medido no teste de integração.
Não se faz neste milestone. Nenhum cupom, nenhum boleto, nenhuma nota fiscal, nenhum prorrateio, nenhum split — todos fora de escopo pela Seção 2.
28.4.9 M8 — Painel do assinante #
Objetivo. Dar ao assinante autonomia completa sobre conteúdo, assinatura, preferências e dados, sem depender de atendimento humano.
Pré-requisitos. M7 concluído.
Tarefas.
- Implementar o layout, a navegação e os estados vazio, de carregamento e de erro das rotas do painel definidas na Seção 14.
- Implementar a tela do devocional do dia, com o player de áudio restrito ao tier pago e o aviso de áudio em geração.
- Implementar o reenvio manual pelo painel respeitando o limite diário do tier.
- Implementar o acervo com paginação por cursor, limite por tier, busca por texto e por data, e download de áudio por URL assinada de curta duração.
- Implementar a tela de assinatura com plano, valor, forma de pagamento, próxima cobrança, histórico de pagamentos, upgrade e cancelamento em duas etapas com pesquisa de motivo.
- Implementar a tela de perfil, incluindo a troca de número com re-verificação completa e invalidação de sessões.
- Implementar a tela de preferências com pausa temporária de 1 a 30 dias, reativação e opt-out, deixando explícita a distinção entre parar mensagens e cancelar a cobrança.
- Implementar a tela de dados pessoais com exportação em JSON, solicitação de exclusão confirmada por código e histórico de consentimentos.
- Implementar a revalidação periódica do estado de servidor no cliente, sem canal persistente, nos intervalos definidos na Seção 14.
- Escrever os testes E2E de acervo, reenvio, pausa, opt-out e cancelamento.
Artefatos. apps/web/src/app/app/**, apps/web/src/app/api/me/**, componentes de player e de acervo, testes E2E.
Critérios de saída.
pnpm --filter @app/web test:e2e -- subscriber-panel
curl -s -H "Cookie: __Host-session=<free>" localhost:3000/api/devotionals | jq '.data | length'Um assinante do tier gratuito deve enxergar apenas os últimos sete dias de acervo e receber FEATURE_NOT_AVAILABLE com status 403 ao pedir a URL de áudio. O segundo reenvio no mesmo dia por um assinante gratuito deve retornar RESEND_LIMIT_REACHED com status 429.
Não se faz neste milestone. Nenhuma alteração no motor de envio. Nenhuma tela pública de marketing.
28.4.10 M9 — Landing page e página de vendas #
Objetivo. Converter visitante em assinante, com amostra real de áudio, comparativo de planos derivado da matriz de entitlements e desempenho dentro do orçamento declarado.
Pré-requisitos. M8 concluído. O produto precisa existir antes de ser vendido.
Tarefas.
- Implementar as rotas públicas da Seção 9 com os textos aprovados da Seção 10.
- Implementar os blocos da home na ordem especificada, com os dados dinâmicos que cada um consome.
- Renderizar o comparativo de planos a partir da matriz de entitlements da Seção 13, sem duplicar regras no componente.
- Implementar o player de amostra com o devocional público de demonstração, incluindo estados de carregamento, erro e acessibilidade.
- Implementar o formulário de captura e o encaminhamento para o fluxo de cadastro de M3.
- Implementar os metadados por rota, os dados estruturados, o mapa do site e o arquivo de robôs conforme a Seção 9.
- Implementar o consentimento de cookies e o comportamento sem consentimento definido na Seção 9.
- Implementar os eventos de conversão listados na Seção 9 e verificados pela Seção 21.
- Implementar as páginas de erro e a página de manutenção.
- Implementar as páginas legais com a estrutura obrigatória da Seção 22.
- Ajustar imagens, fontes e estratégia de renderização até o orçamento de performance da Seção 9 ser cumprido.
- Rodar auditoria de acessibilidade e corrigir todas as violações de nível AA.
Artefatos. apps/web/src/app/(public)/**, apps/web/src/components/marketing/**, apps/web/public/robots.txt, sitemap.xml, páginas legais.
Critérios de saída.
pnpm --filter @app/web test:e2e -- landing
pnpm --filter @app/web exec playwright test tests/a11y --grep "wcag"
pnpm --filter @app/web exec lhci autorun --collect.url=https://staging.<dominio>/A auditoria de acessibilidade automatizada deve retornar zero violação de nível AA. A auditoria de performance deve cumprir as metas de carregamento declaradas na Seção 9.12. Alterar a matriz de entitlements deve alterar o comparativo de planos sem edição de componente — verificado por teste.
Não se faz neste milestone. Nenhum teste A/B, nenhuma campanha, nenhum cupom.
28.4.11 M10 — Métricas, observabilidade e alertas #
Objetivo. Tornar o sistema legível para o operador: números de produto, sinais técnicos e alertas acionáveis antes de o assinante reclamar.
Pré-requisitos. M7 concluído. Pode ser feito em paralelo a M8 e M9 se houver mais de um executor.
Tarefas.
- Implementar o logger estruturado com os campos obrigatórios e a redação de dados sensíveis da Seção 23.
- Implementar a correlação de identificador de requisição entre aplicativo web, fila e worker.
- Implementar o endpoint de métricas técnicas e o catálogo completo de contadores, histogramas e medidores da Seção 23.
- Implementar os health checks de vivacidade e de prontidão com o comportamento em dependência degradada.
- Implementar o rastreamento distribuído com propagação de contexto entre web, fila e worker.
- Implementar o job de agregação diária que popula
daily_metrics, com o horário, o reprocessamento histórico e o tratamento de fuso da Seção 21. - Implementar as telas do dashboard administrativo com os gráficos e filtros definidos na Seção 21.
- Implementar as exportações em CSV e JSON com os limites definidos na Seção 21.
- Implementar os alertas da Seção 23, com condição, severidade, canal e destinatário, e ligar cada um ao runbook correspondente da Seção 27.
- Implementar os alertas de negócio derivados de métrica: custo diário acima do orçado, queda de conversão e pico de opt-out.
- Escrever os testes que provam que a agregação não conta duas vezes e que um dia recalculado substitui o anterior.
Artefatos. apps/worker/src/jobs/metrics-rollup.ts, apps/web/src/app/admin/metricas/**, packages/core/src/metrics/**, configuração de alertas, painéis de monitoramento.
Critérios de saída.
curl -s localhost:3001/api/internal/health | jq '.status' # "ok" e nada mais
curl -s -H "Authorization: Bearer $METRICS_TOKEN" localhost:3001/api/internal/ready | jq '.checks'
curl -s -H "Authorization: Bearer $METRICS_TOKEN" localhost:3001/api/internal/metrics | grep -c '^app_'
pnpm --filter @app/ops start metrics:rollup --date 2026-09-06 --forceReexecutar a agregação do mesmo dia duas vezes deve manter os valores idênticos. Um lote diário que não inicia até o limite definido na Seção 23 deve gerar alerta no canal configurado, verificado por teste de integração com relógio controlado.
Não se faz neste milestone. Nenhuma ferramenta de análise de produto de terceiros além da decidida na Seção 21. Nenhum plantão fora do horário definido na Seção 23.
28.4.12 M11 — Endurecimento de segurança e LGPD #
Objetivo. Fechar a superfície de ataque e implementar todos os direitos do titular com prazo e evidência. Todos os controles da Seção 22 exceto a criptografia de campo e os índices cegos, que foram entregues em M1.
Pré-requisitos. M10 concluído. Todas as funcionalidades já existem; aqui elas são endurecidas.
Tarefas.
- Aplicar os cabeçalhos de segurança com os valores exatos da Seção 22, incluindo a política de conteúdo completa para as origens realmente usadas — com a variante das páginas com formulário, que precisa liberar o verificador anti-bot em
script-src, emframe-srce emconnect-src, e com o domínio de mídia emconnect-src, sem o que o cadastro não acontece e o áudio pago não toca. - Conferir que a criptografia de campo e os índices cegos entregues em M1 continuam íntegros: rodar
test:encryptione a varredura deinformation_schemado testeI-37. Nenhuma coluna é convertida aqui — não existe migração de dados em claro para cifrado, porque nunca houve dado em claro. - Implementar a rotação das chaves de cifra e de índice cego em duas fases, com as chaves anteriores aceitas apenas em leitura durante a transição.
- Implementar a proteção anti-bot da landing e do cadastro na modalidade decidida na Seção 22, e acrescentar o destino da verificação à lista de destinos permitidos do cliente HTTP com proteção contra requisição forjada do servidor.
- Implementar a detecção de cadastro em massa e o bloqueio de faixas abusivas.
- Revisar e apertar todos os limites de taxa das rotas sensíveis conforme a Seção 7.
- Implementar a exportação de dados do titular no formato e no prazo da Seção 22, baixada de dentro do painel sob sessão plena, nunca por link portador enviado ao canal de mensagens.
- Implementar a anonimização com o script exato da Seção 22, cobrindo todas as tabelas que guardam identificador pessoal — inclusive os registros de mensagem —, e o job de retenção que a executa no prazo. Criar os papéis de banco nomeados e codificar as exceções dentro do gatilho de imutabilidade dos eventos de consentimento: um controle de integridade não pode impedir o cumprimento de um direito do titular, e nenhum gatilho é desabilitado em momento algum.
- Implementar as políticas de retenção por tabela e o job de limpeza correspondente.
- Publicar os termos de uso e a política de privacidade com todas as cláusulas obrigatórias, o encarregado de dados e o canal público.
- Implementar o registro e a consulta do histórico de consentimento pelo titular.
- Executar a varredura de dependências e a revisão de segredos, e rotacionar tudo que tiver sido usado em desenvolvimento.
- Percorrer o checklist de segurança pré-lançamento da Seção 22 item a item.
Artefatos. apps/web/src/middleware.ts, packages/core/src/crypto/**, apps/worker/src/jobs/maintenance-cleanup.ts, páginas legais atualizadas, relatório do checklist.
Critérios de saída.
pnpm audit --audit-level=high # zero vulnerabilidade alta ou crítica
pnpm --filter @app/web test:integration -- security-headers
pnpm --filter @app/worker test:integration -- lgpd
pnpm --filter @app/ops start lgpd:export --subscriber <id> | jq 'keys'
pnpm --filter @app/ops start lgpd:anonymize --subscriber <id> --dry-runA verificação de cabeçalhos deve provar que a política de conteúdo não usa diretiva permissiva de execução de script, e o cenário E2E de violações da Seção 24.7.3 deve fechar com zero violação no console em todas as rotas com formulário. A exportação deve conter todas as categorias inventariadas na Seção 22. A anonimização simulada deve listar exatamente as colunas previstas, nem mais nem menos, e o teste I-38 deve provar que ela conclui com o gatilho de imutabilidade ativo.
Não se faz neste milestone. Nenhuma funcionalidade nova. Nenhuma mudança de escopo.
28.4.13 M12 — Testes E2E e carga #
Objetivo. Provar, por execução automatizada, que o sistema inteiro cumpre os critérios da Seção 29 e sustenta o alvo de escala da Seção 18.14.
Pré-requisitos. M11 concluído.
Tarefas.
- Completar a suíte E2E com todos os cenários listados na Seção 24.
- Ligar a suíte E2E ao pipeline de integração contínua com ambiente efêmero.
- Implementar o gerador de assinantes sintéticos e proibir por configuração o uso de dados reais em ambiente de teste.
- Executar o teste de carga do lote diário nos três patamares de escala da Seção 24 e registrar os resultados.
- Executar o teste de carga das rotas de API e verificar o percentil de resposta declarado na Seção 9.12.
- Executar o teste de falha injetada de cada dependência externa e verificar a degradação graciosa da Seção 27.
- Executar o teste de restauração de backup e cronometrar o tempo de recuperação.
- Rodar o checklist de testes manuais obrigatórios da Seção 24 com número real, número de teste da plataforma de mensagens e conta de sandbox do provedor de pagamento.
- Corrigir toda falha encontrada e reexecutar a suíte completa.
- Preencher a matriz de rastreabilidade da Seção 29.23 com o resultado de cada critério.
Artefatos. tests/e2e/**, tests/load/**, relatório de carga, relatório de restauração, matriz de rastreabilidade preenchida.
Critérios de saída.
pnpm test:e2e # 100% dos cenários passam
pnpm test:load -- --profile 3000 # dentro da janela declarada na Seção 18.14
pnpm test:load -- --profile 50000 # conclui sem erro, com o tempo registrado
pnpm --filter @app/ops start backup:restore --to-scratch --verifyTodos os critérios marcados como bloqueadores na Seção 29 devem estar verdes. Nenhum critério pode ficar sem resultado registrado.
Não se faz neste milestone. Nenhuma correção de escopo disfarçada de correção de bug. Se um teste revelar funcionalidade faltante, registra-se a decisão e resolve-se dentro do milestone dono daquela funcionalidade.
28.4.14 M13 — Preparação de produção e lançamento #
Objetivo. Colocar o sistema em produção com deploy reversível, backup restaurável e operação documentada.
Pré-requisitos. M12 concluído, X4 com todos os templates aprovados e Y1 com a conta de produção do provedor de pagamento ativa.
Tarefas.
- Provisionar o servidor de produção conforme os requisitos da Seção 25 e aplicar o endurecimento do sistema operacional.
- Escrever os
Dockerfilemulti-estágio de produção do aplicativo web e do worker. - Escrever o
docker-compose.ymlde produção e o arquivo de configuração do proxy reverso, com TLS automático. - Configurar os subdomínios, os redirecionamentos e as regras de firewall da Seção 25.
- Configurar o backup automatizado com destino externo, criptografia e a retenção declarada na Seção 25, e executar uma restauração real de verificação.
- Configurar o pipeline de deploy com aprovação manual, verificação de saúde antes de trocar o tráfego e o comando exato de rollback.
- Injetar todos os segredos de produção pelo mecanismo definido na Seção 26, sem nenhum segredo em código nem em imagem.
- Apontar os webhooks de produção da plataforma de mensagens e do provedor de pagamento para os endereços definitivos e verificar a entrega.
- Rodar as migrations em produção com lock e conferir a ausência de deriva.
- Executar o seed de produção: planos, administrador inicial, definições de template e configurações de runtime.
- Executar um envio de validação para um grupo restrito de números internos, com o interruptor geral ligado, e conferir o pacote completo.
- Percorrer o checklist de go-live da Seção 28.9.
- Ligar o tráfego público e iniciar o acompanhamento dos primeiros 30 dias da Seção 28.10.
Artefatos. apps/web/Dockerfile, apps/worker/Dockerfile, docker-compose.prod.yml, Caddyfile, .github/workflows/deploy.yml, docs/runbooks/ com os runbooks da Seção 27, relatório de restauração de backup.
Critérios de saída.
curl -sI https://<dominio> | head -1 # 200
curl -s https://api.<dominio>/api/internal/health | jq '.status' # "ok"
curl -s -H "Authorization: Bearer $METRICS_TOKEN" \
https://api.<dominio>/api/internal/ready | jq '.status' # "ok"
curl -s -o /dev/null -w '%{http_code}' https://api.<dominio>/api/internal/ready # 404 sem token
docker compose -f docker-compose.prod.yml ps # todos healthy
pnpm --filter @app/ops start backup:restore --verify # restauração conclui e valida
pnpm --filter @app/ops start release:rollback --dry-run # rollback simulado concluiO envio de validação deve produzir um lote com 100% de sucesso para os números internos, com convite por template, texto completo e áudio entregues na ordem correta.
Não se faz neste milestone. Nenhuma funcionalidade nova. Nenhuma migration destrutiva. Nenhuma campanha de aquisição antes de o primeiro lote real fechar em verde.
28.5 Por que esta ordem #
Por que o motor de envio (M6) vem antes do pagamento (M7). Três razões concretas.
- O valor do produto está na entrega, não na cobrança. Um sistema que entrega devocional sem cobrar é um produto gratuito funcional. Um sistema que cobra sem entregar é uma fraude operacional. Construir a entrega primeiro garante que, se o projeto for interrompido em qualquer ponto, o que existe é utilizável.
- O risco técnico está concentrado na plataforma de mensagens. A janela de atendimento de 24 horas, a proibição de áudio no cabeçalho de template, o limite de caracteres em parâmetro e a classificação de categoria são restrições externas que moldam o produto inteiro (ver Seção 17). O provedor de pagamento é uma API REST convencional, com contrato estável e comportamento previsível. Atacar o risco alto primeiro evita descobrir tarde que o desenho não fecha.
- O tier pago pode ser exercitado sem cobrança real. A concessão manual de acesso pago pelo administrador, entregue em M4, permite testar todo o comportamento de entitlement pago em M6 sem um centavo transacionado. A dependência inversa não existe: não há como testar cobrança de forma útil sem ter o que entregar ao pagante.
Por que a landing (M9) vem depois do produto funcionar. A página de vendas promete comportamento concreto: horário fixo, áudio narrado, cancelamento pelo painel, encerramento imediato do acesso ao falhar o pagamento. Escrever a promessa antes de o comportamento existir produz promessa desalinhada, que depois vira retrabalho ou reclamação. Além disso, a landing consome três coisas que só existem depois: a amostra de áudio real (M5), o comparativo derivado da matriz de entitlements (M3 e M7) e o encaminhamento para o checkout (M7). Por fim, a landing é o item de menor risco técnico do projeto — deixá-la por último é deslocar o trabalho fácil para o fim, quando a energia de resolução de problema já foi gasta onde importava.
Por que segurança e LGPD (M11) vêm depois e não antes. Os controles de M11 são transversais: cabeçalhos, retenção, anonimização, exportação. Aplicá-los sobre um sistema incompleto significa reaplicá-los a cada funcionalidade nova. Isso não é postergar segurança: as práticas seguras de base (validação na borda, ORM parametrizado, segredo fora do código, hash de senha correto, verificação de assinatura de webhook) são obrigatórias desde M0 e estão escritas nas regras invioláveis da Seção 30.3. M11 é o endurecimento e a conformidade formal, não a introdução do primeiro controle.
Por que a criptografia de campo é exceção e vive em M1. Criptografia de campo e índice cego são decisões de schema, não de endurecimento: elas trocam o tipo e o índice de subscribers.phone_e164, criam phone_hmac, wa_id_hmac, cpf_hmac e email_hmac, e mudam a forma de toda busca por identidade. Adiá-las para M11 obrigaria a reescrever toda consulta por telefone escrita entre M2 e M10, além da normalização de telefone da Seção 11.4 e do roteamento de webhook da Seção 17.8. Por isso elas nascem na migration inicial, com critério de saída verificável em M1, e M11 apenas confere que continuam íntegras.
Por que a trilha externa começa no dia 1. Verificação de negócio e aprovação de template não têm caminho rápido. Se a submissão de templates esperar M6, o projeto para por dias no momento de maior custo de espera. Iniciando X0 junto com M0, a aprovação chega enquanto M1 a M5 são construídos, e M6 encontra o caminho livre.
28.6 Paralelização com mais de um executor #
Com um único executor, a ordem é estritamente M0 → M1 → M2 → M3 → M4 → M5 → M6 → M7 → M8 → M9 → M10 → M11 → M12 → M13.
Com dois executores, o ganho real é este:
Executor A: M0 ─ M1 ─ M2 ─ M3 ──────── M6 ─ M7 ─ M8 ─── M11 ─ M12 ─ M13
Executor B: └─ M4 ─ M5 ──────┘ └─ M9 ─ M10 ┘- M4 e M5 só dependem de M1 e M2 e não tocam nada de M3. São o par mais seguro para paralelizar: o painel administrativo e o pipeline de áudio compartilham apenas o modelo
devotionals, e o contrato entre eles é a transição de estado editorial. - M9 e M10 não se tocam: um é superfície pública, o outro é agregação e alertas. Ambos dependem de M7.
- M8 e M10 também paralelizam, com atenção a
daily_metrics, que o painel do assinante não lê.
Com três executores, acrescenta-se a extração de M9 para um terceiro fluxo, iniciando a estrutura da landing com dados falsos assim que o copy da Seção 10 estiver disponível, e substituindo os dados falsos por reais quando M7 fechar.
O que nunca deve ser paralelizado.
- M1 com qualquer outro milestone. O schema é a fundação compartilhada; duas mãos nele produzem migrations conflitantes.
- M6 com M7. Ambos escrevem em
subscribers.tiere emdelivery_attemptspor caminhos diferentes, e a regra de revogação imediata precisa de um único autor. - M11 com qualquer coisa. O endurecimento assume um alvo estável.
Protocolo de coordenação entre executores. Cada executor trabalha em branch própria a partir de main. Migrations são serializadas: apenas o executor dono de M1, ou o dono de um milestone que exija migration aditiva, cria arquivos em packages/db/prisma/migrations/, e qualquer necessidade de coluna nova é anunciada em DECISIONS.md antes de ser criada. Nenhum executor edita arquivo fora do escopo do seu milestone sem registrar a razão.
28.7 Esforço relativo por milestone #
| Milestone | Pontos | Peso relativo | Comentário sobre o dimensionamento |
|---|---|---|---|
| M0 | 5 | 4% | Trabalho mecânico e bem conhecido, mas extenso em número de arquivos. |
| M1 | 13 | 10% | Vinte e sete tabelas, particionamento, seeds, criptografia de campo e índices cegos. Volume alto; a parte não trivial é o índice cego que preserva a busca por telefone. |
| M2 | 8 | 6% | Dois fluxos de autenticação distintos, com muitos casos de erro. |
| M3 | 8 | 6% | Máquina de estados do assinante e casos de borda densos. |
| M4 | 13 | 10% | O maior volume de interface do projeto, com editor, calendário e gestão. |
| M5 | 13 | 10% | Integração com dois provedores, processamento de mídia e regras de qualidade. |
| M6 | 21 | 16% | O núcleo do produto e o maior risco. Quatro caminhos de entrega, fila, idempotência. |
| M7 | 13 | 10% | Doze eventos de webhook, reconciliação e a regra de revogação imediata. |
| M8 | 8 | 6% | Interface sobre serviços já existentes. |
| M9 | 8 | 6% | Volume de conteúdo e exigências de desempenho e acessibilidade. |
| M10 | 8 | 6% | Catálogo grande de métricas e alertas, com pouca lógica difícil. |
| M11 | 5 | 4% | Cabeçalhos, retenção, anonimização e direitos do titular. A criptografia de coluna, que era a parte não trivial, já veio em M1. |
| M12 | 8 | 6% | Execução e correção, com cauda imprevisível. |
| M13 | 5 | 4% | Procedimento, não invenção. |
M6 sozinho representa aproximadamente um sexto do esforço total. Qualquer plano que trate o motor de envio como "só mandar mensagem" está subdimensionado.
28.8 Riscos de execução e mitigação #
| # | Risco | Milestone afetado | Probabilidade | Impacto | Mitigação |
|---|---|---|---|---|---|
| R1 | Verificação de negócio na Meta demora ou é recusada por documentação divergente. | X1, bloqueia M6 real | Alta | Alto | Iniciar no dia 1. Conferir antes que razão social, CNPJ, endereço e site coincidem exatamente com os documentos. Manter o desenvolvimento contra o simulador local até a liberação. |
| R2 | Template rejeitado pela Meta por conteúdo ou por categoria. | X4, bloqueia M6 real | Alta | Médio | Submeter os oito templates cedo e em lote. Manter variantes alternativas de redação prontas. Se devocional_diario_v1 for reclassificado para categoria de marketing, o sistema continua funcionando e apenas o custo muda — registrar a categoria efetiva e seguir. |
| R3 | Número de mensagens é limitado por tier inicial baixo. | M6, M13 | Média | Alto | Planejar o crescimento de tier com envios consistentes e qualidade alta. Monitorar o volume planejado contra o tier e alertar antes de atingir o limite, conforme a Seção 17. |
| R4 | Qualidade do número cai por marcação de assinantes. | M13 e pós-lançamento | Média | Alto | Opt-in em duas etapas obrigatório, rodapé de saída em todo convite, palavras-chave de saída com efeito imediato, e o alerta de qualidade da Seção 23. Número reserva provisionado conforme a Seção 27. |
| R5 | Conta do provedor de pagamento demora a ser aprovada para produção. | Y1, bloqueia M13 | Média | Alto | Iniciar em paralelo com X0. Desenvolver e testar inteiramente contra sandbox. O lançamento pode ocorrer com o tier gratuito aberto e o checkout desativado por flag, caso a aprovação atrase. |
| R6 | Fila de webhooks do provedor de pagamento é pausada por lentidão do nosso endpoint. | M7 | Média | Alto | Handler que apenas persiste e enfileira, com resposta abaixo do limite da Seção 9.12, mais o alerta de fila pausada e o runbook correspondente da Seção 27. |
| R7 | Custo de síntese de voz acima do previsto. | M5 | Média | Médio | Áudio gerado uma única vez por devocional, nunca por assinante. Alerta de custo diário acima do orçado na Seção 23. Provedor de fallback com custo distinto já integrado. |
| R8 | Áudio gerado com qualidade ruim, truncado ou silencioso. | M5 | Média | Médio | Verificações automáticas de duração, silêncio e truncamento antes de publicar, mais revisão humana opcional no painel administrativo. |
| R9 | Lote diário não conclui na janela alvo. | M6 | Baixa | Alto | Limitador de taxa configurável, teste de carga nos três patamares em M12 e alerta de lote não concluído. |
| R10 | Envio duplicado para o mesmo assinante. | M6 | Média | Alto | Índice único em delivery_attempts sobre a chave de envio, testado explicitamente por reexecução tripla do planejamento. |
| R11 | Assinante inadimplente continua recebendo áudio. | M7 | Baixa | Alto | Rebaixamento na mesma transação do processamento do webhook, mais verificação de entitlement no momento do envio, não no momento do planejamento. |
| R12 | Deriva entre schema e migrations. | M1 e adiante | Média | Médio | Verificação de deriva no pipeline de integração contínua, falhando o build. |
| R13 | Regra de entitlement recalculada localmente em algum ponto. | M3 em diante | Média | Médio | Função única em packages/core, regra de lint proibindo comparação direta com o campo de tier fora dela, e teste que cobre a matriz completa. |
| R14 | Segredo vazado em log ou em imagem de contêiner. | qualquer | Baixa | Crítico | Redação obrigatória no logger, varredura de segredos no pipeline, e o procedimento de resposta a vazamento da Seção 22. |
| R15 | Mensagem real enviada a partir do ambiente de desenvolvimento. | M6 | Média | Alto | O provedor real só é instanciado quando o ambiente é de produção; em qualquer outro, o simulador é obrigatório e o boot falha se credenciais de produção forem detectadas fora dela. |
| R16 | Conteúdo do dia ausente às 05:00. | M6 e pós-lançamento | Média | Alto | Alerta de menos de sete dias agendados, verificação de prontidão às 05:00 e o mecanismo de devocional de reserva da Seção 18. |
| R17 | Executor cria tabela fora da lista fechada. | M1 em diante | Média | Alto | Regra inviolável da Seção 30.3 e verificação automatizada que compara o schema com a lista da Seção 6. |
| R18 | Restauração de backup nunca testada. | M13 | Baixa | Crítico | Restauração real obrigatória como critério de saída de M13, e teste trimestral recorrente conforme a Seção 25. |
O que se faz enquanto a Meta não responde. Este é o bloqueio externo mais provável e mais longo do projeto. A resposta é operacional, não passiva:
- Todo M6 é construído contra o simulador local da Seção 24, que reproduz o contrato de envio, os estados de mensagem, os webhooks de entrada e os códigos de erro da plataforma.
- Os testes de idempotência, de limitador de taxa, de janela de atendimento e de fallback rodam integralmente sem a plataforma real.
- Enquanto X4 não chega, o executor avança para M7, M8 e M10, que não dependem de aprovação de template.
- Assim que os templates são aprovados, executa-se apenas a bateria de validação real: um envio de convite, uma abertura de janela, um pacote completo e um fallback de vídeo, para números internos.
- Se um template específico for rejeitado, o produto ainda pode lançar sem ele, desde que
devocional_diario_v1ecodigo_acesso_v1estejam aprovados. Os demais têm caminhos alternativos: lembretes por e-mail, confirmações dentro da janela de atendimento. Essa degradação está prevista na Seção 27.
28.9 Checklist de go-live #
Cada item é verificável. Nenhum item aceita "provavelmente".
Plataforma de mensagens (depende de terceiro).
- Verificação de negócio concluída, com status verificado no gerenciador de negócios.
- Número dedicado registrado, com nome de exibição aprovado e foto de perfil publicada.
devocional_diario_v1com status aprovado, categoria efetiva registrada no sistema.codigo_acesso_v1com status aprovado.devocional_diario_video_v1,boas_vindas_v1,lembrete_pagamento_v1,pagamento_confirmado_v1,acesso_encerrado_v1ereativacao_v1com status registrado; ausência de qualquer um documentada com o caminho alternativo ativo.- Webhook de produção verificado, com assinatura validada em pelo menos um evento real.
- Tier de mensagens atual lido pelo sistema e comparado ao volume planejado do primeiro dia.
- Token de acesso de longa duração emitido, armazenado como segredo e com data de rotação registrada.
Provedor de pagamento (depende de terceiro). 9. Conta de produção aprovada e chave emitida. 10. Webhook de produção configurado, com token próprio e resposta verificada abaixo do limite de tempo. 11. Uma cobrança real de valor baixo criada, paga e estornada, com todos os eventos refletidos corretamente no banco. 12. Reconciliação diária executada uma vez em produção sem divergência.
Infraestrutura. 13. Certificado TLS válido em todos os subdomínios, com renovação automática confirmada. 14. Firewall com apenas as portas previstas abertas. 15. Backup automatizado executado, com destino externo e criptografia confirmados. 16. Restauração de backup executada e validada, com o tempo registrado. 17. Rollback simulado com sucesso. 18. Todos os contêineres reportando saudável por mais de 24 horas consecutivas.
Aplicação.
19. Migrations aplicadas sem deriva.
20. Seeds de produção executados: planos, administrador inicial, definições de template e configurações de runtime.
21. Nenhuma variável obrigatória faltando, verificado pela validação de ambiente no boot.
22. Nenhum segredo presente em código, em imagem ou em log.
23. Todos os critérios bloqueadores da Seção 29 verdes, conferidos pela contagem verificável declarada na Seção 29.23.
24. Interruptor geral de envios testado: desliga, verifica que nada sai, religa. A chave é ops.kill_switch, existe no seed e o comando de emergência traz todas as colunas obrigatórias.
25. Bucket de mídia conferido por requisição sem assinatura, respondendo 403, inclusive no prefixo de exportações.
26. Cadeia de alerta validada de ponta a ponta pelos itens M-23 e M-24 da Seção 24.11, incluindo o alerta externo com o servidor desligado.
27. Salvaguarda de transferência internacional comprovada e arquivada para cada destinatário que recebe dado pessoal.
Conteúdo. 28. No mínimo 30 dias de devocionais agendados com status publicado ou pronto. 29. No mínimo 3 devocionais de reserva marcados como conteúdo perene. 30. Áudio gerado e verificado para todos os devocionais dos próximos 7 dias. 31. Devocional público de demonstração publicado na landing.
Legal e conformidade.
32. Termos de uso e política de privacidade publicados, com data de vigência e versão.
33. Encarregado de dados nomeado, com canal público funcionando e resposta testada.
34. Texto de consentimento versionado em uso e gravado em cada novo cadastro.
35. Versão bíblica em uso conferida quanto a direitos: o código gravado é ALMEIDA_1911 ou BIBLIA_LIVRE, e a sigla ARC não aparece como código nem como atribuição em nenhuma entrega.
Operação. 36. Todos os alertas da Seção 23 configurados, com um disparo de teste recebido no canal correto. 37. Runbooks da Seção 27 acessíveis ao operador de plantão. 38. Envio de validação para números internos concluído com 100% de sucesso. 39. Painel de envios exibindo o lote de validação com contagens corretas.
28.10 Plano dos primeiros 30 dias após o lançamento #
Dias 1 a 3 — vigilância intensa.
Monitorar a cada lote: horário real de início e de término do disparo; taxa de entrega; taxa de falha por código de erro; taxa de abertura da janela de atendimento; qualidade do número; consumo do tier de mensagens.
Ajustes esperados neste período: calibrar o limitador de taxa de envio se o lote encostar na janela alvo; corrigir o texto do teaser se a taxa de abertura da janela ficar abaixo de 25%; ajustar o horário de planejamento se o enfileiramento não terminar com folga antes do disparo.
Dias 4 a 10 — validação do fallback e do ciclo de cobrança.
O caminho de fallback por vídeo só aparece a partir do quarto dia sem interação: verificar que ele dispara para o público correto, que o arquivo de vídeo é aceito e que o custo por mensagem correspondente é registrado. Verificar o primeiro ciclo de cobrança por PIX, incluindo lembretes antes do vencimento e o comportamento em caso de não pagamento. Confirmar que a revogação imediata funciona em produção sem carência acidental.
Dias 11 a 20 — conversão e custo.
Acompanhar a taxa de conversão do tier gratuito para o pago e o tempo médio até a conversão. Acompanhar o custo por assinante por mês, decomposto entre mensagens, síntese de voz e infraestrutura. Comparar o custo real de mensagem com a faixa prevista para a categoria efetiva do template. Ajustar o dia de envio do tier gratuito se a taxa de abertura de domingo ficar sistematicamente abaixo da média dos demais dias.
Dias 21 a 30 — retenção e estabilização.
Acompanhar churn, motivos de cancelamento agregados e taxa de opt-out. Revisar o trabalho morto — jobs no estado failed com linha job_runs.status = 'DEAD' — e reprocessar o que for reprocessável. Revisar todos os alertas disparados no período e eliminar os que geraram ruído sem ação. Executar a primeira análise pós-incidente, se houver incidente. Fechar a lista de ajustes de conteúdo com o editor.
Números que se observa todos os dias, sem exceção.
| Indicador | Fonte | Faixa saudável | Ação se sair da faixa |
|---|---|---|---|
| Lote iniciado no horário | Painel de envios | até 06:05 | Runbook de lote não disparado, Seção 27. |
| Lote concluído | Painel de envios | até 06:30 | Runbook de lote parado no meio, Seção 27. |
| Taxa de entrega | Métrica da Seção 21 | acima de 95% | Investigar por código de erro; excluir da conta o código esperado e recorrente no Brasil. |
| Taxa de falha | Métrica da Seção 21 | abaixo de 5% | Runbook de taxa de falha alta, Seção 27. |
| Qualidade do número | Webhook de conta | verde | Alerta imediato em amarelo; plano de contingência em vermelho. |
| Taxa de abertura da janela | Métrica da Seção 21 | acima de 30% | Revisar teaser e horário; aumentar uso do atalho de janela aberta. |
| Custo diário de mensagens | Métrica da Seção 21 | dentro do orçado | Revisar proporção de fallback de vídeo, que é o item mais caro. |
| Assinaturas ativas e churn | Métrica da Seção 21 | churn abaixo de 8% ao mês | Revisar motivos agregados de cancelamento. |
Trabalho morto (job_runs.status = 'DEAD') |
Painel de filas | zero linhas novas no dia | Inspecionar e reprocessar conforme a Seção 27. |
Gatilhos de reversão. Cada gatilho tem uma ação definida, e a ação é executada sem discussão quando o gatilho é atingido.
| Gatilho | Ação imediata |
|---|---|
| Qualidade do número em vermelho. | Acionar o interruptor geral de envios, suspender o disparo diário, aplicar o runbook de qualidade vermelha e só religar depois de o indicador voltar. |
| Taxa de falha acima de 20% em um lote. | Cancelar o lote em andamento, reprocessar apenas após diagnóstico, comunicar os assinantes afetados conforme a Seção 27. |
| Qualquer assinante recebendo o mesmo devocional duas vezes. | Suspender o disparo, auditar delivery_attempts, corrigir a chave de idempotência antes de religar. |
| Assinante inadimplente recebendo conteúdo pago. | Suspender o disparo do pacote de áudio, forçar reconciliação, corrigir o ponto de verificação de entitlement. |
| Dado pessoal exposto em log ou em resposta de API. | Executar o procedimento de resposta a vazamento da Seção 22, incluindo avaliação de notificação à autoridade. |
| Fila de webhooks do provedor de pagamento pausada por mais de 30 minutos. | Aplicar o runbook correspondente, executar reconciliação manual e só então reativar a fila. |
| Erro 5xx acima do limiar por mais de 15 minutos. | Rollback para a versão anterior com o comando definido na Seção 25, e diagnóstico depois. |
| Custo diário acima de duas vezes o orçado. | Desativar por flag o fallback de vídeo, que é o maior componente unitário, e reavaliar. |
O que não se muda nos primeiros 30 dias. Preço, horário de envio, regra de revogação imediata e desenho de entrega em duas etapas. Mudar qualquer um desses no primeiro mês destrói a capacidade de interpretar os números. Ajustes de texto, de teaser e de calendário editorial são livres.
29. Critérios de Aceitação #
Esta seção define o que significa "funciona" para cada área do produto. Todos os critérios são escritos no formato Dado / Quando / Então e são observáveis do lado de fora: verificam comportamento visível por interface, por resposta de API, por conteúdo de mensagem entregue ou por estado consultável, nunca por detalhe interno de implementação.
29.1 Como ler e usar esta seção #
- Cada critério tem identificador estável no formato
AC-NNN. O identificador nunca é reutilizado nem renumerado. - Critérios marcados com
[BLOQUEADOR]impedem o lançamento. Nenhum bloqueador pode estar vermelho na conclusão de M12 nem no go-live da Seção 28.9. - Onde um valor numérico aparece, ele é o valor efetivo do sistema. Se a Seção 1 alterar um padrão configurável, o critério passa a valer com o novo valor, sem alteração de texto.
- Um critério é considerado atendido quando existe teste automatizado que o exercita ou, quando isso é impossível por depender de terceiro, quando existe evidência registrada da verificação manual descrita na Seção 24.
- A matriz de rastreabilidade da Seção 29.23 liga cada funcionalidade à seção que a especifica e aos critérios que a cobrem.
29.2 Cadastro e opt-in #
AC-001 · [BLOQUEADOR] — Dado um visitante com número de celular brasileiro válido não cadastrado, quando ele preenche nome, telefone e marca o consentimento e envia o formulário, então o sistema cria o assinante no estado de verificação pendente, registra um evento de consentimento com o texto versionado, o IP, o agente de usuário e o horário, e envia o código de acesso ao número informado.
AC-002 — Dado um visitante que não marcou o checkbox de consentimento, quando ele tenta enviar o formulário, então o envio é bloqueado, o campo é destacado, a mensagem em português indica que o consentimento é obrigatório e nenhum registro é criado no sistema.
AC-003 · [BLOQUEADOR] — Dado um número informado em qualquer formato de escrita usual no Brasil, com ou sem o nono dígito, com ou sem parênteses, hífen, espaço ou prefixo de país, quando o cadastro é processado, então o número é armazenado em formato internacional canônico com o nono dígito, e duas grafias diferentes do mesmo número nunca produzem dois assinantes.
AC-004 — Dado um número com código de país diferente do Brasil, quando o visitante tenta se cadastrar, então a requisição é recusada com o código de erro de país não suportado e uma mensagem clara em português explicando que o serviço atende apenas números brasileiros.
AC-005 — Dado um número já cadastrado e ativo, quando alguém tenta cadastrá-lo novamente, então a resposta indica que o número já existe, nenhum registro duplicado é criado e o visitante é orientado a entrar em vez de se cadastrar.
AC-006 — Dado um número que já fez opt-out, quando ele é cadastrado novamente com consentimento explícito, então o assinante existente é reativado, um novo evento de consentimento é registrado e o histórico anterior é preservado.
AC-007 — Dado um número de um assinante excluído por solicitação do titular, quando ele se cadastra de novo, então um assinante novo é criado sem qualquer dado do anterior, e o acervo anterior não é recuperado.
AC-008 · [BLOQUEADOR] — Dado um assinante que verificou o telefone mas ainda não confirmou o opt-in no aplicativo de mensagens, quando o lote diário é planejado, então ele não é incluído em nenhum envio, sob nenhuma circunstância.
AC-009 · [BLOQUEADOR] — Dado um assinante que acabou de confirmar o opt-in, quando a confirmação ocorre depois do horário de envio do dia e ele é elegível a receber naquele dia, então ele recebe o devocional do dia uma única vez de forma imediata e entra no ciclo normal a partir do dia seguinte, sem receber duas vezes.
AC-010 — Dado um assinante que confirmou o opt-in antes do horário de envio, quando o lote do dia é disparado, então ele recebe apenas pelo fluxo normal e não recebe envio imediato adicional.
AC-011 — Dado dois formulários de cadastro abertos em abas diferentes com o mesmo número, quando ambos são enviados quase ao mesmo tempo, então exatamente um assinante é criado e o segundo envio recebe o erro de número já existente, sem violação de restrição no banco visível ao usuário.
AC-012 — Dado um visitante que informou e-mail, quando o e-mail já pertence a outro assinante, então o cadastro é recusado com mensagem específica sobre o e-mail, e o telefone não é consumido.
AC-013 — Dado um nome com caracteres não permitidos ou com menos caracteres que o mínimo, quando o formulário é enviado, então a validação retorna erro apontando o campo name com a mensagem em português, e nenhum outro campo é processado antes da correção.
29.3 Autenticação do assinante #
AC-014 · [BLOQUEADOR] — Dado um assinante cadastrado, quando ele solicita o código de acesso, então um código numérico de seis dígitos é entregue no aplicativo de mensagens, válido por dez minutos, e o mesmo código nunca é reaproveitado após uso bem-sucedido.
AC-015 · [BLOQUEADOR] — Dado um código de acesso válido e dentro do prazo, quando o assinante o informa, então uma sessão é criada, o cookie de sessão é definido com as flags de segurança exigidas e o assinante é levado ao painel.
AC-016 — Dado um código expirado, quando o assinante o informa, então a resposta traz OTP_EXPIRED com status 410, e a interface habilita o reenvio. O status 410 distingue código expirado — recurso que existiu e não existe mais — de código inválido, que é 401; a distinção é o que permite ao cliente oferecer o reenvio sem ambiguidade, e vale em todos os pontos do sistema.
AC-017 — Dado cinco tentativas incorretas para o mesmo código, quando o assinante tenta pela sexta vez, então o código é invalidado, a resposta traz o erro de tentativas excedidas e uma nova solicitação passa a ser necessária.
AC-018 — Dado um número que já solicitou três códigos na última hora, quando ele solicita o quarto, então a resposta é 429 com o erro de limite de taxa e os cabeçalhos de limite preenchidos com o instante de liberação.
AC-019 · [BLOQUEADOR] — Dado um número cadastrado e um não cadastrado, quando cada um recebe 200 pedidos de código em ambiente de homologação, então a diferença entre as medianas dos dois conjuntos de latência é inferior a 15 ms e a diferença entre os percentis 95 é inferior a 40 ms, e as respostas são idênticas em código HTTP, corpo e error.code. Como verificar: script que alterna os dois números, descarta as 20 primeiras medições de aquecimento e imprime as duas distribuições.
AC-020 — Dado um assinante com e-mail verificado, quando ele escolhe entrar por e-mail, então um link de uso único é enviado, válido pelo prazo definido, e o uso do link cria sessão equivalente à do código de acesso.
AC-021 — Dado um assinante com sessão ativa em três dispositivos, quando ele aciona "sair de todos os dispositivos", então todas as sessões são revogadas e qualquer requisição autenticada subsequente com os cookies antigos retorna 401.
AC-022 — Dado um assinante autenticado que troca o número de telefone, quando a re-verificação do novo número é concluída, então todas as sessões anteriores são invalidadas e uma nova sessão é emitida.
AC-023 — Dado um assinante que foi rebaixado de pago para gratuito enquanto tinha sessão aberta, quando ele recarrega qualquer tela do painel, então as capacidades exibidas refletem o tier gratuito imediatamente, sem exigir novo login.
AC-024 — Dado um cookie de sessão adulterado, quando ele é enviado em qualquer rota autenticada, então a resposta é 401 e o evento é registrado em log com o identificador de requisição, sem expor detalhe do motivo ao cliente.
29.4 Autenticação do administrador #
AC-025 · [BLOQUEADOR] — Dado um administrador com credenciais corretas, quando ele entra com e-mail e senha, então o sistema exige o segundo fator antes de emitir qualquer sessão, sem exceção e sem opção de pular.
AC-026 · [BLOQUEADOR] — Dado um administrador que informou o segundo fator correto, quando a verificação conclui, então uma sessão administrativa é emitida com prazo mais curto que o de assinante, conforme a Seção 8.
AC-027 — Dado um administrador que errou a senha o número de vezes definido como limite, quando ele tenta novamente, então a conta é bloqueada temporariamente, a resposta traz o erro de conta bloqueada e o desbloqueio segue o procedimento da Seção 8.
AC-028 — Dado um código de recuperação de segundo fator, quando ele é usado com sucesso, então ele é consumido e não pode ser reutilizado.
AC-029 — Dado um usuário com papel de editor, quando ele acessa uma rota administrativa restrita a papéis superiores, então a resposta é 403 quando a existência do recurso não é sensível, e 404 quando revelar a existência seria vazamento, conforme a regra da Seção 8.
AC-030 · [BLOQUEADOR] — Dado o único proprietário do sistema, quando alguém tenta removê-lo ou rebaixá-lo, então a operação é recusada com erro específico e o sistema permanece com pelo menos um proprietário.
AC-031 · [BLOQUEADOR] — Dado um administrador que assume a identidade de um assinante para suporte, quando a impersonação começa e termina, então ambos os eventos ficam registrados na trilha de auditoria com autor, alvo, motivo e horário, e a sessão impersonada é limitada em duração.
AC-032 — Dado qualquer mudança de papel de um usuário, quando ela é efetivada, então um registro de auditoria imutável é criado com o papel anterior, o novo, o autor e o horário.
29.5 Entitlements de plano gratuito e pago #
AC-033 · [BLOQUEADOR] — Dado um assinante do tier gratuito, quando o lote diário é planejado em um dia que não é o dia de envio gratuito configurado, então ele não é incluído no lote.
AC-034 · [BLOQUEADOR] — Dado um assinante do tier gratuito, quando o lote é planejado no dia de envio gratuito configurado, então ele é incluído e recebe apenas o texto completo, nunca o áudio.
AC-035 · [BLOQUEADOR] — Dado um assinante do tier pago, quando o lote diário é planejado em qualquer dia da semana, então ele é incluído e recebe texto e áudio.
AC-036 · [BLOQUEADOR] — Dado um assinante do tier gratuito, quando ele solicita a URL de áudio de qualquer devocional por qualquer rota, então a resposta é 403 com o erro de recurso indisponível para o plano, e nenhuma URL assinada é emitida.
AC-037 — Dado um assinante do tier gratuito no painel, quando ele abre o acervo, então apenas os devocionais dos últimos sete dias aparecem, com aviso claro de que o acervo completo pertence ao plano pago.
AC-038 — Dado um assinante do tier pago, quando ele abre o acervo, então todos os devocionais desde a primeira assinatura paga dele aparecem, paginados por cursor. Quem foi pago, deixou de ser e voltou vê tudo desde a primeira vez.
AC-039 — Dado um assinante do tier gratuito que já usou o reenvio manual do dia, quando ele tenta reenviar de novo no mesmo dia, então a resposta é 429 com o erro de limite de reenvio atingido.
AC-040 — Dado um assinante do tier pago que já usou três reenvios no dia, quando ele tenta o quarto, então a resposta é 429 com o mesmo erro de limite, e o contador zera na virada do dia no fuso operacional.
AC-041 · [BLOQUEADOR] — Dado qualquer ponto do sistema que precise decidir uma capacidade, quando a decisão é tomada, então ela vem da função única de resolução de entitlements, e nenhum outro trecho compara o campo de tier diretamente — verificado por regra de lint e por teste.
AC-042 — Dado um assinante que faz upgrade no meio do dia, quando o pagamento é confirmado, então as capacidades pagas passam a valer imediatamente no painel e no próximo envio, sem esperar o próximo ciclo.
29.6 Editor e calendário editorial #
AC-043 · [BLOQUEADOR] — Dado um editor autenticado, quando ele cria um devocional com título, referência bíblica, versão, texto bíblico, reflexão, oração e data de agendamento, então o devocional é salvo no estado de rascunho e uma revisão é criada.
AC-044 — Dado um devocional salvo várias vezes, quando o editor abre o histórico, então cada salvamento aparece como revisão distinta, com autor e horário, e é possível comparar e restaurar qualquer uma.
AC-045 · [BLOQUEADOR] — Dado um texto de reflexão de qualquer tamanho, quando o teaser é gerado automaticamente, então o resultado tem no máximo 300 caracteres e não contém quebra de linha, tabulação nem quatro ou mais espaços consecutivos.
AC-046 · [BLOQUEADOR] — Dado um editor que cola manualmente um teaser com quebra de linha, quando ele tenta salvar, então a validação recusa com mensagem específica e o devocional não avança de estado.
AC-047 — Dado um devocional em edição, quando o editor abre a pré-visualização, então ele vê a representação fiel do convite: cabeçalho, corpo com os dois parâmetros substituídos, rodapé e os dois botões, exatamente como o assinante receberá.
AC-048 · [BLOQUEADOR] — Dado um devocional sem áudio pronto e a existência de assinantes pagos ativos, quando o editor tenta publicá-lo, então a transição é recusada com o erro de áudio obrigatório e o estado permanece o anterior.
AC-049 — Dado um devocional publicado, quando o editor altera qualquer campo que compõe o roteiro de narração, então o áudio existente é invalidado, o devocional volta ao estado de áudio pendente e uma nova geração é enfileirada automaticamente.
AC-050 — Dado o calendário editorial aberto, quando existem menos de sete dias futuros com conteúdo agendado, então um alerta visível aparece no painel e o alerta operacional correspondente é disparado no canal configurado.
AC-051 — Dado um devocional agendado, quando o editor o arrasta para outra data livre, então o agendamento é atualizado e o calendário reflete a mudança sem recarregar a página.
AC-052 — Dado duas tentativas de agendar devocionais diferentes para a mesma data, quando a segunda é salva, então a operação é recusada com o erro de data já ocupada, preservando a regra de um devocional por dia.
AC-053 — Dado um arquivo CSV de importação com linhas válidas e inválidas, quando ele é enviado, então as linhas válidas são importadas, as inválidas são listadas com número da linha e motivo, e nenhuma linha inválida gera registro parcial.
AC-054 — Dado um devocional marcado como conteúdo de reserva, quando o calendário é consultado, então ele aparece separado do calendário regular e não ocupa data.
AC-055 — Dado qualquer ação administrativa sobre conteúdo ou sobre assinante, quando ela é executada, então um registro de auditoria é criado com autor, ação, alvo, valores anterior e novo, e horário.
29.7 Geração e qualidade do áudio #
AC-056 · [BLOQUEADOR] — Dado um devocional que atinge o estado de pronto, quando a transição ocorre, então um trabalho de geração de áudio é enfileirado automaticamente, sem ação humana.
AC-057 · [BLOQUEADOR] — Dado um roteiro de narração montado a partir do devocional, quando a referência bíblica abreviada aparece no texto, então ela é expandida por extenso na narração, de modo que a referência abreviada de capítulo e versículo é lida como livro, capítulo e versículo.
AC-058 · [BLOQUEADOR] — Dado a geração concluída, quando o arquivo destinado à mensagem de voz é inspecionado, então ele está no formato, no número de canais e na taxa de amostragem exigidos pela Seção 16, e é renderizado pelo aplicativo de mensagens como mensagem de voz com forma de onda.
AC-059 — Dado a geração concluída, quando o arquivo destinado ao player web é inspecionado, então ele está no formato e na taxa definidos na Seção 16 e toca no navegador sem download completo prévio.
AC-060 · [BLOQUEADOR] — Dado o provedor primário de síntese de voz indisponível, quando três tentativas falham, então o provedor de fallback é acionado automaticamente, o áudio é gerado e o provedor efetivamente usado fica registrado no ativo de áudio.
AC-061 — Dado um áudio gerado acima do limiar intermediário de tamanho, quando a verificação de tamanho roda, então o arquivo é reencodado com a taxa reduzida definida na Seção 16 e um aviso é registrado.
AC-062 · [BLOQUEADOR] — Dado um áudio gerado, quando ele excede o limite duro de tamanho de mídia da plataforma mesmo após reencode, então o devocional não avança para publicado, um alerta é disparado e nenhum envio de áudio é tentado.
AC-063 — Dado um áudio gerado com duração fora da faixa aceitável, quando a verificação automática roda, então o devocional permanece pendente de revisão e o editor é avisado no painel.
AC-064 — Dado um áudio integralmente silencioso ou truncado, quando a verificação automática roda, então ele é rejeitado, a geração é repetida uma vez e, persistindo, um alerta é disparado.
AC-065 · [BLOQUEADOR] — Dado um devocional com áudio pronto, quando o envio do dia ocorre para mil assinantes pagos, então o upload de mídia para a plataforma acontece uma única vez e o mesmo identificador de mídia é reutilizado em todos os envios.
AC-066 — Dado um identificador de mídia expirado pelo prazo de validade da plataforma, quando um envio precisa dele, então o sistema faz o reupload automaticamente e prossegue sem falhar o envio.
AC-067 — Dado um assinante pago no painel web, quando ele pede para ouvir o áudio, então uma URL assinada de curta duração é emitida, e a mesma URL deixa de funcionar após o prazo definido na Seção 16.
29.8 Envio diário e motor de entrega #
AC-068 · [BLOQUEADOR] — Dado um dia com devocional publicado e áudio pronto, quando o relógio atinge 05:40 no fuso America/Sao_Paulo, então um lote é criado com exatamente os assinantes elegíveis, e cada destinatário tem uma tentativa de entrega registrada com a chave de idempotência do dia. O job repetível está registrado com o padrão cron correspondente e com o fuso nomeado; deslocamento numérico fixo reprova o critério.
AC-069 · [BLOQUEADOR] — Dado um planejamento já executado para uma data, quando ele é executado de novo, duas ou três vezes, então nenhum destinatário ganha tentativa adicional e o lote permanece com a mesma composição.
AC-070 · [BLOQUEADOR] — Dado um lote planejado e pronto, quando o relógio do sistema atinge 06:00:00 no fuso America/Sao_Paulo, então a primeira chamada ao provedor de mensagens ocorre entre 06:00:00 e 06:00:30 nesse fuso, nenhuma chamada ocorre antes de 06:00:00, e o convite por template é enviado a cada destinatário sem janela de atendimento aberta, respeitando o limite de taxa configurado. Como verificar: executar em homologação com relógio injetado, registrar o instante de cada chamada ao provedor de gravação e comparar com o fuso nomeado; repetir com o servidor configurado em UTC e confirmar resultado idêntico.
AC-071 · [BLOQUEADOR] — Dado um destinatário com janela de atendimento aberta no momento do disparo, quando o envio ocorre, então o sistema pula o convite por template e envia diretamente o pacote completo, e nenhuma cobrança de mensagem de template é gerada para ele.
AC-072 — Dado um lote de três mil destinatários, quando o disparo executa, então ele conclui dentro da janela de tempo declarada na Seção 18.14, medido do primeiro ao último envio aceito pela plataforma.
AC-073 · [BLOQUEADOR] — Dado o horário de verificação de prontidão, quando não existe devocional publicado para o dia, então um alerta é disparado imediatamente ao operador e o sistema usa o conteúdo de reserva marcado como perene.
AC-074 · [BLOQUEADOR] — Dado o horário de verificação de prontidão, quando não existe devocional do dia nem conteúdo de reserva disponível, então nenhum envio é feito, o lote não é criado e o alerta de conteúdo ausente permanece ativo até resolução — o sistema nunca envia conteúdo incompleto.
AC-075 — Dado um envio que falha com erro classificado como transitório, quando a política de tentativas roda, então o envio é repetido com espera exponencial e variação aleatória, até o número máximo de tentativas definido na Seção 27.
AC-076 — Dado um envio que falha com erro classificado como permanente, quando a classificação ocorre, então nenhuma nova tentativa é feita, a falha é registrada com o código da plataforma e o destinatário entra no lote do dia seguinte.
AC-077 · [BLOQUEADOR] — Dado um envio recusado com o código de limitação de entrega esperado e recorrente no Brasil, quando a classificação ocorre, então ele não é contado como falha de entrega nas métricas e o destinatário é reagendado para o dia seguinte.
AC-078 — Dado um lote em andamento, quando o administrador abre a tela de envios, então ele vê progresso, contagem por status e a lista de falhas com motivo, atualizados sem recarregar a página.
AC-079 — Dado um lote com falhas, quando o administrador aciona reprocessar falhas, então apenas os destinatários com falha são reprocessados e nenhum destinatário já entregue recebe segunda mensagem.
AC-080 · [BLOQUEADOR] — Dado um lote em andamento, quando o administrador aciona o cancelamento do lote, então nenhuma nova mensagem sai a partir daquele instante e o lote fica marcado como cancelado com o horário e o autor.
AC-081 · [BLOQUEADOR] — Dado o interruptor geral de envios acionado, quando qualquer trabalho de envio tenta executar, então ele termina sem enviar nada, registra o motivo e o estado é visível ao operador.
29.9 Janela de atendimento e entrega completa #
AC-082 · [BLOQUEADOR] — Dado um assinante que recebeu o convite por template, quando ele toca no botão de abrir o devocional, então o sistema envia, em ordem, o texto completo, o áudio quando o tier permite, e a mensagem de fechamento com a data do próximo envio.
AC-083 · [BLOQUEADOR] — Dado um assinante que responde qualquer mensagem em vez de tocar no botão, quando a mensagem é recebida, então a janela de atendimento é aberta e o pacote completo do dia é entregue igualmente.
AC-084 — Dado a interação do assinante, quando o pacote é disparado, então a primeira mensagem do pacote sai dentro do tempo alvo de reação declarado na Seção 18, medido do recebimento do evento até o aceite pela plataforma.
AC-085 · [BLOQUEADOR] — Dado um assinante do tier gratuito que abre a janela, quando o pacote é entregue, então ele recebe o texto completo e a mensagem de fechamento, e nunca o áudio.
AC-086 — Dado um assinante que toca no botão de adiar, quando o evento é recebido, então a janela é aberta, nenhum pacote é enviado naquele instante e o sistema entrega o pacote quando ele voltar a interagir, dentro do mesmo dia.
AC-087 — Dado um assinante que interage duas vezes no mesmo dia, quando a segunda interação ocorre, então o pacote do dia não é enviado novamente, e a resposta é a mensagem de ajuda ou a confirmação apropriada da Seção 19.
AC-088 — Dado um assinante que recebeu o convite e nunca interagiu, quando o dia termina no fuso operacional, então a entrega pendente expira, é registrada como não aberta e o contador de dias sem interação é incrementado.
AC-089 — Dado um texto de devocional mais longo que o limite de mensagem livre da plataforma, quando o pacote é montado, então o sistema divide o conteúdo em mensagens sequenciais respeitando o limite, sem cortar palavra ao meio e sem perder trecho.
29.10 Fallback de vídeo #
AC-090 · [BLOQUEADOR] — Dado um assinante pago que não abriu a janela por três dias consecutivos, quando o envio do quarto dia é planejado, então o sistema seleciona o template com cabeçalho de vídeo em vez do convite padrão.
AC-091 — Dado o fallback de vídeo selecionado, quando a mídia é preparada, então o arquivo de vídeo combina a capa estática do dia com a faixa de áudio, dentro do limite de tamanho da plataforma.
AC-092 — Dado um assinante que recebe o fallback de vídeo e interage, quando a interação ocorre, então o contador de dias sem interação é zerado e o envio do dia seguinte volta ao convite padrão.
AC-093 — Dado um assinante do tier gratuito com muitos dias sem interação, quando o envio é planejado, então o fallback de vídeo nunca é usado para ele, porque o tier gratuito não recebe áudio.
29.11 Checkout com cartão e com PIX #
AC-094 · [BLOQUEADOR] — Dado um assinante autenticado no tier gratuito, quando ele escolhe o plano mensal e conclui o pagamento com cartão, então a assinatura é criada no provedor de pagamento com o ciclo correto e o identificador externo apontando para o nosso registro.
AC-095 · [BLOQUEADOR] — Dado o pagamento com cartão confirmado, quando o evento correspondente é processado, então o assinante passa ao tier pago, a assinatura fica ativa, a data de fim do período corrente é gravada e a mensagem de confirmação é enviada.
AC-096 · [BLOQUEADOR] — Dado um checkout com cartão, quando a requisição é processada, então nenhum dado de cartão é persistido em qualquer tabela nem aparece em qualquer registro de log, verificado por inspeção automatizada dos logs do teste.
AC-097 — Dado um CPF com dígitos verificadores inválidos, quando o checkout é enviado, então a validação local recusa antes de qualquer chamada externa, com o erro específico de CPF inválido.
AC-098 — Dado um cartão recusado pelo emissor, quando o provedor responde a recusa, então o assinante vê a mensagem em português correspondente, permanece no tier gratuito e pode tentar de novo sem duplicar assinatura.
AC-099 · [BLOQUEADOR] — Dado um assinante que escolhe PIX, quando a cobrança é criada, então ele recebe o código copia-e-cola e a imagem do código, com o prazo de expiração exibido.
AC-100 — Dado uma cobrança PIX paga, quando o evento de confirmação chega, então o acesso pago é liberado no mesmo processamento, e o assinante recebe a mensagem de confirmação.
AC-101 — Dado uma cobrança PIX não paga, quando o vencimento passa, então o assinante não é promovido, permanece no tier gratuito e recebe a comunicação prevista na Seção 19.
AC-102 · [BLOQUEADOR] — Dado um assinante com assinatura ativa, quando ele tenta criar uma segunda assinatura, então a operação é recusada com o erro de assinatura já existente e nenhuma cobrança é criada no provedor.
AC-103 — Dado um assinante do plano mensal, quando ele muda para o plano anual, então a regra exata de troca definida na Seção 13 é aplicada, sem prorrateio, e o novo ciclo começa conforme especificado.
29.12 Webhooks de pagamento e revogação de acesso #
AC-104 · [BLOQUEADOR] — Dado um evento de webhook do provedor de pagamento, quando ele chega ao endpoint, então a resposta 200 é devolvida abaixo do limite de tempo declarado na Seção 9.12, tendo apenas persistido o evento e enfileirado o processamento.
AC-105 · [BLOQUEADOR] — Dado um webhook com token de autenticação incorreto, quando ele chega, então a resposta é 401, nada é persistido e a comparação do token não vaza informação por diferença de tempo.
AC-106 · [BLOQUEADOR] — Dado o mesmo evento entregue cinco vezes, quando todos são processados, então o efeito no estado do sistema é idêntico ao de uma única entrega, e apenas um registro de evento é considerado processado.
AC-107 · [BLOQUEADOR] — Dado um assinante pago ativo, quando o evento de pagamento vencido é processado, então na mesma transação a assinatura passa ao estado de expirada e o assinante é rebaixado ao tier gratuito, sem nenhum período de tolerância.
AC-108 · [BLOQUEADOR] — Dado o rebaixamento por inadimplência ocorrido às 10:00 de um dia, quando o assinante interage no mesmo dia após o rebaixamento, então ele recebe apenas o texto, nunca o áudio, mesmo que o pacote do dia já tivesse sido planejado quando ele ainda era pagante.
AC-109 · [BLOQUEADOR] — Dado um evento de pagamento removido, estornado ou de contestação, quando ele é processado, então a revogação é imediata, com o estado específico de cada caso conforme a Seção 12, e o assinante recebe a comunicação prevista.
AC-110 — Dado um evento que chega fora de ordem, com um estado anterior ao já registrado, quando ele é processado, então ele não regride o estado da assinatura e o descarte é registrado com o motivo.
AC-111 — Dado um pagamento confirmado que chega depois do cancelamento da assinatura, quando ele é processado, então o sistema aplica a regra definida na Seção 13 e registra a divergência para conferência na reconciliação.
AC-112 · [BLOQUEADOR] — Dado a reconciliação diária, quando o estado no provedor de pagamento difere do nosso, então o nosso estado é corrigido para refletir o do provedor, a correção é registrada e um relatório de divergências é gerado.
AC-113 — Dado uma assinatura existente no provedor sem assinante correspondente no nosso banco, quando a reconciliação roda, então a divergência é registrada, um alerta é disparado e nenhum assinante é criado automaticamente.
AC-114 — Dado a fila de webhooks do provedor pausada por falhas consecutivas, quando a condição é detectada, então o alerta correspondente é disparado e o runbook da Seção 27 está acessível a partir dele.
29.13 Cancelamento voluntário #
AC-115 · [BLOQUEADOR] — Dado um assinante pago que cancela pelo painel, quando ele confirma nas duas etapas, então a assinatura é marcada para não renovar, o acesso pago permanece até o fim do período já pago e a data exata de término é exibida e confirmada por mensagem.
AC-116 · [BLOQUEADOR] — Dado um cancelamento voluntário com fim de período em uma data futura, quando essa data chega, então o assinante é rebaixado ao tier gratuito e passa a receber apenas no dia de envio gratuito.
AC-117 — Dado o fluxo de cancelamento, quando o assinante avança da primeira para a segunda etapa, então a pesquisa de motivo com opções fechadas e campo livre é apresentada e a resposta é registrada para as métricas de motivo.
AC-118 — Dado o fluxo de cancelamento, quando a oferta de retenção é apresentada, então ela é oferecida uma única vez por assinante e a recusa leva direto à confirmação.
AC-119 — Dado um assinante que cancelou e ainda está dentro do período pago, quando ele reativa a assinatura, então nenhuma cobrança nova é criada antes do fim do período corrente e a renovação volta a ficar ativa.
AC-120 — Dado um assinante que cancelou, quando ele acessa o painel após o fim do período pago, então o acervo visível passa a ser limitado ao do tier gratuito, e o histórico anterior não é apagado do banco.
29.14 Opt-out, reativação e pausa #
AC-121 · [BLOQUEADOR] — Dado um assinante que envia uma das sete palavras-chave de saída reconhecidas, em qualquer combinação de maiúsculas, minúsculas e acentos, e em qualquer estado — inclusive pausado, bloqueado, em atendimento humano ou em modo silencioso pela proteção anti-laço —, quando a mensagem é processada, então opt_out_at fica preenchido em menos de 60 segundos contados a partir da chegada do webhook, nenhuma mensagem posterior é enviada e uma confirmação é enviada uma única vez. Como verificar: injetar cada palavra-chave em cada estado possível do assinante e conferir opt_out_at e o provedor de gravação; o reconhecimento da palavra de saída é a primeira operação executada sobre toda mensagem de entrada.
AC-122 · [BLOQUEADOR] — Dado um assinante que fez opt-out, quando qualquer lote é planejado, então ele nunca é incluído, nem em envio de conteúdo, nem em campanha de retorno, nem em qualquer outra mensagem pelo canal de mensagens.
AC-123 — Dado um assinante pagante que faz opt-out pelo canal de mensagens, quando a confirmação é enviada, então ela avisa explicitamente que os envios pararam e que a cobrança do ciclo seguinte está suspensa, informa que a assinatura não foi cancelada e diz como voltar a receber ou como encerrar de vez. Passados 30 dias sem reativação, a assinatura é encerrada ao fim do período já pago, com aviso.
AC-124 — Dado um assinante que fez opt-out, quando ele envia a palavra-chave de retorno, então ele é reativado, um novo registro de consentimento é criado e ele volta a receber no próximo ciclo do seu tier.
AC-125 — Dado um assinante que aciona a pausa temporária por um número de dias entre um e trinta, quando o lote é planejado dentro do período de pausa, então ele não é incluído.
AC-126 — Dado uma pausa que chega ao fim, quando o primeiro lote após o término é planejado, então o assinante volta a ser incluído e recebe a mensagem de retorno prevista na Seção 19.
AC-127 — Dado um pedido de pausa com valor fora da faixa permitida, quando ele é enviado, então a validação recusa com o erro específico e nenhuma pausa é registrada.
AC-128 — Dado um assinante pausado, quando ele acessa o painel, então o estado de pausa e a data de retorno aparecem em destaque, com a ação de retomar imediatamente disponível.
AC-129 · [BLOQUEADOR] — Dado o opt-out e o cancelamento de assinatura, quando o assinante executa um deles, então o outro não é executado implicitamente, e a interface deixa a distinção explícita antes da confirmação.
29.15 Painel do assinante #
AC-130 — Dado um assinante autenticado, quando ele abre a tela do dia, então vê o devocional de hoje com título, referência, texto bíblico, reflexão e oração, e o player de áudio apenas se o tier permitir.
AC-131 — Dado um devocional cujo áudio ainda está em geração, quando o assinante pago abre a tela do dia, então ele vê o aviso de áudio em preparo em vez de um player quebrado.
AC-132 — Dado o acervo aberto, quando o assinante rola até o fim da página, então a próxima página é carregada por cursor, sem duplicar nem pular itens quando novos devocionais são publicados durante a navegação.
AC-133 — Dado a tela de assinatura, quando o assinante a abre, então vê plano, valor, forma de pagamento, data da próxima cobrança e o histórico de pagamentos com status.
AC-134 — Dado a tela de perfil, quando o assinante altera o nome ou o e-mail, então a alteração é validada, salva e refletida imediatamente, e o e-mail alterado exige nova verificação antes de servir de canal de acesso.
AC-135 — Dado qualquer rota do painel, quando um assinante tenta acessar dado de outro assinante manipulando identificadores, então a resposta é 404 e nada do outro assinante é exposto.
AC-136 — Dado o painel aberto por tempo prolongado, quando o estado de servidor muda, então a interface se atualiza pelo mecanismo de revalidação periódica definido na Seção 14, sem canal persistente.
AC-137 — Dado qualquer tela do painel, quando os dados ainda não chegaram, quando a lista está vazia ou quando ocorre erro, então existe um estado visual próprio para cada uma das três situações, com texto em português e ação de recuperação quando aplicável.
29.16 Painel administrativo #
AC-138 — Dado um administrador na busca de assinantes, quando ele pesquisa por telefone em qualquer grafia, por nome parcial ou por e-mail, então o assinante correto é encontrado.
AC-139 — Dado a ficha de um assinante, quando o administrador a abre, então vê estado, tier, histórico de mensagens, histórico de pagamentos, consentimentos e as ações administrativas disponíveis.
AC-140 · [BLOQUEADOR] — Dado o administrador concedendo acesso pago por cortesia por um número de dias, quando a concessão é feita, então o assinante passa ao tier pago, a expiração é registrada, a ação é auditada e, ao expirar, ele volta ao tier gratuito automaticamente.
AC-141 — Dado um assinante com divergência de assinatura, quando o administrador aciona a re-sincronização com o provedor de pagamento, então o estado local é corrigido conforme o provedor e a ação é auditada.
AC-142 — Dado a tela de templates, quando o administrador aciona a sincronização, então o status de aprovação de cada template na plataforma é atualizado localmente, incluindo o motivo de rejeição quando houver.
AC-143 — Dado a tela de auditoria, quando o administrador aplica filtros por autor, ação e período, então os resultados correspondem e a exportação em CSV contém exatamente as linhas filtradas.
AC-144 — Dado um usuário com papel de editor, quando ele abre o painel, então apenas as telas permitidas ao seu papel aparecem na navegação, e o acesso direto por URL às demais é recusado.
AC-145 — Dado o administrador aplicando opt-out em nome de um assinante por solicitação de suporte, quando a ação é executada, então ela exige confirmação, é auditada com o motivo e produz o mesmo efeito do opt-out feito pelo próprio assinante.
29.17 Exportação e exclusão de dados #
AC-146 · [BLOQUEADOR] — Dado um assinante autenticado, quando ele solicita a exportação dos seus dados, então um arquivo JSON é disponibilizado contendo todas as categorias de dado pessoal inventariadas na Seção 22, dentro do prazo declarado.
AC-147 — Dado a exportação concluída, quando o assinante a baixa, então o download exige sessão válida e a URL expira após o prazo definido.
AC-148 · [BLOQUEADOR] — Dado um assinante que solicita a exclusão da conta, quando ele confirma com o código enviado ao seu número, então a solicitação é registrada, o acesso é encerrado e a anonimização ocorre dentro do prazo declarado na Seção 22.
AC-149 · [BLOQUEADOR] — Dado a anonimização executada, quando o banco é inspecionado, então nome, telefone, e-mail e documento estão irreversivelmente substituídos, enquanto os registros retidos por obrigação legal permanecem com o vínculo pseudonimizado, exatamente conforme a Seção 22.
AC-150 — Dado um assinante com assinatura ativa que pede exclusão, quando a solicitação é processada, então a assinatura é cancelada no provedor de pagamento antes da anonimização, e isso é informado ao titular.
AC-151 — Dado o histórico de consentimentos, quando o assinante o abre, então vê cada evento com data, canal, versão do texto e a íntegra do texto aceito naquela versão.
AC-152 — Dado o canal público do encarregado de dados, quando uma solicitação chega por ele, então existe procedimento registrado e prazo declarado de resposta conforme a Seção 22.
AC-153 — Dado as políticas de retenção configuradas, quando o trabalho de manutenção roda, então os registros que ultrapassaram o prazo de cada tabela são removidos ou anonimizados conforme a Seção 22, e a execução é registrada.
29.18 Métricas e dashboard #
AC-154 — Dado um dia encerrado, quando o trabalho de agregação roda, então a linha correspondente de métricas diárias é gravada com todos os indicadores definidos na Seção 21.
AC-155 — Dado a agregação de um dia já processado, quando ela é reexecutada, então os valores permanecem idênticos e nenhuma contagem é duplicada.
AC-156 — Dado o dashboard aberto, quando o administrador escolhe um período, então todos os gráficos e números refletem o mesmo período e a comparação com o período anterior é exibida.
AC-157 — Dado a métrica de taxa de entrega, quando ela é calculada, então ela usa exatamente a fórmula declarada na Seção 21 e o resultado bate com a contagem manual sobre os registros de mensagem do período.
AC-158 — Dado a métrica de receita recorrente mensal, quando existem assinaturas anuais ativas, então o valor anual entra normalizado por doze, conforme a fórmula da Seção 21.
AC-159 — Dado qualquer relatório exportável, quando o administrador o exporta, então o arquivo contém os mesmos números exibidos na tela, no mesmo período e com o mesmo fuso de agregação.
29.19 Segurança #
AC-160 · [BLOQUEADOR] — Dado qualquer resposta HTTP da aplicação, quando os cabeçalhos são inspecionados, então todos os cabeçalhos de segurança da Seção 22 estão presentes com os valores exatos, incluindo a política de conteúdo sem diretiva permissiva de execução de script.
AC-161 · [BLOQUEADOR] — Dado um webhook de entrada do canal de mensagens, quando a assinatura do corpo cru não confere, então a requisição é recusada, nada é persistido e o evento é registrado.
AC-162 · [BLOQUEADOR] — Dado qualquer registro de log produzido em qualquer ambiente, quando os logs são inspecionados, então telefone, documento, código de acesso, token e dado de cartão nunca aparecem em texto claro.
AC-163 · [BLOQUEADOR] — Dado o código-fonte e as imagens de contêiner, quando eles são varridos, então nenhum segredo de produção está presente.
AC-164 — Dado a coluna de telefone criptografada em nível de aplicação, quando uma busca por telefone é feita, então ela retorna o resultado correto pelo mecanismo de hash determinístico definido na Seção 22, sem descriptografar a tabela inteira.
AC-165 — Dado um formulário público, quando um agente automatizado tenta enviá-lo em volume, então a proteção anti-bot e o limite de taxa impedem a criação em massa e o bloqueio é registrado.
AC-166 — Dado qualquer rota que aceite entrada, quando a entrada não corresponde ao schema esperado, então a resposta é 422 com o detalhamento por campo previsto no envelope da Seção 7, sem expor detalhe interno.
AC-167 — Dado o upload de arquivo no painel administrativo, quando o arquivo não é de um tipo permitido ou excede o tamanho limite, então ele é recusado com erro específico, e a verificação considera o conteúdo real e não apenas a extensão.
AC-168 — Dado todo o tráfego público, quando ele é inspecionado, então ele ocorre exclusivamente por conexão cifrada, e requisições em texto claro são redirecionadas.
29.20 Observabilidade e alertas #
AC-169 — Dado qualquer requisição HTTP, quando ela é processada, então o identificador de requisição aparece no cabeçalho da resposta, no envelope e em todos os registros de log da requisição.
AC-170 — Dado uma operação que atravessa aplicação web, fila e worker, quando os logs são consultados pelo identificador de requisição, então todos os registros das três camadas são recuperados juntos.
AC-171 · [BLOQUEADOR] — Dado o endpoint de prontidão consultado com o token do monitor externo, quando o banco, a fila ou o armazenamento estão indisponíveis, então ele responde com falha e discrimina qual dependência falhou; e quando o mesmo endpoint é consultado sem o token, então ele responde 404 e não revela nenhuma dependência. O endpoint de vivacidade continua público e devolve apenas o estado geral, sem versão, sem nome de serviço e sem tempo de atividade.
AC-172 — Dado o endpoint de métricas técnicas, quando ele é consultado, então todos os indicadores catalogados na Seção 23 estão expostos, com os rótulos previstos.
AC-173 · [BLOQUEADOR] — Dado o horário limite de início do lote diário, quando o lote não começou, então o alerta correspondente é disparado no canal configurado, com link para o runbook da Seção 27.
AC-174 — Dado a taxa de falha de envio acima do limiar definido, quando a condição persiste pelo período configurado, então o alerta é disparado com a decomposição por código de erro.
AC-175 · [BLOQUEADOR] — Dado a qualidade do número de envio caindo para o nível de atenção ou crítico, quando o evento chega, então o alerta é disparado imediatamente e o runbook correspondente está acessível.
AC-176 — Dado uma fila crescendo acima do limiar ou com trabalho travado além do tempo máximo, quando a condição é detectada, então o alerta é disparado e o trabalho morto é inspecionável pelo operador pelo comando de operação.
AC-177 — Dado um incidente encerrado, quando o prazo definido na Seção 23 passa, então existe um relatório pós-incidente registrado com causa, impacto, linha do tempo e ações preventivas.
29.21 Resiliência e degradação #
AC-178 · [BLOQUEADOR] — Dado a plataforma de mensagens indisponível, quando o lote tenta executar, então o disjuntor abre, os envios ficam na fila sem serem descartados, o alerta é disparado e o sistema retoma automaticamente quando o serviço volta.
AC-179 — Dado o provedor de pagamento indisponível, quando um checkout é tentado, então o assinante vê mensagem clara de indisponibilidade temporária, nenhuma assinatura parcial é criada e nenhum acesso é concedido.
AC-180 — Dado o armazenamento de mídia indisponível, quando o painel tenta emitir uma URL assinada, então o erro é tratado com mensagem específica e o restante do painel continua funcionando.
AC-181 — Dado o serviço de fila indisponível, quando o sistema tenta enfileirar, então a falha é registrada, o alerta é disparado e nenhuma confirmação falsa de envio é dada ao usuário.
AC-182 — Dado trabalhos que esgotaram todas as tentativas, quando eles falham definitivamente, então eles ficam no estado failed da própria fila, uma linha é gravada em job_runs com status = 'DEAD', o alerta é disparado e o reprocessamento manual está disponível pelo comando de operação. Não existe fila de mensagens mortas separada: job_runs é a dead-letter do sistema.
29.22 Critérios acrescentados na revisão de integração #
Os critérios abaixo cobrem comportamentos que o restante desta seção descrevia de forma não verificável, ou não descrevia. Cada um nasceu de uma falha concreta que a suíte anterior deixaria passar em verde.
AC-183 · [BLOQUEADOR] — Limite de acervo imposto no servidor. Dado um assinante do plano gratuito com 40 devocionais publicados nos últimos 40 dias, quando ele chama diretamente a rota de listagem do acervo com o maior limite aceito e percorre todos os cursores, então recebe no máximo os 7 dias mais recentes; e quando chama a rota de leitura de um devocional de 30 dias atrás, recebe 403. Como verificar: com sessão de assinante gratuito e cliente HTTP, sem usar a interface. AC-037 verifica a tela; este verifica a regra.
AC-184 · [BLOQUEADOR] — Idempotência do disparo. Dado um lote de 100 itens, quando o processo do motor é encerrado abruptamente após 50 envios confirmados e reiniciado, então cada assinante recebe exatamente uma mensagem de cada tipo naquele dia; e quando o mesmo job é reentregue pela fila, nenhuma mensagem adicional é enviada. Como verificar: contagem no provedor de gravação e SELECT count(*) ... GROUP BY subscriber_id, date, kind HAVING count(*) > 1 devolvendo zero linhas. AC-069 prova a idempotência do planejamento; esta prova a do disparo.
AC-185 · [BLOQUEADOR] — Opt-out durante o disparo. Dado um lote em execução, quando um assinante ainda não processado envia uma palavra de saída, então ele não recebe nenhuma mensagem daquele lote e opt_out_at fica preenchido em menos de 60 segundos a partir do webhook. Como verificar: injetar o webhook de entrada durante o disparo e conferir o provedor de gravação.
AC-186 · [BLOQUEADOR] — Revogação sem carência alcança o lote. Dado um assinante pago planejado às 05:40, quando o evento de inadimplência é processado às 05:52, então ele recebe às 06:00 apenas o conteúdo do plano gratuito e nenhum áudio, com o rebaixamento registrado na tentativa de entrega. O nível de acesso é relido no instante do disparo, nunca aproveitado do planejamento.
AC-187 · [BLOQUEADOR] — Pedido de código não é oráculo de enumeração. Dado um número cadastrado e um não cadastrado, quando cada um recebe quatro pedidos de código em sequência, então as quatro respostas são idênticas em código HTTP, corpo e error.code para os dois números. Número bloqueado também responde como o caminho normal, sem erro próprio e sem enviar mensagem.
AC-188 · [BLOQUEADOR] — Cookies de sessão são aceitos pelo navegador. Dado qualquer resposta que emita cookie, quando os cabeçalhos Set-Cookie são inspecionados, então todo cookie cujo nome comece com __Host- tem Secure, tem Path=/ e não tem Domain; e o cookie de proteção contra requisição forjada tem um único nome em todo o sistema. Como verificar: teste de integração sobre os cabeçalhos emitidos, mais um cenário de navegador real que confirma que a sessão sobrevive à expiração do token de acesso. Um cookie __Host- com outro Path é descartado silenciosamente pelo navegador, e o sintoma é o assinante ser deslogado a cada 15 minutos.
AC-189 · [BLOQUEADOR] — Impersonação é somente leitura, sem exceção. Dado um administrador em sessão de impersonação, quando ele tenta qualquer método não seguro em qualquer rota executável sob sessão de assinante — inclusive cancelamento de assinatura, reativação, troca de plano, troca de cartão, reenvio, pedido de eliminação e opt-out —, então a requisição é recusada antes de chegar ao handler. Como verificar: um teste de arquitetura enumera todo POST, PATCH, PUT e DELETE cuja guarda seja a de assinante — o critério é a guarda, não o prefixo da rota — e falha o build se algum não estiver protegido.
AC-190 · [BLOQUEADOR] — A eliminação elimina em todas as tabelas. Dado um titular que exerceu o direito de eliminação, quando o banco é inspecionado após a execução, então nenhuma tabela retém telefone, wa_id, CPF, e-mail ou trecho de conteúdo de mensagem do titular em texto claro — inclusive os registros de mensagem e as mensagens recebidas. O que a lei obriga a reter é retido de forma pseudonimizada, ligado apenas ao identificador interno. Como verificar: gerar tráfego em todas as tabelas, executar a anonimização e falhar se uma busca pelo telefone, pelo wa_id ou pelo e-mail original devolver qualquer linha em qualquer tabela.
AC-191 · [BLOQUEADOR] — A política de conteúdo não bloqueia o próprio produto. Dado a política de segurança de conteúdo em modo de imposição, quando um visitante abre cada rota com formulário e um assinante pago abre a tela do dia com o player, então o console do navegador não registra nenhuma violação, o verificador anti-bot renderiza e o áudio toca. Como verificar: cenário de navegador que coleta as violações do console em cada rota e falha se houver qualquer uma.
AC-192 · [BLOQUEADOR] — Corpo de webhook inválido não derruba o canal de eventos. Dado um webhook autenticado cujo corpo não corresponde ao schema esperado, quando ele é recebido, então a resposta é 200 com corpo vazio, o ocorrido é persistido com o motivo da rejeição, o corpo cru é guardado para reprocessamento e o alerta correspondente dispara acima do limiar. Resposta 4xx ou 5xx leva o provedor a desativar a assinatura do webhook, e perder o canal de eventos é pior do que engolir um corpo malformado. A única resposta não-2xx permitida é 413 para corpo acima do limite, medido antes da leitura.
29.23 Matriz de rastreabilidade #
Funcionalidade do produto, seção que a especifica em detalhe e critérios que a cobrem.
| Funcionalidade | Seção dona | Critérios |
|---|---|---|
| Decisões configuráveis e padrões | 1 | AC-033, AC-034, AC-039, AC-040, AC-068, AC-070 |
| Escopo e fora de escopo | 2 | AC-093, AC-103 |
| Papéis e permissões | 3 | AC-029, AC-030, AC-031, AC-032, AC-144, AC-189 |
| Arquitetura e stack | 4 | AC-170, AC-171, AC-172 |
| Convenções de código | 5 | AC-041, AC-162, AC-166, AC-188 |
| Modelo de dados | 6 | AC-003, AC-011, AC-069, AC-106, AC-149, AC-153, AC-164, AC-190 |
| Contratos e erros de API | 7 | AC-016, AC-018, AC-024, AC-135, AC-166, AC-169, AC-192 |
| Autenticação e sessões | 8 | AC-014 a AC-032, AC-187, AC-188, AC-189 |
| Landing e página de vendas | 9 | AC-165, AC-168, AC-191 |
| Copy aprovado | 10 | AC-002, AC-004, AC-013, AC-098, AC-123, AC-137 |
| Cadastro, opt-in e onboarding | 11 | AC-001 a AC-013 |
| Integração de pagamentos | 12 | AC-094 a AC-114, AC-179 |
| Ciclo de vida e entitlements | 13 | AC-033 a AC-042, AC-107, AC-108, AC-115 a AC-120, AC-140, AC-183, AC-186 |
| Painel do assinante | 14 | AC-130 a AC-137, AC-146, AC-147, AC-151, AC-183 |
| Painel administrativo | 15 | AC-043 a AC-055, AC-078 a AC-080, AC-138 a AC-145 |
| Geração de áudio e mídia | 16 | AC-056 a AC-067, AC-091, AC-180 |
| Integração do canal de mensagens | 17 | AC-058, AC-065, AC-066, AC-070, AC-071, AC-077, AC-090, AC-142, AC-161, AC-175, AC-178 |
| Motor de envio diário | 18 | AC-068 a AC-089, AC-173, AC-174, AC-184, AC-185, AC-186 |
| Catálogo de mensagens e conversas | 19 | AC-082 a AC-087, AC-121 a AC-129, AC-185 |
| Cancelamento, opt-out e retenção | 20 | AC-115 a AC-129, AC-150 |
| Métricas e dashboard | 21 | AC-154 a AC-159 |
| Segurança, privacidade e LGPD | 22 | AC-146 a AC-153, AC-160 a AC-168, AC-187 a AC-191 |
| Observabilidade e alertas | 23 | AC-169 a AC-177 |
| Testes e QA | 24 | cobertura de execução de todos os critérios |
| Infraestrutura e deploy | 25 | AC-168, AC-171, AC-181 |
| Configuração e variáveis | 26 | AC-163 |
| Erros, resiliência e runbooks | 27 | AC-075, AC-076, AC-114, AC-178 a AC-182, AC-192 |
Contagem. 192 critérios numerados, de AC-001 a AC-192, sem lacuna na sequência,
dos quais 78 são bloqueadores de lançamento. Todo bloqueador precisa de evidência
automatizada ou de registro de verificação manual antes do go-live da Seção 28.9.
Como conferir a contagem, sem ambiguidade e sem contar a frase explicativa da Seção 29.1:
# Aponte <especificacao> para o arquivo desta especificação.
# Total de critérios numerados: precisa devolver 192
grep -oE '^\*\*AC-[0-9]{3}\*\*' <especificacao> | sort -u | wc -l
# Bloqueadores: precisa devolver 78
grep -cE '^\*\*AC-[0-9]{3}\*\*.*\[BLOQUEADOR\]' <especificacao>O segundo comando ancora a busca no início da linha do critério. Um grep que procure apenas
a palavra entre colchetes conta também a linha da Seção 29.1 que descreve a marcação, e
devolve um a mais — foi assim que a contagem anterior divergiu. Se qualquer um dos dois
números não bater, a diferença é defeito de documento e é corrigida antes do go-live.
30. Instruções para o Agente Executor #
Esta seção é dirigida diretamente ao agente de inteligência artificial que vai construir o sistema inteiro, sozinho, do repositório vazio até produção, tendo como entrada apenas este documento. Você não pode fazer perguntas. Não há ninguém para responder. Tudo que você precisa decidir está aqui ou é decidível por você seguindo o procedimento da Seção 30.4.
Leia esta seção antes de escrever a primeira linha de código, e releia a Seção 30.3 no início de cada milestone.
30.1 Como ler este documento e em que ordem #
Este documento não é para ser lido linearmente do início ao fim antes de começar. Leia em três passadas.
Passada 1 — orientação (leitura completa, rápida). Leia as Seções 1, 2, 3, 4 e 5, inteiras e com atenção. Elas definem o que o produto é, para quem, com que tecnologia e com que convenções. Depois, percorra o restante lendo apenas os títulos de subseção e as tabelas. O objetivo desta passada é saber onde cada assunto mora, não memorizar detalhe.
Passada 2 — fundação (leitura profunda, antes de M0 e M1). Leia integralmente as Seções 4, 5, 6, 7 e 26. Estas cinco seções são a espinha do sistema: arquitetura, convenções, schema, contrato de API e configuração. Nada do que você construir depois pode contrariá-las. Se você só puder decorar cinco seções, sejam estas.
Passada 3 — sob demanda (no início de cada milestone). Antes de iniciar um milestone da Seção 28, leia integralmente as seções que o milestone referencia, e apenas elas. Ao terminar, leia os critérios de aceitação correspondentes na Seção 29 e confirme que cada um está atendido antes de considerar o milestone concluído.
Regras de leitura que valem sempre:
- Onde este documento diz "conforme a Seção N", vá ler a Seção N. Não improvise a partir do resumo.
- Quando duas seções parecerem discordar, a seção dona do assunto vence. A tabela da Seção 30.2 diz quem é dona do quê.
- Nenhuma seção redefine o que outra já definiu. Se você encontrar o que parece ser uma redefinição, trate como referência e siga a dona.
30.1.1 Precedência, em ordem, quando duas passagens discordarem #
"A seção dona vence" resolve a maior parte dos casos, mas não todos. Quando não resolver, aplique esta escada, na ordem, sem pular degrau:
- A Seção 30 vence sobre qualquer outra. As regras invioláveis de 30.3 são o teto.
- A seção dona do assunto vence sobre qualquer seção que apenas o mencione. As donas estão na tabela de 30.2. As mais disputadas, explicitamente: Seção 6 para schema; Seção 7 para contrato de API e catálogo de erros; Seção 8 para autenticação e sessão; Seção 13 para planos e entitlements; Seção 18 para filas e motor de envio; Seção 22 para segurança, criptografia e LGPD; Seção 26 para nome e formato de variável de ambiente e de chave de configuração; Seção 29 para critério de aceitação.
- Entre duas passagens da mesma seção, vence a que estiver em bloco de código normativo sobre a que estiver em prosa.
- Persistindo o conflito, vence a alternativa mais restritiva em segurança e
privacidade, e você registra a decisão em
DECISIONS.mdcitando as duas passagens em conflito e a escolha feita.
Nunca invente um terceiro valor e nunca deixe o item pela metade.
Um caso específico que já é decidido, para você não gastar tempo com ele: um critério de
aceitação que só possa ser verificado depois do lançamento — porque depende de tráfego
real, de aprovação de terceiro ou de um ciclo de cobrança completo — é marcado como
pós-lançamento e não bloqueia o marco anterior. A lista desses critérios é fixa e está
na Seção 29. Exigir que um marco de homologação prove algo que só existe em produção é um
impasse artificial, e impasse trava execução.
30.2 Quem é dono de quê #
Cada assunto tem exatamente uma seção dona. A dona é a fonte da verdade. As demais apenas referenciam.
| Assunto | Seção dona | O que a dona decide |
|---|---|---|
| Padrões configuráveis pelo dono do produto | 1 | Valor padrão de cada decisão aberta e onde ela impacta. |
| Escopo do MVP e lista de fora de escopo | 2 | O que se constrói e, sobretudo, o que não se constrói. |
| Papéis, permissões e matriz de acesso | 3 | Enum de papéis, o que cada papel pode e não pode fazer. |
| Versões de dependência e linhas de versão | 4 | Toda versão citada no projeto. Nenhuma outra seção declara versão. |
| Arquitetura, monorepo e limites de escala | 4 | Estrutura de diretórios, responsabilidade de cada app e pacote. |
| Convenções de nome, camadas, log, erro em código | 5 | Como o código é escrito e organizado. |
| Tabelas, colunas, índices, chaves e migrations | 6 | O schema inteiro. Lista fechada de tabelas. |
| Envelope de resposta, catálogo de erros, paginação | 7 | Formato de toda resposta HTTP e todo código de erro. |
| Rate limiting, idempotência de rota, cabeçalhos | 7 | Limites, chaves de idempotência e cabeçalhos de segurança de API. |
| Inventário de rotas | 7 | Lista mestra de todas as rotas e o papel exigido em cada uma. |
| Login, sessão, cookie, segundo fator, autorização | 8 | Todo mecanismo de identidade e de acesso. |
| Estrutura e comportamento das páginas públicas | 9 | Blocos, rotas públicas, SEO, performance, acessibilidade. |
| Texto de marketing e de interface | 10 | Todo texto voltado ao público e todo microcopy. |
| Fluxo de cadastro, opt-in e estados do assinante | 11 | Máquina de estados do assinante e casos de borda do cadastro. |
| Provedor de pagamento, checkout e webhooks de cobrança | 12 | Toda chamada ao provedor e todo evento tratado. |
| Estados da assinatura, inadimplência e entitlements | 13 | Máquina de estados da assinatura e a matriz de capacidades. |
| Telas e rotas do painel do assinante | 14 | Comportamento de cada tela do assinante. |
| Telas do painel administrativo e fluxo editorial | 15 | Editor, calendário, gestão de assinantes, auditoria. |
| Síntese de voz, transcodificação, storage e mídia | 16 | Pipeline de áudio de ponta a ponta. |
| Regras da plataforma de mensagens e templates | 17 | Janela de atendimento, categorias, templates, códigos de erro. |
| Agendamento, lote, filas e idempotência de envio | 18 | Motor de envio diário e todas as filas. |
| Texto de cada mensagem e palavras-chave de entrada | 19 | Catálogo de mensagens e fluxos conversacionais. |
| Opt-out, pausa, cancelamento e retenção | 20 | Os dois caminhos de saída e o que cada um faz. |
| Definição e fórmula de cada métrica | 21 | Toda métrica de produto e a agregação diária. |
| Segurança, privacidade, LGPD e retenção legal | 22 | Controles, bases legais, direitos do titular, anonimização. |
| Log, métrica técnica, tracing, health check, alerta | 23 | Toda instrumentação e todo alerta. |
| Testes, simulador local e definição de pronto de tarefa | 24 | Estratégia de teste e critérios de aceite de mudança. |
| Servidor, contêineres, deploy, backup, continuidade | 25 | Infraestrutura e entrega. |
| Variáveis de ambiente e configuração de runtime | 26 | Toda variável e todo parâmetro configurável. |
| Taxonomia de erro, retry, disjuntor e runbooks | 27 | Resiliência e procedimento operacional. |
| Sequência de construção e milestones | 28 | Ordem do trabalho e critérios de saída de cada etapa. |
| Critérios de aceitação e rastreabilidade | 29 | O que significa estar pronto, do lado de fora. |
| Procedimento de execução do agente | 30 | Como você trabalha. |
| Glossário e referências externas | 31 | Definição de termo e onde consultar documentação oficial. |
30.3 Regras invioláveis #
Estas regras não admitem exceção, interpretação criativa nem "só desta vez". Violar qualquer uma delas invalida o trabalho do milestone.
- Não invente tabela. O conjunto de 28 tabelas é fechado na Seção 6.1.7. Se você precisar persistir algo que não cabe em nenhuma delas, use a tabela de configuração chave-valor prevista naquela seção e registre a decisão conforme a Seção 30.4. Nunca crie uma tabela nova.
- Não invente coluna sem migration aditiva registrada. Coluna nova exige migration própria, justificativa em
DECISIONS.mde atualização do schema declarativo. Nunca altere uma migration já aplicada. - Não redefina o envelope de API. Sucesso, erro, paginação, formato de data, formato de dinheiro e identificador de requisição são definidos na Seção 7 e valem para toda rota, sem variação por conveniência.
- Não crie código de erro fora do catálogo. Se um caso novo aparecer, use o código genérico apropriado do catálogo da Seção 7. Se for realmente novo, adicione ao catálogo e registre a decisão — nunca invente um código solto no meio de um arquivo.
- Não pine versões exatas. Instale a release estável corrente de cada dependência, confirme que a linha major corresponde ao declarado na Seção 4 e deixe o arquivo de lock registrar as versões resolvidas. Não escreva versão exata em prosa, em comentário nem em documentação.
- Não deixe pendência aberta. Nada de
TODO,FIXME,TBD, "a definir", função vazia, rota que retorna dado falso ou tela com texto de espaço reservado. Se você não sabe, decida pelo procedimento da Seção 30.4 e implemente a decisão. - Não crie segredo em código. Nenhuma chave, token, senha ou credencial no repositório, em comentário, em teste, em arquivo de exemplo com valor real ou em imagem de contêiner. Segredos vêm exclusivamente do ambiente, validados no boot conforme a Seção 26.
- Não envie mensagem real em ambiente que não seja produção. O provedor real do canal de mensagens só pode ser instanciado quando o ambiente é de produção. Em qualquer outro ambiente, o simulador local é obrigatório. Faça o boot falhar se credenciais de produção forem detectadas fora de produção.
- Não altere a regra de revogação imediata. Falha de pagamento rebaixa o assinante ao tier gratuito na mesma transação do processamento do evento. Não existe carência, tolerância, período de graça, retentativa silenciosa que mantenha o acesso, nem "só até o fim do dia". A única situação em que o acesso pago sobrevive é o cancelamento voluntário dentro do período já pago, que é coisa diferente e está na Seção 13.
- Não recalcule entitlement localmente. Toda decisão de capacidade passa pela função única definida na Seção 13. Nenhum componente, rota, consulta ou trabalho de fila compara o campo de tier diretamente.
- Não faça upload de mídia por assinante. O upload para a plataforma de mensagens acontece uma vez por devocional. Reutilize o identificador de mídia.
- Não registre dado pessoal sensível em log. Telefone, documento, código de acesso, token e dado de cartão são redigidos pelo logger conforme a Seção 23. Não contorne a redação nem em depuração temporária.
- Não use deslocamento fixo de fuso. Toda conversão de horário usa a biblioteca de fuso da Seção 4 com o fuso operacional nomeado. Nunca escreva um deslocamento numérico fixo.
- Não use identificador sequencial nem aleatório universal. Identificadores são gerados na aplicação no formato definido na Seção 6.
- Não implemente item que a Seção 2 declara fora de escopo. Nem parcialmente, nem "só a estrutura para depois", nem escondido atrás de uma flag desligada.
- Não confunda opt-out com cancelamento. São dois caminhos distintos com efeitos distintos, definidos na Seção 20. Um nunca dispara o outro implicitamente.
- Não publique devocional sem áudio quando houver assinantes pagos ativos. A guarda está na Seção 15 e é obrigatória.
- Não envie conteúdo incompleto. Se o devocional do dia não estiver pronto e não houver conteúdo de reserva, não envie nada e alerte. Melhor um dia sem mensagem do que uma mensagem quebrada.
30.4 Como resolver ambiguidade #
Você vai encontrar situações não cobertas explicitamente. Isso é esperado. Nunca pare, nunca pergunte, nunca deixe pendência. Siga estes três passos, nesta ordem.
Passo 1 — procure a resposta no documento. Consulte a tabela de donos da Seção 30.2 e leia a seção dona do assunto por inteiro. A maior parte das ambiguidades aparentes é uma seção lida pela metade. Verifique também a Seção 1, que traz padrões explícitos para decisões abertas, e a Seção 2, que pode declarar o assunto fora de escopo — nesse caso a resposta é não fazer.
Passo 2 — derive da decisão mais próxima. Se a resposta não estiver escrita, encontre a decisão análoga mais próxima e estenda a mesma lógica. Ordem de preferência para a decisão derivada:
- A opção que preserva a regra de revogação imediata e a integridade do entitlement.
- A opção que não envia mensagem quando há dúvida sobre elegibilidade ou sobre conteúdo — o erro de omissão é recuperável, o de envio indevido não é.
- A opção mais simples de reverter, incluindo migration aditiva em vez de destrutiva.
- A opção que respeita o limite mais restritivo da plataforma externa envolvida.
- A opção que mantém a consistência com o padrão já usado em outro ponto do sistema.
- A opção mais barata de operar, quando as anteriores empatam.
Passo 3 — registre e siga em frente. Grave a decisão em DECISIONS.md, na raiz do repositório, e continue trabalhando no mesmo instante. O registro é obrigatório e o formato é fixo:
### D-007 — Comportamento quando o assinante troca de número no meio de um lote
- **Data:** 2026-09-14
- **Milestone:** M6
- **Contexto:** O documento especifica a troca de número na Seção 14 e o planejamento
do lote na Seção 18, mas não cruza os dois casos.
- **Alternativas consideradas:**
1. Enviar para o número antigo, já registrado na tentativa de entrega.
2. Enviar para o número novo, relendo o assinante no momento do disparo.
3. Pular o destinatário no dia da troca.
- **Decisão:** Alternativa 2. O disparo relê o assinante e usa o número corrente.
- **Justificativa:** A Seção 30.4 prioriza não enviar para destino incorreto; o número
antigo pode já pertencer a outra pessoa, o que seria envio indevido a terceiro.
- **Impacto:** `apps/worker/src/jobs/send-dispatch.ts`; nenhuma mudança de schema.
- **Critério afetado:** nenhum critério existente é invalidado.Regras do registro:
- Numeração sequencial,
D-001em diante, nunca reutilizada. - Uma decisão por entrada, com as alternativas realmente consideradas.
- Se uma decisão posterior substituir uma anterior, não apague a antiga: adicione a nova e marque a anterior como substituída, indicando o número que a substitui.
- Se a decisão exigir mudança de comportamento em uma seção deste documento, diga isso explicitamente no campo de impacto. O documento não é editado por você; a divergência fica registrada.
- Nunca registre em
DECISIONS.mdalgo que já está decidido no documento. Isso não é ambiguidade, é leitura incompleta.
30.5 Ordem de trabalho dentro de um milestone #
Sempre a mesma sequência, em fatias verticais finas. Nunca construa uma camada inteira sem consumidor.
- Schema. Se o milestone exige mudança de dados, ela vem primeiro: migration aditiva, schema declarativo atualizado, verificação de ausência de deriva.
- Tipos e schemas de validação. Defina os tipos do domínio e os schemas de validação compartilhados entre a aplicação web e o worker, no pacote de domínio. Eles são o contrato de tudo que vem depois.
- Serviços de domínio. Implemente a regra de negócio pura, sem HTTP, sem fila, sem interface. Testável com chamada direta de função.
- Integrações. Se o milestone toca serviço externo, implemente o cliente tipado no pacote de integrações, com timeout, tentativas e disjuntor, e o duplo correspondente no simulador local.
- Rotas e trabalhos de fila. Exponha o serviço pelo transporte adequado, com validação na borda, envelope de resposta e todos os códigos de erro previstos.
- Interface. Só depois de a rota existir e responder corretamente. Construa os estados de carregamento, vazio e erro junto com o estado feliz, não depois.
- Testes. Unitários para a regra pura, de integração para a rota e o trabalho de fila, ponta a ponta para o fluxo do usuário. Escreva o teste do caso de erro antes de considerar o caso feliz pronto.
- Verificação. Rode a bateria completa da Seção 30.7 e confirme os critérios da Seção 29 que o milestone cobre.
Dentro de cada passo, prefira terminar um caminho completo a começar três. Um fluxo inteiro funcionando vale mais que três fluxos pela metade.
30.6 Padrão de commit e de pull request #
Branches. Trabalhe em branch nomeada pelo milestone e pelo assunto, no formato m6/send-engine-dispatch. Nunca faça commit direto no branch padrão.
Commits. Siga a convenção de commits definida na Seção 5. Um commit por unidade lógica coerente. Nunca misture migration com refatoração, nem correção com funcionalidade nova.
feat(worker): planeja lote diário com idempotência por assinante e data
Implementa o trabalho de planejamento descrito na Seção 18: monta a coorte,
aplica as exclusões de opt-out, pausa e exclusão, e grava as tentativas de
entrega com a chave única do dia.
Cobre AC-068 e AC-069.Regras:
- Assunto no imperativo, em português, com no máximo 72 caracteres.
- Corpo explicando o porquê, não o quê. O diff já mostra o quê.
- Rodapé citando os critérios da Seção 29 cobertos pelo commit, quando houver.
- Nunca cite número de seção errado. Se você não sabe qual critério o commit cobre, provavelmente o commit não deveria existir ainda.
Pull request. Um por milestone, ou um por fatia vertical coerente quando o milestone for grande. O corpo do pull request tem sempre estas partes:
**Milestone**
M6 — Integração do canal de mensagens e motor de envio (parcial: planejamento e disparo)
**O que muda**
Descrição objetiva do comportamento novo, do ponto de vista de quem usa o sistema.
**Seções do documento implementadas**
Seção 17.4, Seção 18.2, Seção 18.5.
**Critérios de aceitação cobertos**
AC-068, AC-069, AC-070, AC-071, AC-081.
**Como verificar**
Comandos exatos que um revisor pode rodar, com a saída esperada.
**Decisões registradas**
D-007, D-008.
**Risco e reversão**
O que pode quebrar e como reverter.O pull request só entra quando todos os comandos da Seção 30.7 passam e todos os critérios listados estão verdes.
30.7 Como verificar o próprio trabalho #
Antes de considerar qualquer tarefa pronta, rode esta bateria por inteiro e exija saída limpa. Não existe "passa quase tudo".
# 1. Integridade do workspace
pnpm install --frozen-lockfile
# 2. Tipos — zero erro, em todos os pacotes
pnpm -r typecheck
# 3. Estilo e regras de código — zero erro, zero aviso
pnpm -r lint
# 4. Formatação — nada fora do padrão
pnpm exec prettier --check .
# 5. Sem pendência textual proibida no código versionado
! grep -rInE '\b(TODO|FIXME|XXX|TBD|a definir)\b' apps packages --include='*.ts' --include='*.tsx'
# 6. Sem deriva entre schema declarativo e migrations
pnpm --filter @app/db exec prisma migrate diff \
--from-migrations ./prisma/migrations \
--to-schema-datamodel ./prisma/schema.prisma \
--exit-code
# 7. Testes unitários e de integração
pnpm -r test
# 8. Build de produção de todos os aplicativos
pnpm -r build
# 9. Varredura de dependências — nada alto ou crítico
pnpm audit --audit-level=high
# 10. Nenhum segredo no repositório (mesma ferramenta do pipeline, Seção 24.3)
gitleaks detect --no-banner --redact
# 11. Ponta a ponta (a partir de M8; obrigatório em M12 e M13)
pnpm test:e2eRegras de verificação:
- Se um comando falhar, corrija a causa. Nunca silencie a regra, nunca marque o teste como ignorado, nunca acrescente exceção de lint para fazer o comando passar.
- Se um teste for legitimamente inaplicável, remova-o com justificativa registrada em
DECISIONS.md. Não o deixe desativado. - Rode a bateria em ambiente limpo antes de abrir o pull request: contêineres recriados, banco recriado a partir das migrations, seeds reaplicados.
- Depois da bateria, confira manualmente os critérios da Seção 29 que o milestone cobre. Um critério que nenhum teste exercita é um critério não atendido.
30.8 Quando um serviço externo não estiver disponível #
Nenhum milestone pode ficar bloqueado por indisponibilidade de terceiro. O simulador local descrito na Seção 24 existe exatamente para isso e é a única forma permitida de desenvolver contra serviços externos fora de produção.
Regra geral. Todo cliente de serviço externo mora no pacote de integrações atrás de uma interface. A escolha entre implementação real e simulada é feita no ponto de composição, a partir do ambiente, e nunca dentro da regra de negócio. Nenhum serviço de domínio sabe se está falando com o mundo real.
Plataforma de mensagens. Use o simulador enquanto os templates não estiverem aprovados e sempre que o ambiente não for produção. O simulador precisa reproduzir: aceite de envio com identificador de mensagem, sequência de estados de mensagem, webhook de entrada com assinatura válida, abertura de janela de atendimento, e os códigos de erro nomeados na Seção 17, incluindo o código de limitação de entrega esperado no Brasil, o de fora da janela e o de limite de taxa. Sem cobrir esses cenários, o simulador não serve.
Provedor de pagamento. Use o ambiente de teste oficial do provedor quando ele estiver disponível, porque ele reproduz o contrato real melhor que qualquer simulação. Quando ele não estiver disponível, use o simulador local, que precisa emitir os doze eventos obrigatórios da Seção 12, permitir emissão fora de ordem, permitir entrega duplicada do mesmo evento e permitir simular vencimento e estorno.
Síntese de voz. O simulador devolve um arquivo de áudio pré-gravado curto, com duração e formato válidos, para que a transcodificação e as verificações de qualidade sejam exercitadas de verdade. Nunca devolva um arquivo vazio: isso mascara a verificação de áudio silencioso.
Armazenamento de mídia. Use um serviço compatível local em contêiner. O código nunca deve depender de comportamento específico de um fornecedor.
E-mail transacional. Em ambiente que não seja produção, as mensagens são gravadas em arquivo e expostas em uma tela de inspeção local. Nada sai para a internet.
Manutenção do simulador. O simulador fica defasado se ninguém cuidar. A Seção 24 define o mecanismo: os testes de contrato validam as respostas simuladas contra as respostas reais gravadas, e o pipeline falha quando a gravação real e a simulada divergem em forma. Quando você tocar em um cliente de integração, atualize o simulador no mesmo commit.
O que nunca fazer. Nunca aponte o ambiente local para credenciais de produção. Nunca envie mensagem real para número que não seja de teste interno. Nunca crie cobrança real para validar comportamento. Nunca desligue a validação de assinatura de webhook "porque o simulador não assina" — o simulador assina.
30.9 Contas e credenciais que dependem de um humano #
Você não consegue criar estas contas sozinho: elas exigem identidade jurídica, documento, cartão ou aceite de contrato. Peça-as no formato de bloqueio registrado, siga trabalhando no que não depende delas e retome quando chegarem. A coluna "quando é necessária" indica o milestone que fica bloqueado sem ela.
| # | Conta ou credencial | Quem cria | Quando é necessária | O que fica bloqueado sem ela | Alternativa enquanto não chega |
|---|---|---|---|---|---|
| 1 | Domínio registrado e DNS | Dono do produto | Antes de M13, e antes de X1 | Verificação de negócio e TLS de produção | Trabalhar em domínio local |
| 2 | Conta de gerenciamento de negócios na plataforma de mensagens | Dono do produto | Início do projeto (X0) | Toda a trilha externa | Simulador local |
| 3 | Verificação de negócio aprovada | Dono do produto, com documentos da empresa | Início do projeto (X1) | Envio real e crescimento de tier | Simulador local |
| 4 | Conta de mensagens e número dedicado | Dono do produto | X2, antes de M13 | Envio real | Número de teste da plataforma |
| 5 | Token de acesso de longa duração da plataforma de mensagens | Dono do produto | Antes de M13 | Envio real em produção | Simulador local |
| 6 | Segredo de aplicativo para validação de assinatura de webhook | Dono do produto | Junto com o item 5 | Recebimento real de mensagens | Simulador local assinado |
| 7 | Templates aprovados | Submetidos pelo sistema, aprovados pela plataforma (X4) | Antes de M13 | Envio real do convite e do código de acesso | Simulador local |
| 8 | Conta de teste do provedor de pagamento | Dono do produto | Antes de M7 (Y0) | Testes de checkout com contrato real | Simulador local de pagamento |
| 9 | Conta de produção do provedor de pagamento e chave | Dono do produto, com dados bancários | Antes de M13 (Y1) | Cobrança real | Lançar com checkout desativado por flag |
| 10 | Token do webhook do provedor de pagamento | Dono do produto | Junto com o item 9 | Processamento real de eventos de cobrança | Simulador local |
| 11 | Conta do provedor primário de síntese de voz | Dono do produto | Antes de M5 | Áudio com a voz definitiva | Simulador com áudio de exemplo |
| 12 | Conta do provedor de fallback de síntese de voz | Dono do produto | Antes de M13 | Resiliência do pipeline de áudio | Fallback simulado |
| 13 | Armazenamento compatível com o padrão de objetos e credenciais | Dono do produto | Antes de M5 em produção | Persistência de mídia em produção | Serviço compatível local |
| 14 | Conta de e-mail transacional e domínio verificado para envio | Dono do produto | Antes de M8 | Link mágico e e-mails transacionais reais | Captura local de e-mail |
| 15 | Servidor de produção com acesso administrativo | Dono do produto | Antes de M13 | Todo o M13 | Ambiente local em contêineres |
| 16 | Destino externo de backup e suas credenciais | Dono do produto | Antes de M13 | Backup fora do servidor | Backup local temporário |
| 17 | Canal de alertas operacionais | Dono do produto | Antes de M10 | Entrega real de alerta | Alerta gravado em log |
| 18 | Razão social, CNPJ, endereço e nome do encarregado de dados | Dono do produto | Antes de M9 | Páginas legais e verificação de negócio | Marcadores explícitos, substituídos antes do go-live |
Quando um item da tabela estiver faltando no momento em que você precisa dele: registre o bloqueio em DECISIONS.md como decisão de contorno, ative o caminho alternativo da última coluna, siga para o próximo trabalho não bloqueado da Seção 28.6 e deixe o ponto de troca isolado em um único lugar do código, para que a substituição seja trivial.
30.10 Definição de pronto #
Uma tarefa está pronta quando: o comportamento especificado existe; os casos de erro estão tratados com os códigos do catálogo; existe teste automatizado cobrindo o caso feliz e ao menos um caso de erro; a bateria da Seção 30.7 passa inteira; e nenhuma pendência textual proibida foi introduzida.
Um milestone está pronto quando: todas as suas tarefas estão prontas; todos os artefatos listados existem no repositório; todos os critérios de saída da Seção 28.4 correspondente foram executados e passaram; todos os critérios da Seção 29 mapeados ao milestone estão verdes; e as decisões tomadas no período estão registradas em DECISIONS.md.
O projeto inteiro está pronto quando, e somente quando, todos os itens abaixo forem verdadeiros:
- Os catorze milestones da Seção 28 estão concluídos pelos seus próprios critérios de saída.
- Os 192 critérios da Seção 29 têm resultado registrado, e os 78 marcados como bloqueadores estão verdes. As duas contagens são conferidas pelos comandos da Seção 29.23.
- O checklist de go-live da Seção 28.9 está integralmente cumprido, incluindo os itens que dependem de terceiros.
- O sistema está em produção, com todos os contêineres saudáveis por mais de 24 horas.
- Um lote diário real foi disparado, concluído dentro da janela alvo, com taxa de entrega dentro da faixa saudável da Seção 28.10.
- Um assinante real completou o percurso inteiro: cadastro, opt-in, recebimento gratuito, compra, recebimento pago com áudio, cancelamento e rebaixamento.
- Um pagamento real falhou, de propósito ou naturalmente, e o acesso foi revogado imediatamente, sem carência, comprovado no banco e no envio seguinte.
- Um backup foi restaurado com sucesso e o tempo de recuperação foi registrado.
- Um rollback foi executado com sucesso em ambiente de produção ou em réplica fiel dele.
- Todos os alertas da Seção 23 dispararam ao menos uma vez em teste, no canal correto.
- O direito de acesso e o de eliminação foram exercidos de ponta a ponta por um titular de teste, com evidência.
- Nenhum segredo consta do repositório, das imagens ou dos logs.
DECISIONS.mdestá completo e coerente com o código.- A documentação de operação existe, com os runbooks da Seção 27 acessíveis ao operador.
Faltando qualquer um dos catorze, o projeto não está pronto. Não negocie com esta lista.
30.11 Erros comuns a evitar #
Estes erros são específicos deste produto e todos já custaram caro em sistemas parecidos. Leia a lista antes de M6 e de novo antes de M7.
- Tentar enviar áudio como primeira mensagem do dia. O cabeçalho de template não aceita áudio. Todo desenho que dependa disso está errado. O caminho correto é o desenho em duas etapas da Seção 17.
- Colocar o devocional inteiro dentro de um parâmetro de template. Parâmetro não aceita quebra de linha, tabulação nem quatro espaços seguidos, e o corpo tem limite de caracteres bem menor que o texto completo. Use o teaser sanitizado.
- Esquecer de sanitizar o teaser vindo de conteúdo editado por humano. Um editor cola texto de um processador de texto e traz caracteres invisíveis. A sanitização precisa ser aplicada na escrita e verificada de novo antes do envio.
- Fazer upload do áudio uma vez por assinante. Isso multiplica custo e tempo por milhares. O upload é uma vez por devocional.
- Ignorar a validade do identificador de mídia. Ele expira. Sem verificação e reupload automático, os envios começam a falhar em massa depois de algumas semanas, e o sintoma aparece longe da causa.
- Verificar entitlement no planejamento e não no disparo. Um assinante rebaixado às 05:50 não pode receber áudio às 06:00. Relê o assinante por chave primária imediatamente antes de chamar o provedor, dentro do mesmo trabalho de fila, e aplica o resultado: excluído ou bloqueado não recebe nada, com opt-out não recebe nada, e rebaixado recebe só o texto. O campo de tier gravado no planejamento é registro histórico, nunca autoridade. Sem essa releitura o produto concede, na prática, um dia de carência a todo inadimplente — exatamente o que a regra 9 proíbe.
- Implementar carência por engano. Um retry silencioso de cobrança, um estado intermediário, ou um "aguarda o fim do dia" reintroduzem carência sem que ninguém perceba. A revogação é na mesma transação.
- Confundir cancelamento voluntário com inadimplência. Um mantém o acesso até o fim do período pago, o outro corta na hora. Implementar os dois pelo mesmo caminho quebra os dois.
- Deixar o handler de webhook fazer trabalho pesado. O provedor de pagamento pausa a fila se o endpoint demorar. Persista o evento bruto, enfileire e responda.
- Não deduplicar evento de webhook. Reentrega é normal, não exceção. Sem chave única por identificador de evento, um pagamento vira dois e um cancelamento vira dois.
- Assumir que o identificador do contato no canal de mensagens é igual ao número cadastrado. No Brasil, o nono dígito faz os dois divergirem. Sem a busca em cascata da Seção 11, mensagens de entrada não encontram o assinante e a janela nunca abre.
- Normalizar telefone com expressão regular caseira. Use a biblioteca prevista, com região padrão brasileira. Toda tentativa artesanal falha em algum caso de discagem.
- Usar deslocamento fixo de fuso no agendador. Use o fuso nomeado. Deslocamento fixo é uma bomba-relógio silenciosa.
- Planejar o lote pelo horário do servidor em vez do horário operacional. O servidor pode estar em outro fuso. A data do devocional é sempre a data local operacional.
- Não tornar o planejamento idempotente. Uma reexecução do agendador, um reinício de contêiner ou um disparo manual duplicam o envio. A chave única resolve; a ausência dela é irrecuperável do ponto de vista do assinante.
- Contar como falha o código de limitação de entrega esperado no Brasil. Isso polui a métrica de entrega, dispara alerta falso e faz o operador perseguir um problema que não existe.
- Repetir envio para erro permanente. Número inexistente não melhora com retry. Classifique o erro antes de decidir repetir.
- Enviar mensagem de saída mais de uma vez. Quem pediu para sair e recebe três confirmações marca o número como incômodo, e a qualidade do número cai.
- Responder automaticamente a mensagens automáticas. Isso cria laço infinito. Aplique o limite anti-laço da Seção 19.
- Deixar a landing prometer o que o produto não faz. Horário personalizado, período de teste, aplicativo, grupo, pedido de oração — nada disso existe. O texto aprovado da Seção 10 é a única fonte.
- Renderizar o comparativo de planos com valores fixos no componente. Ele deriva da matriz de entitlements. Fixar valores garante que a página e o sistema divirjam na primeira mudança.
- Expor URL pública e permanente de mídia. As URLs são assinadas e de curta duração. Uma URL permanente vaza conteúdo pago.
- Persistir ou registrar em log dado de cartão. Nem para depurar, nem por um minuto, nem mascarado pela metade.
- Coletar o documento fiscal sem tratá-lo como dado pessoal. Ele entra no inventário, na criptografia e na anonimização, conforme a Seção 22.
- Anonimizar apagando linha. Registros com retenção legal precisam sobreviver pseudonimizados. Apagar quebra a obrigação fiscal e a rastreabilidade de consentimento.
- Publicar devocional sem áudio existindo assinante pagante. O pagante recebe menos do que comprou e a falha só aparece às 06:00.
- Deixar o calendário editorial esvaziar. Sem o alerta de menos de sete dias agendados, o problema aparece na madrugada, quando ninguém está olhando.
- Tratar o conteúdo de reserva como opcional. Ele é o que separa um dia sem conteúdo de um incidente visível para milhares de pessoas.
- Rodar migration destrutiva em uma fase. Sempre duas fases, conforme a Seção 25.
- Deixar o backup sem restauração testada. Backup nunca restaurado é backup inexistente.
- Criar tabela nova por conveniência. A lista é fechada. Use a tabela de configuração chave-valor e registre a decisão.
- Escrever versão exata de dependência em prosa ou em comentário. Só a linha major, e só na seção dona.
- Deixar tela sem estado vazio e sem estado de erro. O assinante que abre o painel no primeiro dia vê exatamente o estado vazio.
- Traduzir identificador técnico. Tabela, coluna, rota, variável de ambiente, campo de JSON, valor de enum e mensagem de log ficam em inglês. Texto para pessoa fica em português.
- Adicionar funcionalidade não pedida. Se está na lista de fora de escopo, não existe. Se não está no documento, não é para construir agora.
- Emitir cookie com prefixo
__Host-e caminho estreito. O prefixo exigeSecure,Path=/e ausência deDomain. Qualquer outro caminho faz o navegador descartar o cookie sem erro, e o sintoma em produção é o usuário deslogando sozinho, com causa raiz invisível para quem lê o log do servidor. - Dar dois nomes à mesma coisa. Um segredo, uma fila, uma classe de erro, uma chave de configuração e um cookie têm exatamente um nome cada. Dois nomes para a mesma coisa é a falha mais barata de cometer e a mais cara de encontrar: o processo não sobe, a fila não consome, o
403aparece em toda escrita, e nada disso se parece com um erro de nome. - Criar fila de mensagens mortas. Não existe. Trabalho morto é job no estado
failedda própria fila mais linha emjob_runscom estado morto. Criar uma fila paralela produz dois inventários de falha que divergem no primeiro incidente. - Responder erro a um webhook malformado. Autenticado o remetente, qualquer falha posterior responde
2xxe é persistida. Resposta4xxou5xxleva o provedor a desativar a assinatura do webhook, e é por esse canal que a revogação de acesso pago acontece. - Confiar em um identificador de dispositivo derivado de cabeçalho. User-agent e idioma são controlados pelo cliente e têm entropia baixíssima. Servem como sinal informativo, nunca como fator de autenticação nem como decisão de autorização.
- Deixar um controle de integridade impedir um direito do titular. Tabela somente-inserção precisa de um caminho privilegiado, explícito e auditado, para a anonimização exigida por lei e para o expurgo por retenção. Um gatilho que bloqueia tudo faz a eliminação falhar em silêncio e o prazo legal vencer.
- Entregar conta, documento ou histórico a um número só porque ele respondeu. Números são reciclados pelas operadoras. Mudança do identificador de contato associado a um telefone invalida as sessões daquele assinante e exige nova verificação por código antes de liberar qualquer dado pessoal.
31. Glossário e Referências Externas #
31.1 Glossário #
Termos usados ao longo do documento, com a definição que vale aqui. Onde o termo tem significado específico neste produto, a definição prevalece sobre o uso geral do mercado.
31.1.1 Termos de negócio e de produto #
| Termo | Definição |
|---|---|
| Devocional | Peça de conteúdo cristão diária composta por título, referência bíblica, texto bíblico, reflexão e oração. É a unidade de entrega do produto. Um por dia de calendário. |
| Teaser | Resumo curto do devocional, com no máximo 300 caracteres, sem quebra de linha, sem tabulação e sem quatro ou mais espaços seguidos. Existe porque parâmetro de template não aceita esses caracteres. Ver Seção 15. |
| Pacote completo | Conjunto de mensagens livres enviado depois que a janela de atendimento abre: texto integral, áudio quando o tier permite, e mensagem de fechamento. Ver Seção 17. |
| Convite | Primeira mensagem do dia, enviada por template aprovado, com título, teaser e botões de ação. Não contém o devocional inteiro. |
| Acervo | Coleção de devocionais anteriores acessível no painel do assinante. Limitado a sete dias no tier gratuito e completo, desde a primeira assinatura paga, no tier pago. O limite é imposto no servidor, não apenas na interface. Ver Seção 13.6.1. |
| Freemium | Modelo comercial em que existe um plano gratuito permanente com capacidades reduzidas e um plano pago com capacidades completas. Não há período de teste do plano pago. |
| Tier | Nível de acesso do assinante. Existem exatamente dois: gratuito e pago. Não há plano intermediário. |
| Entitlement | Capacidade concedida a um assinante em função do seu tier. Resolvido por uma função única, conforme a Seção 13. |
| Opt-in | Consentimento explícito para receber mensagens. Neste produto é obrigatoriamente em duas etapas: marcação no formulário web e confirmação ativa no canal de mensagens. |
| Opt-out | Pedido de parar de receber mensagens. Tem efeito imediato sobre os envios e suspende a cobrança do ciclo seguinte, sem cancelar a assinatura de imediato — um pedido acidental não destrói a contratação. Passados 30 dias sem reativação, a assinatura é encerrada ao fim do período já pago. Ver Seção 20. |
| Pausa temporária | Suspensão voluntária dos envios por um período de 1 a 30 dias, com retorno automático. Diferente de opt-out. |
| Cancelamento voluntário | Pedido de parar de pagar. O acesso pago permanece até o fim do período já pago. Não é carência. |
| Inadimplência | Situação em que uma cobrança não foi paga. Neste produto, causa revogação imediata do acesso pago. |
| Carência | Período de tolerância em que o acesso pago sobreviveria à falha de pagamento. Não existe neste produto. O termo aparece apenas para ser negado. |
| Revogação imediata | Rebaixamento do assinante ao tier gratuito na mesma transação em que o evento de falha de pagamento é processado. Regra dura da Seção 13. |
| Cortesia | Concessão manual de acesso pago por um número de dias, feita por um administrador, com auditoria e expiração automática. |
| Conteúdo de reserva | Devocional atemporal, marcado como perene, usado quando o conteúdo do dia não está pronto. Não ocupa data no calendário. |
| Lote de envio | Conjunto de destinatários planejado para um dia específico, com uma tentativa de entrega registrada por assinante. |
| Coorte de destinatários | Lista de assinantes elegíveis a receber em um dia, depois de aplicadas todas as exclusões. |
| Fallback de vídeo | Envio por template com cabeçalho de vídeo, usado no quarto dia consecutivo sem interação de um assinante pago, para entregar áudio sem depender de interação. |
| Reenvio manual | Ação do assinante que solicita novamente o devocional do dia, limitada por tier. |
| Churn | Proporção de assinaturas que deixaram o estado ativo em um período sobre as ativas no início do período. Fórmula na Seção 21. |
| MRR | Receita recorrente mensal. Soma normalizada por mês das assinaturas ativas, com o plano anual dividido por doze. Fórmula na Seção 21. |
| ARR | Receita recorrente anual. Projeção anualizada do MRR. |
| ARPU | Receita média por assinante ativo no período. |
| LTV | Valor estimado que um assinante gera ao longo do seu ciclo de vida. |
| Taxa de conversão | Proporção de assinantes que passaram do tier gratuito ao pago em um período. |
| Taxa de abertura da janela | Proporção de assinantes que interagiram com o convite sobre os que o receberam. Indicador central deste produto. |
| Win-back | Campanha de recuperação de quem cancelou. Proibida pelo canal de mensagens para quem fez opt-out. |
| Encarregado de dados | Pessoa nomeada como canal entre titulares, autoridade e a empresa. Ver Seção 22. |
31.1.2 Termos do canal de mensagens #
| Termo | Definição |
|---|---|
| WABA | Conta de negócios do WhatsApp. Entidade que agrupa números, templates e configurações do canal de mensagens. |
| Cloud API | Interface oficial hospedada pela Meta para envio e recebimento de mensagens, usada diretamente neste produto, sem intermediário. |
| BSP | Provedor de solução de negócios. Intermediário credenciado que revende acesso ao canal. Não é usado aqui, mas a camada de abstração permite adotar um sem reescrever o motor de envio. |
| Graph API | Interface HTTP da Meta sobre a qual a Cloud API é exposta. Versionada por caminho de URL. |
| Janela de atendimento | Período de 24 horas aberto por qualquer mensagem enviada pelo usuário, dentro do qual mensagens livres são permitidas e não têm custo por mensagem. Conceito central do desenho do produto. |
| Mensagem livre | Mensagem enviada dentro da janela de atendimento, sem template, com até 4096 caracteres de texto ou mídia. |
| Template | Modelo de mensagem previamente aprovado pela Meta, obrigatório para iniciar conversa fora da janela de atendimento. |
| Categoria de template | Classificação que determina regras e custo. As categorias relevantes aqui são utilidade, marketing, autenticação e atendimento. |
| Parâmetro de template | Valor variável injetado no corpo ou no cabeçalho de um template. Não pode conter quebra de linha, tabulação nem quatro ou mais espaços consecutivos. |
| Cabeçalho de template | Parte superior opcional de um template. Aceita texto, imagem, documento, vídeo e localização. Não aceita áudio — restrição que define todo o desenho de entrega. |
| Botão de resposta rápida | Botão de template que, ao ser tocado, envia uma carga útil ao sistema e abre a janela de atendimento. |
| Quality rating | Classificação de qualidade do número, derivada do comportamento dos destinatários. Cair para o nível crítico ameaça a operação. |
| Messaging tier | Limite de contatos únicos que o número pode iniciar conversa em 24 horas. Evolui com volume e qualidade. |
| wa_id | Identificador do contato no canal de mensagens, retornado pela plataforma. Pode divergir do número cadastrado por causa do nono dígito no Brasil. |
| Phone number ID | Identificador do número de envio dentro da conta de negócios, usado no caminho das chamadas de envio e de mídia. |
| Media ID | Identificador de um arquivo previamente enviado à plataforma, reutilizável em vários envios e com validade limitada. Ver Seção 16. |
| Media API | Interface de envio e recuperação de arquivos da plataforma de mensagens. |
| Assinatura de webhook | Cabeçalho com o resumo criptográfico do corpo bruto da requisição, usado para provar que o evento veio da plataforma. Verificado em tempo constante. |
| Token de verificação | Valor combinado usado na verificação inicial do endpoint de webhook. |
| Verificação de negócio | Processo em que a Meta confirma a existência legal da empresa. Pré-requisito para limites maiores e para operação estável. |
| Nome de exibição | Nome que o assinante vê como remetente. Passa por aprovação. |
31.1.3 Termos do provedor de pagamento #
| Termo | Definição |
|---|---|
| Customer | Registro do pagador no provedor de pagamento. Exige nome e documento, o que obriga a coleta de CPF no checkout. |
| Subscription | Registro de assinatura recorrente no provedor, com ciclo, valor e forma de cobrança. |
| Payment | Cobrança individual gerada por uma assinatura ou avulsa. |
| billingType | Campo que define a forma de cobrança. Neste produto, cartão de crédito ou PIX. Boleto está fora de escopo. |
| cycle | Periodicidade da assinatura. Neste produto, mensal ou anual. |
| cpfCnpj | Documento fiscal do pagador, obrigatório para criar o registro de cliente. Tratado como dado pessoal conforme a Seção 22. |
| externalReference | Campo livre usado para gravar o nosso identificador de assinatura dentro do registro do provedor, permitindo correlação segura. |
| creditCardToken | Referência opaca a um cartão tokenizado. Substitui o dado do cartão em cobranças futuras. |
| Fila de webhooks | Mecanismo do provedor que entrega eventos em ordem e pausa após falhas consecutivas do nosso endpoint. Motivo pelo qual o handler responde rápido. |
| PIX copia-e-cola | Representação textual do código de pagamento instantâneo, que o pagador cola no aplicativo do banco. |
| Reconciliação | Comparação diária entre o estado das assinaturas e cobranças no provedor e o estado no nosso banco, com correção automática. O provedor vence em caso de divergência. |
| Chargeback | Contestação de cobrança feita pelo portador do cartão junto ao emissor. Causa revogação imediata do acesso. |
| Estorno | Devolução de um pagamento já recebido. Causa revogação imediata do acesso. |
31.1.4 Termos técnicos do projeto #
| Termo | Definição |
|---|---|
| Monorepo | Repositório único que contém os três aplicativos e os três pacotes compartilhados do sistema. Ver Seção 4. |
| Worker | Processo separado que executa trabalhos de fila e tarefas agendadas. Não atende tráfego público. |
| Agendador | Componente que registra os trabalhos repetíveis com o fuso operacional e dispara o planejamento e o envio nos horários definidos. |
| ULID | Identificador de 26 caracteres, ordenável por tempo de criação, gerado na aplicação. Formato canônico de todas as chaves primárias. Ver Seção 6. |
| Envelope de API | Formato fixo de toda resposta HTTP interna, com bloco de dados ou de erro e bloco de metadados. Definido na Seção 7. |
| requestId | Identificador único de uma requisição, ecoado em cabeçalho, presente no envelope e em todos os registros de log correlacionados. |
| Paginação por cursor | Estratégia de paginação baseada em um marcador opaco, estável sob inserção concorrente. Única permitida neste projeto. |
| Idempotência | Propriedade de uma operação que produz o mesmo efeito quando executada uma ou várias vezes com a mesma entrada. |
| Chave de idempotência | Valor que identifica unicamente uma operação, garantido por índice único no banco. Neste produto, existe uma para planejamento diário e uma para envio por assinante e data. |
| Disjuntor | Mecanismo que interrompe chamadas a uma dependência externa após um número de falhas, evitando desperdício e cascata, e que testa a recuperação periodicamente. Ver Seção 27. |
| Trabalho morto | Job que esgotou todas as tentativas. Fica no estado failed da própria fila e grava uma linha de estado morto em job_runs, que é a dead-letter do sistema. Inspecionável e reprocessável por comando de operação, com alerta associado. Não existe fila de mensagens mortas separada. Ver Seção 18.8. |
| Backoff exponencial | Estratégia de espera crescente entre tentativas. |
| Jitter | Variação aleatória aplicada à espera entre tentativas, para evitar que muitos clientes tentem no mesmo instante. |
| Limitador de taxa | Componente que restringe quantas operações ocorrem por unidade de tempo. Usado no envio e nas rotas públicas. |
| Roteiro de narração | Texto linear construído a partir do devocional para ser lido pela síntese de voz, com marcação removida e referências expandidas por extenso. Ver Seção 16. |
| Síntese de voz | Conversão de texto em áudio falado por um provedor externo. Há um provedor primário e um de fallback. |
| Transcodificação | Conversão do áudio gerado para os formatos exigidos: um para mensagem de voz no canal de mensagens e outro para o player web. |
| Normalização de volume | Ajuste do áudio para um nível percebido consistente entre devocionais. |
| URL assinada | Endereço temporário que autoriza o acesso a um arquivo em armazenamento privado, com prazo curto de validade. |
| Soft delete | Marcação de exclusão sem remoção física da linha. Permitido apenas nas três tabelas indicadas na Seção 6. |
| Migration aditiva | Alteração de schema que apenas acrescenta, sem remover nem renomear, e por isso é segura de aplicar antes do deploy do código. |
| Migration destrutiva | Alteração que remove ou renomeia estrutura. Sempre em duas fases, conforme a Seção 25. |
| Deriva de schema | Divergência entre o schema declarativo e o conjunto de migrations aplicadas. Detectada no pipeline e tratada como falha de build. |
| Feature flag | Chave que liga ou desliga um comportamento em tempo de execução, sem novo deploy. Catálogo na Seção 26. |
| Configuração de runtime | Parâmetro editável pelo administrador durante a operação, armazenado no banco, distinto de variável de ambiente. |
| Simulador local | Implementação local dos serviços externos, usada em desenvolvimento e teste, obrigatória fora de produção. Ver Seção 24. |
| Interruptor geral | Comando que interrompe todos os envios imediatamente, com efeito global e reversão explícita. Ver Seção 27. |
| Backfill | Reprocessamento de um período passado, executado por comando de operação. |
| Health check de vivacidade | Verificação de que o processo está vivo. |
| Health check de prontidão | Verificação de que o processo consegue atender, incluindo suas dependências. |
| Trilha de auditoria | Registro imutável de ações administrativas, com autor, alvo, valores anterior e novo, e horário. |
| Criptografia de campo | Cifra aplicada pela aplicação, e não pelo banco, sobre o valor de uma coluna antes de gravar. O que fica armazenado é um envelope; o banco nunca vê o valor. Por isso a coluna cifrada não recebe restrição de formato de conteúdo e a validação de formato acontece na borda. Ver Seção 22.5. |
| Índice cego | Coluna auxiliar que guarda o resumo criptográfico com chave do valor original, permitindo busca por igualdade e unicidade sem armazenar o valor em claro. Toda busca por telefone, identificador de contato, documento ou e-mail passa por ela. Ver Seções 6.3 e 22.5. |
| Revalidação no disparo | Releitura do estado do assinante por chave primária imediatamente antes de chamar o provedor de mensagens. O nível de acesso gravado no planejamento é registro histórico, nunca autoridade. Ver Seção 18.5. |
| Classe de erro da aplicação | AppError, definida em packages/core/src/errors.ts. É a única classe de erro de negócio do sistema; nenhum outro nome é aceito. Ver Seção 5.7. |
31.1.5 Termos de proteção de dados #
| Termo | Definição |
|---|---|
| LGPD | Lei Geral de Proteção de Dados Pessoais, Lei nº 13.709 de 2018, que rege o tratamento de dados pessoais no Brasil. |
| Titular | Pessoa natural a quem os dados pessoais se referem. Neste produto, o assinante. |
| Controlador | Quem decide sobre o tratamento dos dados. Neste produto, a empresa que opera o serviço. |
| Operador | Quem trata dados em nome do controlador. Neste produto, os fornecedores de mensagens, pagamento, síntese de voz, armazenamento e e-mail. |
| Base legal | Fundamento jurídico que autoriza um tratamento. Aqui, principalmente consentimento e execução de contrato, com obrigação legal para dados fiscais. Ver Seção 22. |
| Consentimento | Manifestação livre, informada e inequívoca do titular. Registrado de forma imutável e versionada. |
| Execução de contrato | Base legal que autoriza o tratamento necessário para entregar o serviço contratado pelo assinante pagante. |
| Legítimo interesse | Base legal aplicável a tratamentos de segurança e prevenção a fraude, com avaliação registrada. |
| Encarregado | Pessoa indicada como canal de comunicação entre controlador, titulares e autoridade. Contato público obrigatório. |
| ANPD | Autoridade Nacional de Proteção de Dados. Órgão responsável por fiscalizar a aplicação da lei e destinatário de notificações de incidente. |
| Anonimização | Transformação que impede a identificação do titular de forma irreversível. Alcança todas as tabelas que guardam identificador pessoal, inclusive os registros de mensagem: nenhuma retém telefone, identificador de contato, documento ou e-mail em claro depois dela. O que a lei obriga a reter é preservado de forma pseudonimizada, ligado apenas ao identificador interno. |
| Pseudonimização | Substituição de identificadores diretos por referências, mantendo utilidade e vínculo interno controlado. |
| Portabilidade | Direito do titular de obter seus dados em formato estruturado e interoperável. Atendido por exportação em JSON. |
| Eliminação | Direito do titular de ter seus dados excluídos, respeitadas as retenções legais. |
| Transferência internacional | Envio de dados pessoais para fora do país, o que ocorre com os fornecedores externos usados. Exige salvaguarda e informação ao titular. |
| Incidente de segurança | Evento que compromete confidencialidade, integridade ou disponibilidade de dados pessoais, com procedimento e prazos de comunicação. |
| Retenção | Período pelo qual cada categoria de dado é mantida, com justificativa legal por tabela. Ver Seção 22. |
31.1.6 Termos de qualidade e acessibilidade #
| Termo | Definição |
|---|---|
| WCAG 2.2 | Diretrizes de acessibilidade para conteúdo web. O produto adota o nível AA como exigência. |
| Nível AA | Conjunto intermediário de critérios de acessibilidade, incluindo contraste mínimo, foco visível e operação por teclado. |
| Leitor de tela | Software que converte a interface em fala ou braile. A interface precisa ser navegável por ele. |
| Movimento reduzido | Preferência do sistema operacional que pede menos animação. Respeitada pela interface. |
| LCP | Tempo até o maior elemento de conteúdo ser renderizado. Meta declarada na Seção 9.12. |
| CLS | Medida de deslocamento visual inesperado durante o carregamento. |
| INP | Medida da capacidade de resposta da interface à interação do usuário. |
31.2 Referências externas #
Documentação oficial que o agente executor deve consultar durante a construção. A coluna "o que buscar" indica exatamente para que serve cada fonte neste projeto. Consulte sempre a versão corrente publicada pelo fornecedor: o comportamento real da plataforma prevalece sobre qualquer descrição aqui.
| Fonte | Endereço | O que buscar nela |
|---|---|---|
| WhatsApp Cloud API | developers.facebook.com/docs/whatsapp/cloud-api |
Endpoints de envio, formato de payload por tipo de mensagem, upload e recuperação de mídia, e configuração de webhook. |
| Referência de mensagens do WhatsApp | developers.facebook.com/docs/whatsapp/cloud-api/reference/messages |
Estrutura exata dos objetos de texto, áudio, vídeo, template e resposta interativa. |
| Templates de mensagem | developers.facebook.com/docs/whatsapp/message-templates |
Regras de criação, categorias, componentes, restrições de parâmetro e processo de aprovação. |
| Categorização de template | developers.facebook.com/docs/whatsapp/updates-to-pricing |
Como a categoria é atribuída, o que muda no custo e como a reclassificação ocorre. |
| Janela de atendimento e tipos de conversa | developers.facebook.com/docs/whatsapp/pricing |
Definição da janela de 24 horas, o que é cobrado e o que não é. |
| Códigos de erro da plataforma de mensagens | developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes |
Significado de cada código, se é retentável e a ação recomendada. Base da tabela da Seção 17. |
| Webhooks da plataforma de mensagens | developers.facebook.com/docs/graph-api/webhooks |
Verificação do endpoint, validação de assinatura do corpo bruto e formato dos eventos. |
| Versionamento da Graph API | developers.facebook.com/docs/graph-api/guides/versioning |
Ciclo de vida das versões, prazo de descontinuação e como confirmar a versão corrente no momento do build. |
| Limites de taxa da plataforma de mensagens | developers.facebook.com/docs/graph-api/overview/rate-limiting |
Tetos de throughput e comportamento sob limitação. |
| Política de mensagens de negócios da Meta | business.whatsapp.com/policy |
O que é permitido enviar, exigência de opt-in comprovável e causas de suspensão. |
| Política de comércio da Meta | transparency.meta.com/policies/commerce-policies |
Restrições de conteúdo comercializado pelo canal. |
| Qualidade e limites de número | developers.facebook.com/docs/whatsapp/messaging-limits |
Como o limite de contatos únicos evolui e o que faz a qualidade cair. |
| Verificação de negócio na Meta | developers.facebook.com/docs/development/release/business-verification |
Documentos exigidos, prazos e causas comuns de recusa. |
| API do provedor de pagamento | docs.asaas.com |
Endpoints de cliente, assinatura e cobrança, autenticação por cabeçalho e ambientes de produção e teste. |
| Webhooks do provedor de pagamento | docs.asaas.com/docs/webhooks |
Lista de eventos, formato do payload, autenticação do webhook e comportamento de pausa da fila. |
| Cobrança recorrente e PIX | docs.asaas.com/reference |
Criação de assinatura, ciclos, geração de código PIX e tokenização de cartão. |
| Provedor primário de síntese de voz | elevenlabs.io/docs |
Endpoint de conversão, modelos multilíngues, parâmetros de voz, formatos de saída e limites por requisição. |
| Provedor de fallback de síntese de voz | cloud.google.com/text-to-speech/docs |
Vozes em português do Brasil, uso de marcação de fala e formatos de saída. |
| Framework web | nextjs.org/docs |
Roteamento por aplicação, renderização, manipuladores de rota, cache e revalidação. |
| Biblioteca de interface | react.dev/reference/react |
Componentes, hooks e limites entre componentes de servidor e de cliente. |
| ORM e migrations | prisma.io/docs |
Modelagem, geração de migrations, verificação de deriva e uso do cliente tipado. |
| Fila e agendamento | docs.bullmq.io |
Filas, trabalhadores, trabalhos repetíveis com fuso, limitador de taxa, tentativas e estado failed de job esgotado. |
| Banco de dados | postgresql.org/docs |
Tipos, índices, particionamento por intervalo, restrições e manutenção de tabelas de alto volume. |
| Armazenamento em memória | redis.io/docs |
Persistência, política de memória e estruturas usadas pelo limitador de taxa. |
| Processamento de áudio e vídeo | ffmpeg.org/documentation.html |
Codecs, filtros de normalização de volume, geração de vídeo a partir de imagem e faixa de áudio, e inspeção de mídia. |
| Validação de dados | zod.dev |
Definição de schemas compartilhados, inferência de tipo e formatação de erro por campo. |
| Testes unitários e de integração | vitest.dev |
Configuração, isolamento, controle de relógio e cobertura. |
| Testes ponta a ponta | playwright.dev/docs/intro |
Cenários de navegador, fixtures, espera determinística e auditoria de acessibilidade. |
| Normalização de telefone | gitlab.com/catamphetamine/libphonenumber-js |
Análise, validação e formatação internacional de números, com região padrão brasileira. |
| Assinatura de tokens | github.com/panva/jose |
Emissão e verificação de tokens assinados e gerenciamento de chaves. |
| Registro de log estruturado | getpino.io/#/docs/api |
Campos padrão, níveis, serializadores e redação de campos sensíveis. |
| Datas e fuso horário | date-fns.org/docs |
Conversão entre horário local operacional e horário universal, sem deslocamento fixo. |
| Armazenamento de objetos | docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest |
Operações de envio e recuperação de objeto e geração de URL assinada. |
| Proxy reverso e TLS | caddyserver.com/docs |
Configuração de domínios, certificados automáticos e cabeçalhos. |
| Contêineres | docs.docker.com/compose |
Definição de serviços, redes, volumes e verificações de saúde. |
| Métricas técnicas | prometheus.io/docs |
Tipos de métrica, convenções de nome e formato de exposição. |
| Tracing distribuído | opentelemetry.io/docs |
Instrumentação, propagação de contexto entre processos e amostragem. |
| Convenção de commits | conventionalcommits.org |
Formato de mensagem de commit adotado pelo projeto. |
| Especificação de identificadores | github.com/ulid/spec |
Formato, ordenação por tempo e garantias de unicidade. |
| Acessibilidade | w3.org/TR/WCAG22 |
Critérios de sucesso do nível AA e técnicas de conformidade. |
| Lei de proteção de dados | planalto.gov.br/ccivil_03/_ato2015-2018/2018/lei/l13709.htm |
Texto integral da Lei nº 13.709 de 2018: bases legais, direitos do titular e obrigações. |
| Autoridade de proteção de dados | gov.br/anpd |
Orientações, prazos de comunicação de incidente e regulamentos aplicáveis. |
| Documentação de e-mail transacional | resend.com/docs |
Envio, verificação de domínio e tratamento de eventos de entrega. |
31.3 Convenções de leitura deste documento #
- Numeração. As seções são numeradas de 1 a 31 e a numeração é fixa. Subseções seguem o padrão hierárquico decimal. Referências cruzadas sempre citam número de seção, nunca nome de arquivo.
- Idioma. A prosa, os títulos, as tabelas explicativas, os critérios de aceitação e todo texto voltado a pessoas estão em português do Brasil. Identificadores técnicos — tabelas, colunas, rotas, variáveis de ambiente, campos de JSON, valores de enum, nomes de função e mensagens de log — permanecem em inglês. Essa separação é deliberada e deve ser preservada no código.
- Blocos de código. Todo bloco declara a linguagem. Blocos marcados como comandos de terminal são executáveis como estão, exceto onde há um marcador entre sinais de menor e maior, que indica um valor que você substitui.
- Autoridade. Cada assunto tem uma seção dona, listada na Seção 30.2. Em caso de aparente conflito, a dona vence.
- Ausência de pendência. O documento não contém marcador de decisão adiada. Onde uma escolha era possível, ela foi feita e está registrada. Se você encontrar algo que pareça indefinido, aplique o procedimento da Seção 30.4.
- Valores configuráveis. Números como preço, dia de envio do plano gratuito e limites de reenvio são padrões declarados na Seção 1 e alteráveis sem mudança de código. As demais seções os citam como valor corrente, não como valor imutável.
- Restrições externas. Onde o documento afirma um limite de plataforma de terceiro, o comportamento real da plataforma no momento do build prevalece. Se ele mudar, ajuste o código, registre a decisão e não contorne a restrição.
31.4 Índice remissivo #
Conceitos mais consultados e as seções onde são especificados. O primeiro número é a seção dona.
| Conceito | Seções |
|---|---|
| Acervo e limite por tier | 13, 14, 29 |
| Alertas operacionais | 23, 27, 28 |
| Anonimização de dados | 22, 20, 29 |
| Áudio: formato e transcodificação | 16, 17, 29 |
| Áudio: provedor primário e fallback | 16, 27 |
| Auditoria administrativa | 3, 15, 23 |
| Backup e restauração | 25, 27, 28 |
| Bases legais de tratamento | 22, 19 |
| Cancelamento voluntário | 20, 13, 14 |
| Carência (inexistência) | 13, 12, 20, 30 |
| Catálogo de erros de API | 7, 27 |
| Checkout com cartão | 12, 9, 29 |
| Checkout com PIX | 12, 9, 29 |
| Códigos de erro da plataforma de mensagens | 17, 18, 27 |
| Consentimento e registro imutável | 22, 11, 19 |
| Cortesia de acesso pago | 13, 15 |
| Cursor de paginação | 7, 14 |
| Dashboard de métricas | 21, 15 |
| Devocional: campos e estados | 15, 6 |
| Direitos do titular | 22, 14 |
| Disjuntor e degradação | 27, 23 |
| Encarregado de dados | 22, 1 |
| Entitlements: matriz e função | 13, 3, 14 |
| Envelope de resposta | 7, 5 |
| Envio diário: cronograma | 18, 28 |
| Escopo e fora de escopo | 2, 30 |
| Estados da assinatura | 13, 12 |
| Estados do assinante | 11, 6 |
| Exportação de dados | 22, 14 |
| Fallback de vídeo | 17, 16, 18 |
| Feature flags | 26, 6 |
| Filas e trabalhos | 18, 16, 12 |
| Criptografia de campo e índice cego | 22, 6, 28 |
| Trabalho morto e reprocessamento | 18, 27, 25 |
| Precedência entre seções | 30, 31 |
| Fuso horário e agendamento | 18, 5, 21 |
| Idempotência de envio | 18, 6 |
| Idempotência de webhook | 12, 7 |
| Impersonação de assinante | 8, 3 |
| Índices e chaves do banco | 6 |
| Interruptor geral de envios | 27, 18, 28 |
| Janela de atendimento | 17, 18, 19 |
| Landing page: blocos e SEO | 9, 10 |
| Limites de taxa de API | 7, 22 |
| Logs e redação de dados sensíveis | 23, 22 |
| Máquina de estados editorial | 15, 16 |
| Mensagens: catálogo e textos | 19, 10 |
| Migrations e deriva de schema | 6, 25, 30 |
| Normalização de telefone | 11, 6, 24 |
| Onboarding e primeira entrega | 11, 19 |
| Opt-in em duas etapas | 11, 19, 22 |
| Opt-out e palavras-chave | 20, 19 |
| Painel administrativo | 15, 3 |
| Painel do assinante | 14, 13 |
| Pausa temporária | 20, 14 |
| Permissões por papel | 3, 8 |
| Planos e preços | 13, 1, 10 |
| Prontidão de conteúdo e reserva | 18, 15 |
| Reconciliação de pagamentos | 12, 27 |
| Reenvio manual | 14, 18, 13 |
| Retenção de dados por tabela | 22, 6 |
| Revogação imediata de acesso | 13, 12, 30 |
| Runbooks operacionais | 27, 23 |
| Segredos e rotação | 26, 22 |
| Sessões e cookies | 8, 22 |
| Simulador de serviços externos | 24, 30 |
| Tabelas do banco | 6 |
| Teaser: geração e sanitização | 15, 17, 24 |
| Templates: definição e aprovação | 17, 15, 28 |
| Testes: pirâmide e cobertura | 24, 30 |
| Upload de mídia e validade | 16, 17 |
| URLs assinadas | 16, 14, 22 |
| Variáveis de ambiente | 26, 25 |
| Verificação por código de acesso | 8, 11, 19 |
Fim da especificação. A ordem de precedência entre seções, em caso de conflito aparente, é a da Seção 30.2: vence sempre a seção declarada dona do assunto.
Generated at GenerateSpecs.com — one idea in, one buildable spec out.
Licensed under CC BY 4.0. Use it for anything — just credit GenerateSpecs.com.