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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user