diff --git a/.env.example b/.env.example index 8196e1c..fd4e01f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Etapas/09-controle-de-progresso.md b/Etapas/09-controle-de-progresso.md index 7a6aa0b..56a7766 100644 --- a/Etapas/09-controle-de-progresso.md +++ b/Etapas/09-controle-de-progresso.md @@ -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--`/`cobranca--`) — 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. diff --git a/README.md b/README.md index ec5a2a2..a12893e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 7e91d9d..49774e7 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index b15e349..93cd152 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/package-lock.json b/package-lock.json index 82cc77b..f67a73d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 288b220..c231c00 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app.module.ts b/src/app.module.ts index a54868b..11c248c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 }], }) diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 296d6fa..f7fdec8 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -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) { diff --git a/src/modules/alerts/alert-format.util.ts b/src/modules/alerts/alert-format.util.ts new file mode 100644 index 0000000..a91457d --- /dev/null +++ b/src/modules/alerts/alert-format.util.ts @@ -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()}`; +} diff --git a/src/modules/alerts/alert-maintenance.service.ts b/src/modules/alerts/alert-maintenance.service.ts new file mode 100644 index 0000000..92280cb --- /dev/null +++ b/src/modules/alerts/alert-maintenance.service.ts @@ -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 }; + } +} diff --git a/src/modules/alerts/alert-template-resolver.service.ts b/src/modules/alerts/alert-template-resolver.service.ts new file mode 100644 index 0000000..0dee427 --- /dev/null +++ b/src/modules/alerts/alert-template-resolver.service.ts @@ -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 { + 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 { + 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; + } +} diff --git a/src/modules/alerts/alert-template-tipos.ts b/src/modules/alerts/alert-template-tipos.ts new file mode 100644 index 0000000..5adf3ab --- /dev/null +++ b/src/modules/alerts/alert-template-tipos.ts @@ -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']; diff --git a/src/modules/alerts/alerts.module.ts b/src/modules/alerts/alerts.module.ts new file mode 100644 index 0000000..ba7e320 --- /dev/null +++ b/src/modules/alerts/alerts.module.ts @@ -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 {} diff --git a/src/modules/alerts/dto/create-master-alert-template.dto.ts b/src/modules/alerts/dto/create-master-alert-template.dto.ts new file mode 100644 index 0000000..f9a74e3 --- /dev/null +++ b/src/modules/alerts/dto/create-master-alert-template.dto.ts @@ -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; +} diff --git a/src/modules/alerts/dto/create-tenant-alert-template.dto.ts b/src/modules/alerts/dto/create-tenant-alert-template.dto.ts new file mode 100644 index 0000000..8ad3d39 --- /dev/null +++ b/src/modules/alerts/dto/create-tenant-alert-template.dto.ts @@ -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; +} diff --git a/src/modules/alerts/dto/list-alert-logs.query.dto.ts b/src/modules/alerts/dto/list-alert-logs.query.dto.ts new file mode 100644 index 0000000..0f3e2af --- /dev/null +++ b/src/modules/alerts/dto/list-alert-logs.query.dto.ts @@ -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; +} diff --git a/src/modules/alerts/dto/list-alert-templates.query.dto.ts b/src/modules/alerts/dto/list-alert-templates.query.dto.ts new file mode 100644 index 0000000..3bde808 --- /dev/null +++ b/src/modules/alerts/dto/list-alert-templates.query.dto.ts @@ -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; +} diff --git a/src/modules/alerts/dto/update-master-alert-template.dto.ts b/src/modules/alerts/dto/update-master-alert-template.dto.ts new file mode 100644 index 0000000..8a7ae9e --- /dev/null +++ b/src/modules/alerts/dto/update-master-alert-template.dto.ts @@ -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; +} diff --git a/src/modules/alerts/dto/update-tenant-alert-template.dto.ts b/src/modules/alerts/dto/update-tenant-alert-template.dto.ts new file mode 100644 index 0000000..c62696b --- /dev/null +++ b/src/modules/alerts/dto/update-tenant-alert-template.dto.ts @@ -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; +} diff --git a/src/modules/alerts/master-alert-logs.controller.ts b/src/modules/alerts/master-alert-logs.controller.ts new file mode 100644 index 0000000..65d5194 --- /dev/null +++ b/src/modules/alerts/master-alert-logs.controller.ts @@ -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); + } +} diff --git a/src/modules/alerts/master-alert-logs.service.ts b/src/modules/alerts/master-alert-logs.service.ts new file mode 100644 index 0000000..3fbc5bc --- /dev/null +++ b/src/modules/alerts/master-alert-logs.service.ts @@ -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 } }; + } +} diff --git a/src/modules/alerts/master-alert-templates.controller.ts b/src/modules/alerts/master-alert-templates.controller.ts new file mode 100644 index 0000000..3e67631 --- /dev/null +++ b/src/modules/alerts/master-alert-templates.controller.ts @@ -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); + } +} diff --git a/src/modules/alerts/master-alert-templates.service.ts b/src/modules/alerts/master-alert-templates.service.ts new file mode 100644 index 0000000..bd8a88e --- /dev/null +++ b/src/modules/alerts/master-alert-templates.service.ts @@ -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; + }); + } +} diff --git a/src/modules/alerts/placeholders.ts b/src/modules/alerts/placeholders.ts new file mode 100644 index 0000000..bb134e5 --- /dev/null +++ b/src/modules/alerts/placeholders.ts @@ -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 = { + 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' }, + ], +}; diff --git a/src/modules/alerts/queue.constants.ts b/src/modules/alerts/queue.constants.ts new file mode 100644 index 0000000..2eebaa5 --- /dev/null +++ b/src/modules/alerts/queue.constants.ts @@ -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; +} diff --git a/src/modules/alerts/template-render.util.ts b/src/modules/alerts/template-render.util.ts new file mode 100644 index 0000000..71f9f59 --- /dev/null +++ b/src/modules/alerts/template-render.util.ts @@ -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 { + return corpo.replace(/\{\{(\w+)\}\}/g, (match, chave: string) => dados[chave] ?? match); +} diff --git a/src/modules/alerts/tenant-alert-logs.controller.ts b/src/modules/alerts/tenant-alert-logs.controller.ts new file mode 100644 index 0000000..7b8e6ea --- /dev/null +++ b/src/modules/alerts/tenant-alert-logs.controller.ts @@ -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); + } +} diff --git a/src/modules/alerts/tenant-alert-logs.service.ts b/src/modules/alerts/tenant-alert-logs.service.ts new file mode 100644 index 0000000..0229b3a --- /dev/null +++ b/src/modules/alerts/tenant-alert-logs.service.ts @@ -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 } }; + } +} diff --git a/src/modules/alerts/tenant-alert-templates.controller.ts b/src/modules/alerts/tenant-alert-templates.controller.ts new file mode 100644 index 0000000..a8220fd --- /dev/null +++ b/src/modules/alerts/tenant-alert-templates.controller.ts @@ -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); + } +} diff --git a/src/modules/alerts/tenant-alert-templates.service.ts b/src/modules/alerts/tenant-alert-templates.service.ts new file mode 100644 index 0000000..cb239df --- /dev/null +++ b/src/modules/alerts/tenant-alert-templates.service.ts @@ -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; + }); + } +} diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..abdd361 --- /dev/null +++ b/src/worker.ts @@ -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(); diff --git a/src/worker/alert-scheduler.service.ts b/src/worker/alert-scheduler.service.ts new file mode 100644 index 0000000..b53d34d --- /dev/null +++ b/src/worker/alert-scheduler.service.ts @@ -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('ALERT_JOB_MAX_ATTEMPTS', 3), + backoff: { + type: 'exponential' as const, + delay: this.configService.get('ALERT_JOB_BACKOFF_MS', 5000), + }, + removeOnComplete: true, + removeOnFail: 100, + }; + } + + @Cron(process.env.ALERT_CRON_MENSALIDADE || '0 8 * * *') + async jobMensalidade(): Promise { + this.logger.log('Iniciando job diário de alertas de mensalidade'); + await this.alertMaintenance.marcarFaturasAtrasadas(); + + const dias = this.configService.get('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 { + this.logger.log('Iniciando job diário de alertas de cobrança'); + await this.alertMaintenance.marcarParcelasAtrasadas(); + + const dias = this.configService.get('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`); + } +} diff --git a/src/worker/cobranca.processor.ts b/src/worker/cobranca.processor.ts new file mode 100644 index 0000000..09605c9 --- /dev/null +++ b/src/worker/cobranca.processor.ts @@ -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): Promise { + 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 { + 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, + }, + }); + } + } +} diff --git a/src/worker/mensalidade.processor.ts b/src/worker/mensalidade.processor.ts new file mode 100644 index 0000000..00964ab --- /dev/null +++ b/src/worker/mensalidade.processor.ts @@ -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): Promise { + 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(); + 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 { + 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, + }, + }); + } + } +} diff --git a/src/worker/worker.module.ts b/src/worker/worker.module.ts new file mode 100644 index 0000000..a35c320 --- /dev/null +++ b/src/worker/worker.module.ts @@ -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('REDIS_HOST', 'redis'), + port: configService.get('REDIS_PORT', 6379), + }, + }), + }), + BullModule.registerQueue({ name: QUEUE_ALERTAS_MENSALIDADE }, { name: QUEUE_ALERTAS_COBRANCA }), + PrismaModule, + EncryptionModule, + AlertDispatchModule, + AlertsModule, + ], + providers: [AlertSchedulerService, MensalidadeProcessor, CobrancaProcessor], +}) +export class WorkerModule {} diff --git a/test/alertas.e2e-spec.ts b/test/alertas.e2e-spec.ts new file mode 100644 index 0000000..3fe0a89 --- /dev/null +++ b/test/alertas.e2e-spec.ts @@ -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 { + this.server = createServer((req, res) => this.handle(req, res)); + await new Promise((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 { + await new Promise((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[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 { + 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 { + 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> = {}) { + 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>) { + 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[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(getQueueToken(QUEUE_ALERTAS_MENSALIDADE)); + filaCobranca = workerApp.get(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); + }); + }); +});