Files
DeskcommCRM/lib/api
Rafael Melgaçoandmelgarafael 40493fd735 fix(322): o resend dizia "deu certo" sem ter reenviado nada, e o teto de campo só valia para string
Dois consertos de borda no PR #322. O trabalho dele é sólido — a tripla de
migration está completa nas duas, o baseline passa em install E update com
`ON_ERROR_STOP=1`, não há anti-pattern nº 10, e as funções novas revogam as duas
origens de EXECUTE. O que segue é o que a medição encontrou nas pontas.

─── 1. O falso-verde do `resend` — na rota que o próprio PR adiciona ──────────

`app/api/v1/automation-rules/runs/[runId]/resend/route.ts:98`

    const failed = results.filter((r) => r.status === "failed").length;
    const status = failed === 0 ? "success" : …

`failed === 0` é VERDADEIRO para lista vazia. Uma regra que perdeu as ações de
webhook — o operador removeu a ação no editor, e o botão "Reenviar" segue
renderizado no run antigo — grava uma linha `status: "success"` com
`actions_result: []`. A tela mostra o toast verde e o badge "Sucesso" com corpo
vazio: o operador é informado de um reenvio que não aconteceu.

É exatamente a classe de defeito que este PR existe para fechar — "a automação
para de dizer que deu certo quando não deu" —, reaparecendo pela porta nova.

409 `no_actions_to_resend`, código próprio pelo mesmo critério que criou o
`event_gone` nesta mesma rota: o estado do MUNDO mudou desde o run original, e
isso é informação, não erro do chamador.

─── 2. `limitarCampos` cortava só string; valor aninhado entrava inteiro ──────

`lib/webhooks/captacao.ts:80` cortava o ramo `typeof valor === "string"` e o
resto caía num `else` que guardava o valor INTEIRO. Um campo aninhado (array de
carrinho, objeto de endereço, qualquer JSON) entrava sem limite — enquanto a
prosa do módulo e o cálculo de retenção (`retencao-da-captacao.ts:20`, que
dimensiona a tabela em "60 campos × 2.000 caracteres") prometiam o contrário.

Quem manda o corpo é o site do cliente, então o tamanho não é escolha nossa: é a
superfície de quem quiser encher a tabela que a tela lê.

`limitarValor` aplica o teto a qualquer valor. Pequeno passa INTACTO com o tipo
original (número segue número, objeto segue objeto); só quando o JSON passa do
teto vira a string cortada, porque guardar "quase o objeto" seria guardar um
objeto que não é o que chegou. Referência circular — em que `JSON.stringify`
LANÇA — vira `[valor não serializável]` em vez de derrubar a captação: é do
formulário estranho que quem depura mais precisa do registro.

**Não havia um único teste** (`git grep limitarCampos -- tests` voltava vazio).
Agora são 6, e a sabotagem bate a previsão escrita antes: revertendo para o corte
só-string, **3 falhas** (array, objeto, circular) e os DOIS controles verdes — o
de valor pequeno intacto existe porque serializar tudo passaria nos outros três e
destruiria a feature.

(Meu primeiro teste falhou por erro MEU: media `JSON.stringify` do resultado já
cortado, e as aspas/escapes inflam o tamanho. A asserção certa mede o valor
guardado, não a re-serialização dele.)

─── Sobre o relógio: quatro sessões, o mesmo bug ──────────────────────────────

Este PR também achou o defeito do relógio de parede, com medição própria (22:29
local, 01:37 UTC) — depois do #319 e do #323. Quando fui resolver o conflito, a
sessão deste PR já havia mergeado a main e resolvido do MESMO jeito: clock
declarado no topo, as duas guardas usando `clock()`. Convergência independente
de novo, e nada a fazer — só confirmar que as duas guardas ficaram cobertas
(`grep -n 'const agora = clock()\|janelaDeAtendimento, clock()'` → 1087 e 1179).

─── O que NÃO foi consertado aqui, e por quê ──────────────────────────────────

`webhook_lead_captures` não está em `lib/database.types.ts`. Medido antes de
agir: `agent_inbox_items`, `channel_knobs`, `api_audit_log` e `platform_branding`
também não estão, e não há script `gen:types` no `package.json`. É dívida antiga
e generalizada do repo — o PR seguiu a prática, não criou o problema. Consertar
só a tabela dele seria conserto por instância, e inflaria este PR com dívida
alheia.

`typecheck` exit 0 · `lint` 0 erros · `lint:channels` ok · `pnpm test:unit`
**483 arquivos, 5402 testes** · `pnpm test:db` **115 arquivos, 857 testes,
exit 0** (rodado às 08:02 sobre a base anterior, com o baseline em install E
update).

NÃO MEDIDO: nada foi dirigido num browser (DoD 12) — a tela nova de Capturas, o
badge do resend e a aba de Atividade foram lidos no código, não vistos. O
`.or()` duplicado em `lead-captures/route.ts:70,80` (busca livre + cursor na
mesma query) não foi exercitado contra um PostgREST vivo: se filtros repetidos
não forem ANDados, a segunda página de uma busca devolve o conjunto errado em
silêncio. E a poda de captação nunca rodou sobre meses de histórico real.

Co-authored-by: melgarafael <melgarafael@users.noreply.github.com>
2026-08-25 08:11:01 -03:00
..
…
…

lib/api/

Helpers e convenções compartilhadas por toda rota /api/v1/*.

  • wrappers.ts — ok(data, opts) / fail(code, message, status, opts) / noContent() + tipos ApiSuccess<T> / ApiError
  • errors.ts — ApiErrorCodes (constante canônica de códigos)

Exemplo

import { ok, fail } from "@/lib/api/wrappers";
import { ApiErrorCodes } from "@/lib/api/errors";

export async function GET(req: Request) {
  const data = await fetchSomething();
  if (!data) return fail(ApiErrorCodes.not_found, "Lead não encontrado", 404);
  return ok(data, { meta: { cursor: nextCursor, has_more: true } });
}

A adicionar (próximas specs)

  • auth.ts — extrai user / tenant da request (cookie OU bearer); valida MFA; retorna AuthContext
  • idempotency.ts — middleware que valida Idempotency-Key via Upstash (TTL 24h)
  • rate-limit.ts — sliding window via Upstash; injeta headers X-RateLimit-*
  • pagination.ts — encode/decode de cursor opaco base64 + HMAC
  • audit.ts — fire-and-forget write em api_audit_log
  • cors.ts — allowlist por tenant