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
+15
View File
@@ -68,3 +68,18 @@ TENANT_TRIAL_DIAS=14
# Nunca reutilize o valor de exemplo em produção — trocar invalida todos os
# segredos já cifrados no banco (seria necessário recriá-los).
ENCRYPTION_KEY=troque-este-valor-por-um-segredo-forte-com-32-caracteres-ou-mais
# --- Motor de Alertas (BullMQ) — só lido pelo processo worker (src/worker.ts) ----
# Horário dos jobs diários, em formato cron (padrão node-cron/crontab: min hora dia mes dia-semana)
ALERT_CRON_MENSALIDADE=0 8 * * *
ALERT_CRON_COBRANCA=0 8 * * *
# Quantos dias antes do vencimento o alerta já é disparado (além de toda fatura/parcela
# já vencida e ainda sem alerta hoje)
ALERT_DIAS_ANTECEDENCIA_MENSALIDADE=3
ALERT_DIAS_ANTECEDENCIA_COBRANCA=3
# Retry do BullMQ para falha transitória de envio: nº de tentativas e atraso-base (ms)
# do backoff exponencial (tentativa N espera ALERT_JOB_BACKOFF_MS * 2^(N-1))
ALERT_JOB_MAX_ATTEMPTS=3
ALERT_JOB_BACKOFF_MS=5000
+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.
+51 -8
View File
@@ -26,7 +26,9 @@ Docker/Docker Compose. Multi-tenancy via banco único, isolado por
healthcheck, e só inicia a API depois que as duas dependências estiverem
saudáveis. `docker-compose.override.yml` é aplicado automaticamente e monta
o código-fonte local no container, então alterações em `src/` reiniciam a
API sozinhas (`npm run dev` / `nest start --watch`).
API sozinhas (`npm run dev` / `nest start --watch`). O mesmo `docker
compose up` já sobe o serviço `worker` (motor de alertas — ver seção
própria abaixo) lado a lado com a API.
3. Rode as migrations do Prisma (tabelas, enums, índices) e a migration
manual de RLS/views/checks:
@@ -133,6 +135,41 @@ contexto usam `TenantContextService.getTx()` (ver
`AuthController.listTenantUsers` como exemplo). Requests master usam
`PrismaMasterService` (conexão `app_master`, sem passar pelas policies).
## Motor de alertas (worker)
O disparo automatizado de alertas (mensalidade do tenant, cobrança de
parcelas do customer) roda num **processo separado do processo web**:
- `src/main.ts` (processo `app`) — só HTTP. Não importa `ScheduleModule`
nem processa nenhuma fila.
- `src/worker.ts` (processo `worker`) — sem HTTP. Sobe via
`NestFactory.createApplicationContext(WorkerModule)`, que inicializa os
dois crons diários (`AlertSchedulerService`, `@nestjs/schedule`) e os
dois processors do BullMQ (`MensalidadeProcessor`/`CobrancaProcessor`,
filas `alertas-mensalidade`/`alertas-cobranca` no Redis já usado pelo
`docker-compose`).
Essa separação existe para poder escalar os dois processos de forma
independente sem duplicar o disparo do cron: se o `app` rodar em várias
réplicas (comum atrás de um load balancer), um `@Cron()` registrado nele
dispararia o mesmo job uma vez por réplica. Como os crons vivem só no
`worker`, escalar `worker` (`docker compose up --scale worker=3`)
paraleliza o *processamento* das filas, não a geração dos jobs.
`docker compose up` já sobe os dois processos (serviço `worker` no
`docker-compose.yml`/`docker-compose.override.yml`, mesma imagem do `app`,
`command` diferente: `node dist/worker` em produção, `npm run worker:dev`
em desenvolvimento). Para rodar só o worker manualmente:
```bash
docker compose up worker
```
Horário dos crons, antecedência do alerta e política de retry são
configuráveis via `ALERT_CRON_MENSALIDADE`/`ALERT_CRON_COBRANCA`,
`ALERT_DIAS_ANTECEDENCIA_MENSALIDADE`/`ALERT_DIAS_ANTECEDENCIA_COBRANCA` e
`ALERT_JOB_MAX_ATTEMPTS`/`ALERT_JOB_BACKOFF_MS` (ver `.env.example`).
## Scripts
| Script | Descrição |
@@ -140,6 +177,8 @@ contexto usam `TenantContextService.getTx()` (ver
| `npm run dev` | Inicia a API em modo watch |
| `npm run build` | Compila TypeScript para `dist/` |
| `npm run start:prod` | Roda a build de produção (`dist/main.js`) |
| `npm run worker:dev` | Inicia o worker de alertas em modo watch |
| `npm run worker:prod` | Roda a build de produção do worker (`dist/worker.js`) |
| `npm run lint` | ESLint (com `--fix`) |
| `npm run test` | Testes unitários (Jest) |
| `npm run test:e2e` | Testes end-to-end (Jest + Supertest) |
@@ -152,17 +191,21 @@ contexto usam `TenantContextService.getTx()` (ver
```
src/
├── main.ts
├── app.module.ts
├── config/ # ConfigModule, validação de env, PrismaService/PrismaMasterService/
│ # PrismaTenantService, TenantContextService (mecanismo de RLS por request)
├── main.ts # processo web (HTTP)
├── worker.ts # processo worker (crons + filas BullMQ, sem HTTP)
├── app.module.ts # módulos do processo web
├── config/ # ConfigModule, validação de env, PrismaService/PrismaMasterService/
│ # PrismaTenantService, TenantContextService (mecanismo de RLS por request)
├── worker/ # WorkerModule, AlertSchedulerService (crons), processors do BullMQ
└── modules/
├── health/ # GET /health
└── auth/ # login/refresh/logout/reset, guards, decorators, JwtStrategy
├── health/ # GET /health
├── auth/ # login/refresh/logout/reset, guards, decorators, JwtStrategy
└── alerts/ # CRUD de alert_templates, GET .../alertas/logs, resolver de fallback
# e placeholders (usado pelo módulo web e pelo worker)
prisma/
├── schema.prisma
└── migrations/
Etapas/ # Prompts e controle de progresso do build faseado
Etapas/ # Prompts e controle de progresso do build faseado
```
## Progresso do build
+10
View File
@@ -10,3 +10,13 @@ services:
- /app/node_modules
healthcheck:
disable: true
worker:
build:
target: dev
command: npm run worker:dev
environment:
NODE_ENV: development
volumes:
- .:/app
- /app/node_modules
+19
View File
@@ -20,6 +20,25 @@ services:
retries: 5
start_period: 10s
worker:
build:
context: .
target: production
command: ['node', 'dist/worker']
restart: unless-stopped
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
# Sem HTTP: os dois crons diários (`AlertSchedulerService`) e os
# processors do BullMQ mantêm o processo vivo. Escala independente do
# `app` — ex: `docker compose up --scale worker=3` — sem duplicar o
# disparo dos crons (eles não vivem no processo `app`, ver
# `src/worker/worker.module.ts`).
postgres:
image: postgres:16-alpine
restart: unless-stopped
+351 -1
View File
@@ -9,16 +9,19 @@
"version": "0.1.0",
"license": "UNLICENSED",
"dependencies": {
"@nestjs/bullmq": "^10.2.3",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^4.1.2",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0",
"argon2": "^0.45.1",
"axios": "^1.19.0",
"bullmq": "^5.34.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"nodemailer": "^9.0.5",
@@ -1129,6 +1132,12 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@ioredis/commands": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz",
"integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -1746,6 +1755,112 @@
"node": ">=8"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@nestjs/bull-shared": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.2.3.tgz",
"integrity": "sha512-XcgAjNOgq6b5DVCytxhR5BKiwWo7hsusVeyE7sfFnlXRHeEtIuC2hYWBr/ZAtvL/RH0/O0tqtq0rVl972nbhJw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/@nestjs/bullmq": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.2.3.tgz",
"integrity": "sha512-Lo4W5kWD61/246Y6H70RNgV73ybfRbZyKKS4CBRDaMELpxgt89O+EgYZUB4pdoNrWH16rKcaT0AoVsB/iDztKg==",
"license": "MIT",
"dependencies": {
"@nestjs/bull-shared": "^10.2.3",
"tslib": "2.8.1"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0",
"bullmq": "^3.0.0 || ^4.0.0 || ^5.0.0"
}
},
"node_modules/@nestjs/cli": {
"version": "10.4.9",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
@@ -2011,6 +2126,20 @@
"@nestjs/core": "^10.0.0"
}
},
"node_modules/@nestjs/schedule": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz",
"integrity": "sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==",
"license": "MIT",
"dependencies": {
"cron": "3.2.1",
"uuid": "11.0.3"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/@nestjs/schematics": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
@@ -2517,6 +2646,12 @@
"@types/node": "*"
}
},
"node_modules/@types/luxon": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==",
"license": "MIT"
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -3845,6 +3980,31 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/bullmq": {
"version": "5.81.3",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.3.tgz",
"integrity": "sha512-Q7uEH2G92rVjX3Yl2qw3+6VEO15uxehC6DfeTHQBQTehddcRDSDuquD9zuvDjxiCffXsPUaBwrRlvlbVcgKs7g==",
"license": "MIT",
"dependencies": {
"cron-parser": "4.9.0",
"ioredis": "5.11.1",
"msgpackr": "2.0.5",
"node-abort-controller": "3.1.1",
"semver": "7.8.5",
"tslib": "2.8.1"
},
"engines": {
"node": ">=12.22.0"
},
"peerDependencies": {
"redis": ">=5.0.0"
},
"peerDependenciesMeta": {
"redis": {
"optional": true
}
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -4157,6 +4317,15 @@
"node": ">=0.8"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
"integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -4396,6 +4565,29 @@
"dev": true,
"license": "MIT"
},
"node_modules/cron": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/cron/-/cron-3.2.1.tgz",
"integrity": "sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.4.0",
"luxon": "~3.5.0"
}
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
"deprecated": "v4 is no longer maintained, upgrade to v5",
"license": "MIT",
"dependencies": {
"luxon": "^3.2.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
@@ -4508,6 +4700,15 @@
"node": ">=0.4.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -4527,6 +4728,16 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -6039,6 +6250,51 @@
"node": ">=12.0.0"
}
},
"node_modules/ioredis": {
"version": "5.11.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
"integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "1.10.0",
"cluster-key-slot": "1.1.1",
"debug": "4.4.3",
"denque": "2.1.0",
"redis-errors": "1.2.0",
"redis-parser": "3.0.0",
"standard-as-callback": "2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ioredis/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/ioredis/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -7300,6 +7556,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/luxon": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz",
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.8",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
@@ -7638,6 +7903,37 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/msgpackr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz",
"integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.4"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
}
},
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
@@ -7690,7 +7986,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/node-addon-api": {
@@ -7743,6 +8038,21 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -8455,6 +8765,27 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -8940,6 +9271,12 @@
"node": ">=8"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -9931,6 +10268,19 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz",
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
+6
View File
@@ -11,6 +11,9 @@
"start": "nest start",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"worker:dev": "nest start --watch --entryFile worker",
"worker": "nest start --entryFile worker",
"worker:prod": "node dist/worker",
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
@@ -22,16 +25,19 @@
"prisma:studio": "prisma studio"
},
"dependencies": {
"@nestjs/bullmq": "^10.2.3",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^4.1.2",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0",
"argon2": "^0.45.1",
"axios": "^1.19.0",
"bullmq": "^5.34.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"nodemailer": "^9.0.5",
+2
View File
@@ -6,6 +6,7 @@ import { EncryptionModule } from './common/encryption.module';
import { validate } from './config/env.validation';
import { PrismaModule } from './config/prisma.module';
import { AlertDispatchModule } from './modules/alert-dispatch/alert-dispatch.module';
import { AlertsModule } from './modules/alerts/alerts.module';
import { AuthModule } from './modules/auth/auth.module';
import { CustomersModule } from './modules/customers/customers.module';
import { EmailConfigModule } from './modules/email-config/email-config.module';
@@ -37,6 +38,7 @@ import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
CustomersModule,
ProductsModule,
SalesModule,
AlertsModule,
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
+24
View File
@@ -60,6 +60,30 @@ class EnvironmentVariables {
@IsInt()
@IsOptional()
TENANT_TRIAL_DIAS?: number;
@IsString()
@IsOptional()
ALERT_CRON_MENSALIDADE?: string;
@IsString()
@IsOptional()
ALERT_CRON_COBRANCA?: string;
@IsInt()
@IsOptional()
ALERT_DIAS_ANTECEDENCIA_MENSALIDADE?: number;
@IsInt()
@IsOptional()
ALERT_DIAS_ANTECEDENCIA_COBRANCA?: number;
@IsInt()
@IsOptional()
ALERT_JOB_MAX_ATTEMPTS?: number;
@IsInt()
@IsOptional()
ALERT_JOB_BACKOFF_MS?: number;
}
export function validate(config: Record<string, unknown>) {
+17
View File
@@ -0,0 +1,17 @@
import { Prisma } from '@prisma/client';
export function inicioDoDia(data: Date): Date {
const copia = new Date(data);
copia.setUTCHours(0, 0, 0, 0);
return copia;
}
export function formatMoney(valor: Prisma.Decimal | number | string): string {
return `R$ ${Number(valor).toFixed(2).replace('.', ',')}`;
}
export function formatDateBR(data: Date): string {
const dia = String(data.getUTCDate()).padStart(2, '0');
const mes = String(data.getUTCMonth() + 1).padStart(2, '0');
return `${dia}/${mes}/${data.getUTCFullYear()}`;
}
@@ -0,0 +1,88 @@
import { Injectable, Logger } from '@nestjs/common';
import { writeAuditLog } from '../../common/audit-log.util';
import { PrismaMasterService } from '../../config/prisma-master.service';
import { inicioDoDia } from './alert-format.util';
/**
* Marca faturas/parcelas vencidas como `atrasado`, para *todos os
* tenants*, chamado pelo job diário (`AlertSchedulerService`) — não pelo
* request de um tenant específico. Por isso usa `PrismaMasterService`
* (bypassa RLS de propósito: é uma manutenção em lote entre tenants, não
* uma consulta em nome de um tenant autenticado) em vez de
* `TenantContextService`.
*
* Duplica, de propósito, uma lógica parecida com
* `FaturasService.atualizarAtrasadas` (master, Etapa 3) e
* `SalePaymentsService.atualizarAtrasadas` (tenant via RLS, Etapa 6): essas
* duas são operações *de um único tenant/contexto* (a de faturas já é
* cross-tenant mas amarrada a um `actor` humano que chama o endpoint; a de
* parcelas é estritamente escopada a um tenant via RLS) — não são a mesma
* operação que "atualizar todos os tenants, sem usuário humano, chamado
* pelo cron", então não foi reaproveitado só por semelhança de nome.
*/
@Injectable()
export class AlertMaintenanceService {
private readonly logger = new Logger(AlertMaintenanceService.name);
constructor(private readonly prismaMaster: PrismaMasterService) {}
async marcarFaturasAtrasadas(): Promise<{ atualizadas: number; tenants_afetados: number }> {
const hoje = inicioDoDia(new Date());
const vencidas = await this.prismaMaster.tenant_faturas.findMany({
where: { status: 'pendente', vencimento: { lt: hoje } },
select: { id: true, tenant_id: true },
});
if (vencidas.length === 0) {
return { atualizadas: 0, tenants_afetados: 0 };
}
const tenantIds = [...new Set(vencidas.map((f) => f.tenant_id))];
return this.prismaMaster.$transaction(async (tx) => {
await tx.tenant_faturas.updateMany({
where: { id: { in: vencidas.map((f) => f.id) } },
data: { status: 'atrasado' },
});
await tx.tenants.updateMany({
where: { id: { in: tenantIds }, status: 'ativo' },
data: { status: 'inadimplente', atualizado_em: new Date() },
});
await writeAuditLog(tx, {
usuario_tipo: 'master',
usuario_id: null,
acao: 'job_atualizar_faturas_atrasadas',
entidade: 'tenant_faturas',
detalhes: { quantidade: vencidas.length, tenant_ids: tenantIds },
});
this.logger.log(`${vencidas.length} fatura(s) marcada(s) como atrasada(s)`);
return { atualizadas: vencidas.length, tenants_afetados: tenantIds.length };
});
}
async marcarParcelasAtrasadas(): Promise<{ atualizadas: number }> {
const hoje = inicioDoDia(new Date());
const resultado = await this.prismaMaster.sale_payments.updateMany({
where: { status: 'pendente', vencimento: { lt: hoje } },
data: { status: 'atrasado' },
});
if (resultado.count > 0) {
await writeAuditLog(this.prismaMaster, {
usuario_tipo: 'master',
usuario_id: null,
acao: 'job_atualizar_parcelas_atrasadas',
entidade: 'sale_payments',
detalhes: { quantidade: resultado.count },
});
this.logger.log(`${resultado.count} parcela(s) marcada(s) como atrasada(s)`);
}
return { atualizadas: resultado.count };
}
}
@@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import { alert_templates } from '@prisma/client';
import { PrismaMasterService } from '../../config/prisma-master.service';
import { CANAIS_TEMPLATE, CanalTemplate, TipoTemplate } from './alert-template-tipos';
export interface CanalResolvido {
canal: CanalTemplate;
template: alert_templates;
}
/**
* Resolve, canal a canal, qual template usar para um `tipo` — o próprio do
* tenant se existir e estiver ativo, senão o padrão da plataforma
* (`tenant_id = null`). Usa `PrismaMasterService` (nunca RLS): assim como o
* `AlertDispatchService` (Etapa 4), este service roda tanto dentro de uma
* request HTTP quanto dentro do worker BullMQ, sem contexto de tenant
* ativo — e `alert_templates` não tem RLS habilitado (não está na lista de
* tabelas isoladas por tenant da migration da Etapa 1), então o filtro por
* `tenant_id` é sempre manual e explícito.
*/
@Injectable()
export class AlertTemplateResolverService {
constructor(private readonly prismaMaster: PrismaMasterService) {}
private buscar(tenantId: string | null, tipo: TipoTemplate, canal: CanalTemplate) {
return this.prismaMaster.alert_templates.findFirst({
where: { tenant_id: tenantId, tipo, canal, ativo: true },
orderBy: { criado_em: 'desc' },
});
}
/** Só o template padrão do master (usado por `mensalidade_tenant`, que não tem versão por tenant). */
async resolverPadrao(tipo: TipoTemplate, canal: CanalTemplate): Promise<alert_templates | null> {
return this.buscar(null, tipo, canal);
}
/** Para tipos tenant→customer: tenta o template do tenant, cai pro padrão do master. Um resultado por canal que tiver algum template ativo (próprio ou padrão). */
async resolverPorCanais(tenantId: string, tipo: TipoTemplate): Promise<CanalResolvido[]> {
const resolvidos: CanalResolvido[] = [];
for (const canal of CANAIS_TEMPLATE) {
const proprio = await this.buscar(tenantId, tipo, canal);
const template = proprio ?? (await this.buscar(null, tipo, canal));
if (template) {
resolvidos.push({ canal, template });
}
}
return resolvidos;
}
}
@@ -0,0 +1,22 @@
export const CANAIS_TEMPLATE = ['whatsapp', 'email'] as const;
export type CanalTemplate = (typeof CANAIS_TEMPLATE)[number];
/**
* `mensalidade_tenant` é a única direção master→tenant (a mensalidade que o
* tenant paga à plataforma) — só o master define/edita esse tipo.
* `cobranca_cliente`/`venda_confirmada`/`lembrete_vencimento` são
* tenant→customer; o tenant pode ter o próprio template (sobrepõe o padrão
* do master), e o master pode manter um padrão de plataforma como fallback.
*/
export const TIPOS_TEMPLATE = [
'mensalidade_tenant',
'cobranca_cliente',
'venda_confirmada',
'lembrete_vencimento',
] as const;
export type TipoTemplate = (typeof TIPOS_TEMPLATE)[number];
export const TIPOS_TEMPLATE_TENANT = TIPOS_TEMPLATE.filter((tipo) => tipo !== 'mensalidade_tenant');
/** Tipos com disparo automatizado por job diário nesta etapa (os outros dois ficam disponíveis para automação futura). */
export const TIPOS_TEMPLATE_COM_JOB: TipoTemplate[] = ['mensalidade_tenant', 'cobranca_cliente'];
+38
View File
@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { AlertMaintenanceService } from './alert-maintenance.service';
import { AlertTemplateResolverService } from './alert-template-resolver.service';
import { MasterAlertLogsController } from './master-alert-logs.controller';
import { MasterAlertLogsService } from './master-alert-logs.service';
import { MasterAlertTemplatesController } from './master-alert-templates.controller';
import { MasterAlertTemplatesService } from './master-alert-templates.service';
import { TenantAlertLogsController } from './tenant-alert-logs.controller';
import { TenantAlertLogsService } from './tenant-alert-logs.service';
import { TenantAlertTemplatesController } from './tenant-alert-templates.controller';
import { TenantAlertTemplatesService } from './tenant-alert-templates.service';
/**
* Módulo "web": CRUD de templates + consulta de logs, montado no processo
* HTTP principal (`AppModule`/`main.ts`). `AlertTemplateResolverService` e
* `AlertMaintenanceService` são exportados porque o processo worker
* (`src/worker/worker.module.ts`) também precisa deles — nenhum dos dois
* depende de nada HTTP-específico (só `PrismaMasterService`), então
* reaproveitar em vez de duplicar.
*/
@Module({
controllers: [
MasterAlertTemplatesController,
TenantAlertTemplatesController,
MasterAlertLogsController,
TenantAlertLogsController,
],
providers: [
MasterAlertTemplatesService,
TenantAlertTemplatesService,
MasterAlertLogsService,
TenantAlertLogsService,
AlertTemplateResolverService,
AlertMaintenanceService,
],
exports: [AlertTemplateResolverService, AlertMaintenanceService],
})
export class AlertsModule {}
@@ -0,0 +1,29 @@
import { IsBoolean, IsIn, IsOptional, IsString, MinLength, ValidateIf } from 'class-validator';
import {
CANAIS_TEMPLATE,
CanalTemplate,
TIPOS_TEMPLATE,
TipoTemplate,
} from '../alert-template-tipos';
export class CreateMasterAlertTemplateDto {
@IsIn(TIPOS_TEMPLATE)
tipo: TipoTemplate;
@IsIn(CANAIS_TEMPLATE)
canal: CanalTemplate;
/** Obrigatório só para `canal: 'email'` (vira o assunto do e-mail; sem uso no WhatsApp). */
@ValidateIf((dto: CreateMasterAlertTemplateDto) => dto.canal === 'email')
@IsString()
@MinLength(1)
assunto?: string;
@IsString()
@MinLength(1)
corpo: string;
@IsOptional()
@IsBoolean()
ativo?: boolean;
}
@@ -0,0 +1,26 @@
import { IsBoolean, IsIn, IsOptional, IsString, MinLength, ValidateIf } from 'class-validator';
import { CANAIS_TEMPLATE, CanalTemplate, TIPOS_TEMPLATE_TENANT } from '../alert-template-tipos';
type TipoTemplateTenant = (typeof TIPOS_TEMPLATE_TENANT)[number];
/** `mensalidade_tenant` fica de fora de propósito — é o master notificando o tenant sobre a própria mensalidade, o tenant não define esse template. */
export class CreateTenantAlertTemplateDto {
@IsIn(TIPOS_TEMPLATE_TENANT)
tipo: TipoTemplateTenant;
@IsIn(CANAIS_TEMPLATE)
canal: CanalTemplate;
@ValidateIf((dto: CreateTenantAlertTemplateDto) => dto.canal === 'email')
@IsString()
@MinLength(1)
assunto?: string;
@IsString()
@MinLength(1)
corpo: string;
@IsOptional()
@IsBoolean()
ativo?: boolean;
}
@@ -0,0 +1,43 @@
import { Type } from 'class-transformer';
import { IsDateString, IsIn, IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator';
import { CANAIS_TEMPLATE, CanalTemplate } from '../alert-template-tipos';
const STATUSES = ['enviado', 'falhou', 'entregue', 'lido'] as const;
type AlertLogStatus = (typeof STATUSES)[number];
export class ListAlertLogsTenantQueryDto {
@IsOptional()
@IsIn(CANAIS_TEMPLATE)
canal?: CanalTemplate;
@IsOptional()
@IsIn(STATUSES)
status?: AlertLogStatus;
@IsOptional()
@IsDateString()
data_inicio?: string;
@IsOptional()
@IsDateString()
data_fim?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
export class ListAlertLogsMasterQueryDto extends ListAlertLogsTenantQueryDto {
@IsOptional()
@IsUUID()
tenant_id?: string;
}
@@ -0,0 +1,23 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsIn, IsOptional } from 'class-validator';
import {
CANAIS_TEMPLATE,
CanalTemplate,
TIPOS_TEMPLATE,
TipoTemplate,
} from '../alert-template-tipos';
export class ListAlertTemplatesQueryDto {
@IsOptional()
@IsIn(TIPOS_TEMPLATE)
tipo?: TipoTemplate;
@IsOptional()
@IsIn(CANAIS_TEMPLATE)
canal?: CanalTemplate;
@IsOptional()
@Transform(({ value }) => (value === undefined ? undefined : value === 'true' || value === true))
@IsBoolean()
ativo?: boolean;
}
@@ -0,0 +1,31 @@
import { IsBoolean, IsIn, IsOptional, IsString, MinLength } from 'class-validator';
import {
CANAIS_TEMPLATE,
CanalTemplate,
TIPOS_TEMPLATE,
TipoTemplate,
} from '../alert-template-tipos';
export class UpdateMasterAlertTemplateDto {
@IsOptional()
@IsIn(TIPOS_TEMPLATE)
tipo?: TipoTemplate;
@IsOptional()
@IsIn(CANAIS_TEMPLATE)
canal?: CanalTemplate;
@IsOptional()
@IsString()
@MinLength(1)
assunto?: string;
@IsOptional()
@IsString()
@MinLength(1)
corpo?: string;
@IsOptional()
@IsBoolean()
ativo?: boolean;
}
@@ -0,0 +1,28 @@
import { IsBoolean, IsIn, IsOptional, IsString, MinLength } from 'class-validator';
import { CANAIS_TEMPLATE, CanalTemplate, TIPOS_TEMPLATE_TENANT } from '../alert-template-tipos';
type TipoTemplateTenant = (typeof TIPOS_TEMPLATE_TENANT)[number];
export class UpdateTenantAlertTemplateDto {
@IsOptional()
@IsIn(TIPOS_TEMPLATE_TENANT)
tipo?: TipoTemplateTenant;
@IsOptional()
@IsIn(CANAIS_TEMPLATE)
canal?: CanalTemplate;
@IsOptional()
@IsString()
@MinLength(1)
assunto?: string;
@IsOptional()
@IsString()
@MinLength(1)
corpo?: string;
@IsOptional()
@IsBoolean()
ativo?: boolean;
}
@@ -0,0 +1,16 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { MasterGuard } from '../auth/guards/master.guard';
import { ListAlertLogsMasterQueryDto } from './dto/list-alert-logs.query.dto';
import { MasterAlertLogsService } from './master-alert-logs.service';
@UseGuards(JwtAuthGuard, MasterGuard)
@Controller('master/alertas/logs')
export class MasterAlertLogsController {
constructor(private readonly masterAlertLogsService: MasterAlertLogsService) {}
@Get()
findAll(@Query() query: ListAlertLogsMasterQueryDto) {
return this.masterAlertLogsService.findAll(query);
}
}
@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaMasterService } from '../../config/prisma-master.service';
import { ListAlertLogsMasterQueryDto } from './dto/list-alert-logs.query.dto';
@Injectable()
export class MasterAlertLogsService {
constructor(private readonly prismaMaster: PrismaMasterService) {}
async findAll(query: ListAlertLogsMasterQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const where: Prisma.alert_logsWhereInput = {
...(query.tenant_id ? { tenant_id: query.tenant_id } : {}),
...(query.canal ? { canal: query.canal } : {}),
...(query.status ? { status: query.status } : {}),
...(query.data_inicio || query.data_fim
? {
enviado_em: {
...(query.data_inicio ? { gte: new Date(query.data_inicio) } : {}),
...(query.data_fim ? { lte: new Date(query.data_fim) } : {}),
},
}
: {}),
};
const [data, total] = await this.prismaMaster.$transaction([
this.prismaMaster.alert_logs.findMany({
where,
include: { template: { select: { tipo: true } } },
orderBy: { enviado_em: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prismaMaster.alert_logs.count({ where }),
]);
return { data, meta: { total, page, limit } };
}
}
@@ -0,0 +1,66 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { MasterGuard } from '../auth/guards/master.guard';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CreateMasterAlertTemplateDto } from './dto/create-master-alert-template.dto';
import { ListAlertTemplatesQueryDto } from './dto/list-alert-templates.query.dto';
import { UpdateMasterAlertTemplateDto } from './dto/update-master-alert-template.dto';
import { MasterAlertTemplatesService } from './master-alert-templates.service';
@UseGuards(JwtAuthGuard, MasterGuard)
@Controller('master/alertas/templates')
export class MasterAlertTemplatesController {
constructor(private readonly masterAlertTemplatesService: MasterAlertTemplatesService) {}
@Post()
create(
@Body() dto: CreateMasterAlertTemplateDto,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.masterAlertTemplatesService.create(dto, user, req.ip);
}
@Get()
findAll(@Query() query: ListAlertTemplatesQueryDto) {
return this.masterAlertTemplatesService.findAll(query);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.masterAlertTemplatesService.findOne(id);
}
@Put(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateMasterAlertTemplateDto,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.masterAlertTemplatesService.update(id, dto, user, req.ip);
}
@Delete(':id')
remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.masterAlertTemplatesService.remove(id, user, req.ip);
}
}
@@ -0,0 +1,93 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { writeAuditLog } from '../../common/audit-log.util';
import { PrismaMasterService } from '../../config/prisma-master.service';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CreateMasterAlertTemplateDto } from './dto/create-master-alert-template.dto';
import { ListAlertTemplatesQueryDto } from './dto/list-alert-templates.query.dto';
import { UpdateMasterAlertTemplateDto } from './dto/update-master-alert-template.dto';
@Injectable()
export class MasterAlertTemplatesService {
constructor(private readonly prismaMaster: PrismaMasterService) {}
async create(dto: CreateMasterAlertTemplateDto, actor: JwtPayload, ip?: string) {
return this.prismaMaster.$transaction(async (tx) => {
const template = await tx.alert_templates.create({ data: { ...dto, tenant_id: null } });
await writeAuditLog(tx, {
usuario_tipo: 'master',
usuario_id: actor.sub,
acao: 'criar_alert_template_master',
entidade: 'alert_templates',
entidade_id: template.id,
detalhes: dto as unknown as Prisma.InputJsonValue,
ip_origem: ip,
});
return template;
});
}
findAll(query: ListAlertTemplatesQueryDto) {
return this.prismaMaster.alert_templates.findMany({
where: {
tenant_id: null,
...(query.tipo ? { tipo: query.tipo } : {}),
...(query.canal ? { canal: query.canal } : {}),
...(query.ativo === undefined ? {} : { ativo: query.ativo }),
},
orderBy: { criado_em: 'desc' },
});
}
async findOne(id: string) {
const template = await this.prismaMaster.alert_templates.findFirst({
where: { id, tenant_id: null },
});
if (!template) {
throw new NotFoundException('Template não encontrado');
}
return template;
}
async update(id: string, dto: UpdateMasterAlertTemplateDto, actor: JwtPayload, ip?: string) {
await this.findOne(id);
return this.prismaMaster.$transaction(async (tx) => {
const atualizado = await tx.alert_templates.update({ where: { id }, data: dto });
await writeAuditLog(tx, {
usuario_tipo: 'master',
usuario_id: actor.sub,
acao: 'atualizar_alert_template_master',
entidade: 'alert_templates',
entidade_id: id,
detalhes: dto as unknown as Prisma.InputJsonValue,
ip_origem: ip,
});
return atualizado;
});
}
/** Soft delete — `alert_logs.template_id` referencia o template sem CASCADE; exclusão real quebraria o histórico. */
async remove(id: string, actor: JwtPayload, ip?: string) {
await this.findOne(id);
return this.prismaMaster.$transaction(async (tx) => {
const desativado = await tx.alert_templates.update({ where: { id }, data: { ativo: false } });
await writeAuditLog(tx, {
usuario_tipo: 'master',
usuario_id: actor.sub,
acao: 'desativar_alert_template_master',
entidade: 'alert_templates',
entidade_id: id,
ip_origem: ip,
});
return desativado;
});
}
}
+44
View File
@@ -0,0 +1,44 @@
import { TipoTemplate } from './alert-template-tipos';
export interface PlaceholderDoc {
chave: string;
descricao: string;
}
/**
* Placeholders suportados por tipo de template, usados por
* `renderTemplate()` (substituição simples de `{{chave}}`). Documentado
* aqui para quem for escrever/editar um template — um placeholder fora
* dessa lista não é validado na criação (não é um erro), só não é
* substituído (fica literal no texto enviado).
*
* Só `mensalidade_tenant` e `cobranca_cliente` têm job automatizado nesta
* etapa (ver `TIPOS_TEMPLATE_COM_JOB`); `venda_confirmada`/
* `lembrete_vencimento` já têm os placeholders documentados para quando
* ganharem um disparo automatizado numa etapa futura.
*/
export const PLACEHOLDERS_POR_TIPO: Record<TipoTemplate, PlaceholderDoc[]> = {
mensalidade_tenant: [
{ chave: 'nome', descricao: 'Nome fantasia (ou razão social) do tenant' },
{ chave: 'valor', descricao: 'Valor da fatura, formatado como "R$ 199,90"' },
{ chave: 'vencimento', descricao: 'Data de vencimento da fatura, formato dd/mm/aaaa' },
{ chave: 'referencia', descricao: 'Mês/ano de referência da fatura, formato aaaa-mm' },
],
cobranca_cliente: [
{ chave: 'nome', descricao: 'Nome fantasia (ou razão social) do customer' },
{ chave: 'valor', descricao: 'Valor da parcela, formatado como "R$ 199,90"' },
{ chave: 'vencimento', descricao: 'Data de vencimento da parcela, formato dd/mm/aaaa' },
{ chave: 'numero_venda', descricao: 'Número sequencial da venda' },
{ chave: 'numero_parcela', descricao: 'Número da parcela dentro da venda (1, 2, 3...)' },
],
venda_confirmada: [
{ chave: 'nome', descricao: 'Nome fantasia (ou razão social) do customer' },
{ chave: 'numero_venda', descricao: 'Número sequencial da venda' },
{ chave: 'valor', descricao: 'Valor total da venda, formatado como "R$ 199,90"' },
],
lembrete_vencimento: [
{ chave: 'nome', descricao: 'Nome fantasia (ou razão social) do customer' },
{ chave: 'valor', descricao: 'Valor da parcela, formatado como "R$ 199,90"' },
{ chave: 'vencimento', descricao: 'Data de vencimento da parcela, formato dd/mm/aaaa' },
],
};
+13
View File
@@ -0,0 +1,13 @@
export const QUEUE_ALERTAS_MENSALIDADE = 'alertas-mensalidade';
export const QUEUE_ALERTAS_COBRANCA = 'alertas-cobranca';
export const JOB_ALERTA_MENSALIDADE = 'alerta-mensalidade';
export const JOB_ALERTA_COBRANCA = 'alerta-cobranca';
export interface AlertaMensalidadeJobData {
fatura_id: string;
}
export interface AlertaCobrancaJobData {
parcela_id: string;
}
@@ -0,0 +1,4 @@
/** Substitui `{{chave}}` pelos valores de `dados` — placeholder sem valor correspondente fica literal (não lança erro). */
export function renderTemplate(corpo: string, dados: Record<string, string>): string {
return corpo.replace(/\{\{(\w+)\}\}/g, (match, chave: string) => dados[chave] ?? match);
}
@@ -0,0 +1,18 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { TenantGuard } from '../auth/guards/tenant.guard';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { ListAlertLogsTenantQueryDto } from './dto/list-alert-logs.query.dto';
import { TenantAlertLogsService } from './tenant-alert-logs.service';
@UseGuards(JwtAuthGuard, TenantGuard)
@Controller('tenant/alertas/logs')
export class TenantAlertLogsController {
constructor(private readonly tenantAlertLogsService: TenantAlertLogsService) {}
@Get()
findAll(@Query() query: ListAlertLogsTenantQueryDto, @CurrentUser() user: JwtPayload) {
return this.tenantAlertLogsService.findAll(query, user);
}
}
@@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaMasterService } from '../../config/prisma-master.service';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { ListAlertLogsTenantQueryDto } from './dto/list-alert-logs.query.dto';
@Injectable()
export class TenantAlertLogsService {
constructor(private readonly prismaMaster: PrismaMasterService) {}
async findAll(query: ListAlertLogsTenantQueryDto, actor: JwtPayload) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const where: Prisma.alert_logsWhereInput = {
tenant_id: actor.tenant_id,
...(query.canal ? { canal: query.canal } : {}),
...(query.status ? { status: query.status } : {}),
...(query.data_inicio || query.data_fim
? {
enviado_em: {
...(query.data_inicio ? { gte: new Date(query.data_inicio) } : {}),
...(query.data_fim ? { lte: new Date(query.data_fim) } : {}),
},
}
: {}),
};
const [data, total] = await this.prismaMaster.$transaction([
this.prismaMaster.alert_logs.findMany({
where,
include: { template: { select: { tipo: true } } },
orderBy: { enviado_em: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prismaMaster.alert_logs.count({ where }),
]);
return { data, meta: { total, page, limit } };
}
}
@@ -0,0 +1,69 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { TenantGuard } from '../auth/guards/tenant.guard';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CreateTenantAlertTemplateDto } from './dto/create-tenant-alert-template.dto';
import { ListAlertTemplatesQueryDto } from './dto/list-alert-templates.query.dto';
import { UpdateTenantAlertTemplateDto } from './dto/update-tenant-alert-template.dto';
import { TenantAlertTemplatesService } from './tenant-alert-templates.service';
@UseGuards(JwtAuthGuard, TenantGuard, RolesGuard)
@Roles('tenant_admin')
@Controller('tenant/alertas/templates')
export class TenantAlertTemplatesController {
constructor(private readonly tenantAlertTemplatesService: TenantAlertTemplatesService) {}
@Post()
create(
@Body() dto: CreateTenantAlertTemplateDto,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.tenantAlertTemplatesService.create(dto, user, req.ip);
}
@Get()
findAll(@Query() query: ListAlertTemplatesQueryDto, @CurrentUser() user: JwtPayload) {
return this.tenantAlertTemplatesService.findAll(query, user);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: JwtPayload) {
return this.tenantAlertTemplatesService.findOne(id, user);
}
@Put(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTenantAlertTemplateDto,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.tenantAlertTemplatesService.update(id, dto, user, req.ip);
}
@Delete(':id')
remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.tenantAlertTemplatesService.remove(id, user, req.ip);
}
}
@@ -0,0 +1,114 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { writeAuditLog } from '../../common/audit-log.util';
import { PrismaMasterService } from '../../config/prisma-master.service';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CreateTenantAlertTemplateDto } from './dto/create-tenant-alert-template.dto';
import { ListAlertTemplatesQueryDto } from './dto/list-alert-templates.query.dto';
import { UpdateTenantAlertTemplateDto } from './dto/update-tenant-alert-template.dto';
/**
* `alert_templates` não tem RLS habilitado (não está na lista de tabelas
* isoladas por tenant da migration de RLS da Etapa 1) — diferente de
* `customers`/`products_services`/etc, o isolamento entre tenants aqui é
* 100% manual: todo `findFirst`/`findMany`/`update` abaixo inclui
* `tenant_id` explicitamente no `where`, nunca confia em RLS. Coberto por
* teste e2e dedicado (ver `alertas.e2e-spec.ts`) que tenta acessar/editar
* template de outro tenant pelo UUID exato.
*/
@Injectable()
export class TenantAlertTemplatesService {
constructor(private readonly prismaMaster: PrismaMasterService) {}
async create(dto: CreateTenantAlertTemplateDto, actor: JwtPayload, ip?: string) {
const tenantId = actor.tenant_id as string;
return this.prismaMaster.$transaction(async (tx) => {
const template = await tx.alert_templates.create({
data: { ...dto, tenant_id: tenantId },
});
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: tenantId,
acao: 'criar_alert_template_tenant',
entidade: 'alert_templates',
entidade_id: template.id,
detalhes: dto as unknown as Prisma.InputJsonValue,
ip_origem: ip,
});
return template;
});
}
findAll(query: ListAlertTemplatesQueryDto, actor: JwtPayload) {
return this.prismaMaster.alert_templates.findMany({
where: {
tenant_id: actor.tenant_id,
...(query.tipo ? { tipo: query.tipo } : {}),
...(query.canal ? { canal: query.canal } : {}),
...(query.ativo === undefined ? {} : { ativo: query.ativo }),
},
orderBy: { criado_em: 'desc' },
});
}
async findOne(id: string, actor: JwtPayload) {
const template = await this.prismaMaster.alert_templates.findFirst({
where: { id, tenant_id: actor.tenant_id },
});
if (!template) {
throw new NotFoundException('Template não encontrado');
}
return template;
}
async update(id: string, dto: UpdateTenantAlertTemplateDto, actor: JwtPayload, ip?: string) {
await this.findOne(id, actor);
return this.prismaMaster.$transaction(async (tx) => {
const atualizado = await tx.alert_templates.update({
where: { id, tenant_id: actor.tenant_id as string },
data: dto,
});
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: actor.tenant_id,
acao: 'atualizar_alert_template_tenant',
entidade: 'alert_templates',
entidade_id: id,
detalhes: dto as unknown as Prisma.InputJsonValue,
ip_origem: ip,
});
return atualizado;
});
}
async remove(id: string, actor: JwtPayload, ip?: string) {
await this.findOne(id, actor);
return this.prismaMaster.$transaction(async (tx) => {
const desativado = await tx.alert_templates.update({
where: { id, tenant_id: actor.tenant_id as string },
data: { ativo: false },
});
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: actor.tenant_id,
acao: 'desativar_alert_template_tenant',
entidade: 'alert_templates',
entidade_id: id,
ip_origem: ip,
});
return desativado;
});
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { WorkerModule } from './worker/worker.module';
/**
* Entry point separado de `main.ts` (web). Não sobe HTTP —
* `createApplicationContext` só inicializa o container de DI e os
* providers (crons do `@nestjs/schedule`, workers do BullMQ), que mantêm
* o processo vivo sozinhos (timers/conexões abertas com o Redis).
*
* Em desenvolvimento (`docker-compose.override.yml`) roda no mesmo
* container `app`, com `command` diferente. Em produção
* (`docker-compose.yml`), é um serviço `worker` próprio, mesma imagem,
* `CMD ["node", "dist/worker"]` — pode escalar (`docker compose up --scale
* worker=3`) independente do processo web, já que os crons não vivem nele
* (ver `WorkerModule`).
*/
async function bootstrap() {
const logger = new Logger('Worker');
await NestFactory.createApplicationContext(WorkerModule);
logger.log('Worker iniciado — filas: alertas-mensalidade, alertas-cobranca');
}
void bootstrap();
+116
View File
@@ -0,0 +1,116 @@
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import { Queue } from 'bullmq';
import { PrismaMasterService } from '../config/prisma-master.service';
import { AlertMaintenanceService } from '../modules/alerts/alert-maintenance.service';
import { inicioDoDia } from '../modules/alerts/alert-format.util';
import {
JOB_ALERTA_COBRANCA,
JOB_ALERTA_MENSALIDADE,
QUEUE_ALERTAS_COBRANCA,
QUEUE_ALERTAS_MENSALIDADE,
} from '../modules/alerts/queue.constants';
function somarDias(data: Date, dias: number): Date {
const copia = new Date(data);
copia.setUTCDate(copia.getUTCDate() + dias);
return copia;
}
/**
* `@Cron()` lê `process.env` diretamente (não `ConfigService`): decorators
* são avaliados na importação da classe, antes do Nest terminar de
* inicializar `ConfigModule` — mas como o deploy real desta etapa injeta
* as variáveis via `env_file`/ambiente do container (não um `.env` lido em
* runtime pelo dotenv), `process.env` já está populado nesse momento, então
* ler direto aqui é seguro (mesma razão pela qual isso *não* dá pra fazer
* com `ConfigService`, que só fica pronto depois do bootstrap).
*/
@Injectable()
export class AlertSchedulerService {
private readonly logger = new Logger(AlertSchedulerService.name);
constructor(
private readonly prismaMaster: PrismaMasterService,
private readonly alertMaintenance: AlertMaintenanceService,
private readonly configService: ConfigService,
@InjectQueue(QUEUE_ALERTAS_MENSALIDADE) private readonly filaMensalidade: Queue,
@InjectQueue(QUEUE_ALERTAS_COBRANCA) private readonly filaCobranca: Queue,
) {}
private opcoesJob() {
return {
attempts: this.configService.get<number>('ALERT_JOB_MAX_ATTEMPTS', 3),
backoff: {
type: 'exponential' as const,
delay: this.configService.get<number>('ALERT_JOB_BACKOFF_MS', 5000),
},
removeOnComplete: true,
removeOnFail: 100,
};
}
@Cron(process.env.ALERT_CRON_MENSALIDADE || '0 8 * * *')
async jobMensalidade(): Promise<void> {
this.logger.log('Iniciando job diário de alertas de mensalidade');
await this.alertMaintenance.marcarFaturasAtrasadas();
const dias = this.configService.get<number>('ALERT_DIAS_ANTECEDENCIA_MENSALIDADE', 3);
const hoje = inicioDoDia(new Date());
const limite = somarDias(hoje, dias);
const candidatas = await this.prismaMaster.tenant_faturas.findMany({
where: {
status: { in: ['pendente', 'atrasado'] },
vencimento: { lte: limite },
tenant: { status: { not: 'cancelado' } },
OR: [{ alerta_enviado_em: null }, { alerta_enviado_em: { lt: hoje } }],
},
select: { id: true },
});
const opcoes = this.opcoesJob();
const dataStr = hoje.toISOString().slice(0, 10);
for (const fatura of candidatas) {
await this.filaMensalidade.add(
JOB_ALERTA_MENSALIDADE,
{ fatura_id: fatura.id },
{ ...opcoes, jobId: `mensalidade-${fatura.id}-${dataStr}` },
);
}
this.logger.log(`Enfileirado(s) ${candidatas.length} alerta(s) de mensalidade`);
}
@Cron(process.env.ALERT_CRON_COBRANCA || '0 8 * * *')
async jobCobranca(): Promise<void> {
this.logger.log('Iniciando job diário de alertas de cobrança');
await this.alertMaintenance.marcarParcelasAtrasadas();
const dias = this.configService.get<number>('ALERT_DIAS_ANTECEDENCIA_COBRANCA', 3);
const hoje = inicioDoDia(new Date());
const limite = somarDias(hoje, dias);
const candidatas = await this.prismaMaster.sale_payments.findMany({
where: {
status: { in: ['pendente', 'atrasado'] },
vencimento: { lte: limite },
sale: { tenant: { status: { not: 'cancelado' } } },
OR: [{ alerta_enviado_em: null }, { alerta_enviado_em: { lt: hoje } }],
},
select: { id: true },
});
const opcoes = this.opcoesJob();
const dataStr = hoje.toISOString().slice(0, 10);
for (const parcela of candidatas) {
await this.filaCobranca.add(
JOB_ALERTA_COBRANCA,
{ parcela_id: parcela.id },
{ ...opcoes, jobId: `cobranca-${parcela.id}-${dataStr}` },
);
}
this.logger.log(`Enfileirado(s) ${candidatas.length} alerta(s) de cobrança`);
}
}
+143
View File
@@ -0,0 +1,143 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { PrismaMasterService } from '../config/prisma-master.service';
import {
AlertDispatchService,
DispatchResult,
} from '../modules/alert-dispatch/alert-dispatch.service';
import {
AlertTemplateResolverService,
CanalResolvido,
} from '../modules/alerts/alert-template-resolver.service';
import { formatDateBR, formatMoney, inicioDoDia } from '../modules/alerts/alert-format.util';
import { AlertaCobrancaJobData, QUEUE_ALERTAS_COBRANCA } from '../modules/alerts/queue.constants';
import { renderTemplate } from '../modules/alerts/template-render.util';
interface ResultadoCanal extends DispatchResult {
canal: string;
template: CanalResolvido['template'];
conteudo: string;
}
/**
* Mesmo padrão do `MensalidadeProcessor`, mas tenant→customer: usa a
* instância de WhatsApp/config de e-mail **do tenant dono da venda** (nunca
* a do master), resolvida via `parcela.sale.tenant_id` — nunca de um valor
* vindo do job em si (o job só carrega `parcela_id`, tudo mais é resolvido
* fresco no momento do processamento).
*/
@Processor(QUEUE_ALERTAS_COBRANCA)
export class CobrancaProcessor extends WorkerHost {
private readonly logger = new Logger(CobrancaProcessor.name);
constructor(
private readonly prismaMaster: PrismaMasterService,
private readonly templateResolver: AlertTemplateResolverService,
private readonly alertDispatch: AlertDispatchService,
) {
super();
}
async process(job: Job<AlertaCobrancaJobData>): Promise<void> {
const parcela = await this.prismaMaster.sale_payments.findUnique({
where: { id: job.data.parcela_id },
include: { sale: { include: { customer: true } } },
});
if (
!parcela ||
!parcela.sale ||
!parcela.sale.customer ||
parcela.status === 'pago' ||
parcela.status === 'cancelado'
) {
return;
}
const hoje = inicioDoDia(new Date());
if (parcela.alerta_enviado_em && parcela.alerta_enviado_em >= hoje) {
return;
}
const tenantId = parcela.sale.tenant_id;
const canais = await this.templateResolver.resolverPorCanais(tenantId, 'cobranca_cliente');
if (canais.length === 0) {
this.logger.warn(
`Nenhum template ativo de "cobranca_cliente" (próprio ou padrão) para o tenant ${tenantId}`,
);
return;
}
const dados = {
nome: parcela.sale.customer.nome_fantasia ?? parcela.sale.customer.razao_social,
valor: formatMoney(parcela.valor),
vencimento: formatDateBR(parcela.vencimento),
numero_venda: String(parcela.sale.numero_venda),
numero_parcela: String(parcela.numero_parcela),
};
const resultados: ResultadoCanal[] = await Promise.all(
canais.map(async ({ canal, template }) => {
const conteudo = renderTemplate(template.corpo, dados);
const resultado =
canal === 'whatsapp'
? await this.alertDispatch.enviarWhatsapp(
tenantId,
parcela.sale.customer?.telefone_whatsapp ?? '',
conteudo,
)
: await this.alertDispatch.enviarEmail(
tenantId,
parcela.sale.customer?.email ?? '',
template.assunto ?? 'Cobrança pendente',
conteudo,
);
return { ...resultado, canal, template, conteudo };
}),
);
const algumSucesso = resultados.some((r) => r.sucesso);
const customerId = parcela.sale.customer_id;
if (algumSucesso) {
await this.prismaMaster.sale_payments.update({
where: { id: parcela.id },
data: { alerta_enviado_em: new Date() },
});
await this.registrarLogs(tenantId, customerId, resultados);
return;
}
// `job.attemptsMade` conta as tentativas *antes* desta (0 na primeira
// execução) — ver o mesmo comentário em `MensalidadeProcessor`.
const ultimaTentativa = job.attemptsMade + 1 >= (job.opts.attempts ?? 1);
if (ultimaTentativa) {
await this.registrarLogs(tenantId, customerId, resultados);
}
throw new Error(resultados.map((r) => `${r.canal}: ${r.erro}`).join('; '));
}
private async registrarLogs(
tenantId: string,
customerId: string,
resultados: ResultadoCanal[],
): Promise<void> {
for (const r of resultados) {
await this.prismaMaster.alert_logs.create({
data: {
tenant_id: tenantId,
destinatario_tipo: 'customer',
destinatario_id: customerId,
canal: r.canal,
template_id: r.template.id,
conteudo_enviado: r.conteudo,
status: r.sucesso ? 'enviado' : 'falhou',
erro: r.erro,
},
});
}
}
}
+141
View File
@@ -0,0 +1,141 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { alert_templates } from '@prisma/client';
import { Job } from 'bullmq';
import { PrismaMasterService } from '../config/prisma-master.service';
import {
AlertDispatchService,
DispatchResult,
} from '../modules/alert-dispatch/alert-dispatch.service';
import { formatDateBR, formatMoney, inicioDoDia } from '../modules/alerts/alert-format.util';
import {
AlertaMensalidadeJobData,
QUEUE_ALERTAS_MENSALIDADE,
} from '../modules/alerts/queue.constants';
import { renderTemplate } from '../modules/alerts/template-render.util';
interface ResultadoCanal extends DispatchResult {
canal: string;
template: alert_templates;
conteudo: string;
}
/**
* Um job = uma fatura (não um job por canal): `tenant_faturas.alerta_enviado_em`
* é um único timestamp (não por canal), então "enviado" é tratado como uma
* ação conceitualmente única que pode sair por WhatsApp e/ou e-mail juntos.
* Ver Observações da Etapa 7 no controle de progresso para a discussão
* completa dessa decisão.
*/
@Processor(QUEUE_ALERTAS_MENSALIDADE)
export class MensalidadeProcessor extends WorkerHost {
private readonly logger = new Logger(MensalidadeProcessor.name);
constructor(
private readonly prismaMaster: PrismaMasterService,
private readonly alertDispatch: AlertDispatchService,
) {
super();
}
async process(job: Job<AlertaMensalidadeJobData>): Promise<void> {
const fatura = await this.prismaMaster.tenant_faturas.findUnique({
where: { id: job.data.fatura_id },
include: { tenant: true },
});
if (!fatura || !fatura.tenant || fatura.status === 'pago' || fatura.status === 'cancelado') {
return;
}
const hoje = inicioDoDia(new Date());
if (fatura.alerta_enviado_em && fatura.alerta_enviado_em >= hoje) {
return; // já enviado hoje — idempotência (segunda checagem, além do filtro do produtor)
}
const templatesAtivos = await this.prismaMaster.alert_templates.findMany({
where: { tenant_id: null, tipo: 'mensalidade_tenant', ativo: true },
orderBy: { criado_em: 'desc' },
});
const templatePorCanal = new Map<string, alert_templates>();
for (const template of templatesAtivos) {
if (!templatePorCanal.has(template.canal)) {
templatePorCanal.set(template.canal, template);
}
}
if (templatePorCanal.size === 0) {
this.logger.warn('Nenhum template ativo de "mensalidade_tenant" configurado — nada a enviar');
return;
}
const dados = {
nome: fatura.tenant.nome_fantasia ?? fatura.tenant.razao_social,
valor: formatMoney(fatura.valor),
vencimento: formatDateBR(fatura.vencimento),
referencia: fatura.referencia,
};
const resultados: ResultadoCanal[] = await Promise.all(
[...templatePorCanal.values()].map(async (template) => {
const conteudo = renderTemplate(template.corpo, dados);
const resultado =
template.canal === 'whatsapp'
? await this.alertDispatch.enviarWhatsapp(
fatura.tenant_id,
fatura.tenant?.telefone ?? '',
conteudo,
)
: await this.alertDispatch.enviarEmail(
fatura.tenant_id,
fatura.tenant?.email_contato ?? '',
template.assunto ?? 'Aviso de mensalidade',
conteudo,
);
return { ...resultado, canal: template.canal, template, conteudo };
}),
);
const algumSucesso = resultados.some((r) => r.sucesso);
if (algumSucesso) {
await this.prismaMaster.tenant_faturas.update({
where: { id: fatura.id },
data: { alerta_enviado_em: new Date() },
});
await this.registrarLogs(fatura.tenant_id, resultados);
return;
}
// Nenhum canal funcionou: se essa é a última tentativa permitida (BullMQ
// já configurado com retry/backoff em AlertSchedulerService), registra a
// falha definitiva em alert_logs agora — tentativas intermediárias não
// gravam log, só a última, pra não poluir o histórico com uma linha por
// retry do mesmo alerta. `job.attemptsMade` conta as tentativas *antes*
// desta (0 na primeira execução), por isso o `+1`: esta é a última
// quando (tentativas anteriores + a atual) atinge o limite configurado.
const ultimaTentativa = job.attemptsMade + 1 >= (job.opts.attempts ?? 1);
if (ultimaTentativa) {
await this.registrarLogs(fatura.tenant_id, resultados);
}
throw new Error(resultados.map((r) => `${r.canal}: ${r.erro}`).join('; '));
}
private async registrarLogs(tenantId: string, resultados: ResultadoCanal[]): Promise<void> {
for (const r of resultados) {
await this.prismaMaster.alert_logs.create({
data: {
tenant_id: tenantId,
destinatario_tipo: 'tenant',
destinatario_id: tenantId,
canal: r.canal,
template_id: r.template.id,
conteudo_enviado: r.conteudo,
status: r.sucesso ? 'enviado' : 'falhou',
erro: r.erro,
},
});
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { EncryptionModule } from '../common/encryption.module';
import { validate } from '../config/env.validation';
import { PrismaModule } from '../config/prisma.module';
import { AlertDispatchModule } from '../modules/alert-dispatch/alert-dispatch.module';
import { AlertsModule } from '../modules/alerts/alerts.module';
import {
QUEUE_ALERTAS_COBRANCA,
QUEUE_ALERTAS_MENSALIDADE,
} from '../modules/alerts/queue.constants';
import { AlertSchedulerService } from './alert-scheduler.service';
import { CobrancaProcessor } from './cobranca.processor';
import { MensalidadeProcessor } from './mensalidade.processor';
/**
* Módulo do processo *worker* — só o worker importa `ScheduleModule`/
* `BullModule`: os dois crons diários (`AlertSchedulerService`) e os dois
* processors (`MensalidadeProcessor`/`CobrancaProcessor`) vivem
* exclusivamente aqui, nunca no `AppModule` do processo web
* (`src/app.module.ts`). Motivo: se o processo web escalar horizontalmente
* (várias réplicas), um `@Cron()` registrado nele dispararia o mesmo job
* uma vez por réplica — o worker, por design, roda como processo(s)
* separado(s) do web (ver `worker.ts`, `docker-compose.yml`), então
* escalar workers não duplica o disparo do cron (é só o processamento das
* filas que paraleliza).
*/
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, validate }),
ScheduleModule.forRoot(),
BullModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
connection: {
host: configService.get<string>('REDIS_HOST', 'redis'),
port: configService.get<number>('REDIS_PORT', 6379),
},
}),
}),
BullModule.registerQueue({ name: QUEUE_ALERTAS_MENSALIDADE }, { name: QUEUE_ALERTAS_COBRANCA }),
PrismaModule,
EncryptionModule,
AlertDispatchModule,
AlertsModule,
],
providers: [AlertSchedulerService, MensalidadeProcessor, CobrancaProcessor],
})
export class WorkerModule {}
+601
View File
@@ -0,0 +1,601 @@
import { randomUUID } from 'node:crypto';
import { IncomingMessage, Server, ServerResponse, createServer } from 'node:http';
import { AddressInfo } from 'node:net';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getQueueToken } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { WorkerModule } from '../src/worker/worker.module';
import { PrismaService } from '../src/config/prisma.service';
import { hashPassword } from '../src/modules/auth/password.util';
import { AlertSchedulerService } from '../src/worker/alert-scheduler.service';
import {
QUEUE_ALERTAS_COBRANCA,
QUEUE_ALERTAS_MENSALIDADE,
} from '../src/modules/alerts/queue.constants';
const mockSendMail = jest
.fn()
.mockImplementation((opts: { to: string }) => Promise.resolve({ messageId: `ok-${opts.to}` }));
jest.mock('nodemailer', () => ({
createTransport: jest.fn(() => ({ sendMail: mockSendMail })),
}));
interface TokenPair {
access_token: string;
refresh_token: string;
}
/** Subset mínimo da Evolution API v2 usado por esta etapa (criar instância, status, enviar mensagem) — mesmo padrão do `FakeEvolutionServer` de `integracoes.e2e-spec.ts`, sem os endpoints não usados aqui. */
class FakeEvolutionServer {
server: Server;
baseUrl = '';
async start(): Promise<void> {
this.server = createServer((req, res) => this.handle(req, res));
await new Promise<void>((resolve) => this.server.listen(0, '127.0.0.1', resolve));
const { port } = this.server.address() as AddressInfo;
this.baseUrl = `http://127.0.0.1:${port}`;
}
async stop(): Promise<void> {
await new Promise<void>((resolve) => this.server.close(() => resolve()));
}
private handle(req: IncomingMessage, res: ServerResponse): void {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
const url = req.url ?? '';
const method = req.method ?? 'GET';
const json = (status: number, payload: unknown) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(payload));
};
if (method === 'POST' && url === '/instance/create') {
return json(201, { instance: {} });
}
if (method === 'GET' && url.startsWith('/instance/connectionState/')) {
return json(200, { instance: { state: 'open' } });
}
if (method === 'POST' && url.startsWith('/message/sendText/')) {
return json(201, { status: 'PENDING' });
}
json(404, { message: 'rota não simulada' });
});
}
}
describe('Motor de Alertas — Templates, Jobs Diários e Logs (e2e)', () => {
let httpApp: INestApplication;
let workerApp: INestApplication;
let prisma: PrismaService;
let httpServer: Parameters<typeof request>[0];
let evolution: FakeEvolutionServer;
let alertScheduler: AlertSchedulerService;
let filaMensalidade: Queue;
let filaCobranca: Queue;
const senha = 'SenhaForteDemo#26';
const suffix = randomUUID().slice(0, 8);
const masterEmail = `master-etapa7-${suffix}@teste.com`;
let masterId: string;
let masterToken: string;
const tenantIds: string[] = [];
const planoIds: string[] = [];
const tenantUserIds: string[] = [];
const evolutionConfigIds: string[] = [];
const masterEmailConfigIds: string[] = [];
const masterTemplateIds: string[] = [];
async function aguardarFilasOciosas(timeoutMs = 15000): Promise<void> {
const inicio = Date.now();
while (Date.now() - inicio < timeoutMs) {
const [m, c] = await Promise.all([
filaMensalidade.getJobCounts('waiting', 'active', 'delayed'),
filaCobranca.getJobCounts('waiting', 'active', 'delayed'),
]);
const pendentes =
(m.waiting ?? 0) +
(m.active ?? 0) +
(m.delayed ?? 0) +
(c.waiting ?? 0) +
(c.active ?? 0) +
(c.delayed ?? 0);
if (pendentes === 0) return;
await new Promise((resolve) => setTimeout(resolve, 150));
}
throw new Error('Timeout esperando as filas de alerta ficarem ociosas');
}
async function authMaster(): Promise<string> {
const res = await request(httpServer)
.post('/auth/master/login')
.send({ email: masterEmail, senha })
.expect(200);
return (res.body as TokenPair).access_token;
}
function asMaster() {
return {
get: (url: string) =>
request(httpServer).get(url).set('Authorization', `Bearer ${masterToken}`),
post: (url: string) =>
request(httpServer).post(url).set('Authorization', `Bearer ${masterToken}`),
delete: (url: string) =>
request(httpServer).delete(url).set('Authorization', `Bearer ${masterToken}`),
};
}
function asTenant(token: string) {
return {
get: (url: string) => request(httpServer).get(url).set('Authorization', `Bearer ${token}`),
post: (url: string) => request(httpServer).post(url).set('Authorization', `Bearer ${token}`),
put: (url: string) => request(httpServer).put(url).set('Authorization', `Bearer ${token}`),
delete: (url: string) =>
request(httpServer).delete(url).set('Authorization', `Bearer ${token}`),
};
}
async function criarPlano() {
const res = await asMaster()
.post('/master/planos')
.send({
nome: `Plano ${suffix} ${randomUUID().slice(0, 4)}`,
valor_mensal: 199.9,
limite_instancias_whatsapp: 1,
})
.expect(201);
planoIds.push((res.body as { id: string }).id);
return res.body as { id: string };
}
async function criarTenantComAdmin(telefone?: string) {
const plano = await criarPlano();
const emailAdmin = `admin-${randomUUID().slice(0, 8)}-${suffix}@teste.com`;
const emailContato = `contato-${randomUUID().slice(0, 6)}-${suffix}@teste.com`;
const res = await asMaster()
.post('/master/tenants')
.send({
razao_social: `Tenant Etapa7 ${suffix} ${randomUUID().slice(0, 6)}`,
email_contato: emailContato,
telefone,
plano_id: plano.id,
admin: { nome: 'Admin de Teste', email: emailAdmin },
})
.expect(201);
const body = res.body as {
tenant: { id: string };
admin: { id: string; email: string; senha_provisoria: string };
};
tenantIds.push(body.tenant.id);
tenantUserIds.push(body.admin.id);
await prisma.tenants.update({ where: { id: body.tenant.id }, data: { status: 'ativo' } });
const login = await request(httpServer)
.post('/auth/tenant/login')
.send({ email: body.admin.email, senha: body.admin.senha_provisoria })
.expect(200);
return {
tenantId: body.tenant.id,
emailContato,
token: (login.body as TokenPair).access_token,
};
}
async function criarEvolutionConfigAtiva() {
const res = await asMaster()
.post('/master/evolution-config')
.send({
nome: `Evolution ${suffix}`,
base_url: evolution.baseUrl,
api_key: 'chave-evolution-secreta-1234',
})
.expect(201);
evolutionConfigIds.push((res.body as { id: string }).id);
}
async function criarMasterEmailConfigAtiva() {
const res = await asMaster()
.post('/master/email-config')
.send({
smtp_host: 'smtp.exemplo.com',
smtp_porta: 587,
smtp_usuario: 'no-reply',
smtp_senha: 'senha-smtp-secreta',
email_remetente: 'no-reply@seconectai.com.br',
})
.expect(201);
masterEmailConfigIds.push((res.body as { id: string }).id);
}
async function criarInstanciaConectada(token: string) {
const res = await asTenant(token).post('/tenant/whatsapp/instancias').expect(201);
const instanceId = (res.body as { id: string }).id;
await asTenant(token).get(`/tenant/whatsapp/instancias/${instanceId}/status`).expect(200);
return instanceId;
}
async function criarCustomer(token: string, overrides: Partial<Record<string, unknown>> = {}) {
const res = await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: `Cliente ${randomUUID().slice(0, 8)}`, ...overrides })
.expect(201);
return res.body as { id: string };
}
async function criarProduto(token: string) {
const res = await asTenant(token)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: `Produto ${randomUUID().slice(0, 8)}`, valor_venda: 150 })
.expect(201);
return res.body as { id: string };
}
/** Cria uma venda com uma única parcela já vencida (útil pro job de cobrança não precisar esperar `ALERT_DIAS_ANTECEDENCIA_COBRANCA`). */
async function criarVendaComParcelaVencida(token: string, customerId: string, produtoId: string) {
const res = await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 150 }],
parcelas: [{ valor: 150, vencimento: '2020-01-01' }],
})
.expect(201);
return res.body as { id: string; parcelas: Array<{ id: string }> };
}
async function criarMasterTemplate(overrides: Partial<Record<string, unknown>>) {
const res = await asMaster().post('/master/alertas/templates').send(overrides).expect(201);
masterTemplateIds.push((res.body as { id: string }).id);
return res.body as { id: string };
}
beforeAll(async () => {
evolution = new FakeEvolutionServer();
await evolution.start();
const httpFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
httpApp = httpFixture.createNestApplication();
httpApp.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await httpApp.init();
httpServer = httpApp.getHttpServer() as Parameters<typeof request>[0];
// O processo worker de verdade (`src/worker.ts`) roda separado do processo
// web — aqui, no teste, os dois são inicializados como duas aplicações
// Nest independentes no mesmo processo de teste (mesma técnica usada por
// toda a suíte pra testar contra Postgres/Redis reais), só pra poder
// aguardar o processamento das filas de forma síncrona no teste.
const workerFixture: TestingModule = await Test.createTestingModule({
imports: [WorkerModule],
}).compile();
workerApp = workerFixture.createNestApplication();
await workerApp.init();
alertScheduler = workerApp.get(AlertSchedulerService);
filaMensalidade = workerApp.get<Queue>(getQueueToken(QUEUE_ALERTAS_MENSALIDADE));
filaCobranca = workerApp.get<Queue>(getQueueToken(QUEUE_ALERTAS_COBRANCA));
prisma = httpApp.get(PrismaService);
const senha_hash = await hashPassword(senha);
const master = await prisma.master_admins.create({
data: { nome: 'Master Etapa 7', email: masterEmail, senha_hash },
});
masterId = master.id;
masterToken = await authMaster();
await criarEvolutionConfigAtiva();
await criarMasterEmailConfigAtiva();
});
afterAll(async () => {
await filaMensalidade.obliterate({ force: true }).catch(() => undefined);
await filaCobranca.obliterate({ force: true }).catch(() => undefined);
await prisma.alert_logs.deleteMany({ where: { tenant_id: { in: tenantIds } } });
await prisma.alert_templates.deleteMany({
where: { id: { in: masterTemplateIds }, tenant_id: null },
});
await prisma.sales.deleteMany({ where: { tenant_id: { in: tenantIds } } });
await prisma.tenant_whatsapp_instances.deleteMany({ where: { tenant_id: { in: tenantIds } } });
await prisma.tenant_faturas.deleteMany({ where: { tenant_id: { in: tenantIds } } });
await prisma.tenants.deleteMany({ where: { id: { in: tenantIds } } });
await prisma.planos.deleteMany({ where: { id: { in: planoIds } } });
await prisma.evolution_api_config.deleteMany({ where: { id: { in: evolutionConfigIds } } });
await prisma.master_email_config.deleteMany({ where: { id: { in: masterEmailConfigIds } } });
await prisma.auth_refresh_tokens.deleteMany({
where: { usuario_id: { in: [masterId, ...tenantUserIds] } },
});
await prisma.login_attempts.deleteMany({
where: { usuario_tipo: 'master', email: masterEmail },
});
await prisma.audit_logs.deleteMany({ where: { usuario_id: masterId } });
await prisma.master_admins.deleteMany({ where: { id: masterId } });
await workerApp.close();
await httpApp.close();
await evolution.stop();
});
describe('alert_templates — CRUD master e tenant', () => {
it('master cria template padrão de mensalidade_tenant', async () => {
const res = await asMaster()
.post('/master/alertas/templates')
.send({
tipo: 'mensalidade_tenant',
canal: 'whatsapp',
corpo: 'Olá {{nome}}, sua fatura de {{valor}} vence em {{vencimento}}.',
})
.expect(201);
masterTemplateIds.push((res.body as { id: string }).id);
expect(res.body).toMatchObject({ tenant_id: null, tipo: 'mensalidade_tenant', ativo: true });
});
it('master cria template de email exige assunto', async () => {
await asMaster()
.post('/master/alertas/templates')
.send({ tipo: 'mensalidade_tenant', canal: 'email', corpo: 'Corpo sem assunto' })
.expect(400);
});
it('tenant não pode criar template tipo mensalidade_tenant', async () => {
const tenant = await criarTenantComAdmin();
await asTenant(tenant.token)
.post('/tenant/alertas/templates')
.send({ tipo: 'mensalidade_tenant', canal: 'whatsapp', corpo: 'Tentativa inválida' })
.expect(400);
});
it('tenant cria template próprio de cobranca_cliente', async () => {
const tenant = await criarTenantComAdmin();
const res = await asTenant(tenant.token)
.post('/tenant/alertas/templates')
.send({
tipo: 'cobranca_cliente',
canal: 'whatsapp',
corpo: 'Olá {{nome}}, parcela {{numero_parcela}} vence em {{vencimento}}.',
})
.expect(201);
expect(res.body).toMatchObject({ tenant_id: tenant.tenantId, tipo: 'cobranca_cliente' });
});
it('tenant B não enxerga/edita template do tenant A (404, não 403)', async () => {
const tenantA = await criarTenantComAdmin();
const tenantB = await criarTenantComAdmin();
const criado = await asTenant(tenantA.token)
.post('/tenant/alertas/templates')
.send({ tipo: 'cobranca_cliente', canal: 'email', assunto: 'Assunto', corpo: 'Corpo' })
.expect(201);
const templateId = (criado.body as { id: string }).id;
await asTenant(tenantB.token).get(`/tenant/alertas/templates/${templateId}`).expect(404);
await asTenant(tenantB.token)
.put(`/tenant/alertas/templates/${templateId}`)
.send({ corpo: 'Tentativa de editar' })
.expect(404);
await asTenant(tenantB.token).delete(`/tenant/alertas/templates/${templateId}`).expect(404);
});
it('DELETE faz soft delete (ativo=false, continua existindo)', async () => {
const tenant = await criarTenantComAdmin();
const criado = await asTenant(tenant.token)
.post('/tenant/alertas/templates')
.send({ tipo: 'lembrete_vencimento', canal: 'whatsapp', corpo: 'Lembrete' })
.expect(201);
const id = (criado.body as { id: string }).id;
await asTenant(tenant.token).delete(`/tenant/alertas/templates/${id}`).expect(200);
const naBase = await prisma.alert_templates.findUnique({ where: { id } });
expect(naBase?.ativo).toBe(false);
});
});
describe('job diário — mensalidade_tenant', () => {
let tenantSucesso: { tenantId: string; emailContato: string; token: string };
let tenantFalha: { tenantId: string; emailContato: string; token: string };
beforeAll(async () => {
await criarMasterTemplate({
tipo: 'mensalidade_tenant',
canal: 'whatsapp',
corpo:
'Olá {{nome}}, sua mensalidade de {{valor}} (ref. {{referencia}}) vence em {{vencimento}}.',
});
await criarMasterTemplate({
tipo: 'mensalidade_tenant',
canal: 'email',
assunto: 'Mensalidade {{referencia}}',
corpo: 'Olá {{nome}}, sua mensalidade de {{valor}} vence em {{vencimento}}.',
});
tenantSucesso = await criarTenantComAdmin('11999990000');
await criarInstanciaConectada(tenantSucesso.token);
tenantFalha = await criarTenantComAdmin(); // sem instância de WhatsApp conectada
mockSendMail.mockImplementation((opts: { to: string }) =>
opts.to === tenantFalha.emailContato
? Promise.reject(new Error('falha simulada de SMTP'))
: Promise.resolve({ messageId: `ok-${opts.to}` }),
);
});
it('fatura vencida recebe alerta por whatsapp e email; marca alerta_enviado_em e atrasado', async () => {
const fatura = await prisma.tenant_faturas.create({
data: {
tenant_id: tenantSucesso.tenantId,
referencia: '2026-01',
valor: 199.9,
vencimento: new Date('2020-01-05'),
},
});
await alertScheduler.jobMensalidade();
await aguardarFilasOciosas();
const atualizada = await prisma.tenant_faturas.findUnique({ where: { id: fatura.id } });
expect(atualizada?.status).toBe('atrasado');
expect(atualizada?.alerta_enviado_em).not.toBeNull();
const logs = await prisma.alert_logs.findMany({
where: { tenant_id: tenantSucesso.tenantId, destinatario_id: tenantSucesso.tenantId },
});
expect(logs).toHaveLength(2);
expect(logs.every((l) => l.status === 'enviado')).toBe(true);
expect(logs.find((l) => l.canal === 'whatsapp')?.conteudo_enviado).toContain('R$ 199,90');
expect(logs.find((l) => l.canal === 'email')?.conteudo_enviado).toContain('05/01/2020');
});
it('rodar o job de novo no mesmo dia não duplica o alerta', async () => {
await alertScheduler.jobMensalidade();
await aguardarFilasOciosas();
const logs = await prisma.alert_logs.findMany({
where: { tenant_id: tenantSucesso.tenantId, destinatario_id: tenantSucesso.tenantId },
});
expect(logs).toHaveLength(2); // continua 2, não duplicou
});
it('falha em todos os canais é registrada em alert_logs só após esgotar as tentativas', async () => {
const fatura = await prisma.tenant_faturas.create({
data: {
tenant_id: tenantFalha.tenantId,
referencia: '2026-01',
valor: 50,
vencimento: new Date('2020-01-05'),
},
});
await alertScheduler.jobMensalidade();
await aguardarFilasOciosas();
const atualizada = await prisma.tenant_faturas.findUnique({ where: { id: fatura.id } });
expect(atualizada?.alerta_enviado_em).toBeNull(); // nunca "conseguiu" enviar
const logs = await prisma.alert_logs.findMany({
where: { tenant_id: tenantFalha.tenantId, destinatario_id: tenantFalha.tenantId },
});
expect(logs).toHaveLength(2); // um por canal, não um por tentativa de retry
expect(logs.every((l) => l.status === 'falhou' && !!l.erro)).toBe(true);
});
});
describe('job diário — cobranca_cliente', () => {
let tenantComTemplateProprio: { tenantId: string; token: string };
let tenantComFallback: { tenantId: string; token: string };
beforeAll(async () => {
await criarMasterTemplate({
tipo: 'cobranca_cliente',
canal: 'email',
assunto: 'Cobrança — parcela {{numero_parcela}}',
corpo:
'Olá {{nome}}, a parcela {{numero_parcela}} da venda #{{numero_venda}} no valor de {{valor}} venceu em {{vencimento}}.',
});
tenantComTemplateProprio = await criarTenantComAdmin('11988880000');
await criarInstanciaConectada(tenantComTemplateProprio.token);
await asTenant(tenantComTemplateProprio.token)
.post('/tenant/alertas/templates')
.send({
tipo: 'cobranca_cliente',
canal: 'whatsapp',
corpo: 'Template PRÓPRIO do tenant: {{nome}}, parcela {{numero_parcela}} venceu.',
})
.expect(201);
tenantComFallback = await criarTenantComAdmin(); // sem instância, sem template próprio
});
it('usa o template e o canal (whatsapp) PRÓPRIO do tenant quando existe, sobrepondo o padrão do master', async () => {
const customer = await criarCustomer(tenantComTemplateProprio.token, {
telefone_whatsapp: '11977770000',
});
const produto = await criarProduto(tenantComTemplateProprio.token);
const venda = await criarVendaComParcelaVencida(
tenantComTemplateProprio.token,
customer.id,
produto.id,
);
await alertScheduler.jobCobranca();
await aguardarFilasOciosas();
const parcela = await prisma.sale_payments.findUnique({
where: { id: venda.parcelas[0].id },
include: { sale: true },
});
expect(parcela?.status).toBe('atrasado');
expect(parcela?.alerta_enviado_em).not.toBeNull();
const log = await prisma.alert_logs.findFirst({
where: { tenant_id: tenantComTemplateProprio.tenantId, destinatario_id: customer.id },
include: { template: true },
});
expect(log?.canal).toBe('whatsapp');
expect(log?.conteudo_enviado).toContain('Template PRÓPRIO do tenant');
expect(log?.template?.tenant_id).toBe(tenantComTemplateProprio.tenantId);
});
it('usa o template PADRÃO do master (fallback) quando o tenant não tem o próprio, por email', async () => {
const customer = await criarCustomer(tenantComFallback.token);
const produto = await criarProduto(tenantComFallback.token);
const venda = await criarVendaComParcelaVencida(
tenantComFallback.token,
customer.id,
produto.id,
);
await alertScheduler.jobCobranca();
await aguardarFilasOciosas();
const parcela = await prisma.sale_payments.findUnique({
where: { id: venda.parcelas[0].id },
});
expect(parcela?.alerta_enviado_em).not.toBeNull();
const log = await prisma.alert_logs.findFirst({
where: { tenant_id: tenantComFallback.tenantId, destinatario_id: customer.id },
include: { template: true },
});
expect(log?.canal).toBe('email');
expect(log?.template?.tenant_id).toBeNull(); // template do master, não do tenant
});
});
describe('GET /master/alertas/logs e /tenant/alertas/logs — filtros', () => {
it('master lista logs com filtro por tenant_id e canal', async () => {
const res = await asMaster().get('/master/alertas/logs?canal=email').expect(200);
const body = res.body as { data: Array<{ canal: string }> };
expect(body.data.every((l) => l.canal === 'email')).toBe(true);
});
it('tenant só enxerga os próprios logs', async () => {
const outroTenant = await criarTenantComAdmin();
const res = await asTenant(outroTenant.token).get('/tenant/alertas/logs').expect(200);
const body = res.body as { data: unknown[] };
expect(body.data).toHaveLength(0);
});
});
describe('acesso master sem contexto de tenant', () => {
it('master "puro" recebe 403 nos endpoints de tenant desta etapa', async () => {
await asMaster().get('/tenant/alertas/templates').expect(403);
await asMaster().get('/tenant/alertas/logs').expect(403);
});
});
});