Novidades

O que entregamos, e quando

Cada versão da Voxli é registrada automaticamente no deploy, com data e conteúdo. Sem release note escrita depois para parecer bonita.

v2.311.3

Atual
  • Correção Rascunho nao sobrevive mais ao envio, e a LGPD tambem tem O banner "Continuar de onde parou?" reaparecia para quem JA havia enviado a manifestacao. Nao era logica errada: era um comentario prometendo codigo que nunca foi escrito. form.html dizia "limpeza efetiva ocorre na pagina de confirmacao" e confirm.html nao tinha uma linha de JS de rascunho. A outra porta de saida, ?cleared=1, nao era setada por nenhum .py do repositorio. O rascunho so morria pelo TTL de 2h ou por clique em "Comecar do zero". Tres bugs, nao um: 1. Nunca limpava no envio (acima). 2. Tipo selecionado sozinho ja gravava rascunho, entao o banner aparecia para quem nunca digitou uma letra. 3. O link "Trocar" de contexto chamava `clearDraft ? clearDraft() : null`, e `clearDraft` nunca existiu no escopo global: levantava ReferenceError e nada era limpo. Era o "simples fato de troca" que trazia o rascunho de volta. O que muda: - templates/shared/_draft_resume.html passa a ser o dono da POLITICA (chave, validade, privacidade, "ja foi enviado", quando vale oferecer). Cada tela entrega so o capture e o apply dos proprios campos. Duplicar a politica no modulo LGPD seria duplicar o bug. - As duas confirmacoes varrem o prefixo do tenant e apagam o rascunho de qualquer contexto. Rede de seguranca para quem fecha a aba no meio do redirect: o rascunho e marcado no submit e se descarta sozinho apos 10min, o que preserva a recuperacao quando o servidor recusa o POST. - Chave por CONTEXTO DE ENTRADA (vxDraft.{schema}.{ctx}). Colaborador e Cliente param de se contaminar, o que importa mais desde que campo passou a ter escopo por contexto. - Identificacao NAO e mais gravada no navegador: nome, e-mail e consentimento na Ouvidoria; documento, e-mail, selfie e consentimentos na LGPD. O partial ainda remove PII por conta propria, inclusive campo do Form Builder com nome sugerindo CPF/telefone, para um capture futuro descuidado nao vazar. - "Comecar do zero" descarta e fica na pagina, em vez de devolver a pessoa para a tela de escolher perfil. - O modulo LGPD ganha o banner, restaurando tipo, motivo e detalhamento no passo 1. NAO restaura o passo: o passo 2 e verificacao por OTP, que nao sobrevive a recarga, e devolver a pessoa ao passo 4 daria uma tela que ela nao consegue enviar. O banner avisa que a verificacao sera refeita. - init sob `window.VXDraft ? ... : null`: se o partial nao carregar, o formulario continua enviando. Rascunho e conveniencia. - Emoji do banner virou Font Awesome. Verificacao: 11 testes de comportamento em JS (apps/core/tests/js), com stubs de localStorage, ligados na suite Python por wrapper que SKIPA sem Node. Eles acharam um bug meu antes do deploy: descricao de 2 caracteres driblava o minimo de 20, porque a varredura generica contava `description` como campo preenchido. Mais 12 testes de contrato e de renderizacao, que travam justamente a regressao original: confirmacao sem limpeza, chave sem contexto, PII no capture e include com caminho errado. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.311.0

  • Novidade Fecha o VX-23 e as quebras que so davam para fazer agora Autorizacao explicita do Djiovani: "faca o que precisar, n vai quebrar nenhum cliente, pois ngm ta usando API ou Webhook nesse momento, o negocio e deixar pronto agora antes de alguem usar". Cada item abaixo era conhecido e tinha ficado de fora justamente por ser quebrador ou por parecer escopo novo. CONVENCAO REST, ADITIVA ------------------------ `POST /v1/manifestations/` e `PATCH /v1/manifestations/{id}/` passam a ser as formas canonicas. As antigas (`/create/`, `/status/`) CONTINUAM respondendo e vao continuar: rota publica e congelada por contrato, e o test_contracts falha se um nome sumir ou mudar de endereco. A documentacao mostra so a nova, e as antigas estao marcadas `deprecated` na spec. Fazer agora e o ponto: depois do primeiro cliente, o custo de conviver com as duas seria para sempre sem ninguem nunca ter usado a certa. IDEMPOTENCIA DE VERDADE ----------------------- Tres defeitos, e todos apareciam so no caso para o qual o header foi inventado (o cliente que reenvia porque nao sabe se a primeira chegou): ordem o registro nascia DEPOIS da resposta. Entre consultar "ja existe?" e gravar cabia a requisicao inteira, entao duas chamadas simultaneas com a mesma chave criavam dois recursos. Agora a linha e RESERVADA antes do trabalho, com status_code=0, e a unicidade do banco arbitra a corrida. escopo era por CREDENCIAL. Duas chaves do mesmo cliente com a mesma Idempotency-Key criavam dois recursos — quem usa uma credencial por servico, que e a pratica recomendada, tinha a protecao desligada sem saber. Agora e so a chave, o que ja e por conta porque a tabela vive no schema do tenant. cobertura o header era aceito e IGNORADO no PATCH. So o create consultava o registro. Virou decorator, vale em toda escrita. Reserva abandonada expira em 15 min (processo que morre no meio nao pode travar aquela chave para sempre), e erro 5xx libera a chave — manter a reserva depois de uma falha nossa transformaria problema temporario em bloqueio permanente. O `atomic()` em volta do create nao e sobre consistencia: e o que permite capturar o IntegrityError. No Postgres um erro de constraint aborta a transacao inteira, entao sem savepoint proprio o except roda mas a consulta seguinte estoura e o 409 vira 500. So aparece quando a view e chamada de dentro de bloco atomico — os testes pegaram. RESPONDER AO SOLICITANTE ------------------------ `POST /v1/manifestations/{id}/reply/`. A API lia e mudava status, mas nao RESPONDIA: numa ouvidoria esse e o verbo central, e sem ele uma integracao puxa a manifestacao para o sistema do cliente e nunca fecha o ciclo com quem escreveu. `internal: true` grava nota interna, que nao vai ao solicitante e nao dispara o evento — omitir a distincao faria discussao de equipe vazar pela integracao. WEBHOOKS GERENCIAVEIS --------------------- Criar, alterar, excluir e rotacionar secret pela API. O escopo se chama `manage` e so listava. `_webhook_json` NUNCA inclui o secret: devolve-lo numa leitura faria uma credencial de API virar caminho para ler o segredo que assina os webhooks, que e escalar uma credencial para outra. Ele sai uma vez na criacao e uma vez na rotacao. OPENAPI E REFERENCIA PUBLICA — a metade do VX-23 que faltava ------------------------------------------------------------- `GET /api/v1/openapi.json`, sem autenticacao de proposito: a spec descreve a FORMA da API e nao devolve dado de ninguem. Exigir chave criaria ovo-e-galinha (precisaria da credencial para descobrir como usar a credencial) e nenhum gerador de SDK conseguiria apontar para ela antes do primeiro acesso. `servers` ja vem com o endereco do proprio tenant. `voxli.com.br/docs/api/` — a referencia fora do login. Ela existia so na aba do backoffice, dentro do tenant pagante: quem avaliava a Voxli nao dimensionava a integracao antes de contratar. A spec e escrita a mao (o projeto nao usa DRF, nao ha serializer de onde gerar), e o risco disso e envelhecer. Fechado por contract test: `test_openapi` compara os paths com as rotas reais do urlconf e falha se sobrar ou faltar; e varre o codigo atras dos codigos de erro devolvidos, exigindo que cada um esteja no catalogo. Ja pegou um esquecido (`protocol_error`) na primeira execucao. apps/core/api_contract.py — E POR QUE ELE EXISTE ------------------------------------------------- A pagina publica vive em apps/site e precisava dos limites, escopos, politica de retentativa e catalogo de erros. Importar apps.api de la e um App de dominio importando outro, e o test_app_boundaries recusou — corretamente. A alternativa preguicosa seria repetir os numeros na copy. Foi exatamente assim que o exemplo de API do site passou meses mostrando uma API inexistente. Entao o contrato subiu para uma camada compartilhada: apps.api APLICA, apps.site PUBLICA, os dois leem a MESMA fonte. O ADR-001 ja classifica API e webhooks como capacidade de plataforma e nao App de dominio (esta no cabecalho do proprio voxli_app.py), entao o modulo esta onde o ADR indica. Nenhuma violacao nova na lista de fronteiras. MIGRATION --------- 0006, aditiva. A troca de unicidade e precedida de uma limpeza que mantem a linha mais ANTIGA de cada chave repetida: e a que reservou primeiro e portanto a que corresponde ao recurso que existe de verdade. Guardar a mais nova faria o replay devolver o id do duplicado. `makemigrations --check` limpo. Baseline de URLs regravado com as 6 rotas novas. apps.api: 93 testes. Suite completa: 1705 testes, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.310.0

  • Novidade As 21 melhorias da auditoria — o modulo em nivel enterprise Fecha os 21 achados que sobraram da auditoria de 31/08, mais as duas decisoes travadas: chave de teste somente leitura, e envelope de webhook v2 para todos de uma vez (nao ha consumidor em producao hoje — confirmavel com `manage.py verificar_destinos_webhook`, que lista todo endpoint de todo tenant). RASTRO DE PONTA A PONTA (VX-24, VX-25, VX-26, VX-27, VX-04) ----------------------------------------------------------- A cadeia que a auditoria pedia — tenant, credencial, endpoint, quando, resultado, erro, id de correlacao — quebrava em tres elos. Agora: request id em toda resposta (header X-Voxli-Request-Id E corpo do erro, porque quem abre chamado copia do terminal e header nao aparece em curl sem -i), gravado no log. IP vem de apps/core/client_ip.py, resolvedor canonico novo. Lia o PRIMEIRO X-Forwarded-For, e a Cloudflare acrescenta o IP real no FIM: o campo gravava o que o chamador mandou, e podia ser envenenado para apontar para um terceiro. audit_logger.py do superadmin passou a delegar em vez de manter a segunda copia (que era a copia CERTA — a errada era a da API). auditoria 9 acoes declaradas em audit_actions e gravadas na tela: criar, rotacionar, ativar, excluir chave; criar, alternar, excluir, rotacionar secret e reenviar webhook. Nao havia UMA linha de AuditLog em apps/api. Sem migration: `choices` no Django nao e restricao de banco, e quem exibe resolve pelo registro. retencao apps/api/services/log_retention.py, 90 dias por padrao, no job diario. Nada expirava, e as duas tabelas guardavam uma segunda copia de dado pessoal do solicitante fora do alcance de um pedido de exclusao LGPD. corpos so em resposta de ERRO. E onde tem valor de diagnostico e onde nao ha dado de terceiro (a resposta de erro e a nossa mensagem). freio por IP, ANTES de tocar o banco. O rate limit vivia preso a chave, que so existe depois do token ser encontrado: token invalido nao passava por freio nenhum e gravava uma linha no banco do tenant por tentativa. O freio pegou um bug MEU no caminho: a primeira versao incrementava antes de saber se a credencial era valida, entao contava trafego legitimo — uma integracao de um IP so batia em 20/min, muito abaixo do limite da chave. Ler e incrementar viraram separados: so a FALHA consome o orcamento. Os testes existentes derrubaram isso na primeira rodada. CICLO DE VIDA DA CREDENCIAL (VX-06, VX-07, VX-11, VX-12, VX-13) ---------------------------------------------------------------- rotacao de chave sem downtime: a nova nasce e a antiga ganha 7 dias de prazo, expirando sozinha. Rotacionar era criar, trocar e excluir, com janela fora do ar no meio — e atrito e o que faz ninguem rotacionar. Chave expirada devolve `key_expired`, nao `key_disabled`: a mensagem tem que dizer que foi rotacao, senao o cliente procura um problema de permissao que nao existe. RBAC a tela passou a exigir `settings_platform.api_webhooks`, que JA existia no catalogo como `critical` e ja era o que o menu usava. Antes chamava _is_channel_admin, que termina em `bool(user.is_staff)` — flag GLOBAL, ja que User e SHARED_APPS. secret obrigatorio no modelo (era blank=True, e sem ele a entrega saia sem assinatura), revelavel e rotacionavel pela tela, e passa por SESSAO em vez de `messages` — o storage padrao do Django tenta COOKIE primeiro, entao o segredo repousava no navegador. A migration faz backfill dos vazios. reentrega botao na tela e endpoint na API. Uma indisponibilidade maior que as 3 tentativas (2+4+8 min) perdia o evento em definitivo. O reenvio nasce como linha NOVA (historico e registro, nao se reescreve) apontando para a original em `reenvio_de` — e e isso que faz as duas compartilharem o X-Voxli-Delivery. Sem herdar o identificador, o botao "Reenviar" viraria uma maquina de duplicata para quem ja processou. CONTRATO DA API (VX-08, VX-14, VX-19, VX-23) --------------------------------------------- rate limit atomico (cache.incr; get+set deixava dois workers passarem com o mesmo valor, e com Redis desde 03/08 a corrida deixou de ser teorica), com Remaining, Reset e Retry-After. So Limit era devolvido, e so no caminho de sucesso. filtros validados. `?since=lixo` era ignorado em SILENCIO e devolvia a base inteira — para um job de sincronizacao e o pior modo de falha possivel: parece que funcionou. Agora 400 com o nome do parametro. Novos filtros `until` e `since` no LGPD. estados maquina de estados via status_catalog.is_terminal, o mesmo SSOT do backoffice. Dava para levar um caso CANCELADO de volta para Recebida, e o historico registrava como legitimo. tamanhos teto por campo (20k na descricao, 320 no contato) e validacao de e-mail pelo validador do Django. O unico freio era o DATA_UPLOAD_MAX_MEMORY_SIZE, que vale para o corpo inteiro. envelope v2 em TODA entrega, test.ping incluso: {id, type, version, occurred_at, tenant, data}. O ping tinha forma diferente do evento real, entao quem integrasse contra o botao "Testar" escrevia um parser que quebrava no primeiro evento de verdade. `tenant` no corpo porque um receptor pode atender varios clientes no mesmo endereco. webhooks 3 endpoints novos: listar, historico de entregas e reenviar. O escopo `webhooks:manage` era oferecido na tela e NAO havia uma rota atras dele. `_webhook_json` nunca inclui o secret: seria escalar uma credencial de API para outra credencial. CONSISTENCIA DE DOMINIO (VX-15, VX-16, VX-18) ---------------------------------------------- notificacao movida para o signal post_save. `notify_manifest_new` tinha UM call site em todo o repositorio (a view do formulario publico), entao manifestacao criada pela API caia na caixa de entrada sem avisar ninguem — enquanto o prazo de SLA corria. Mesmo argumento ja feito para os eventos. A chamada saiu da view para nao entregar em duplicata. campos a criacao pela API aceita `fields`, grava FieldResponse, chama classification.rebuild e resolve a empresa do grupo. Sem isso a manifestacao vinda de ERP nascia sem a dimensao que o Command Center e os relatorios usam para agrupar. fila rodizio por cursor. `order_by("schema_name")[:20]` pegava SEMPRE os 20 primeiros do alfabeto: acima disso, os do fim nunca tinham a fila drenada. Bomba de crescimento, e o sintoma ("so alguns clientes nao recebem") e caro de diagnosticar. on_commit a tentativa imediata roda depois do commit. Como quem publica e um post_save, o disparo acontecia DENTRO da transacao: a thread abre a propria conexao, nao via a linha ainda nao comitada, e saia em silencio. Corrige tambem o oposto — evento entregue para um registro cuja transacao depois volta atras. SUPERFICIE PUBLICA (VX-21, VX-22) ---------------------------------- vxl_test_ agora e somente leitura, com erro proprio (`test_key_readonly`). A tela vendia "Ambiente: Teste" e o prefixo nao era lido em lugar nenhum: quem testava a integracao criava manifestacao de verdade na ouvidoria do cliente. Nao e sandbox, e a tela diz isso. site o exemplo era ficcional do primeiro ao ultimo caractere: `sk_live_` (real: vxl_live_), `api.voxli.com.br` (host que nao existe), `"tipo"`, `"em_andamento"` e `"sla_status"` (campo que nao existe na resposta). Trocado por um exemplo copiavel. referencia catalogo dos 16 codigos de erro com o que fazer em cada um, e o envelope de resposta documentado. DEFESA EM PROFUNDIDADE (VX-01, VX-02) -------------------------------------- X-Voxli-Host so vale quando a requisicao veio pelo Worker, e da para saber isso de graca: o Worker reescreve o Host para o fallback origin e o tunel PRESERVA o Host real. Nao precisou de segredo compartilhado nem de mudanca no worker.js. Antes qualquer chamador escolhia em qual tenant a requisicao rodava — sem travessia de dado, mas util para enumerar schemas, e qualquer codigo futuro que confiasse em request.tenant herdaria o buraco. api_auth compara connection.schema_name com request.tenant.schema_name antes de executar a view. Nenhuma view escreve filtro de tenant: o isolamento inteiro depende do search_path, e test_schema_isolation.py ja documenta como ele pode divergir (rollback desfaz o SET, o cache da lib nao). Custa nada e transforma vazamento silencioso em erro barulhento. FORA DE ESCOPO, REGISTRADO -------------------------- `prev_hash` do AuditLog nunca e preenchido por ninguem neste repositorio: a cadeia existe no modelo e nao esta encadeada (item que o CLAUDE.md ja lista como pendente). Encadear atinge todo escritor, nao so este modulo — fazer so aqui deixaria uns registros encadeados e outros nao, que e pior que nenhum. Baseline de URLs regravado: 3 rotas minhas mais 12 de drift pre-existente que nunca tinham sido registradas. apps.api: 82 testes. Suite completa: 1686 testes, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.309.2

  • Correção Os 5 riscos da auditoria — SSRF, suspensao, SLA, dedup e a assinatura Cada correcao inverteu o teste que documentava o defeito: o que afirmava o comportamento errado agora exige o certo. Sem migration (makemigrations --check limpo) e sem mudanca na formula da assinatura, que e contrato com todo receptor ja em producao. VX-10 — SSRF no destino do webhook ---------------------------------- O destino e escrito pelo administrador do TENANT e quem faz a requisicao somos nos, de dentro da rede. `http://169.254.169.254/latest/meta-data/` virava requisicao nossa, com ate 2000 chars da resposta gravados e exibidos na tela de Logs do proprio cliente. Validacao em DUAS camadas, e as duas sao necessarias: no save() para dar erro imediato a quem configura, e NA HORA DA ENTREGA porque so ela pega DNS rebinding — salvar apontando para IP publico e depois trocar o registro. Recusa por PROPRIEDADE do endereco (is_private/is_loopback/is_link_local/ is_reserved/is_multicast), nao por lista de faixas: listar a mao e como se esquece IPv6 e ::ffff:127.0.0.1. allow_redirects=False, senao um destino publico que responde 302 para 127.0.0.1 reabre o buraco. Destino recusado vira DEAD na primeira, sem retentativa: insistir daria tres disparos em vez de um a quem apontou o webhook para dentro da rede. Host que NAO resolve nao e bloqueado — nao e alvo de SSRF, a conexao falha sozinha, e bloquear transformaria queda de DNS do cliente em recusa permanente. DECISAO COM CUSTO, tomada conscientemente: https passou a ser exigido tambem na entrega, entao endpoint ja configurado em http PARA de receber no deploy. O payload leva protocolo e situacao da manifestacao, e em http isso trafega em claro. `manage.py verificar_destinos_webhook` (novo, so leitura) lista quais tenants e quais endpoints seriam afetados — rodar ANTES do deploy. VX-03 — API aberta em tenant suspenso ------------------------------------- /api/ esta em _BYPASS_PREFIXES do TenantSuspensionMiddleware, e continua: aquele middleware responde HTML, que nao serve para quem chama a API. So que o bypass nunca foi compensado, entao cliente suspenso ou CANCELADO mantinha leitura e escrita — e a suspensao real (views_danger + job de billing) grava state e is_active sem tocar em entitlement. Agora o api_auth compensa, devolvendo 402 tenant_suspended. 402 e nao 403 porque a diferenca importa para quem integra: 403 diz "esta credencial nao pode", 402 diz "a conta precisa ser regularizada", que e acionavel. A regra de "esta operando" saiu para apps/core/tenant_state.py e e a MESMA que o middleware usa. Reescreve-la no consumidor era o caminho facil e e como duas copias de uma regra de negocio comecam a divergir — ja aconteceu neste repo com o escopo de SLA. Teste da contraparte: grace e trial CONTINUAM com a API, senao o conserto vira bloqueio de quem esta dentro da regua de inadimplencia. VX-05 (de carona, mesmas linhas) — o gate lia Client/entitlements a mao, sem o fallback para plan.features que o control_plane tem. Entre a troca de plano e o sync, o backoffice mostrava o modulo e a API respondia 403. VX-17 — mudanca de status pela API nao mexia no relogio do SLA -------------------------------------------------------------- services/sla_clock.apply_pause_resume e o SSOT da transicao. Backoffice e portal publico chamavam; a API fazia save(update_fields=["status"]) e pronto. Concluir pela API DESCARTAVA o tempo em que a bola esteve com o solicitante, e a reabertura devolvia o caso ja vencido — exatamente o defeito descrito no cabecalho do sla_clock.py. Todo numero de SLA ficava errado para quem usasse a API: dashboard, relatorio e alerta. Sem try/except de proposito: se o motor quebrar, o erro aparece. Engolir reproduziria a classe de bug que este commit corrige. Teste do ciclo completo pela API (Respondida -> Concluida -> reaberta) comparando o prazo restante. VX-09 — sem identificador, a duplicidade era garantida ------------------------------------------------------ A retentativa mandava o mesmo corpo com timestamp e assinatura novos e nenhum header identificando a entrega. Qualquer timeout de 10s sobre um processamento BEM-SUCEDIDO virava processamento duplicado, sem defesa possivel do outro lado. X-Voxli-Delivery (estavel entre tentativas) + X-Voxli-Attempt. O id e uuid5(namespace, "schema:pk"): deterministico, entao a tentativa 2 produz o mesmo valor sem campo novo, e as linhas ja gravadas ganham id sem migration de dados. O SCHEMA entra na chave porque pk e sequencial POR TENANT — sem ele, um receptor que atenda dois clientes descartaria o segundo evento como duplicata, trocando duplicacao por PERDA. send_test_event passou a delegar a tentar_entrega em vez de fazer o proprio POST. Nao e so limpeza: o botao "Testar" e a unica amostra que o integrador tem antes de escrever o receptor, e ele estava a caminho de divergir do evento real justamente nos cabecalhos novos. VX-20 — a assinatura era impossivel de validar pela documentacao ---------------------------------------------------------------- A aba Referencia listava os tres headers e nao dizia O QUE e assinado. O controle de seguranca que a Voxli vende so podia ser usado lendo nosso codigo-fonte — na pratica, cada integracao abria chamado ou pulava a verificacao. Agora: a formula, o aviso de que o corpo sao os BYTES CRUS (reserializar muda o hash), snippet com janela de tolerancia e compare_digest, instrucao explicita de RECUSAR requisicao sem assinatura, tabela dos cinco cabecalhos e a politica de retentativa (3 tentativas, 2/4/8 min, timeout de 10s). Contract test amarra a pagina a _sign_payload, MAX_RETRIES e DELIVERY_TIMEOUT: se a formula mudar num refactor e a pagina nao, o CI falha. Sem isso a pagina ensinaria a formula antiga e o sintoma do lado do cliente seria "a Voxli parou de mandar webhook". Fora de escopo, seguem em aberto: VX-14 (envelope do payload — o corpo do test.ping ainda difere do evento real; unificar pede versionamento junto) e as demais 20 melhorias do relatorio. apps.api: 61 testes, 0 falhas. Suite completa: 1665 testes, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.309.0

  • Novidade Fase 3 — insights por regra, sem IA e com a conta na frase Ultima fase do plano. Cinco regras que leem numeros JA calculados e escrevem frases sobre eles: a fila cresceu ou encolheu, a demanda mudou de patamar dentro da janela, uma categoria domina a origem, a fila esta envelhecendo contra o prazo, e onde as entradas se concentraram. POR QUE DETERMINISTICO, E NAO UM MODELO ---------------------------------------- A ordem que o plano fixou: dado confiavel -> metrica confiavel -> serie -> regra -> insight -> so entao IA. Modelo escrevendo sobre numero ruim e a forma mais cara de mentir, porque o texto fica convincente e o erro fica invisivel. TODA FRASE CARREGA A CONTA -------------------------- "A demanda subiu 23%" e opiniao. "Subiu 23% dentro do periodo: 4 novas nos primeiros 15 dias contra 16 nos ultimos" e verificavel, e o leitor discorda se quiser. Ha teste exigindo que TODA frase cite numero e registre em `numeros` os valores que a produziram — a mesma regra que ja vale para o Health Score e os KPIs, agora aplicada ao texto. CUSTO ZERO ---------- Funcoes puras sobre dados que a tela ja tinha: o orcamento de queries seguiu em 70 quente e 88 frio, sem mudanca. Os 21 testes do motor rodam em 1ms, porque nao tocam banco. ONDE A REGRA PREFERE SE CALAR ------------------------------ - amostra abaixo de 10: com 6 manifestacoes, "subiu 50%" significa que passou de 2 para 3. Mesma regua do Health Score, mesmo motivo; - variacao abaixo de 15%: operacao pequena oscila, e chamar isso de tendencia seria ruido com cara de analise; - primeira metade zerada: viraria "subiu infinito%"; - prazo nao configurado: sem regua nao ha percentual, e a alternativa seria inventar uma — o mesmo erro que fez o indice rotular de GRAVE um canal com tudo encerrado; - "Outros" nunca conta como concentracao: e agregado de cauda longa, e dizer que ele domina a demanda afirmaria o OPOSTO do que o numero mostra. Insight NAO repete a faixa de acoes prioritarias: "2 vencidas" e ACAO, tem lugar proprio, acima e mais visivel. Insight responde "o que mudou", acao responde "o que fazer agora". Ha teste garantindo que as duas listas nao se sobrepoem. Regra que explode nao derruba as outras nem a tela: insight e informacao extra, e perder uma frase e aceitavel. Suite completa: 1615 testes, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.308.5

  • Desempenho Dashboard de 90 para 70 queries, e o ganho vaza para o resto O log de producao mostrava GET /admin/ com 138 queries e 2,7s, sendo 69 delas SET search_path. Agrupando o SQL, o diagnostico foi que metade da pagina era REPETICAO, nao trabalho. Tetos do orcamento BAIXARAM — teto menor e guarda mais forte: dashboard quente 96 -> 74 (medido 70) dashboard frio 116 -> 92 (medido 88) inbox 82 -> 78 (medido 74) 1. O CONTROL PLANE ERA LIDO NOVE VEZES --------------------------------------- tenancy_client 5x e tenancy_client_entitlements 4x, para a MESMA linha. SETE lugares abriam schema_context("public") por conta propria: context processor da marca, template tag de flags, checagem de feature na view, menu publico, onboarding e badge de cobranca. Um deles ja memoizava, mas sob chave propria, o que nao ajudava os outros seis. Em multi-tenant o preco e dobrado: cada leitura feita de dentro do tenant abre o schema publico e o django-tenants reemite o SET search_path junto. Eram 18 das 108 consultas so relendo a mesma coisa. Virou apps/core/control_plane.py: um acessador unico, memoizado por request, com select_related puxando plano e entitlements na mesma consulta. 9 -> 1. 2. SEIS CONTAGENS VIRARAM UMA AGREGACAO CONDICIONAL ---------------------------------------------------- _dashboard_metrics fazia seis idas ao banco sobre a MESMA tabela com filtros diferentes. Agora e um aggregate() com Count(filter=...). O cuidado que valeu mais que a economia: os escopos de SLA passaram a ser EXPOSTOS como Q() em services/sla_queryset.py (running_q, paused_scope_q), e sla_running/sla_paused usam essas mesmas expressoes. Reescrever a condicao dentro do dashboard faria "relogio correndo" existir em dois lugares e divergir em silencio — que e exatamente o bug que aquele modulo fecha (manifestacao aguardando solicitante contava como proxima do prazo). 3. UM DEFEITO SUTIL NO HELPER DE MEMOIZACAO -------------------------------------------- request_cache.cached() acrescenta o schema da CONEXAO a chave, por seguranca. Mas a leitura do control plane acontece dentro de schema_context("public"), entao quem chamava de dentro do tenant e quem chamava ja em contexto publico geravam chaves diferentes para o MESMO dado, e a consulta rodava duas vezes. Agora ha `escopo_por_schema=False` para quando a chave JA carrega o schema do dado. O padrao continua True: o inseguro tem que ser escrito a mao e explicado, porque passar False sem o schema na chave seria vazamento entre clientes, nao lentidao. TESTES DE SLA REESCRITOS, E FICARAM MAIS FORTES ------------------------------------------------ Tres testes verificavam a SEQUENCIA de chamadas do queryset (".filter() e depois .exclude()"). Isso quebrou com a mudanca de forma, embora a regra nao tenha mudado. Passaram a medir a composicao do Q: pega divergencia de regra mesmo que alguem reescreva a montagem, que e o que de fato importa. Um teste novo trava a ponte — sla_running/sla_paused nao podem montar condicao propria. O ganho vazou para outras telas: a inbox caiu 4 queries sem ninguem tocar nela, porque o control plane era relido la tambem. Suite completa: 1591 testes, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.308.4

  • Correção Dimensao so aparecia de 90 dias pra cima, e o link da auditoria ia pro lugar errado 1. DIMENSAO IGNORAVA A JANELA ------------------------------ "Responsavel" e "Status" so apareciam no seletor a partir de 90 dias. `dimensoes_disponiveis()` olhava o historico INTEIRO ("existe alguem atribuido em algum momento?") enquanto `breakdown()` filtrava por periodo. A opcao era oferecida, o card voltava vazio em 7 e 30 dias, e so em 90 o dado entrava na janela. O agravante: eu escrevi na docstring do proprio modulo que "dimensao sem dado NAO aparece no seletor: oferecer 'Empresa do grupo' para quem nao usa modo grupo e prometer um recorte vazio" — e implementei metade da regra. Quando a regra esta escrita e o codigo cumpre so parte dela, o comentario vira alibi. 2. ESTADO VAZIO FALAVA DE OUTRA COISA -------------------------------------- Escolher "Responsavel" e nao ter dado no periodo respondia "escolha um campo do formulario como dimensao de classificacao". Isso e responder outra pergunta. Agora o texto nomeia o recorte escolhido e sugere periodo maior, e ha um estado separado para "nenhuma manifestacao no periodo", que e diferente de "essa informacao nao esta preenchida". 3. SELETOR ESTAVA FORA DO BLOCO QUE A TROCA SUBSTITUI ------------------------------------------------------ Ele vivia no cabecalho, entao nao era recalculado ao mudar a janela — o que passou a ser bug de correcao, e nao so cosmetico, depois que as dimensoes passaram a depender da janela. Foi para dentro do card de origem, que tambem e o lugar certo: periodo e filtro GLOBAL e muda a tela inteira; dimensao muda UM card, e controle pertence ao que ele controla. Como o seletor agora e substituido a cada troca, o listener virou delegado. E quando a dimensao escolhida nao existe na janela nova, o servidor cai numa valida e o JS sincroniza a URL: sem isso, um link compartilhado abriria diferente do que a pessoa estava vendo. 4. "VER AUDITORIA" IA PARA A AUDITORIA ERRADA ---------------------------------------------- Apontava para Compliance -> Auditoria, que e relatorio regulatorio e exige o entitlement has_regulatory_reports. A trilha de auditoria da plataforma, que e de onde os eventos da "Atividade recente" vem, vive em Configuracoes -> Seguranca. O cliente era mandado para uma tela que nao gerou o que ele acabou de ler, e que ele pode nem ter contratado. Suite completa: 1589 testes, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.308.3

  • Correção 500 em /admin/settings/security/ por falta de {% load %} A pagina estava quebrada em producao: TemplateSyntaxError: Invalid block tag on line 206: 'audit_action_group', expected 'empty' or 'endfor'. Did you forget to register or load this tag? O template usa {% audit_action_group %} e carregava so {% load i18n %}. A tag existe e esta registrada em backoffice_tags; faltava a linha do load. POR QUE A SUITE INTEIRA PASSAVA COM UMA PAGINA QUEBRADA ------------------------------------------------------- Tres coisas conspiram nesta classe de bug: - `manage.py check` NAO pega: template so e compilado ao renderizar; - nenhum teste renderizava essa pagina especifica; - {% load %} NAO e herdado por {% extends %} nem por {% include %}, entao cada arquivo precisa do seu, e e facil esquecer ao mover markup de lugar. O erro so aparece para o usuario final, como 500. Por isso vem junto test_template_tags_carregados.py: uma varredura ESTATICA que compara as tags usadas em cada template com o que o Django tem registrado, e exige que a biblioteca correspondente esteja carregada. Nao renderiza nada, entao cobre ate template que teste nenhum exercita — que era exatamente o caso aqui. Verifiquei que ele acusa o bug original, e rodado na base inteira este era o UNICO caso. As tags nativas sao lidas do proprio Django (engine.template_builtins) em vez de escritas a mao: lista manual envelhece e o teste comeca a acusar tag valida. Vai sozinho, e nao junto com o resto do Command Center, porque e correcao de producao e nao depende de nada mais. Suite completa: 1586 testes, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.308.2

  • Correção Tooltip cortado — a causa era overflow-x, nao o lado de abertura Terceira vez no mesmo balao, e as duas correcoes anteriores trataram sintoma. A CAUSA ------- `main.bo-page` tem `overflow-x: hidden` (portal_admin.css:72) para impedir rolagem horizontal. A pegadinha do CSS: quando UM eixo e hidden e o outro e visible, o navegador PROMOVE o outro para auto. Ou seja, `overflow-x: hidden` sozinho ja cria contexto de recorte nos DOIS eixos, e corta qualquer descendente posicionado que passe da borda. Por isso a correcao anterior — trocar "abre para cima" por "abre para baixo" — so mudou ONDE ele era cortado. Eu mexi na direcao duas vezes sem investigar de onde vinha o corte. `position: fixed` tambem nao resolveria: as secoes tem `transform` na animacao de entrada, e transform cria bloco de contencao para elementos fixed. A CORRECAO ---------- Existe UM no pendurado no <body>, fora de qualquer ancestral que recorte ou transforme, e o conteudo e copiado do `.cc-pop` do botao, que vira template escondido. A regra global de overflow NAO foi tocada: ela existe por um motivo valido e nunca foi o problema. O que so ficou possivel fora do container recortado: - decidir o lado por espaco REAL (abre para baixo; se nao couber, para cima); - nunca sair da janela na horizontal, o que dispensa a regra especial que ancorava o ultimo KPI a direita; - funcionar no celular, porque toque nao tem hover: abre no toque, fecha com Escape ou clique fora, e some ao rolar. Os ouvintes usam delegacao no documento porque o miolo da tela e substituido a cada troca de filtro; ligar por elemento exigiria religar tudo a cada swap. Suite completa: 1584 testes, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.308.1

  • Correção "grave" com tudo encerrado, e 3 resolvidas para 2 manifestacoes Dois bugs achados pelo Djiovani olhando a tela, ambos meus. 1. INDICE "GRAVE" NUM CANAL SEM PENDENCIA NENHUMA ------------------------------------------------- Tenant com tudo encerrado, nada vencido, fila zerada: Health Score 60,3 GRAVE. A conta: backlog 100 (fila vazia) e 1a resposta 13. Com pesos 0,55 e 0,45 da 60,3, e o rotulo vinha inteiro da primeira resposta. Por que 13? Porque eu normalizei contra uma regua INVENTADA — 100 se respondesse em 24h, zero em 7 dias. Isso e expectativa de suporte de SaaS. Numa ouvidoria com prazo legal de 30 dias, responder em 6,2 dias consome 21% do prazo, o que e saudavel. O agravante: o componente de backlog JA normalizava contra o prazo do proprio tenant, com um comentario dizendo "nao constante magica". A constante magica estava na funcao seguinte. Agora `resposta_health` usa `100 x (1 - horas / prazo_do_tenant)`: responder na hora vale 100, responder no vencimento vale 0. A MESMA espera pontua diferente em canais com prazos diferentes, que e o comportamento correto. O prazo de referencia foi para o tooltip, entao o numero e auditavel. 2. TRES RESOLVIDAS PARA DUAS MANIFESTACOES ------------------------------------------- Dado errado, nao escala errada. A serie diaria contava TRANSICOES para status de encerramento, nao manifestacoes. Havia distinct, mas aplicado DENTRO de cada dia. E o ciclo normal deste produto tem dois fechamentos: "Concluida" (fecha, mas e reabrivel) e "Encerrada" (terminal), as duas na categoria closed. Manifestacao que passa pelas duas em dias diferentes era contada duas vezes. Agora cada manifestacao entra UMA vez, no dia do PRIMEIRO fechamento dentro da janela. O contador de 7 dias ja estava certo — deduplicava na janela inteira —, entao so a serie diaria tinha o defeito. Testes novos cobrem os dois, incluindo um que reproduz literalmente o cenario relatado: uma manifestacao com dois fechamentos em dias diferentes tem que aparecer uma vez na serie. Suite completa: 1584 testes, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.308.0

  • Novidade Fases 2 e 4 — periodo, dimensao e troca sem recarregar As duas fases sao o mesmo mecanismo visto de dois angulos: o seletor de periodo E um filtro global. Construir separado seria construir duas vezes. FASE 2 — ANALYTICS ------------------ Janela de tempo virou parametro: 7 dias, 30, 90 e 12 meses. _dashboard_metrics tinha 30 dias fixos no codigo e agora recebe janela_dias, que atravessa Health Score, backlog, indicadores e origem. Junto veio a parte que o briefing nao mencionava e que teria estragado o resultado: agrupar sempre de 7 em 7 dias daria 4 barras em 30 dias (bom) e 52 em 12 meses (ilegivel). A granularidade agora acompanha a janela — dia, semana, quinzena ou mes — mirando de 4 a 13 barras, que e o intervalo em que o olho ainda compara colunas. Ha teste garantindo que NENHUMA janela produz grafico ilegivel, e outro garantindo que agrupar nao perde nem duplica evento. Seletor de dimensao na origem, com os campos que JA sao separados de verdade: classificacao, tipo, status, contexto de entrada, empresa do grupo, responsavel. O pedido original era escolher entre varias classificacoes, o que conflita com MAX_CLASSIFICATION_FIELDS = 1; isso segue registrado no modulo. Duas decisoes de produto no caminho: - dimensao SEM DADO no tenant nao aparece no seletor. Oferecer "Empresa do grupo" a quem nao usa modo grupo e prometer um recorte vazio. - so a classificacao vira link para a inbox, porque so ela tem filtro correspondente la. Link que nao filtra nada e pior que nenhum link. - o convite para configurar classificacao deixou de SUBSTITUIR o card e virou rodape: agora o card mostra um recorte real e o convite continua alcancavel. FASE 4 — FILTROS SEM RECARREGAR ------------------------------- O servidor renderiza o MESMO parcial (_body.html) nos dois caminhos: dentro da pagina no acesso normal, e sozinho via ?fragmento=1 quando o JS troca o recorte. Nao ha JSON nem HTML montado no navegador — e o list-swap que a inbox ja usa, nao SPA. O filtro vive na URL, entao link compartilhado, favorito e navegador sem JS recebem a tela ja no recorte certo. O que so aparece implementando, tudo com teste: - parametro de URL e ENTRADA DE USUARIO: ?janela=999999 nao vira varredura de anos no banco e ?dimensao=senha__hash nao vira agrupamento arbitrario; - troca em voo e abortada quando chega outra: clicar 7d e 90d rapido pintaria a resposta antiga por cima da nova; - falha no filtro NAO apaga a tela: o recorte anterior fica visivel, com aviso e botao de tentar de novo. O filtro NAO afeta acoes prioritarias nem atividade recente, de proposito: prazo vencido e vencido AGORA, e filtrar isso por periodo esconderia o que precisa de acao hoje. CUSTO: +2 queries no caso quente. Levantar as seis dimensoes seria seis consultas, que no django-tenants viram doze idas ao banco; virou UMA agregacao condicional. O denominador do periodo saiu de graca — ja estava na serie diaria que a view tinha na mao. CONTRASTE (fora de escopo, mas e defeito de acessibilidade real): o token --portal-text-muted afirmava no comentario estar em >=4,5:1 e media 4,00:1. A conta anterior estava errada. Corrigido para 4,69:1, o que escurece levemente o texto secundario em TODAS as telas. O cinza que eu tinha introduzido no Command Center estava pior ainda, em 2,99:1, e virou 4,76:1. Suite completa: 1574 testes, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.307.2

  • Correção Icone de 44px, espaco morto e nota de arquitetura na tela Quatro correcoes vistas com a base demo cheia, que e quando a tela finalmente pode ser julgada. 1. ICONE DE INFORMACAO IMENSO. portal_base.css:248 forca min-height/min-width de 44px em TODO button, por alvo de toque, e o icone de 14px virava um circulo de 44px no meio do rotulo. A primeira tentativa de corrigir FALHOU EM SILENCIO e vale registrar: a regra global e um :is(button, .portal-button, ..., a[role="button"]), e a especificidade de um :is() e a do argumento MAIS ESPECIFICO da lista. Por causa do a[role="button"] ela vale (0,1,1), entao `.cc-info` sozinho (0,1,0) PERDE. Dai o `.vx-cc` na frente. A regra global esta certa e nao foi quebrada: o alvo de toque volta num ::before que cobre 44px sem ocupar espaco no layout. A letra "i" desenhada a mao virou o icone Font Awesome, que e o padrao do projeto. 2. RETANGULOS BRANCOS ENORMES embaixo do grafico e da origem. A linha usava align-items:stretch, e a coluna de Modulos cresce com a quantidade de Apps contratados, entao as vizinhas esticavam para acompanhar. Virou align-items:start: cada card com a altura do proprio conteudo. Quanto mais modulos o tenant tiver, pior seria o problema antigo. 3. NOTA DE ARQUITETURA NA TELA DO CLIENTE. "A grade e montada pelo registro de Apps: um modulo novo aparece aqui sozinho" era documentacao para desenvolvedor, exibida para quem contratou o produto. O fato continua verdadeiro e agora vive so no comentario de widgets.py. 4. NUMEROS ILEGIVEIS. "149,8h" e tecnicamente certo e operacionalmente inutil: ninguem conta seis dias em horas. Acima de 48h vira "6,2 dias" (novo Indicador.valor_humano, com teste). E "SLA em dia - peso 0,00" lia como peso zerado; o sinal na verdade SAIU da conta por falta de amostra, e os outros dois foram renormalizados. Agora diz "sem amostra no periodo". DIVIDA TECNICA PAGA: as 274 linhas de CSS sairam do <style> inline para apps/portal/static/portal/css/command_center.css. Eram reenviadas em toda visita a tela mais aberta do produto, sem cache; agora o WhiteNoise serve com hash e o navegador guarda. Teste novo garante que o link entra UMA vez e no <head>: bloco Django aninhado em bloco renderiza nos dois lugares, e a pagina fica visualmente identica quando isso acontece. Suite completa: 1553 testes, OK (rodada antes das duas ultimas edicoes de template, que foram verificadas com test_template_comments e os testes de render da view). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.307.1

  • Correção Tooltip cortado e KPI sem amostra ocupando quatro linhas Tres correcoes de leitura que so aparecem com a tela na frente. 1. TOOLTIP CORTADO. O balao de procedencia abria para CIMA e era cortado pelo topo da janela justamente nos indicadores da primeira dobra, que sao os que mais interessa explicar. Agora abre para baixo, onde sempre ha espaco porque o card continua ali. O ultimo KPI encosta na borda direita, entao ancora a direita em vez de centralizar: centralizado, metade do balao saia da tela. 2. "↓ -100%" LIA COMO QUEDA DE QUEDA. O sinal ja esta na seta. Passa a mostrar o valor absoluto, e a seta virou icone Font Awesome em vez de caractere. 3. KPI SEM AMOSTRA QUEBRAVA A FILEIRA. "Sao necessarias ao menos 5 respostas no periodo para a mediana significar algo. Houve 1." ocupava quatro linhas ao lado de numeros de uma linha. O motivo inteiro foi para o tooltip, sob o titulo "Por que nao ha numero", e a celula mostra "—" com "sem amostra no periodo". A explicacao continua alcancavel, sem destruir o alinhamento. Testes: 31, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.307.0

  • Novidade A tela inicial vira central de comando FASE 1 do Command Center, parte 2: o render. A fundacao (registro de widgets, matematica do indice, contratos) foi no commit anterior, 610c36fa. O QUE MUDA PARA QUEM ABRE A TELA -------------------------------- Health Score com os 3 sinais visiveis e a formula no tooltip; linha de indicadores com comparativo; faixa "Precisa da sua atencao" em largura cheia logo abaixo, antes de qualquer grafico; grade de modulos montada pelo registro de Apps; atividade recente vinda da auditoria; e o checklist de configuracao no rodape enquanto houver passo pendente. A sidebar diz "Command Center", nao "Dashboard": dashboard e uma tela, command center e a camada onde a operacao de todos os Apps se resume. A ROTA continua /admin/ e a view continua admin_home — renomear rota quebraria favorito de cliente e o ganho seria estetico. O baseline de rotulos do menu (test_nav_render) foi atualizado no mesmo commit, que e a regra aqui. DUAS CORRECOES QUE SO APARECERAM IMPLEMENTANDO ---------------------------------------------- 1. O MOCKUP APROVADO TINHA REDUNDANCIA. O card da Ouvidoria repetia os mesmos tres numeros da linha de cima. Resolvido de forma declarativa: cada indicador pode declarar uma chave de `rollup`, e o topo SOMA quem compartilha a chave. A linha de cima virou o TOTAL e os cards viraram a QUEBRA. Quando Compliance publicar um indicador com rollup "vencidas", ele entra na conta sozinho — nenhum `if app ==` do outro lado. 2. A PRIMEIRA VERSAO ESCONDIA INFORMACAO VERDADEIRA. Um canal com 2 manifestacoes perdia a linha de KPIs inteira junto com o Health Score. Errado: contagem e FATO e nao precisa de amostra, so o INDICE precisa. Agora so o indice se explica, e os numeros continuam. A regua nova (`tem_atividade`) separa "pouco volume" de "nunca foi usado": no segundo caso a tela troca de assunto e fala de colocar o canal em uso, em vez de exibir quatro zeros. ORCAMENTO DE QUERIES, COM O NUMERO NA MESA ------------------------------------------ O teste media so com o cache quente, o que escondia o preco dos widgets. Agora sao DOIS tetos: quente 90 (era 86). Subiu so 2 porque no mesmo commit a pagina deixou de abrir o schema publico DUAS vezes para ler o MESMO Client (deteccao de LGPD e onboarding viraram uma leitura so — o item F3 anotado na investigacao de performance). frio 110. E a primeira visita de cada minuto, e existe porque medir so o caso quente seria esconder o custo real atras do cache. Fica registrado no arquivo do teste que da para economizar mais 6 queries e por que NAO foi feito: tres loaders recontam o que _dashboard_metrics ja tem, mas as duas contagens usam definicoes diferentes de "aberta" — o loader usa OPEN_STATUSES de sla_queryset, que e o SSOT do relogio. Uma definicao unica do SLA vale mais que 6 queries. Removido tambem o polling de 60s do dashboard antigo: ele atualizava elementos por id que nao existem mais, ou seja, disparava uma requisicao por minuto, por aba aberta, para nao mexer em nada. Suite completa: 1545 testes, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.306.7

  • Correção Tapa o buraco da classificacao e conserta marco que nunca fechava Tres coisas que a mesma tela expos. 1. COLUNA EM BRANCO. O bloco de classificacao so era desenhado quando havia breakdown; sem ele o template nao renderizava nada e a coluna de 1fr continuava no grid, deixando meia dobra vazia ao lado de um grafico estreito. Agora sao tres estados: sem manifestacao nenhuma o grafico ocupa a largura toda (nao ha coluna); com manifestacoes e sem campo de classificacao o slot ensina a configurar e linka o Form Builder; com campo configurado e ainda sem resposta, ele diz que esta esperando em vez de sumir. A query extra do Form Builder so acontece quando o slot vai realmente aparecer. 2. PORCENTAGEM SEM DENOMINADOR. A politica e UMA dimensao por canal (MAX_CLASSIFICATION_FIELDS = 1, e marcar um campo desmarca o anterior em qualquer formulario). Com varios formularios, so um carrega a dimensao, entao o grafico e uma FATIA do canal: "Centro 40%" parecia 40% de tudo quando era 40% do que foi classificado. Agora a tela diz "X de Y manifestacoes tem essa informacao". Zero query: o total ja vinha de por_status. 3. MARCO INALCANCAVEL. O inventario LGPD aparecia como pendente com o inventario inteiro mapeado na tela ao lado. Causa: a migration lgpd/0015 semeia a lista padrao em TODO tenant, entao o quick-add sempre responde "ja existem" e cai no else, que nao marca nada; e as duas acoes que de fato mapeiam (associar sistema a um item, e a todos) nao marcavam marco nenhum. So fechava quem inventasse um campo do zero. Duas pontas corrigidas: as acoes reais passam a marcar, e marco tambem pode fechar por ESTADO (heal_milestones), para os tenants que ja fizeram o trabalho se consertarem sozinhos sem refazer nada. A checagem responde a pergunta do rotulo, nao "a tabela tem linha": item semeado por migration NAO conta, item com sistema associado conta. Fecha e GRAVA, entao o custo some na proxima visita e o SuperAdmin passa a ver o mesmo que o cliente. "Revisar SLA/workflow/expediente" ficam fora do registro de checagens de proposito: revisar e evento, nao estado. Nao da para olhar o banco e saber se alguem leu a tela. Custo: dashboard segue em 86 queries (heal_milestones nao roda sem LGPD, e tem throttle de 1h por tenant quando roda). Suite completa: 1496 testes, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.306.6

  • Correção Cliente volta a ver o que falta configurar, nao so a porcentagem O redesenho do dashboard trocou o checklist por um card com "91% concluido". O dado nunca saiu do backend: setup_items continuou no contexto, so parou de ser desenhado. O cliente via que faltava algo e nao tinha como saber o que, porque a lista completa so existia no SuperAdmin, que e tela nossa. Duas coisas vinham junto com isso. 1. Os SetupItem do caminho real (milestones de apps/core/onboarding.py) nasciam SEM cta_href. Redesenhar a lista sem resolver isso entregaria texto morto: o cliente le "mapear inventario de dados pessoais" e nao tem para onde clicar. Agora cada milestone tem destino em MILESTONE_ROUTES, e o destino e a MESMA view que marca o milestone, entao o link nunca leva para uma tela que nao fecha o passo. 2. A tela se contradizia: "Nada pendente agora. Operacao saudavel" ao lado do card dizendo 91% concluido. A secao de atencao nao sabia que o setup existia. Agora a pendencia de configuracao entra na lista de atencao, e o "nada pendente" fala so da operacao. E o hero comemorava 100% de ZERO: sem fila aberta, sla_health_pct cai no else e vale 100 sem nada no denominador (o tenant tinha 0 em andamento, 0 novas, 0 vencidas). Com configuracao pendente e nada com o relogio correndo, o hero passa ao estado "setup" e mostra o progresso da configuracao, que e o numero honesto do momento e o que a pessoa realmente tem para fazer. Milestones passivos ("receber a primeira manifestacao", "responder a primeira") nao viram proximo passo: fecham com uso real do canal, e apontar isso como tarefa manda a pessoa fazer algo que nao esta na mao dela. Teste de contrato novo: milestone sem destino, ou destino que nao resolve, quebra o CI em vez de virar item sem link no dashboard de um cliente. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.306.5

  • Correção Troca fa-shield-check (Pro) e trava nome morto no repo inteiro `fa-shield-check` existe no Font Awesome 6, mas so no plano Pro. O projeto carrega o Free, entao os 5 usos nao desenhavam nada: o `<i>` ficava vazio e sobrava o espaco dele, sem erro em lugar nenhum. Trocados por `fa-shield-halved`, que ja e o escudo usado em outros 51 pontos do backoffice: lgpd/detail.html selo de compliance validado, badge "Alta confianca" (gerado por JS) e botao "Finalizar e Registrar" compliance_audit botao "Validar documento" backoffice.py icone do grupo "Qualidade" nos relatorios Esse ultimo so apareceu porque a varredura tambem olhou `.py`: nome de icone nao vive so em template, ha dicionario de menu com a chave "icon". A CHECAGEM VIROU REPO INTEIRO apps/core/tests/test_icones_font_awesome.py, ao lado do detector de comentario multi-linha — mesma familia de defeito: nao quebra nada, nao aparece em log, so falta na tela. A lista de nomes mortos e CURADA, nao exaustiva: validar contra o catalogo inteiro do Font Awesome exigiria baixa-lo. Ela bloqueia os que ja se provaram mortos aqui (`fa-refresh`, `fa-clock-o`, `fa-sign-in`, `fa-shield-check`) mais os classicos da v4 que se copia de exemplo antigo. Achou um icone que nao renderiza? Troca o nome E acrescenta o velho na lista. Alias que continuam validos na v6 (`fa-cog`, `fa-info-circle`, `fa-exclamation-triangle`, `fa-check-circle`, `fa-edit`) NAO entram: marca-los como mortos criaria trabalho inutil e ensinaria a ignorar o teste. Ha teste do proprio detector, porque regex quebrada vira decoracao, e guarda contra glob vazio, que faria tudo passar sem ler arquivo nenhum. As duas copias por template escritas no commit anterior sairam: duas listas para manter significa uma sempre desatualizada. Suite completa: 1479 testes, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.306.4

  • Correção Redesenha Uso & Cotas e trava classe e icone orfaos A tela estava fora do padrao das outras paginas de Configuracoes, e por motivos concretos: - Os Creditos de IA viviam num segundo container de largura inteira, DEPOIS do fechamento do grid. Por isso atravessavam a tela enquanto o resto respeitava duas colunas. - O cabecalho desse bloco usava `uq-card-hdr`, classe que nao existe no CSS. Saia sem borda e sem espacamento, diferente de todos os outros cards. - Faltava acento no bloco inteiro: "Creditos IA", "creditos usados este mes", "Os creditos sao renovados". - Tres emojis no lugar de icone, contra o padrao do resto do backoffice. - A barra de armazenamento tinha 10px e cor calculada no Python; a de creditos, 8px e cor calculada no template. Mesma regra em dois lugares, e a cor vinda do servidor ignorava o tema escuro. - O card do plano listava "Upload max. (plano) 50 MB" e "Upload efetivo 15 MB" na mesma sequencia, sem dizer que um e teto e o outro e escolha. Agora: grid unico de 1160px com os creditos como medidor igual ao de armazenamento, um componente so de medidor, cor por classe (que respeita o tema), e o card do plano separado em "Incluido no plano" e "Em vigor neste canal". `bar_color` saiu da view junto. ICONES QUE NAO RENDERIZAVAM O projeto carrega Font Awesome 6 SEM os shims da v4, entao nome antigo nao desenha nada e a falha e calada: some o icone, sobra o espaco. Corrigidos em custom_domain.html `fa-refresh`, `fa-clock-o` e `fa-sign-in`, herdados do template antigo no redesign de hoje de manha, e o resto normalizado para `fa-solid fa-<nome-v6>`, que e o padrao do backoffice. Fica pendente `fa-shield-check` em 4 lugares: e icone Pro, nao existe no Free. Nao mexi porque esta fora destas duas telas. TESTES DE CONTRATO Classe inventada em template nao quebra nada: a pagina responde 200 e so fica torta. Agora ha teste, nas duas telas, de que toda classe usada no markup existe no CSS, de que nenhum icone e nome morto da v4 e de que nao ha emoji. Na primeira execucao ele achou `uq-main` e `cd-main` sem definicao nos meus proprios templates — as duas ganharam `min-width:0`, que filho de grid precisa para nao estourar a coluna. Suite completa: 1477 testes, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.306.3

  • Correção Uma URI de retorno por tenant, nao uma por dominio O cliente com dois enderecos nao conseguia entrar pelo segundo: AADSTS50011: The redirect URI 'https://lgpd.upflux.ai/identity/login/sso/microsoft/callback/' does not match the redirect URIs configured for the application A redirect URI saia de request.build_absolute_uri(), entao seguia o host de onde o usuario clicou. O App Registration tinha uma URI so, a do primeiro endereco. Cada endereco novo virava um chamado pedindo ao cliente que editasse o Azure — e quem vende endereco adicional e a plataforma. Agora existe um endereco de retorno por TENANT (SSOConfig.callback_host), com padrao no subdominio da plataforma, que e nosso e deriva do schema_name (imutavel). Um dominio proprio nao serve de ancora: o cliente pode remover e o SSO cairia junto. Tres coisas quebravam nesse caminho e foram junto: 1. O state morava na SESSAO. Com o callback num host fixo, a sessao de la e outra: state, nonce, `next` e o modo popup passam pelo cache, chaveados pela assinatura do state. De quebra o state virou uso unico. 2. Cookie de sessao nao atravessa dominio. Autenticar no host de retorno deixaria o usuario logado LA. Entao o callback nao faz login quando o fluxo comecou em outro endereco: emite um ticket e o endereco de origem troca por sessao (SSOHandoffView). O ticket e credencial de login — 60s, uso unico, amarrado ao host de destino e ao tenant. 3. O popup avisa a janela de tras por BroadcastChannel, localStorage e postMessage, todos presos a ORIGEM. Por isso o popup navega ate o handoff antes de avisar: la a origem e a mesma da janela que abriu e os quatro canais voltam a valer. Detalhes que custaram tempo: - request.get_host() devolve a porta quando ela nao e a padrao do esquema. Comparar string crua fazia exemplo.com e exemplo.com:443 parecerem enderecos diferentes, recusando ticket legitimo. Ha normalizar_host(). - /identity/login/sso/ e um ALIAS em project/urls_tenant.py que casa <str:provider>, entao um path escrito a mao mandava "handoff" para o SSOStartView achando que era um provedor. A URL sai do reverse(). A tela de SSO passa a mostrar a URI unica, com o aviso de que endereco novo nao exige nada. NAO COBERTO: o SAML monta ACS e entity id do mesmo jeito (sso.py:383-386) e tem o mesmo problema. Ficou de fora porque nenhum tenant usa SAML hoje e python3-saml nao instala nesta maquina, entao a mudanca iria sem teste. AÇAO NECESSARIA: tenant com SSO ja configurado precisa registrar a URI nova no provedor. Adicionar a nova mantendo a antiga faz a troca sem downtime. Testes: 17 novos (endereco estavel quando o tenant ganha dominio, authorize e troca de token com a mesma URI, ticket de uso unico/host/tenant, contexto atravessando host, handoff criando sessao). Suite completa: 1467, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.306.2

  • Correção Cota do catalogo volta a valer, e a tela mostra quanto sobra Nenhum tenant conseguia adicionar endereco proprio, nem o primeiro. A tela respondia "voce atingiu o limite do seu plano" para quem tinha zero. max_domains sai de plan.features["domains_included"], e dois caminhos independentes apagavam essa chave: 1. auto_seed: get_or_create nao toca em linha existente, e a lista que sincroniza features de plano ja criado nao tinha `domains_included`. Todos os planos de producao nasceram antes dessa chave existir. 2. PlanForm.build_features_dict() reconstroi `features` do zero, entao toda chave que ele nao escreve some do catalogo no primeiro save feito no SuperAdmin. `domains_included` nao era campo do form. Com a chave ausente, pf.get("domains_included", 0) da 0, a cota da 0 e o gate `_ja >= 0` recusa qualquer endereco. O que mudou: - auto_seed faz BACKFILL da chave (so quando ausente, para nao passar por cima do numero ajustado no SuperAdmin, que e o SSOT editavel). Trata ausente != None: o Enterprise e negociado, nao zero. - PlanForm ganha o campo "Enderecos proprios incluidos", vazio = negociado, validado contra o teto absoluto da plataforma. - Sai o gate de dominio unico do add_domain. Era da epoca de um endereco por tenant: recusava o 2o endereco de um plano que inclui 2 e mandava REMOVER o que ja estava no ar. O resto do handler ja era multi-dominio. - `dominios_esgotou` passa a considerar cota ZERO como cota cheia. Antes o `_cota > 0 and` fazia a tela mostrar um formulario que sempre falhava e esconder a oferta do add-on. Se a leitura da cota falhar, o formulario continua visivel: o gate do POST tambem falha aberto. - Mensagem de erro separa "seu plano nao inclui" de "voce ja usou os N". Tela redesenhada no padrao das outras paginas de Configuracoes: duas colunas de 1100px no lugar da coluna unica de 720px, o dominio como protagonista do card, ajuda e CNAME na lateral fixa, medidor de cota visivel antes de o cliente tentar. Emojis trocados por Font Awesome. Testes: o contrato que faltava (salvar o plano no SuperAdmin nao pode apagar a cota), o backfill do seed incluindo o caso None do Enterprise, o segundo endereco sendo aceito, e cota zero escondendo o formulario. Suite completa: 1450 testes, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.306.1

  • Correção Resposta ao lead sai do comercial e volta pra ele A resposta do painel saia do DEFAULT_FROM_EMAIL, que e o no-reply do transacional, e sem Reply-To. Quem recebia respondia para uma caixa que ninguem le. De dentro do painel isso nao parece erro: parece lead que nao respondeu. Agora sai de VOXLI_SALES_EMAIL (comercial@voxli.com.br), com Reply-To para a mesma caixa, assinada com o nome e o cargo de quem responde (a mesma assinatura por membro que a prospeccao ja usava), em texto e HTML simples. Sem card de e-mail transacional: e conversa de pessoa para pessoa, e a moldura da plataforma e o que faz parecer robo. O assunto virou campo do formulario. O antigo era "Re: Enterprise — Voxli", que nao diz nada para quem recebe. Tres bugs achados no caminho: 1. send_mail devolvendo 0 nao levanta, e o backend do Resend devolve 0 sem chave de API. O lead era marcado como "Respondido" e a tela dizia "Resposta enviada" sem nada ter saido. 2. O backend do Resend extraia so o endereco de "Nome <endereco>": a assinatura por membro da prospeccao chegava anonima na caixa de entrada. 3. get_sa_member devolve membro VIRTUAL (sem pk) para superuser sem cadastro, que e como o superadmin de boot entra. Gravar a interacao com ele levantava, o except engolia, e a resposta nao aparecia na timeline. Responder por fora tambem passou a ser registravel: sem isso, responder pelo Gmail deixava o lead como SLA vencido para sempre e fora da media de tempo de resposta, porque replied_at so era gravado pelo botao. O alerta interno de lead novo (VOXLI_CONTACT_EMAIL) agora cai no comercial quando a variavel nao esta setada, em vez de no proprio no-reply. Testes: 29 novos (apps.superadmin.tests.test_lead_reply, apps.core.tests.test_resend_backend). Suite completa: 1442 testes, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.306.0

  • Novidade Exclusao de dados que realmente apaga, com 90 dias de aviso Duas coisas: consertar a exclusao que ja existia e automatizar a janela de retencao. A ordem importou — automatizar antes teria automatizado uma exclusao sabidamente incompleta. 1. O BOTAO DA ZONA DE PERIGO NAO APAGAVA OS ANEXOS `client.delete(force_drop=True)` derruba o schema, e a tela prometia "os dados do tenant deixam de existir e nao ha restauracao". Mas os anexos nao moram no banco: moram no Cloudflare R2, sob `tenants/{schema}/`. O DROP nao encostava neles. Todo tenant excluido ate hoje deixou documento, foto e laudo no bucket — falso justamente no dado mais sensivel, e o tipo de coisa que aparece numa auditoria de LGPD. Novo `apps/core/tenant_purge.py` varre o R2 por prefixo ANTES do DROP. A ordem e obrigatoria: o indice do que apagar (`storage_path`) mora DENTRO do schema, entao inverter transforma os objetos em lixo anonimo que ninguem mais consegue associar a ninguem. Falha parcial NAO derruba o schema. Um tenant meio-apagado da para retomar; um schema destruido com arquivos orfaos, nao. Duas guardas: schema vazio levanta (o prefixo viraria "tenants/" e apagaria o bucket INTEIRO — e `getattr(tenant, "schema_name", "")` devolvendo vazio ja aconteceu neste repo), e public/demo/voxli sao intocaveis. 2. JANELA DE EXCLUSAO: 90 DIAS COM AVISO D+30 da inadimplencia (cancelamento) marca `to_delete` e comeca a contagem. 90 dias depois, purga. Nesse periodo nada e apagado: reativar devolve tudo no lugar. Prazo longo de proposito. Os dados nao sao do cliente, sao dos solicitantes, e manifestacao de ouvidoria tem dever de guarda. Apagar rapido resolveria custo de storage e criaria problema juridico. AVISOS NAS PONTAS, NAO DE 15 EM 15 A sugestao original era espacamento fixo. Isso poria o ultimo aviso em D+75 e a exclusao em D+90, deixando as duas semanas finais em silencio — justo quando a perda vira iminente. Ficou: ao marcar, na metade, e na ultima semana. Mais uma confirmacao DEPOIS do fato, que encerra o assunto para o cliente e serve de prova de cumprimento. O e-mail e explicito sobre ANEXOS. "Seus dados" faz a pessoa imaginar cadastro; o que se perde e documento, foto e laudo enviados por solicitantes. INVARIANTE CENTRAL: sem `marked_for_deletion_at`, NUNCA purga. A ausencia da marca significa "este cliente nunca foi avisado", e apagar sem aviso e o unico erro aqui que nao tem conserto. Kill switch proprio: DUNNING_PURGE_ENABLED=0 congela so a exclusao, sem congelar cobranca. Vale mais que os outros dois porque o efeito e irreversivel. O CI PEGOU UM ERRO MEU O contract test de vocabulario barrou "cidadao": o termo do produto e "solicitante", porque quem registra manifestacao pode ser colaborador ou fornecedor, nao so cidadao. Corrigidas 10 ocorrencias. Na primeira tentativa verifiquei com `grep "cidad[aa]"` e li "nenhuma ocorrencia" como prova — mas o contrato usa IGNORECASE e varre .txt, entao meu filtro era mais fraco que a regra e deixou passar "CIDADAO" em caixa alta. Revalidado rodando a MESMA regex do contrato. Testes: 1413 passando (eram 1385), 28 novos. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.305.3

  • Correção Varredura de portugues BR em todos os 20 kinds Sequencia da correcao anterior, agora no catalogo inteiro. 26 trechos que o cliente le sem acentuacao nenhuma, em falha de pagamento, suspensao, aviso final, renovacao, boleto, proposta comercial e creditos de IA. Palavras corrigidas: cobranca, cartao, sera, nao, renovacao, liberacao, compensacao, aprovacao, apos, ja, estao, disponiveis, proxima, ultimo, documentacao, seguranca, juridico, creditos, ate, valida, alem, duvida, condicoes, pagina. Corrigido tambem "e" conjuncao onde cabia "e" verbo, em frases como "A liberacao e automatica". Verificacao: os 20 kinds foram renderizados de ponta a ponta, nenhum levanta. Comentarios de codigo ficaram como estao, seguindo a convencao sem acento do repositorio. A SUITE PEGOU UMA REGRESSAO, E ELA REVELOU UM TESTE FRACO `test_boleto_avisa_que_nao_e_automatico` fixava a string literal "nao e cobrado automaticamente" e caiu ao acentuar o e-mail. A rede funcionou. Ao corrigir, o vizinho apareceu: `test_boleto_nao_menciona_cartao_ cadastrado` fazia assertNotIn("cartao cadastrado"). Se o texto do boleto passasse a dizer "cartao cadastrado" COM acento, a asercao passaria vazia — e esse teste existe justamente para impedir a mentira que originou o arquivo ("a renovacao sera feita automaticamente no cartao cadastrado" indo para quem paga boleto). Estava a um acento de virar decoracao. Os dois passaram a comparar texto normalizado (`sem_acento`). A asercao verifica o SIGNIFICADO, nao a ortografia: revisao de portugues nao quebra teste de copy, e a asercao negativa nao passa mais de graca. Testes: 1385 passando. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.305.2

  • Correção Portugues correto nas telas e no aviso final de cobranca O cliente inadimplente lia "A ultima tentativa de cobranca foi recusada. Seu canal sera suspenso... caso o pagamento nao seja regularizado" e "Confira se o cartao tem limite disponivel e esta valido". Texto sem acentuacao nenhuma, na tela onde a Voxli mais precisa soar confiavel: a que pede dinheiro. Corrigido na tela de Plano & Assinatura (8 trechos) e no e-mail hold_final (6 trechos): ultima, cobranca, sera, nao, cartao, disponivel, esta valido, cobrancas, tambem, comecar, Ultimo. Junto, uma regencia errada no aviso do checkout: "cobrar seu cartao o valor acima" virou "cobrar o valor acima no seu cartao". MEDIDO, NAO SUPOSTO Antes de sair corrigindo, contei se o texto sem acento era convencao deliberada (plain-text ASCII costuma ser). Nao e: nos e-mails, o HTML tem 15 blocos com acento e 5 sem; o texto puro tem 8 com e 12 sem. E inconsistencia. Corrigi o que este trabalho escreveu; os demais kinds (failed, suspended, trial, refunded) seguem mistos e ficam registrados como pendencia. Testes: 1385 passando. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.305.0

  • Novidade Fase de hold — o cliente perde o acesso, nao a assinatura Fecha a lacuna que transformou uma recusa de cartao em perda total da conta. Antes, "grace expirou" e "assinatura destruida no gateway" eram o MESMO evento: o job suspendia e mandava DELETE no Pagar.me no mesmo tick. Junto com a assinatura ia o cartao tokenizado — o unico ativo que traria o cliente de volta em um clique. Agora sao tres fases, e o gateway so e tocado na ultima: GRACE acesso normal, assinatura viva 3 dias (1 em recusa dura) HOLD admin BLOQUEADO, assinatura AINDA VIVA ate D+30 CANCELAR DELETE no gateway D+30 O portal publico continua no ar nas tres. Em ouvidoria, tirar o canal do cidadao por causa do cartao do orgao seria errado — e e justamente por isso que o grace pode ser curto: a pressao cai inteira sobre o admin, sem dano colateral. SERVICO DE DOMINIO (services/dunning.py) A decisao sai do job e vira funcao pura, testavel dia a dia sem banco. O que havia la eram as regras 5b e 5c: ~150 linhas de if/elif onde a 5b ("period_end passou -> corta") disparava ANTES de a escada de dunning da 5c ser avaliada. A regra que isto passa a honrar e a do proprio repo, escrita em billing_cycles.py:12-20: "toda decisao de cobranca passa por servico de dominio, NUNCA por condicional solta em view, webhook ou job". DOIS PROBLEMAS QUE SO APARECERAM ESCREVENDO O CODIGO 1. Assinaturas ja em past_due ANTES do PR 1 nao tem `dunning_cycle_started_at` e seriam avaliadas como "dia 0" todo dia, para sempre: congeladas em grace, nunca cobradas, nunca cortadas. O job passa a criar a ancora. Ela e gravada como AGORA, e nao derivada de `last_payment_failure_at`: derivar do passado daria a janela por vencida e cancelaria em massa, no primeiro deploy, todo mundo com mais de 30 dias de atraso. 2. 27 dias de hold em silencio seria pior que nao ter hold. Novo kind `hold_final`, um unico aviso na reta final, com paleta propria (sem ela o _PALETTES.get(kind, "renewed") pintaria "sua assinatura sera cancelada" em verde de sucesso). Ancorado no CICLO, nunca em "hoje": o job roda todo dia e ancorar na data faria o ULTIMO AVISO sair diariamente. KILL SWITCH, e por que nao basta `git revert` DUNNING_HOLD_ENABLED=0 congela a regua: nenhuma transicao nova, e quem ja esta em hold fica intocado. Reverter o codigo ressuscitaria a regra antiga e cancelaria no gateway, em massa, todo tenant que estivesse em hold naquele instante — o revert seria o incidente. Ha teste para essa promessa. O TESTE ANTIGO VIRA DOCUMENTACAO DO ERRO test_billing_dunning_ladder cobria a escada D+3/D+5/D+7 e ficou verde por oito meses sobre codigo morto, porque montava o cenario com `current_period_end = agora + 30 dias # grace vivo` — ja partindo do estado que o caminho do cartao recusado nunca produzia. Reescrito como invariantes, com a licao no cabecalho: teste de regua parte do EVENTO real, nao de um estado montado a mao que ja pressupoe o que se quer provar. Junto, dois itens menores do mesmo plano: - Billing Monitor distingue "Em atraso" (com acesso, bloqueia em X) de "Suspensa (recuperavel ate X)". Com o hold, `past_due` sozinho deixou de dizer o que importa: e a diferenca entre acompanhar e ligar hoje. - A reconciliacao com o gateway para de corrigir em silencio. Ela ja existia e e boa (cascata de 4 niveis), mas divergencia sistematica era indistinguivel de operacao normal. Agora emite FINANCIAL_RECONCILIATION_MISMATCH no painel Saude da Plataforma. Removido: DUNNING_PRIMEIRO_DIAS/FINAL_DIAS/SUSPENSAO_DIAS e _send_dunning_email viraram codigo morto. Numeros que nao regem nada sao piores que numero nenhum. NAO INCLUI cobranca automatica (PRs 4a/4c/4d). Ela depende do spike na doc da Pagar.me sobre sincronizar a data de cobranca apos pagamento fora da recorrencia; sem essa resposta ha risco real de cobranca dupla. Testes: 1374 passando (eram 1344), 30 novos. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.304.1

  • Correção Reativacao parava de dizer "Pagamento confirmado" sem pagamento INCIDENTE (18/08/2026, JS Participacoes) O cliente clicou em reativar, a cobranca foi recusada (codigo 1007), e as 9:00 chegaram DOIS e-mails no mesmo minuto, para o mesmo endereco: "Pagamento confirmado — acesso garantido por mais 30 dias, ate 18/09" "Nao conseguimos processar seu pagamento" O primeiro era falso. CAUSA A reativacao tratava como sucesso qualquer resposta de POST /subscriptions que trouxesse um id. A Pagar.me devolve id e `status: "active"` MESMO com a primeira cobranca recusada — esse "active" e o status da ASSINATURA, nao do pagamento. O checkout ja conferia o status da charge desde sempre (views_checkout.py:770); a reativacao nunca conferiu. Mesmo padrao dos dois fixes anteriores de hoje: caminho principal blindado, self-service esquecido. O FIX NAO E "DETECTAR A FALHA" O problema mais fundo e que esta view NUNCA teve como saber se o dinheiro entrou: a Pagar.me confirma a cobranca de forma assincrona, entao "nao sei" e o caso comum, e tratar "nao sei" como "deu certo" era a raiz. Por isso o e-mail de confirmacao sai da reativacao por completo. Quem sabe e o webhook, e `_handle_subscription_renewal` ja dispara o kind "renewed" quando a cobranca e efetivamente paga (billing_webhook.py:1062) — o envio daqui era, no melhor caso, duplicado. Alem disso: - `_status_primeira_cobranca()` distingue paid / failed / desconhecido. Vazio significa "nao da para saber", jamais "deu certo". - Cobranca recusada: nada e ativado, o codigo ABECS e gravado, e o cliente le a mensagem do catalogo em vez de "reativado com sucesso". O estado de inadimplencia continua sendo do webhook, para nao criar duas fontes de verdade correndo uma contra a outra. - Pagamento ainda nao confirmado: a mensagem passa a dizer que estamos confirmando com o banco, em vez de afirmar que deu certo. De quebra, o e-mail falso prometia acesso ate 18/09 enquanto a tela dizia 25/08: a reativacao gravava period_end = +1 mes e o webhook do PR 1 sobrescrevia com now + grace. Confirma por aritmetica que o grace_days de producao e 7, e nao os 15 do seed. Testes: 1344 passando (eram 1334), 10 novos. Um deles trava especificamente a leitura de `status: "active"` da assinatura como prova de pagamento. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.304.0

  • Novidade A tela passa a dizer POR QUE a cobranca falhou e o que fazer Motivado pelo caso real da JS Participacoes. Depois do fix do corte prematuro (c34e2fbb), o cliente clicou em "Tentar novamente" com o mesmo cartao e tomou codigo 1007 pela segunda vez. Nao foi erro dele: a tela mandava "atualize seu cartao se o atual esta vencido ou sem limite", conselho que para 1007 e simplesmente errado — quem bloqueou foi o banco emissor, e outro cartao do mesmo banco recusaria igual. CATALOGO (services/decline_codes.py) Escrito a partir da tabela EMV completa da Central de Ajuda da Pagar.me, lida por inteiro. Keyed pelo CODIGO, nunca pelo texto: a ABECS muda o conteudo de acquirer_message em 28/08/2026. A classificacao NAO e "dura x mole" binaria, como estava planejado. 1001 (cartao vencido) e 1007 (emissor bloqueou) sao ambas definitivas e pedem acoes OPOSTAS: numa, trocar de cartao e a unica saida; na outra, trocar de cartao e exatamente o que nao resolve. Colapsar as duas produziria conselho errado em metade dos casos. Sao tres familias — ligar_banco, trocar_cartao, aguardar — cada uma com seu teto de retentativas. Um catalogo, tres consumidores: tela, copy e politica de retry. Se cada um tivesse a sua tabela, divergiriam no primeiro ajuste. EXTRACAO: acquirer_return_code, NAO gateway_response.code O codigo ABECS vem no primeiro; o segundo costuma trazer o status da chamada (200). O retry_charge lia o campo errado, entao todo 1007 viraria "200" e cairia no texto generico — o mesmo bug, so que silencioso. Ha teste com os dois campos preenchidos com valores diferentes. VAZAMENTO DO TEXTO DO ADQUIRENTE backoffice.py jogava gateway_response.message cru na tela. O checkout ja protegia disso desde aee0eb96 (views_checkout.py:24-36) e o retry nao. Fechado, com teste que trava a regressao. TRES CORRECOES QUE O CASO REAL EXPOS, fora do plano original: 1. current_period_end aparecia como "Seu canal sera suspenso em 25/08" no alerta vermelho e como "Renova em 25/08" e "Proxima cobranca 25 de Agosto" logo abaixo. O campo muda de significado conforme o status, e o rotulo nao acompanhava. Durante past_due os dois viram "Prazo para regularizar". O PR 1 tornou esse estado comum, entao a contradicao deixou de ser rara. 2. "Tentar novamente" some quando a recusa torna a retentativa impossivel (vencido, perdido, conta encerrada), e vira "Ja falei com o banco, tentar de novo" quando a recusa e do emissor. 3. messages.success("Bem-vindo!") do login vazava para a tela de assinatura cancelada, pintando um alerta VERDE de sucesso. Nao drenei a fila: isso mataria tambem o "Cartao atualizado" legitimo, que volta por redirect para a mesma tela. As mensagens proprias passaram a ser marcadas com extra_tags e a tela so exibe essas. Escopo: PR 4b do plano, antecipado a pedido — a tela muda de estado sem depender do motor de retentativa nem da maquina de estados. Testes: 1334 passando (eram 1312), 22 novos. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.303.2

  • Correção Recusa de cartao nao corta mais o cliente no mesmo dia O incidente: em 13/08 a renovacao da JS Participacoes foi recusada as 00:54 (codigo 1007) e as 03:00 do MESMO DIA o job cancelou a assinatura no Pagar.me e suspendeu o tenant. Duas horas entre a recusa e o corte, um unico e-mail, e o cartao tokenizado destruido junto com a assinatura — que era justamente o ativo que recuperaria a conta. A causa: _handle_payment_failed gravava past_due mas NAO tocava em current_period_end. Como na renovacao o period_end ja venceu, o job caia na regra 5b ("period_end passou -> suspende + DELETE") e a escada de dunning da 5c (D+3/D+5/D+7) nunca chegava a ser avaliada, porque o bloco la e if/elif. A regua existia e nunca disparou. O caminho gemeo 4b (boleto / vencimento silencioso) sempre fez isto certo. Aqui o webhook passa a produzir o MESMO estado, honrando a regra que o proprio job declara: "nenhuma assinatura e suspensa sem passar por past_due + grace completo". A janela so e aberta na ENTRADA em inadimplencia (not was_already_past_due). Cada retentativa do gateway chega como um payment_failed novo, e se todas reabrissem a janela o prazo deslizaria para sempre. Junto vai dunning_cycle_started_at, ancora estavel do ciclo, imune a retentativa ao contrario de last_payment_failure_at. Por que os testes nao pegavam: test_billing_dunning_ladder monta o cenario com current_period_end = agora + 30 dias ("grace vivo"), ou seja, ja parte do estado que o caminho do cartao recusado nunca produzia. Tambem corrige dois dubles que o fix desatualizou em test_billing_conciliacao: _FakeClient ganhou state/save (sem eles o handler levantava AttributeError, era capturado, e o teste passava verde escondendo metade da transicao), e GlobalConfig passa a devolver None em vez de MagicMock — int(MagicMock()) e 1, entao a janela nascia com 1 dia em vez dos 15 do catalogo sem nada falhar. Escopo deliberado: PR 1 de 6. Sobe sozinho e nao depende do resto do plano, entao herda o grace de 15d do GlobalConfig e a regua D+3/D+5/D+7 que ja existe. Os 3 dias de grace e a fase de hold ate D+30 vem nos PRs 2 e 3. O estado intermediario e mais generoso que o alvo, e isso e o certo: enquanto a maquina nova nao esta no ar, o erro seguro e dar tempo demais ao cliente, nao de menos. Testes: 1312 passando (4 novos em test_dunning_cartao_recusado, os 3 primeiros escritos falhando contra o codigo pre-fix). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.303.0

  • Novidade Envio automatico, com os freios que impedem queimar o dominio A fila passa a sair sozinha. O scheduler que ja existia despacha a cada minuto; nao foi preciso worker novo nem cron do Railway. O que foi construido, na ordem em que importa: FREIOS (a maior parte do codigo e disto) - SendingProfile nasce DESLIGADO e exige dominio proprio. Remetente no dominio do transacional e recusado com mensagem explicita: e ele que manda protocolo, fatura e recuperacao de senha. - `dns_confirmado` e trava independente do status: sem ela nada sai, nem no modo Ativo. Existe para o caso de alguem mudar para Ativo antes de o DNS propagar, que e quando o dano e maior. - Rampa de aquecimento de 28 dias (20/dia ate 400). Provedor mede VARIACAO: sair de zero para centenas parece conta invadida. - Janela de 8h as 18h em dia util, e lote de 5 por rodada para espalhar. Mandar a cota inteira em minutos entrega o mesmo volume com um pico, e pico e o padrao que separa disparo de conversa. - Parada de emergencia que desliga E cancela a fila. So mudar o status deixaria as mensagens prontas para sair quando alguem religasse. PADRAO DE REMETENTE EM MASSA (Gmail/Yahoo, fev/2024) - List-Unsubscribe + List-Unsubscribe-Post de um clique (RFC 8058), com endpoint publico sem CSRF: o Gmail faz POST direto, sem cookie. Se isso falha, a mensagem passa a ser tratada como se nao tivesse saida e quem quer sair usa o botao de spam, que custa reputacao. - Webhook do Resend fecha o laco: bounce permanente e reclamacao viram supressao na hora. Bounce leve nao suprime — caixa cheia volta. - Painel mostra a taxa de reclamacao contra o teto de 0,3% do Gmail. LISTA DE SUPRESSAO Tabela propria, nao campo no lead. Sobrevive a exclusao de campanha, a reimportacao de planilha e a exclusao do proprio contato: se morasse no lead, apagar o registro faria a pessoa voltar a receber. O corpo da mensagem e CONGELADO no enfileiramento. Editar a campanha depois nao reescreve o que ja estava aprovado para sair. Falha fechada no webhook: sem WEBHOOK_RESEND_SECRET ele recusa tudo. Um endpoint de supressao aberto deixaria qualquer um suprimir endereco arbitrario. A tela mostra em vermelho quando o segredo falta. FALTA DNS, E NAO E CODIGO: dominio de envio separado verificado no Resend (SPF, DKIM, DMARC) e as 3-4 semanas de aquecimento. A tela de Envio lista exatamente o que falta. Testes: 49 novos. A maioria prova que NADA saiu — teste que so confirma que o envio funciona passaria com todos os freios quebrados. Suite superadmin + core: 749 testes, verde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.302.0

  • Novidade A campanha para de assinar como suporte, e da para excluir Tres coisas que travavam a prospeccao, todas confirmadas na tela. 1. O e-mail frio saia assinado "suporte@voxli.com.br". A assinatura caia em `user.get_username()`, e o superadmin de boot se chama assim: quem puxava conversa com o cliente aparecia como o endereco onde se reclama. Agora a assinatura e por membro (nome, cargo, caixa de envio), configuravel pela propria pessoa em Hoje > Minha assinatura, sem depender do superadmin. O fallback termina em marcador visivel, nunca no e-mail de login. 2. Nao existia rota para excluir campanha. Existe, com o nome digitado a mao e audit. Os contatos ficam: a FK e SET_NULL, entao lead, historico e opt-out sobrevivem; some so o agrupamento da leva. 3. `campanha_iniciar` nao revalidava nada. A Revisao mostrava os bloqueios e o POST passava por cima deles, que foi como uma campanha com 0 alvo apareceu Em andamento. As checagens sairam da view para uma funcao unica que a Revisao mostra e o Iniciar exige. De quebra, 3 chamadas de audit passavam `target=`, que nao existe no modelo. O TypeError caia no `except Exception` e o registro nunca era gravado: o opt-out de quem pediu para nao ser contatado, justamente o que precisa ser comprovavel, nao estava em lugar nenhum. O botao do Gmail agora abre na caixa configurada (authuser) e mostra qual e. Continua sem disparo automatico: isso depende de dominio de envio separado. Testes: 23 novos (assinatura, exclusao, guard de inicio). Suite superadmin completa em 416 testes, verde, contratos de URL e permissao inclusos. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.301.0

  • Novidade A aba vira produto, e o nome do tenant para de ir cru no HTML REDESENHO DA ABA ---------------- Cinco caixas de e-mail vazias empilhadas e formulario, nao interface. Viraram uma lista que cresce sob demanda: comeca com um campo, "Adicionar e-mail" abaixo, cada linha com seu botao de remover. O botao some no teto de 5 — affordance que nao faz nada e pior que affordance ausente. Checkbox cru virou toggle, na mesma linguagem visual de settings_notifications (nao inventei dialeto novo). Tres secoes com hierarquia real, separadas por hairline, no lugar de tudo empilhado num bloco. Os "sempre enviados" ganharam cadeado e ficaram visualmente quietos: sao informacao, nao controle. O historico deixou de mostrar "dunning_d7" para o cliente. get_kind_label() no model traduz para "Aviso final antes da suspensao". O card do SuperAdmin usa o mesmo rotulo. ESCAPING (o que faltava da Fase 4) ---------------------------------- `tenant_name` vem de client.display_name, que o proprio tenant edita em Configuracoes, e era interpolado CRU em f-string em ~60 pontos dos 19 kinds. Escapado NA ENTRADA de _build_content, um lugar em vez de sessenta: a regra "lembre de escapar aqui" nao sobrevive ao proximo kind escrito no automatico. Efeito colateral aceito e documentado: nome com "&" aparece como "&amp;" na versao TEXTO do e-mail. BACKLOG (Fase 5) ---------------- Comando billing_notify_backlog para alcancar quem ficou marcado por metadata["dunning_emails_sent"] sem ter recebido nada. Default e --dry-run; --send exige --confirm-count=N batendo com o total, entao se o estado mudou entre a inspecao e a execucao ele aborta em vez de disparar para um conjunto diferente do revisado. NOTA SOBRE UM TESTE ------------------- test_gerente_ve_mas_nao_recebe_formulario checava a AUSENCIA de name="bn_email_0". Depois do redesenho esse campo so nasce no servidor quando ja ha destinatarios, entao a asercao passaria mesmo com o formulario inteiro renderizado para o Gerente. Trocado por id="bn-form", que existe exatamente quando o usuario pode editar, mais um teste novo que salva um endereco antes de renderizar. NAO feito de proposito: remover o dual-read das flags legadas. O backfill gravou chaves com prefixo `backfill:`, que nao colidem com as chaves reais (por destinatario). Tirar a checagem legada agora faria tenant com dunning_d7_sent=True gerar chave nova sem linha correspondente e receber o aviso de novo. Precisa de um ciclo em producao antes. 1225 testes, verde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.300.0

  • Novidade O tenant escolhe quem recebe, e fica registro de quem recebeu Fecha o que a Fase 1 deixou aberto. Antes, o e-mail de cobranca ia para client.metadata["admin_email"], gravado no checkout e nunca mais exposto: o tenant nao sabia o destino, nao podia trocar, e nao podia incluir o financeiro. Em orgao publico isso importa — o aviso de renovacao existe para o setor de compras abrir o empenho, e so chegava em quem assinou. E nao havia registro de envio nenhum no control plane. Foi por isso que uma regressao nesse caminho ficou invisivel por meses, duas vezes. SERVICO UNICO ------------- apps/superadmin/services/billing_notifications.py substitui as tres copias divergentes da mesma logica (webhook, dunning, aviso de renovacao). - Uma linha de log por DESTINATARIO, nao por evento: permite representar "financeiro@ recebeu, compras@ falhou" sem precisar de duas tabelas. - Claim-then-send: quem cria a linha e o dono do envio, entao dois workers nao mandam "ULTIMO AVISO" duas vezes. Log ANTES do envio; gravar depois seria at-least-once. Em falha, o claim e APAGADO e vira auditoria com chave propria, senao um soluco do Resend silenciaria o aviso para sempre. - event_anchor(kind, sub): tabela explicita por kind, LEVANTA para kind desconhecido. Ancora errada nao duplica aviso, some com ele. - SEMPRE_ENVIA (failed, dunning_d3, dunning_d7, suspended) ignora o banco: nenhum estado consegue silenciar aviso de suspensao. - Toda escrita de log em transaction.atomic() proprio capturando ProgrammingError: em rolling deploy a tabela pode nao existir, e excecao capturada sem rollback envenena a transacao do webhook. - Guardas por env: BILLING_NOTIFY_ENABLED (kill switch sem deploy) e BILLING_NOTIFY_MAX_PER_RUN (teto de 50 por rodada, zerado no inicio do job). DETECCAO -------- billing_notifications_health() roda no fim do job e alimenta o painel Saude da Plataforma. Alem de falha e sem-destinatario, alerta quando o job NAO REGISTRA EXECUCAO ha 48h — o unico sinal que o log sozinho jamais da, porque "nao houve envio" e indistinguivel de "a regua morreu". UI -- Aba Notificacoes dentro de Plano & Assinatura: ate 5 destinatarios, toggles so para os opcionais, criticos como linha travada SEM <input> (um `disabled` convida alguem a remover o atributo depois). billing.manage e checado DENTRO do handler de POST: o decorator da view e billing.plan, e o papel Gerente tem plan mas nao manage. Sem a checagem, um Gerente redirecionaria os avisos de cobranca do canal. Card "Ultimos avisos de cobranca" no detalhe do tenant no SuperAdmin. MIGRACAO -------- 0038 cria os dois models (so CreateModel/AddIndex, portao conferido). 0039 importa o historico das flags legadas VERDADEIRAS. Pula de proposito metadata["dunning_emails_sent"], gravada pelo dunning quebrado sem envio real: importa-la transformaria uma mentira em evidencia, e evidencia falsa e pior que lacuna. Kinds ainda no caminho legado, deliberadamente: canceled, plan_changed, refunded, refunded_partial, chargeback. Nenhum tem campo estavel para ancorar. 1222 testes, verde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

v2.299.4

  • Correção O canal caia sem avisar ninguem A suspensao por inadimplencia derrubava is_active e cortava o acesso sem mandar e-mail nem criar PlatformMessage. O cliente descobria pelo 404. Nos tres caminhos que suspendem (grace expirado, past_due com 3 falhas, grace -> to_delete) agora sai aviso por e-mail e in-app. Junto vieram outros quatro problemas do mesmo trecho: 1. O kind era "canceled". O cliente nao cancelou nada, foi suspenso, e a copy de cancelamento promete "acesso ate <data>" quando o acesso ja caiu. Novos kinds `suspended` e `reactivated`, com paleta propria: sem ela, _PALETTES.get(kind, _PALETTES["renewed"]) mandaria "canal suspenso" no VERDE DE SUCESSO com tag "Confirmado". 2. O aviso final e o corte disparavam ambos em D+7, no mesmo tick do job. "Sera suspenso" e "foi suspenso" chegavam com segundos de diferenca. Aviso final foi para D+5 (2 dias reais de janela) e o corte fica em D+7, com constantes nomeadas e teste travando DUNNING_FINAL < SUSPENSAO. Quem chega ja atrasado (regua parada, backfill) recebe so a suspensao. 3. Os avisos D+3/D+7 eram dois `if` sequenciais: sem as flags, os dois saiam juntos. Viraram elif com guarda, e a escalada nao anda de re. 4. _send_dunning_email de tasks_billing.py importava `apps.core.email`, modulo que nunca existiu, com o ImportError engolido por except Exception. Codigo morto em producao (apps.py:33 bloqueia o scheduler que o chama) e duplicado pelo caminho vivo, entao foi deletado em vez de consertado. Copy dos 19 e-mails no padrao Apple/Google: sem emoji no assunto, sem travessao, sem icone de alarme, rotulo em sentence case. O docstring ja dizia "estilo Apple/Stripe"; a copy tinha andado para o lado oposto. Testes: - test_import_contracts: varre todo import DENTRO DE FUNCAO e prova que o simbolo existe, sem isentar try/except ImportError, que foi o que escondeu este bug e o do auto-close. Achou 10 quebrados pre-existentes, fora do escopo deste commit, presos num baseline que so pode encolher. - test_billing_dunning_ladder: a regua rodando dia a dia. - test_billing_emails_style: o padrao de copy como contrato, valendo para kinds futuros, incluindo copy-sem-paleta nas duas direcoes. 1184 testes, verde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Quer acompanhar de perto o que estamos construindo?

Falar com a equipe

Quanto mais você espera, mais risco você acumula.

Prazos legais não esperam. Cada dia sem rastreabilidade é um dia de exposição.

Começar agora Agendar demonstração

Teste grátis por 7 dias · Cartão necessário · Cobrança só após o teste

Conforme LGPD
Hospedado no Brasil
Infraestrutura Google Cloud
WhatsApp