Etapa 7 — Motor de alertas (BullMQ)

Adiciona um processo worker separado do processo web (src/worker.ts,
WorkerModule) com dois crons diários e processors do BullMQ para alertas
de mensalidade (master→tenant) e cobrança (tenant→customer): marca
faturas/parcelas atrasadas, resolve template com fallback tenant→master
por canal, renderiza placeholders, dispara WhatsApp/e-mail com retry e
backoff exponencial, e grava alert_logs com idempotência por dia.

Módulo alerts (web) traz o CRUD de alert_templates (master e tenant) e a
consulta de alert_logs com filtros. alert_templates/alert_logs não têm
RLS no schema, então o isolamento entre tenants é feito manualmente
(PrismaMasterService + filtro por tenant_id), coberto por teste e2e
dedicado.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 22:50:59 -03:00
co-authored by Claude Sonnet 5
parent 7daaa16935
commit d6d7d5fa61
37 changed files with 2661 additions and 19 deletions
+191 -10
View File
@@ -36,7 +36,7 @@ qualquer nova etapa começar.
| 4 | Integrações Evolution API + E-mail | Concluída | 2026-08-14 |
| 5 | Painel Tenant (clientes/produtos) | Concluída | 2026-08-14 |
| 6 | Vendas, Parcelas e Dashboards | Concluída | 2026-08-14 |
| 7 | Motor de Alertas (BullMQ) | [ ] Não iniciada | |
| 7 | Motor de Alertas (BullMQ) | Concluída | 2026-08-14 |
| 8 | Personalização Whitelabel e Ajustes Finais | [ ] Não iniciada | |
Valores possíveis para "Status": `Não iniciada`, `Em andamento`,
@@ -692,18 +692,158 @@ propositalmente para depois — detalhar em Observações).
## Etapa 7 — Motor de Alertas (BullMQ)
- [ ] Worker processa as duas filas independente do processo web
- [ ] Template do tenant sobrepõe padrão do master; fallback funciona
- [ ] Placeholders substituídos corretamente
- [ ] Job diário marca faturas/parcelas atrasadas corretamente
- [ ] Alerta de mensalidade chega no WhatsApp/e-mail do tenant (teste controlado)
- [ ] Alerta de cobrança chega no WhatsApp/e-mail do customer, com config correta do tenant
- [ ] Mesmo alerta não disparado duas vezes no mesmo dia
- [ ] Falha de envio reprocessada com retry; erro final registrado
- [ ] Endpoints de consulta de logs funcionam com filtros
- [x] Worker processa as duas filas independente do processo web
- [x] Template do tenant sobrepõe padrão do master; fallback funciona
- [x] Placeholders substituídos corretamente
- [x] Job diário marca faturas/parcelas atrasadas corretamente
- [x] Alerta de mensalidade chega no WhatsApp/e-mail do tenant (teste controlado)
- [x] Alerta de cobrança chega no WhatsApp/e-mail do customer, com config correta do tenant
- [x] Mesmo alerta não disparado duas vezes no mesmo dia
- [x] Falha de envio reprocessada com retry; erro final registrado
- [x] Endpoints de consulta de logs funcionam com filtros
**Observações:**
- **Separação real de processo web/worker**, como pedido no prompt: `src/main.ts`
(processo `app`, só HTTP, sem `ScheduleModule`/`BullModule`) e `src/worker.ts`
(processo `worker`, `NestFactory.createApplicationContext(WorkerModule)`, sem HTTP).
`WorkerModule` (`src/worker/`) é o único lugar que importa `ScheduleModule`/`BullModule`
— os dois `@Cron()` (`AlertSchedulerService`) e os dois processors do BullMQ
(`MensalidadeProcessor`/`CobrancaProcessor`) vivem só lá. Motivo de separar os crons do
processo web (não só "documentar", de fato implementado): se o `app` escalar
horizontalmente (várias réplicas), um `@Cron()` nele dispararia o mesmo job uma vez por
réplica; como o `worker` é quem tem os crons, escalar `worker`
(`docker compose up --scale worker=3`) paraleliza só o *processamento* das filas, não a
geração dos jobs. `docker-compose.yml`/`docker-compose.override.yml` ganharam um serviço
`worker` novo (mesma imagem do `app`, `command` diferente — `node dist/worker` em
produção, `npm run worker:dev` em dev). `nest build` já compila `src/worker.ts` para
`dist/worker.js` junto com o resto (não precisa de configuração extra no `nest-cli.json`);
`npm run worker:dev`/`worker` usam `nest start --entryFile worker`.
- Módulo novo `src/modules/alerts/` (web): CRUD de `alert_templates` em
`/master/alertas/templates` (`MasterGuard`) e `/tenant/alertas/templates`
(`TenantGuard`+`RolesGuard`+`@Roles('tenant_admin')`, como pedido), `GET
/master/alertas/logs`/`GET /tenant/alertas/logs`, mais os utilitários compartilhados com o
worker (`AlertTemplateResolverService` — fallback tenant→master por canal —,
`AlertMaintenanceService` — marcar atrasadas em lote —, `template-render.util.ts`,
`placeholders.ts`, `alert-format.util.ts`, `queue.constants.ts`). `AlertsModule` exporta
`AlertTemplateResolverService`/`AlertMaintenanceService` justamente para o `WorkerModule`
importar sem duplicar código — nenhum dos dois depende de nada HTTP-específico.
- **`alert_templates`/`alert_logs` não têm RLS habilitado** (não estão na lista de tabelas
isoladas por tenant da migration de RLS da Etapa 1) — diferente de
`customers`/`products_services`/`sales`/etc. Por isso todo o módulo (master e tenant) usa
`PrismaMasterService` com filtro manual de `tenant_id` em toda query, nunca
`TenantContextService`/RLS — mesmo padrão já registrado na Etapa 4 para o
`AlertDispatchService` (que também precisa funcionar fora de uma request HTTP, dentro do
worker). Isolamento entre tenants nos templates é 100% responsabilidade do código da
aplicação aqui (`tenant_id: actor.tenant_id` em todo `findFirst`/`findMany`/`update`) —
coberto por teste e2e dedicado (`alertas.e2e-spec.ts`, tenant B não enxerga/edita template
do tenant A pelo UUID exato, sempre 404).
- **Decisão de modelagem, registrada com o motivo:** um job da fila cobre *uma fatura/parcela
inteira* (todos os canais configurados), não um job por canal. Motivo:
`tenant_faturas.alerta_enviado_em`/`sale_payments.alerta_enviado_em` são um único timestamp
por linha (não um por canal) — são a fonte de verdade de idempotência que o schema oferece,
então "o alerta foi enviado hoje" só faz sentido como uma ação conceitualmente única que
pode sair por WhatsApp e/ou e-mail juntos. Dentro do job: cada canal é tentado
independentemente (`Promise.all`), um `alert_logs` é gravado por canal tentado — sucesso
grava na hora; se **pelo menos um** canal funcionar, `alerta_enviado_em` é marcado (o
alerta "chegou" de alguma forma) e o log do(s) canal(is) que falhou(aram) junto também é
gravado nesse momento (não tem retry nesse caminho, já que o job resolve como sucesso). Se
**nenhum** canal funcionar, nada é gravado ainda — o job lança erro (BullMQ agenda retry
com backoff) e só grava a falha definitiva em `alert_logs` quando esgota as tentativas (ver
próximo item).
- **Bug pego pelo teste e2e, corrigido antes de terminar a etapa:** a checagem de "essa é a
última tentativa" nos dois processors originalmente era
`job.attemptsMade >= job.opts.attempts` — errada. `job.attemptsMade` do BullMQ conta as
tentativas *anteriores* à execução atual (0 na primeira chamada de `process()`, não 1);
com essa fórmula, a condição nunca ficava verdadeira dentro de `process()` (só ficaria
visível depois, quando não há mais chamada nenhuma) e a falha final nunca era gravada em
`alert_logs`. Corrigido para `job.attemptsMade + 1 >= (job.opts.attempts ?? 1)`. Pego pelo
teste "falha em todos os canais é registrada em alert_logs só após esgotar as tentativas"
(`alertas.e2e-spec.ts`), que falhava com `alert_logs` vazio antes da correção — fica como
lembrete de que `job.attemptsMade` merece essa checagem com cuidado em qualquer novo uso do
BullMQ no projeto.
- **Retry com backoff exponencial** configurado nas opções do job em `AlertSchedulerService`
(`attempts`/`backoff.delay`, lidos de `ALERT_JOB_MAX_ATTEMPTS`/`ALERT_JOB_BACKOFF_MS` via
`ConfigService`, defaults 3/5000ms) — diferente do horário do cron (`@Cron()`), que precisa
ser lido direto de `process.env` porque decorators são avaliados na importação da classe,
antes do `ConfigModule` terminar de inicializar; como o deploy real injeta env vars pelo
ambiente do container (não um `.env` lido em runtime), isso é seguro. Documentado em
`AlertSchedulerService`.
- **Idempotência em duas camadas**, como pedido no prompt ("checar antes de enfileirar, e de
novo dentro do worker"): o produtor (`AlertSchedulerService`) só enfileira faturas/parcelas
com `alerta_enviado_em` nulo ou anterior a hoje, com `jobId` determinístico
(`mensalidade-<id>-<data>`/`cobranca-<id>-<data>`) — rodar o cron duas vezes no mesmo dia
não duplica o job no Redis; o processor re-checa `alerta_enviado_em` fresco do banco antes
de processar (cobre o caso de dois disparos manuais/duas instâncias do worker). Testado
rodando o job duas vezes seguidas no mesmo dia (`alertas.e2e-spec.ts`) — `alert_logs`
continua com a mesma quantidade de linhas.
- **`AlertMaintenanceService`** (`marcarFaturasAtrasadas`/`marcarParcelasAtrasadas`, chamado
no início dos dois crons) duplica, de propósito, uma lógica parecida com a de
`FaturasService.atualizarAtrasadas` (Etapa 3) e `SalePaymentsService.atualizarAtrasadas`
(Etapa 6) — não foi um refactor pra reusar essas duas porque nenhuma faz exatamente a mesma
coisa: a de faturas é cross-tenant mas amarrada a um `actor` humano de request HTTP; a de
parcelas é estritamente escopada a *um* tenant via RLS (`TenantContextService`). O job
precisa de "todos os tenants, sem usuário humano, chamado pelo cron" — `PrismaMasterService`
sem `TenantContextService` (correto aqui: é uma manutenção em lote entre tenants, não uma
consulta em nome de um tenant autenticado). Registrado no código e aqui pra não parecer
esquecimento. Auditoria dessas duas chamadas grava `usuario_tipo:'master', usuario_id: null`
(ação de sistema, sem ator humano) — primeira vez que `writeAuditLog` é chamado com
`usuario_id` nulo no projeto.
- Placeholders documentados em `src/modules/alerts/placeholders.ts`
(`PLACEHOLDERS_POR_TIPO`) para os 4 tipos do schema
(`mensalidade_tenant`/`cobranca_cliente`/`venda_confirmada`/`lembrete_vencimento`) — só os
dois primeiros têm job automatizado nesta etapa (`TIPOS_TEMPLATE_COM_JOB`), os outros dois
já têm CRUD e placeholders prontos pra uma automação futura (ex: e-mail imediato ao
confirmar uma venda). `renderTemplate()` (`template-render.util.ts`) é substituição simples
de `{{chave}}`, sem validar na criação do template se os placeholders usados existem na
lista documentada (um placeholder desconhecido só fica literal no texto, não é erro).
- `mensalidade_tenant` só o master define (`tenant_id` sempre `null`) — o tenant não tem
como criar/editar esse tipo (`CreateTenantAlertTemplateDto`/`UpdateTenantAlertTemplateDto`
usam `TIPOS_TEMPLATE_TENANT`, que exclui `mensalidade_tenant` via `IsIn`); faz sentido, é o
master cobrando o tenant pela própria mensalidade, o tenant não deveria poder mudar como é
notificado sobre a própria fatura em atraso.
- **Limitação de arquitetura documentada, não uma escolha de produto:** alertas de
`mensalidade_tenant` por WhatsApp usam a própria instância de WhatsApp *do tenant* (só
existe `tenant_whatsapp_instances`, não há conceito de "WhatsApp do master" no schema) —
na prática, o tenant recebe a notificação da própria mensalidade através do número de
WhatsApp que ele mesmo conectou, endereçado a `tenants.telefone`. Funciona (o mecanismo de
envio não se importa que remetente e destinatário sejam do mesmo tenant), mas é um
reaproveitamento do que já existia, não um "canal do master" de verdade — só funciona pra
tenants que já conectaram um WhatsApp. Por e-mail não há essa esquisitice:
`EmailService.resolverConfig` já cai pro `master_email_config` padrão quando o tenant não
tem SMTP próprio configurado (a maioria), então a mensalidade chega de fato pela plataforma.
Fica como ponto pra uma etapa futura considerar um "WhatsApp de notificações da
plataforma" dedicado, se o produto precisar.
- Teste e2e novo, `test/alertas.e2e-spec.ts` (14 testes), cobrindo todos os critérios de
aceite: CRUD de templates (master e tenant, e-mail exige assunto via `@ValidateIf`, tenant
não pode criar `mensalidade_tenant`, isolamento entre tenants sempre 404, soft delete),
job de mensalidade batendo fatura vencida com alerta por WhatsApp (via `FakeEvolutionServer`
local, mesmo padrão da Etapa 4) e e-mail (via `nodemailer` mockado) — conteúdo renderizado
conferido (`R$ 199,90`, data `05/01/2020`), marca `atrasado`+`alerta_enviado_em`, roda duas
vezes no mesmo dia sem duplicar, e um cenário de falha total (sem instância de WhatsApp +
e-mail rejeitado pelo mock) provando retry + log definitivo só após esgotar tentativas; job
de cobrança com dois tenants — um com template/canal próprio (sobrepõe o padrão do master) e
um sem template próprio (cai no padrão do master por e-mail) —, confirmando `template_id`
do log aponta pro template certo em cada caso; filtros de `GET .../alertas/logs`
(master por canal, tenant só enxerga os próprios); bloqueio de endpoint de tenant com token
master "puro" (403). Os dois processos (web e worker) são inicializados como duas
aplicações Nest independentes no mesmo processo de teste (`Test.createTestingModule({imports:
[AppModule]})` e `...{imports:[WorkerModule]})`, cada uma com sua própria conexão real a
Postgres/Redis) — mesma técnica de testar contra infraestrutura real já usada no resto da
suíte, só que replicada pros dois processos que existem de verdade em produção; uma função
auxiliar (`aguardarFilasOciosas`) faz polling de `queue.getJobCounts()` até as duas filas
esvaziarem antes de cada asserção. Rodado com `docker compose up -d postgres redis` +
`npm install` (novas dependências: `bullmq`, `@nestjs/bullmq`, `@nestjs/schedule` —
precisou `npm install` em vez de `npm ci`, que exige o lockfile já sincronizado) + `prisma
migrate deploy` (nenhuma migration nova — `alert_templates`/`alert_logs` já existiam desde
a Etapa 1) + `jest --config ./test/jest-e2e.json --runInBand` — **118/118 testes e2e
passando** (os 104 das Etapas 1-6 + os 14 novos). `npm run lint`, `npm run test` (unit) e
`npm run build` também passam sem erros (o build gerou `dist/worker.js` junto com
`dist/main.js`, sem configuração extra). Smoke test manual do processo worker de verdade
(`node dist/worker` contra Postgres/Redis reais) confirmou o boot completo — todos os
módulos inicializam, conecta no Redis, registra as duas filas — antes de escrever os testes
automatizados.
---
## Etapa 8 — Personalização Whitelabel e Ajustes Finais
@@ -866,3 +1006,44 @@ robustez, mesmo sem uso direto no momento.
prompt não especifica essa regra — assumida como comportamento padrão
razoável, documentada aqui para poder ser revisada/parametrizada numa
etapa futura se o negócio precisar de outra periodicidade.
[2026-08-14] Etapa 7 — Um job da fila de alertas cobre uma fatura/parcela
inteira (todos os canais configurados juntos), não um job por canal.
Motivo: `alerta_enviado_em` (tanto em `tenant_faturas` quanto em
`sale_payments`) é um único timestamp por linha, não um por canal — é a
única idempotência que o schema oferece, então "enviado" só faz sentido
como uma ação conceitual única que pode sair por WhatsApp e/ou e-mail
juntos. Cada canal ainda é tentado e logado independentemente dentro do
job (um `alert_logs` por canal), só a decisão de "retry ou não" e de
marcar `alerta_enviado_em` é do job como um todo.
[2026-08-14] Etapa 7 — Bug real corrigido antes de fechar a etapa: a
checagem de "última tentativa antes de desistir" nos processors do
BullMQ usava `job.attemptsMade >= job.opts.attempts`, mas
`job.attemptsMade` conta as tentativas *anteriores* à execução atual (0
na primeira chamada, não 1) — com a fórmula errada, a falha definitiva
nunca era gravada em `alert_logs`. Corrigido para
`job.attemptsMade + 1 >= (job.opts.attempts ?? 1)`. Pego pelo teste e2e
"falha em todos os canais é registrada em alert_logs só após esgotar as
tentativas", que falhava com `alert_logs` vazio antes da correção.
[2026-08-14] Etapa 7 — `alert_templates`/`alert_logs` não têm RLS
habilitado no schema (não estão na migration de RLS da Etapa 1) —
diferente da maior parte das tabelas por-tenant do projeto. Todo o CRUD
de templates e a consulta de logs desta etapa usam `PrismaMasterService`
com filtro manual de `tenant_id`, nunca RLS — mesma exceção arquitetural
já registrada na Etapa 4 para o `AlertDispatchService` (que também
precisa funcionar dentro do worker, sem contexto de tenant HTTP ativo).
Isolamento entre tenants aqui depende 100% do código da aplicação, sem
rede de segurança do banco — coberto por teste e2e dedicado.
[2026-08-14] Etapa 7 — Alertas de `mensalidade_tenant` por WhatsApp usam
a própria instância de WhatsApp do tenant sendo notificado (endereçada a
`tenants.telefone`) — o schema não tem conceito de "WhatsApp da
plataforma/master", só `tenant_whatsapp_instances` por tenant. Funciona
(o envio não se importa que remetente e destinatário sejam do mesmo
tenant), mas só se aplica a tenants que já conectaram um WhatsApp; por
e-mail não há esse problema, já cai pro `master_email_config` padrão
quando o tenant não tem SMTP próprio. Fica como ponto para uma etapa
futura considerar um WhatsApp de notificações dedicado da plataforma, se
o produto precisar.