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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+351
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }],
|
||||
})
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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'];
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user