Files
SeConectai_Vendas/Etapas/09-controle-de-progresso.md
T
tiagoandClaude Sonnet 5 323912748f Frontend completo (Etapas 11-18) e extensões de backend (Etapas 13 e 18)
Adiciona o frontend Next.js em frontend/ (setup, design system,
autenticação/tema, layout/navegação, área master, área tenant completa:
clientes, produtos, vendas, recebimentos, whatsapp, e-mail, alertas,
equipe e perfil). Estende o backend de forma aditiva e autorizada:
cookie httpOnly para refresh token (Etapa 13) e CRUD de equipe +
edição de perfil do tenant (Etapa 18, módulo src/modules/equipe/ novo
e PUT /tenant/perfil). Detalhes completos em Etapas/V2/*.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 09:41:13 -03:00

1261 lines
85 KiB
Markdown

# SeConectai Vendas — Controle de Progresso
Este arquivo é o **estado oficial do projeto**. Ele deve ser mantido
atualizado pela IA que está construindo o sistema e consultado antes de
qualquer nova etapa começar.
## Instruções obrigatórias para a IA
1. **Antes de iniciar qualquer etapa**, peça este arquivo ao usuário (se
ele não for fornecido junto com o prompt da etapa) e leia a seção
"Status Geral" para confirmar que a etapa anterior está 100% concluída.
2. **Não inicie uma etapa** se a etapa anterior tiver qualquer item de
critério de aceite sem marcar. Se estiver incompleta, avise o usuário
e pergunte se deve terminar a etapa pendente antes de avançar.
3. **Ao concluir uma etapa**, marque com `[x]` cada item da checklist
correspondente (só marque o que foi de fato implementado e testado —
não marque por otimismo) e atualize:
- A seção "Status Geral" (etapa concluída, data)
- O campo "Observações" da etapa, se houver algo relevante para as
próximas etapas (ex: decisões tomadas, desvios do prompt original,
pendências técnicas deixadas para depois)
4. **Devolva este arquivo atualizado** ao usuário ao final de cada etapa,
junto com o restante da entrega, para que ele possa repassar na
próxima sessão/etapa.
5. Se o usuário pedir para pular uma etapa ou mudar a ordem, isso deve
ficar explicitamente registrado na seção "Observações" da etapa
afetada, com o motivo.
## Status Geral
| # | Etapa | Status | Concluída em |
|---|-------|--------|---------------|
| 1 | Setup do Projeto | Concluída | 2026-08-12 |
| 2 | Autenticação e RLS | Concluída | 2026-08-13 |
| 3 | Painel Master (tenants/planos/faturas) | Concluída | 2026-08-13 |
| 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) | Concluída | 2026-08-14 |
| 8 | Personalização Whitelabel e Ajustes Finais | Concluída | 2026-08-14 |
Valores possíveis para "Status": `Não iniciada`, `Em andamento`,
`Concluída`, `Concluída com pendências` (usar este último se algo ficou
propositalmente para depois — detalhar em Observações).
---
## Etapa 1 — Setup do Projeto
- [x] `docker compose up` sobe `app`, `postgres` e `redis` sem erro
- [x] `GET /health` retorna 200 com status da API e status da conexão com o banco
- [x] Todas as tabelas, enums, views e índices do schema fornecido existem no banco após a migration
- [x] As policies de RLS estão criadas
- [x] `.env.example` documenta todas as variáveis necessárias
- [x] `npm run lint` e `npm run test` rodam sem erro
- [x] README explica como subir o ambiente do zero
**Observações:**
- Estrutura NestJS, `ConfigModule`/validação de env, `PrismaService`/`PrismaModule`
e o módulo `health` (controller + service + spec) já existiam de uma sessão
anterior; esta sessão completou o restante da etapa: `Dockerfile` multi-stage
(stages `deps`, `dev`, `build`, `prod-deps`, `production`), `docker-compose.yml`,
`docker-compose.override.yml` (hot-reload dev), `.env.example`, `README.md` e as
migrations do Prisma.
- `prisma/schema.prisma` já era a tradução 1:1 do schema SQL fornecido. Construtos
que o Prisma DSL não expressa nativamente (views de dashboard, `CHECK` constraint
de `dia_vencimento_mensalidade`, índices parciais e as `CREATE POLICY` de RLS)
foram implementados em uma migration manual separada
(`prisma/migrations/20260812161100_rls_views_constraints/`), aplicada com
`prisma migrate deploy` logo após a migration inicial gerada automaticamente
(`prisma migrate dev --name init`). Confirmado via `\dt`, `\dv`, `\d+ tenants` e
`\d+ customers` no psql: 22 tabelas, 6 enums, 3 views, o `CHECK` e as 9 policies
de isolamento por tenant.
- As imagens Alpine (`node:20-alpine`) precisaram de `apk add openssl` em todos os
estágios que rodam `prisma generate`/`node`, pois o Prisma não detecta
libssl/openssl no Alpine puro e cai num engine binário incompatível (aviso
"Prisma failed to detect the libssl/openssl version").
- `@types/supertest` estava fixado em `^6.0.2` no `package.json`, mas o `npm
install` resolveu `supertest@7.2.2` (com `superagent@10.x` como dependência) —
uma combinação incompatível com os tipos da v6, quebrando `npm run lint` com
erros de "Unsafe call"/"not callable" em `test/health.e2e-spec.ts`. Corrigido
atualizando para `@types/supertest@^7.0.3` e trocando `import * as request from
'supertest'` por `import request, { Response } from 'supertest'` (a v7 dos tipos
exige import default por causa da interop com `esModuleInterop`).
- `package-lock.json` foi gerado nesta sessão (não existia antes); `npm ci` no
Dockerfile depende dele.
- Ambiente de execução não tinha Node/npm no host, e os containers Docker deste
sandbox não têm acesso de rede via bridge padrão (bloqueado no nível do host) —
só o modo `--network host` alcança a internet. Isso só afetou os comandos usados
para *verificar* a etapa nesta sessão (`docker build --network host`, `docker
run --network host ... npm install`); `Dockerfile`/`docker-compose.yml`
entregues não dependem disso e devem funcionar normalmente em qualquer ambiente
Docker com rede padrão.
---
## Etapa 2 — Autenticação, Autorização e Row Level Security
- [x] Login master e login tenant funcionam e retornam access+refresh token válidos
- [x] Refresh token rotaciona corretamente; token antigo rejeitado após rotação
- [x] Logout revoga o refresh token
- [x] Fluxo de esqueci minha senha gera token, valida expiração/uso único
- [x] `MasterGuard` e `TenantGuard` bloqueiam corretamente rotas erradas
- [x] `RolesGuard` bloqueia usuário sem permissão de papel
- [x] Teste de integração prova isolamento entre tenants via RLS
- [x] Bloqueio temporário após tentativas de login falhas
- [x] Todas as senhas no banco estão como hash argon2id
**Observações:**
- Módulo `src/modules/auth/` completo: `AuthController`/`AuthService`
(`master/login`, `tenant/login`, `refresh`, `logout`, `password/forgot`,
`password/reset`), `JwtStrategy` (passport-jwt), guards (`JwtAuthGuard`,
`MasterGuard`, `TenantGuard`, `RolesGuard`), decorators (`@CurrentUser()`,
`@Roles(...)`), `password.util.ts` (argon2id, parâmetros OWASP 2024:
memoryCost 19456/timeCost 2/parallelism 1, + validação de força mínima) e
`token.util.ts` (refresh/reset tokens opacos, guardados só como hash
sha256 — nunca em texto puro).
- **Desvio do prompt original, registrado em detalhe no histórico abaixo:**
o prompt só previa uma segunda connection string (`DATABASE_URL_MASTER`,
role `app_master BYPASSRLS`) para o master. Foi necessário criar também
uma terceira role/connection string (`app_tenant`/`DATABASE_URL_TENANT`)
para que o RLS realmente filtre as queries de `tenant_user` — a role de
`DATABASE_URL` (`seconectai`) é a superuser bootstrap do Postgres
(criada pelo `POSTGRES_USER` do docker-compose desde a Etapa 1), e
superusers sempre ignoram RLS independente das policies existirem.
Ambas as roles são criadas na migration manual
`prisma/migrations/20260813120000_auth_app_roles/`.
- Mecanismo de RLS por request: `TenantRlsInterceptor` (registrado
globalmente via `APP_INTERCEPTOR`) detecta requests autenticadas como
`tenant_user` e, através de `TenantContextService`, abre uma transação
Prisma na conexão `app_tenant` executando
`SELECT set_config('app.current_tenant_id', '<tenant_id_do_jwt>', true)`
(equivalente a `SET LOCAL`, mas parametrizável — evita interpolar string
na query). O `tenant_id` usado vem **sempre** do JWT da sessão
(`request.user.tenant_id`, populado pelo `JwtStrategy`/`TenantGuard`),
nunca de body/query — verificado no teste de isolamento (endpoint
`GET /auth/tenant/users` ignora um `?tenant_id=<outro-tenant>` forçado
na query string).
- Requests master usam `PrismaMasterService` (conexão `app_master`,
bypassa RLS) — não passam pelo `TenantRlsInterceptor`.
- Endpoints de demonstração/utilidade adicionados para exercitar os guards
e o RLS de ponta a ponta (não fazem parte do prompt original, mas são
necessários para provar os critérios de aceite via HTTP real, e ficam
como funcionalidade útil para as próximas etapas): `GET /auth/me`
(qualquer usuário autenticado), `GET /auth/master/admins` (só master),
`GET /auth/tenant/users` (só `tenant_admin`, lista usuários do próprio
tenant via RLS).
- `email` de `tenant_users` é único só por tenant (`UNIQUE(tenant_id,
email)`, não globalmente) — login/forgot-password por e-mail, sem mais
contexto, não conseguem resolver com segurança um e-mail que colida
entre dois tenants distintos. Decisão: nesse caso (0 ou >1 tenant_user
com o e-mail informado), tratar como credencial inválida em vez de
arriscar autenticar na conta errada (`AuthService.uniqueTenantUserByEmail`).
Fica registrado como limitação conhecida para uma etapa futura resolver
(ex: login por subdomínio/slug do tenant).
- `POST /auth/password/forgot` sempre responde com a mesma mensagem
genérica, exista ou não a conta — prática padrão para não vazar quais
e-mails estão cadastrados (não pedido explicitamente no prompt, mas não
conflita com nenhum critério de aceite).
- Reuso de um refresh token já rotacionado (revogado) é tratado como
indício de roubo de token: além de rejeitar a tentativa, revoga toda a
família de tokens ativos daquele usuário (defesa em profundidade, além
do pedido mínimo do prompt).
- `argon2` é um módulo nativo (binding C++) sem prebuild musl/Alpine para
esta versão — precisou adicionar `python3 make g++` aos estágios `deps`
e `prod-deps` do `Dockerfile` (ausentes na imagem final `production`,
só usados para compilar durante o build).
- Teste e2e `test/auth.e2e-spec.ts` cobre todos os critérios de aceite
contra Postgres real (via `docker compose`): login (master/tenant,
sucesso/falha), rotação de refresh token (incluindo o cenário de reuso
de token revogado revogando a família toda), logout, forgot/reset
(token único-uso, expiração, senha fraca rejeitada), os quatro guards
(`JwtAuthGuard`/`MasterGuard`/`TenantGuard`/`RolesGuard`) nos dois
sentidos, isolamento por RLS (dois tenants reais, tentativa de forçar
`tenant_id` de outro tenant via query string) e bloqueio por força
bruta (5 tentativas falhas → 429 mesmo com senha correta na 6ª).
Rodado com `docker compose exec app npx prisma migrate deploy` seguido
de `npx jest --config ./test/jest-e2e.json` — 21/21 testes passando.
`npm run lint`, `tsc --noEmit` e `npm run build` (produção) também
passam sem erros; smoke test manual via `curl` contra a imagem
`production` real confirmou login + guards funcionando fim a fim.
- **[2026-08-15, revisitado pela Etapa 13 do frontend]** Adicionado
suporte a cookie httpOnly para o refresh token (`security.util.ts`,
`auth.controller.ts`, `RefreshDto`/`LogoutDto`) — mudança aditiva, os
21 testes originais continuam passando sem alteração, mais 4 novos
cobrindo o cookie (total 24/24 em `auth.e2e-spec.ts`). Detalhe completo
em `Etapas/V2/20-controle-de-progresso-frontend.md`, Etapa 13.
- **[2026-08-16, revisitado pela Etapa 18 do frontend]** `tenant_users`
(criada nesta etapa) ganhou CRUD real do lado do tenant: novo módulo
`src/modules/equipe/` (`GET/POST /tenant/equipe`, `PATCH
/tenant/equipe/:id`, só `tenant_admin`, RLS via `TenantContextService`
como `customers`/`products` da Etapa 5) — o `GET /auth/tenant/users`
deste módulo continua exatamente como estava, só leitura/demonstração,
não foi tocado. Também aditivo: `PUT /tenant/perfil`
(`nome_fantasia`/`razao_social`) em `src/modules/tenant-profile/`, ao
lado do `PUT /tenant/perfil/cor` já existente da Etapa 8. Mudança
autorizada explicitamente pelo usuário antes de implementar (a Etapa 18
do frontend precisava de CRUD real de equipe e edição de perfil, que
não existiam). Detalhe completo — DTOs, regras de limite de plano,
auto-bloqueio, testes e2e — em
`Etapas/V2/20-controle-de-progresso-frontend.md`, Etapa 18.
---
## Etapa 3 — Painel Master: Tenants, Planos e Faturas
- [x] CRUD de planos funcional e validado
- [x] Criar tenant também cria o primeiro `tenant_admin` funcional
- [x] Listagem de tenants com filtro por status e busca funciona
- [x] Suspender/cancelar tenant bloqueia login dos usuários dele
- [x] Geração de faturas do mês não duplica fatura existente
- [x] Marcar fatura como paga reverte tenant `inadimplente` → `ativo`
- [x] Marcar fatura como atrasada muda tenant para `inadimplente`
- [x] Dashboard master retorna números corretos
- [x] Ações relevantes aparecem em `audit_logs`
- [x] Endpoints inacessíveis com token de tenant_user
**Observações:**
- Módulo `src/modules/planos/` (`PlanosController`/`PlanosService`, CRUD completo em
`/master/planos`) e módulo `src/modules/tenants/` (`TenantsController`/`TenantsService`
em `/master/tenants`, `FaturasController`/`FaturasService` em `/master/faturas`,
`DashboardController`/`DashboardService` em `/master/dashboard`, todos agrupados no
mesmo `TenantsModule`). Todos os controllers desta etapa usam
`@UseGuards(JwtAuthGuard, MasterGuard)` a nível de classe e a conexão `PrismaMasterService`
(`app_master`, BYPASSRLS) — `tenants`/`planos`/`tenant_faturas`/`audit_logs` não têm RLS
habilitado (só tabelas por-tenant têm), mas a conexão master é usada por consistência
arquitetural com a Etapa 2.
- Auditoria centralizada em `src/common/audit-log.util.ts` (`writeAuditLog(client, params)`,
função simples — não um serviço injetável — para poder receber tanto `PrismaMasterService`
quanto o `tx` de uma transação em andamento, seguindo o mesmo padrão de utilitário puro já
usado em `auth/password.util.ts`/`token.util.ts`). Toda operação de escrita desta etapa
(criar/atualizar plano, excluir/desativar plano, criar tenant, atualizar tenant, mudar
status, gerar faturas do mês, marcar fatura paga, atualizar atrasadas) grava a auditoria
**dentro da mesma transação** da operação principal, para que auditoria e efeito nunca
fiquem dessincronizados.
- **Desvio do prompt original:** o prompt lista `status` como um dos campos que o `PUT
/master/tenants/:id` atualiza. Implementado com `status` **fora** do `UpdateTenantDto` —
toda transição de status passa exclusivamente por `PATCH /master/tenants/:id/status`
(`TenantsService.changeStatus`), que é o único caminho que grava a auditoria da mudança
(`de`/`para`) e aplica o bloqueio de acesso. Deixar `status` editável também via PUT
abriria um caminho para mudar o status sem passar por essa lógica.
- **Desvio/extensão do prompt original, relevante para segurança:** o prompt pedia bloquear
login após suspender/cancelar um tenant ("checar status do tenant no login e no
`TenantGuard`"). Login já checava (Etapa 2); nesta etapa o `TenantGuard`
(`auth/guards/tenant.guard.ts`) passou a consultar o status do tenant a cada request
(antes só populava `request.tenant_id`, sem checar nada) — um access token emitido antes
da suspensão continua criptograficamente válido até expirar (15 min por padrão), então sem
essa checagem o usuário suspenso continuaria acessando a API normalmente até o token
expirar. Isso adiciona uma consulta a `tenants` por request autenticada como tenant_user
(não cacheada por ora — aceitável no volume atual, pode virar ponto de otimização numa
etapa futura). Como defesa em profundidade adicional (mesmo espírito da revogação de
família de refresh tokens da Etapa 2), `changeStatus` também revoga todos os refresh
tokens ativos dos `tenant_users` do tenant ao suspender/cancelar.
- `TENANT_BLOCKED_STATUSES` (antes uma constante privada em `auth.service.ts`) foi exportado
para ser a única fonte de verdade de quais status bloqueiam acesso, reusada tanto no login
(Etapa 2) quanto no `TenantGuard` (Etapa 3) — evita as duas listas divergirem no futuro.
- Exclusão de plano (`DELETE /master/planos/:id`): interpretado "em uso por algum tenant
ativo" como qualquer tenant com `plano_id` apontando pra ele e `status != 'cancelado'`
(não só `status = 'ativo'`) — um tenant `trial`/`inadimplente`/`suspenso` também depende do
plano estar íntegro. Nesse caso a exclusão é convertida em desativação (`ativo = false`,
plano continua existindo e servindo os tenants já associados) e a resposta indica
`{deletado:false, desativado:true}`; sem tenants associados (exceto cancelados), a linha é
excluída de fato (`{deletado:true, desativado:false}`). Um plano desativado não pode ser
associado a tenants novos (`create`/`update` de tenant checam `plano.ativo`), mas tenants
já associados a ele continuam funcionando normalmente (não há "downgrade forçado").
- Senha provisória do primeiro `tenant_admin`: se o payload de `POST /master/tenants` não
informar `admin.senha`, uma é gerada (`tenants/provisional-password.util.ts`,
`randomBytes(9).toString('base64url')`) e devolvida em texto puro **só na resposta desse
request** (nunca fica logada nem persistida em texto puro — só o hash argon2id via
`password.util.ts`, igual ao resto do sistema). Envio por e-mail é a Etapa 4
(Integrações), como o prompt já previa.
- `POST /master/faturas/gerar-mes` usa `createMany({ skipDuplicates: true })` em vez de
checar linha a linha se já existe fatura pro `(tenant_id, referencia)` — o
`UNIQUE(tenant_id, referencia)` do schema garante a idempotência a nível de banco; chamar
de novo no mesmo mês (ou re-chamar manualmente antes da automação virar cron na Etapa 7) é
seguro.
- `POST /master/faturas/atualizar-atrasadas` só rebaixa pra `inadimplente` tenants que
estejam `ativo` no momento — tenants já `suspenso`/`cancelado` com fatura vencida viram
`atrasado` na fatura, mas não voltam a virar `inadimplente` (não faria sentido reativar
parcialmente um tenant cancelado só por causa de uma fatura antiga).
- Dashboard (`GET /master/dashboard`): `mrr` calculado somando `planos.valor_mensal` dos
tenants `ativo` em memória com `Prisma.Decimal` (não dá pra fazer um `groupBy`/`aggregate`
do Prisma somando um campo de tabela relacionada em uma única query) — evita o erro de
arredondamento de ponto flutuante que uma soma em `number` teria com valores monetários.
`total_recebido_mes`/`total_em_aberto` usam `aggregate({_sum})` diretamente. Todos os
valores voltam formatados com `.toFixed(2)`.
- Variável de ambiente nova: `TENANT_TRIAL_DIAS` (default 14, dias de trial concedidos na
criação do tenant), adicionada a `env.validation.ts` e `.env.example`, mesmo padrão das
demais variáveis opcionais com default.
- `package.json`: `test:e2e` passou a rodar com `--runInBand`. Motivo: a partir desta etapa
os testes e2e leem estado **global** do painel master (contagem de tenants por status,
MRR, faturas do mês corrente) — com múltiplos arquivos de teste (`auth.e2e-spec.ts` e o
novo `master.e2e-spec.ts`) rodando em paralelo (padrão do Jest), um teste que cria um
tenant `ativo` num arquivo poderia contaminar uma asserção de contagem global no outro.
`--runInBand` roda os arquivos em série, eliminando essa fonte de flakiness — mesmo assim,
as asserções mais sensíveis (dashboard) comparam o valor antes/depois da operação testada
(delta), em vez do valor absoluto, para não depender de nenhum estado pré-existente no
banco.
- Teste e2e novo, `test/master.e2e-spec.ts`, cobrindo todos os critérios de aceite: CRUD de
planos (validação de `valor_mensal`/limites, exclusão bloqueada por tenant em uso vs.
exclusão real quando não usado), criação de tenant com login do `tenant_admin` gerado
testado de ponta a ponta, listagem com filtro por `status`/busca, mudança de status
bloqueando tanto login novo quanto um token de acesso já emitido (via `TenantGuard`),
geração de faturas idempotente, marcar-pago revertendo `inadimplente`→`ativo` (incluindo
rejeição 409 ao tentar marcar pago de novo), atualizar-atrasadas propagando pra
`tenants.status`, dashboard (com asserções por delta) e bloqueio de todos os grupos de
endpoint com token de `tenant_user`. Rodado com `docker compose exec`-equivalente (`docker
run` num container a partir da imagem `dev` do `Dockerfile`, ligado à mesma rede do
`docker-compose.yml`, já que a imagem `dev` compila os bindings nativos do `argon2` pra
musl/Alpine — o host deste ambiente é glibc e não tem acesso de rede pra build de imagem
sem `--network host`) — `npx prisma migrate status` confirmou que as migrations da Etapa 1
já cobrem todo o schema desta etapa (`planos`/`tenants`/`tenant_faturas`/`audit_logs` já
existiam), então não houve migration nova. `npm run lint`, `npx tsc --noEmit`, `npm run
test` (unit) e `npm run build` passam sem erros; `npx jest --config ./test/jest-e2e.json
--runInBand` — 43/43 testes passando (os 21 da Etapa 2 + 1 do health + 21 novos desta
etapa). Build de produção (`docker build --target production`) também verificado, com
smoke test manual via `curl` confirmando `/health` (200) e bloqueio de `/master/planos`
sem token (401) contra a imagem `production` real.
---
## Etapa 4 — Integrações: Evolution API e E-mail
- [x] Master cadastra config da Evolution API e testa conexão
- [x] `api_key`/senha SMTP nunca aparecem em texto puro nas respostas
- [x] Tenant cria instância WhatsApp, obtém QR code, conecta
- [x] Criar instância além do limite do plano retorna erro claro
- [x] Teste de envio de WhatsApp funciona
- [x] Tenant configura SMTP próprio ou usa o padrão da plataforma
- [x] Teste de envio de e-mail funciona nos dois modos
- [x] `EncryptionService` usado de forma consistente para segredos
**Observações:**
- Módulos novos: `src/modules/evolution/` (`EvolutionController`/`EvolutionService`/`EvolutionApiClient`,
CRUD de `evolution_api_config` em `/master/evolution-config` + `testar-conexao`),
`src/modules/whatsapp/` (`WhatsappController`/`WhatsappService`, instâncias em
`/tenant/whatsapp/instancias`), `src/modules/email-config/` (`MasterEmailConfigController`/`Service`
em `/master/email-config`, `TenantEmailConfigController`/`Service` em `/tenant/email-config`,
`EmailService` com `nodemailer`) e `src/modules/alert-dispatch/` (`AlertDispatchService`, preparando a
Etapa 7). Todos os controllers master usam `@UseGuards(JwtAuthGuard, MasterGuard)` +
`PrismaMasterService`, mesmo padrão da Etapa 3; os controllers tenant usam
`@UseGuards(JwtAuthGuard, TenantGuard, RolesGuard)` + `@Roles('tenant_admin')`.
- `EncryptionService` novo em `src/common/encryption.service.ts` (AES-256-GCM, `node:crypto` nativo, sem
dependência extra), registrado em `EncryptionModule` (`@Global()`, mesmo padrão do `PrismaModule`).
Formato gravado nas colunas `*_cripto`: `iv:authTag:conteudo` (hex, `:`-separado). A chave de
`ENCRYPTION_KEY` (env, nova variável obrigatória, `@MinLength(32)` em `env.validation.ts`, mesmo padrão
de `JWT_SECRET`) é reduzida a 32 bytes via SHA-256 antes de virar a chave AES-256 — assim qualquer
string longa o suficiente serve, sem exigir um valor hex/base64 de tamanho exato. Mascaramento para
exibição (`src/common/mask.util.ts`, `maskSecret`) sempre usa largura fixa de asteriscos + últimos 4
caracteres do valor decifrado — nunca revela o tamanho real do segredo. `api_key_cripto`/
`smtp_senha_cripto` são explicitamente excluídos do objeto de resposta (destructuring) em todo
endpoint que retorna essas entidades — conferido em teste e2e (`api_key_cripto`/`smtp_senha_cripto`
sempre `undefined` no payload).
- **Decisão de arquitetura, para registrar com destaque:** `AlertDispatchService` (e, por baixo dele,
`EmailService`) usam `PrismaMasterService` com filtro manual de `tenant_id` em toda query, em vez de
`TenantContextService`/RLS — diferente do resto do código tenant desta etapa
(`WhatsappService`/`TenantEmailConfigService`, que usam `tenantContext.getTx()` normalmente, já que
`tenant_whatsapp_instances`/`tenant_email_config` têm RLS habilitado desde a migration da Etapa 2).
Motivo: `AlertDispatchService` é explicitamente "o serviço central de envio, preparando a Etapa 7" — o
worker BullMQ da Etapa 7 vai chamá-lo fora de qualquer request HTTP, sem JWT/tenant autenticado e,
portanto, sem nenhum contexto de `AsyncLocalStorage` ativo (`TenantContextService.getTx()` lançaria
"chamado fora de um contexto de tenant ativo"). Usar `PrismaMasterService` desde já evita ter que
reescrever esse service na Etapa 7. Como esse service nunca recebe `tenant_id` de input do usuário
final (sempre do JWT via controller, e futuramente do job da fila), o filtro manual é seguro, mas fica
registrado aqui porque é a única exceção ao padrão RLS-first do resto do projeto.
- **Decisão de wiring do Nest, sem impacto de comportamento:** `EmailService` (o `nodemailer`
propriamente dito) vive num módulo próprio, `EmailModule` (`src/modules/email-config/email.module.ts`),
separado do `EmailConfigModule` (CRUD). Motivo: `AlertDispatchModule` precisa do `EmailService`, e
`EmailConfigModule` precisa do `AlertDispatchService` (endpoint `testar-envio` do tenant) — um único
módulo cobrindo as duas coisas criaria um ciclo de imports (`EmailConfigModule` → `AlertDispatchModule`
→ `EmailConfigModule`). Grafo final, sem ciclos: `EvolutionModule`/`EmailModule` → `AlertDispatchModule`
→ `WhatsappModule`/`EmailConfigModule`.
- **Desvio do prompt original, pequeno e documentado:** `AlertDispatchService.enviarWhatsapp` recebeu um
quarto parâmetro opcional, `instanceId?: string` (assinatura do prompt previa só
`enviarWhatsapp(tenantId, numero, mensagem)`). Sem `instanceId`, resolve a instância `conectado` mais
recente do tenant (comportamento que a Etapa 7 vai usar). Com `instanceId`, envia especificamente por
aquela instância — usado por `POST /tenant/whatsapp/instancias/:id/testar-envio`, que por natureza
precisa testar uma instância específica (não "uma instância qualquer do tenant"), mantendo ainda assim
o pedido do prompt de "usar esse service nos endpoints de testar envio".
- `EvolutionApiClient` (`src/modules/evolution/evolution-api.client.ts`) implementado contra a
especificação pública REST da Evolution API v2 (`apikey` no header, `/instance/create`,
`/instance/connect/:name`, `/instance/connectionState/:name`, `/instance/logout|delete/:name`,
`/message/sendText/:name`) — mesma versão (`2.3`) já assumida como default no schema
(`evolution_api_config.versao`). **Não há uma instância real da Evolution API disponível neste ambiente
de desenvolvimento/CI** (sem internet dedicada a isso nem servidor de referência) para validar contra o
serviço de verdade — mitigado escrevendo, no teste e2e, um servidor HTTP real e local
(`FakeEvolutionServer`, `node:http` puro, sem dependência nova) que implementa o mesmo contrato e roda
na porta efêmera do próprio processo de teste, exercitando o `EvolutionApiClient` real ponta a ponta
(não mockado) contra um servidor de verdade. Fica como pendência natural confirmar contra uma instância
real da Evolution API antes de operar em produção — mesmo espírito da limitação de rede Docker já
registrada na Etapa 1.
- `tenant_whatsapp_instances.status` é `String` livre no schema (não enum do Prisma) — validado/mapeado a
nível de aplicação em `src/modules/whatsapp/whatsapp-status.util.ts`
(`WhatsappInstanceStatus = 'desconectado' | 'conectando' | 'conectado' | 'erro'`,
`mapEvolutionState()` traduz o `state` da Evolution — `open`/`connecting`/`close` — pros valores em
português pedidos no critério de aceite).
- Tenants não têm campo `slug` no schema. Nome de instância (`tenant-<slug>-<sufixo>`) gerado em
`src/modules/whatsapp/instance-name.util.ts` a partir de `nome_fantasia` (ou `razao_social` como
fallback), com uma tabela de substituição de acentos comuns em português (não
`normalize('NFD')`/regex de marcas diacríticas — evitado por instabilidade de encoding observada ao
gravar esse regex específico neste ambiente) + sufixo hex aleatório de 4 bytes. Unicidade é checada
antes do insert (até 5 tentativas) e reforçada pela constraint `@unique` do schema como último recurso.
- Ao criar uma instância, o tenant não escolhe qual `evolution_api_config` usar (a etapa não previu esse
fluxo) — `WhatsappService` seleciona automaticamente a config `ativo=true` mais antiga
(`orderBy: criado_em asc`) e grava o vínculo em `evolution_config_id`. Assume-se, na prática, uma única
config ativa por vez (mesmo espírito documentado no prompt para `master_email_config`: "geralmente só
um registro ativo, mas mantido como tabela"). Fica como ponto a revisitar se o produto precisar de
múltiplas Evolution APIs simultâneas roteadas por critério de negócio.
- `EmailService.resolverConfig` (usado por `AlertDispatchService.enviarEmail` e, portanto, pelos dois
endpoints de `testar-envio`) resolve o SMTP do tenant quando existe `tenant_email_config` com
`usar_config_padrao=false` (e todos os campos preenchidos — senão `BadRequestException` explícito);
caso contrário, usa o `master_email_config` `ativo=true` mais recente
(`orderBy: criado_em desc` — intencionalmente diferente do `asc` da Evolution config, para permitir
trocar o SMTP padrão da plataforma sem precisar excluir o registro antigo, só desativando o velho e
criando um novo).
- `PUT /tenant/email-config` com `usar_config_padrao=false` exige todos os campos de SMTP via
`@ValidateIf` declarativo no DTO (`UpdateTenantEmailConfigDto`); com `usar_config_padrao=true`, os
campos de SMTP são ignorados pelo service mesmo se enviados (não sobrescreve nem apaga o que já estava
salvo) — permite ao tenant alternar entre padrão/próprio sem perder a config própria já cadastrada.
`GET /tenant/email-config` some com os campos de SMTP próprio quando `usar_config_padrao=true` (só
devolve `{usar_config_padrao:true}`), como pedido no prompt ("ocultar/ignorar os campos").
- Teste e2e novo, `test/integracoes.e2e-spec.ts` (11 testes), cobrindo todos os critérios de aceite:
CRUD de `evolution_api_config` com `api_key`/`smtp_senha` nunca em texto puro (conferido tanto na
resposta HTTP quanto decifrando o valor cru do banco via `EncryptionService` e comparando com o
original), `testar-conexao` contra o `FakeEvolutionServer`, criação de instância WhatsApp + QR code +
mudança de status para `conectado`, limite do plano bloqueando a instância excedente, teste de envio de
WhatsApp nos dois cenários (sucesso e falha simulada pela Evolution API, sempre HTTP 201 com
`{sucesso, erro?}` — nunca lança erro HTTP, como pedido no prompt), remoção de instância, isolamento
por RLS entre tenants (acessar instância de outro tenant retorna 404), CRUD de `master_email_config`,
tenant usando padrão vs. SMTP próprio (incluindo rejeição 400 sem os campos obrigatórios), teste de
e-mail nos dois modos (`nodemailer` mockado via `jest.mock('nodemailer')` — sem SMTP real disponível
neste ambiente — verificando que o `from` usado bate com a config resolvida em cada modo) e bloqueio de
todos os grupos de endpoint com o token errado (master vs. tenant). Rodado com `docker compose up -d
postgres redis` + `prisma migrate deploy` + `jest --config ./test/jest-e2e.json --runInBand` apontando
pro Postgres via `localhost` (porta mapeada do compose) — **54/54 testes e2e passando** (os 43 das
Etapas 1-3 + os 11 novos). `npm run lint`, `npx tsc --noEmit`, `npm run test` (unit, incluindo novo
`src/common/encryption.service.spec.ts`) e `npm run build` também passam sem erros. Build de produção
Docker (`docker build --target production`) verificado com sucesso, com smoke test manual confirmando
todas as rotas novas mapeadas no boot e `/health` (200) / endpoints protegidos sem token (401) contra a
imagem real.
- Duas dependências novas: `nodemailer`/`@types/nodemailer` (envio de e-mail) e `axios` (cliente HTTP
para a Evolution API) — adicionadas via `npm install`, sem `@nestjs/axios` (o projeto não usa nenhum
wrapper de HTTP client até agora; `axios` puro, encapsulado só dentro de `EvolutionApiClient`, seguiu o
mesmo espírito minimalista das dependências já escolhidas nas etapas anteriores).
- Nada relacionado ao código desta etapa, mas registrado por transparência: o ambiente local usado nesta
sessão tinha `node_modules/@types`, `dist/` (dono `root`, resíduo de builds Docker rodados como root em
sessão anterior) e um binário nativo de `argon2` compilado para `musl` em `node_modules/argon2/build`
(também resíduo de build Docker anterior, teve precedência indevida sobre o prebuild `glibc` correto
deste host) bloqueando `npm install`/`tsc`/os testes locais. Corrigido com `chown` (autorizado
interativamente pelo usuário) e removendo o `build/` nativo obsoleto — nenhuma mudança de código, só
limpeza de artefatos de build. Também diferente da Etapa 1: `docker build` funcionou nesta sessão com a
rede bridge padrão, sem precisar de `--network host`.
---
## Etapa 5 — Painel Tenant: Clientes, Produtos e Serviços
- [x] CRUD de customers funcional, com soft delete e busca
- [x] Criar customer além do limite do plano retorna erro claro
- [x] CRUD de contatos aninhado funciona; contato principal único respeitado
- [x] CRUD de produtos/serviços funcional, com soft delete e busca
- [x] SKU duplicado no mesmo tenant retorna erro 409; permitido entre tenants
- [x] Teste confirma que acessar recurso de outro tenant retorna 404
- [x] Regra de acesso master a dados de tenant definida e documentada
**Observações:**
- Módulos novos: `src/modules/customers/` (`CustomersController`/`Service` em
`/tenant/customers`, `CustomerContactsController`/`Service` aninhado em
`/tenant/customers/:customerId/contatos`) e `src/modules/products/`
(`ProductsController`/`Service` em `/tenant/produtos`). Todos os
controllers usam só `@UseGuards(JwtAuthGuard, TenantGuard)` — sem
`RolesGuard`/`@Roles`, como o prompt desta etapa pedia ("todos os
endpoints usam TenantGuard") — qualquer papel de `tenant_user`
(`tenant_admin`/`vendedor`/`financeiro`/`visualizador`) acessa; papéis
mais granulares por endpoint ficam para uma etapa futura, se necessário.
Todos usam `TenantContextService.getTx()` (conexão `app_tenant`, RLS
ativo) — nenhuma query manual filtrando por `tenant_id`.
- **Decisão que o prompt pedia confirmar antes de implementar** (critério
de aceite "master deve conseguir acessar em modo de suporte — decisão a
confirmar"): **não** — o master "puro" (sem estar logado como um tenant
específico) não tem nenhum acesso aos endpoints desta etapa — o mesmo
critério que já valia nas etapas anteriores (`TenantGuard` rejeita
qualquer token que não seja `tenant_user`, 403). Confirmado com o
usuário nesta sessão. Se um modo de suporte for necessário no futuro, deve virar uma
etapa própria com endpoints paralelos (ex: `GET
/master/tenants/:tenantId/customers`), guardados por `MasterGuard` e com
log de auditoria de cada acesso — não reaproveitar as rotas `/tenant/*`.
- Limite de `limite_customers` do plano: contado só entre customers
`ativo=true` (`tx.customers.count({ where: { ativo: true } })`) — um
cliente desativado (soft delete) libera espaço pra cadastrar outro,
mesmo espírito de "soft delete não deve punir o tenant". `plano` é
resolvido via `PrismaMasterService` (`tenants.findUnique({include:
{plano:true}})`), mesmo padrão já usado em `WhatsappService` pro limite
de instâncias (Etapa 4) — `limite_customers` `NULL` = ilimitado, não
bloqueia.
- Regra de contato principal único (`customer_contacts.principal`):
aplicada com um `updateMany` desmarcando o(s) principal(is) anterior(es)
do mesmo `customer_id`, seguido do `create`/`update` do novo — como o
interceptor de RLS (`TenantRlsInterceptor`, Etapa 2) já envolve a
request inteira numa única transação Prisma, essas duas operações já são
atômicas por construção, sem precisar abrir uma `$transaction` aninhada
(que nem seria possível: `tx` aqui já é um `Prisma.TransactionClient`,
que não expõe `$transaction`).
Comportamento explícito verificado em teste: criar dois contatos
seguidos com `principal:true` deixa só o segundo como principal.
- SKU duplicado no mesmo tenant → 409: `products_services` tem
`@@unique([tenant_id, sku])` no schema; o service tenta o `create`/
`update` dentro de um `try/catch` e traduz
`Prisma.PrismaClientKnownRequestError` com `code === 'P2002'` para
`ConflictException` com mensagem citando o SKU. Não filtra por
`error.meta.target` incluir `'sku'` — testado nesta sessão que o driver
Postgres do Prisma nem sempre popula esse campo de forma previsível (às
vezes só o nome da constraint, às vezes indisponível); como `sku` é a
única constraint `UNIQUE` da tabela além da chave primária, checar só
`code === 'P2002'` é suficiente e mais robusto.
- Validação de `documento` (customers): formato de CPF (11 dígitos) ou
CNPJ (14 dígitos), puro ou com a pontuação usual — **não** confere
dígito verificador, como o prompt desta etapa já autorizava
("não precisa validar dígito verificador na primeira versão"). Comentário
no código (`src/modules/customers/documento.util.ts`) registrando que
pode evoluir para validação completa (módulo 11) numa etapa futura.
- Isolamento por RLS desta etapa não precisou de nenhuma policy nova — já
criadas na migration da Etapa 1/2
(`tenant_isolation_customers`/`tenant_isolation_products`/
`tenant_isolation_customer_contacts`, essa última via subquery em
`customer_id` já que `customer_contacts` não tem `tenant_id` direto).
Testado nesta etapa (não só confiado na policy): tenant B tentando
ler/editar/excluir customer, produto e lista de contatos do tenant A
pelo UUID exato — sempre 404 (nunca 403), já que a linha simplesmente
não existe do ponto de vista da conexão `app_tenant` com
`app.current_tenant_id` do tenant B.
- Teste e2e novo, `test/tenant-customers-produtos.e2e-spec.ts` (20 testes),
cobrindo todos os critérios de aceite: CRUD de customers (campo mínimo
obrigatório, validação de formato de documento, busca por nome/
documento, paginação, update, soft delete confirmando a linha
permanecer no banco com `ativo=false` via query direta ao Prisma),
bloqueio ao estourar `limite_customers` do plano com mensagem clara,
CRUD de contatos aninhado com a regra de principal único, contato de
customer inexistente retornando 404, CRUD de produtos/serviços
(validação de `tipo`/`valor_venda > 0`, SKU duplicado 409 vs. SKU igual
permitido entre tenants diferentes, filtro por `tipo`/`ativo`/busca,
soft delete), isolamento RLS entre dois tenants reais (customer, produto
e lista de contatos de outro tenant sempre 404) e bloqueio de todos os
endpoints desta etapa com token master "puro" (403). Rodado com `docker
compose up -d postgres redis` + `prisma migrate deploy` (nenhuma
migration nova — `customers`/`customer_contacts`/`products_services` já
existiam desde a Etapa 1) + `jest --config ./test/jest-e2e.json
--runInBand` apontando pro Postgres via `localhost` — **74/74 testes e2e
passando** (os 54 das Etapas 1-4 + os 20 novos desta etapa). `npm run
lint`, `npm run test` (unit) e `npm run build` também passam sem erros.
Ambiente deste host não tem Node/npm nativo (só Docker) — dependências
instaladas via `docker run node:20-alpine` com `python3 make g++`
adicionados (necessários pro binding nativo do `argon2`, mesmo motivo já
documentado na Etapa 1/4); rede bridge padrão do Docker funcionou nesta
sessão (sem precisar `--network host` pra `npm install`/pull de imagens,
só pro container de teste alcançar o Postgres publicado pelo
`docker-compose` via `localhost`).
---
## Etapa 6 — Vendas, Parcelas e Dashboards
- [x] Criar venda com itens e parcelas funciona em transação única
- [x] Soma das parcelas validada contra o valor total da venda
- [x] Cancelar venda cancela as parcelas pendentes associadas
- [x] Venda confirmada não editável em itens/valores
- [x] Marcar parcela como paga reflete no dashboard financeiro
- [x] Dashboards semanal/mensal batem com dados de teste
- [x] Endpoint de atualizar atrasadas funciona
- [x] Isolamento por RLS reforçado e testado nesta etapa
**Observações:**
- Módulo novo `src/modules/sales/`, agrupando os três blocos pedidos pelo
prompt no mesmo módulo Nest (mesmo padrão de agrupamento já usado no
`TenantsModule` da Etapa 3): `SalesController`/`Service` em
`/tenant/vendas`, `SalePaymentsController`/`Service` em
`/tenant/recebimentos` e `TenantDashboardController`/`Service` em
`/tenant/dashboard`. Todos usam só `TenantGuard` (sem `RolesGuard`),
mesmo critério já decidido e registrado na Etapa 5.
- **Achado de segurança desta etapa, corrigido antes de expor os
endpoints de dashboard:** o prompt pede usar as views
`vw_resumo_vendas_semanal`/`vw_resumo_vendas_mensal`/`vw_recebimentos`
(criadas na Etapa 1). Views do Postgres rodam, por padrão, com os
privilégios de quem as *criou* (a role bootstrap `seconectai`,
superuser) — não de quem consulta. Como superuser ignora RLS, qualquer
role com só `SELECT` na view (ex: `app_tenant`) conseguiria ler dados de
**todos os tenants** através dela, mesmo com as policies de RLS
corretas em `sales`/`sale_payments` por trás. Corrigido com uma
migration nova, `20260815090000_dashboard_views_security_invoker`
(`ALTER VIEW ... SET (security_invoker = true)`, suportado a partir do
Postgres 15 — a imagem do compose já é `postgres:16-alpine`): com
`security_invoker`, a view roda com os privilégios de quem chamou, e a
RLS de `sales`/`sale_payments` volta a valer também através das views.
Coberto por um teste e2e dedicado (ver abaixo) que teria falhado sem
essa migration.
- `GET /tenant/dashboard/resumo-semanal`/`resumo-mensal` consultam as
views via `tx.$queryRaw` (com `Prisma.sql`/`Prisma.join` — sempre
parametrizado, nunca concatenando string do usuário) dentro da mesma
transação por-request (`TenantContextService.getTx()`), então o
`SET LOCAL app.current_tenant_id` já ativo na transação vale também
para a query raw. Prisma não modela views nativamente sem o preview
feature `views` (que exigiria declarar manualmente cada view no schema
com uma chave só pra satisfazer o Prisma) — raw query parametrizada foi
a via mais simples e direta pro que são, no fim, duas agregações
(`GROUP BY date_trunc(...)`) que o query builder do Prisma não expressa
de forma declarativa.
- **Desvio do prompt, registrado com o motivo:** `GET
/tenant/recebimentos` **não** usa `vw_recebimentos` como pedido
literalmente — usa `tx.sale_payments.findMany` (Prisma normal,
tipado) com `include` trazendo `sale.numero_venda`/`sale.customer`. A
view só expõe `id, tenant_id, customer_id, valor, vencimento, status,
pago_em` — sem `sale_id`/`numero_parcela`/`forma_pagamento`, campos
claramente úteis numa listagem de recebimentos de verdade. Como
`sale_payments` já tem RLS própria (via subquery em `sale_id`, não
precisa do fix de `security_invoker` acima) e o Prisma tipado dá o
mesmo resultado com mais segurança de tipo, não haveria ganho em
reescrever como raw SQL só pra "usar a view" — mesmo espírito das
Etapas 1/3 que já preferiram o caminho mais direto quando o Prisma DSL
ou uma view não encaixava perfeitamente. `security_invoker` foi
aplicado na view mesmo assim, por robustez (qualquer consulta futura
direta a ela, via `prisma studio` ou uma etapa nova, já nasce segura).
- Regras de transição de status de venda
(`orcamento→confirmada/cancelada`, `confirmada→concluida/cancelada`,
`concluida`/`cancelada` terminais) implementadas como uma tabela
(`TRANSICOES_VALIDAS`) em `sales.service.ts`, mesmo espírito de
`TENANT_BLOCKED_STATUSES` da Etapa 3 — uma fonte única, fácil de
auditar visualmente. Transição fora da tabela → 409, não 400 (é um
conflito de estado, não um payload inválido).
Cancelar uma venda cancela `sale_payments` com status `pendente`
**ou** `atrasado` (o prompt cita só "parcelas pendentes", mas uma
parcela `atrasado` é uma parcela pendente vencida — deixá-la pendurada
numa venda cancelada não faz sentido).
- `PUT /tenant/vendas/:id` (só com a venda em `orcamento`) redefine itens
e parcelas por inteiro (`deleteMany` + `createMany` dentro da mesma
transação da request) em vez de tentar um diff item a item — mais
simples e viável porque só se aplica a uma venda ainda não confirmada
(sem consequência em `audit_logs`/histórico financeiro real).
- Cálculo de valores todo em centavos inteiros
(`sale-calculo.util.ts`, `roundMoney`/`somaCentavos`/
`distribuirParcelasIguais`) pra nunca perder/sobrar 1 centavo por
ponto-flutuante — mesma preocupação já registrada no dashboard master
da Etapa 3 (lá resolvida com `Prisma.Decimal`; aqui, como as operações
são só soma/divisão/arredondamento em valores de entrada já `number`,
aritmética de centavos inteiros é mais simples e igualmente exata).
Divisão de `numero_parcelas` parcelas iguais deixa o resto da divisão
(0 a N-1 centavos) na última parcela.
- **Decisão de produto não especificada pelo prompt, registrada:**
quando `numero_parcelas` é informado (em vez da lista explícita), os
vencimentos são gerados de 30 em 30 dias a partir de `data_venda`
(1ª parcela vence 30 dias depois da venda). O prompt não define essa
regra — fica como comportamento assumido, fácil de trocar numa etapa
futura se o negócio pedir outra periodicidade (ex: mesmo dia do mês
seguinte, ou vencimento configurável por venda).
Validação explícita de negócio (RLS ajuda, mas retorna erro de negócio
claro, como pedido no prompt): `customer_id` e todo
`product_service_id` são resolvidos por `findUnique` dentro da mesma
transação com RLS ativa — se não existir ou não pertencer ao tenant,
404 do ponto de vista do banco vira `BadRequestException` (400, não
404, porque o problema está no corpo do `POST`, não numa rota com
identificador na URL) explicando qual item falhou; cliente ou produto
inativo (soft-deletados na Etapa 5) também bloqueiam a criação/edição
da venda com mensagem própria.
- "Falha em qualquer parte reverte tudo" já é garantido estruturalmente
desde a Etapa 2: o `TenantRlsInterceptor` já envolve a request inteira
numa única transação Prisma — criar venda + itens + parcelas nunca
precisou de um `$transaction` aninhado (que nem seria possível: o `tx`
aqui já é um `Prisma.TransactionClient`). Testado no e2e validando que,
após uma tentativa de criação que falha (produto inexistente num dos
itens), a contagem de vendas do tenant não muda.
- Teste e2e novo, `test/vendas-dashboards.e2e-spec.ts` (30 testes),
cobrindo todos os critérios de aceite: criação com `numero_parcelas`
(divisão igual) e com `parcelas` explícitas, desconto reduzindo
`valor_total`, soma de parcelas divergente (400), desconto maior que os
itens (400), `numero_parcelas`+`parcelas` juntos ou nenhum dos dois
(400), cliente/produto inexistente ou de outro tenant (400), produto
inativo (400), reversão total numa criação que falha no meio,
listagem/detalhe, todas as transições de status válidas e inválidas
(409), cancelamento cascateando pra `sale_payments`, `PUT` só em
`orcamento` (409 em venda confirmada), `marcar-pago` (com rejeição 409
em parcela já paga/cancelada), `atualizar-atrasadas`, dashboards
semanal/mensal/financeiro batendo com os dados inseridos no teste (só
venda `confirmada`/`concluida` conta — uma venda em `orcamento` criada
de propósito no teste confirma que não aparece nos resumos), isolamento
RLS entre dois tenants reais nas vendas/recebimentos, **e um teste
dedicado provando que o dashboard do tenant B não inclui uma venda de
valor único do tenant A** (regressão direta do achado de segurança do
`security_invoker` acima — sem a migration, esse teste falha). Rodado
com `docker compose up -d postgres redis` + `prisma migrate deploy`
(aplicou a migration nova de `security_invoker` além das 3 já
existentes) + `jest --config ./test/jest-e2e.json --runInBand` — **104/
104 testes e2e passando** (os 74 das Etapas 1-5 + os 30 novos). `npm
run lint`, `npm run test` (unit) e `npm run build` também passam sem
erros — o build pegou um erro real (`TS4053`, interfaces de retorno das
rows do `$queryRaw` não exportadas, `ResumoSemanalRow`/
`ResumoMensalRow`), corrigido exportando as duas interfaces.
Limpeza do `afterAll` do teste precisou apagar `sales` explicitamente
antes de `tenants` — `sale_items`/`sale_payments` não têm `ON DELETE
CASCADE` de `products_services` no schema (de propósito, é o que
garante que produto usado numa venda não pode ser hard-deletado), então
apagar um tenant cujas `sales` ainda não foram removidas pode disparar
o cascade de `products_services` antes do cascade de `sale_items` e
violar a FK — só um detalhe de limpeza de teste, não afeta a aplicação
(que nunca hard-deleta nada nesse grafo).
---
## Etapa 7 — Motor de Alertas (BullMQ)
- [x] Worker processa as duas filas independente do processo web
- [x] Template do tenant sobrepõe padrão do master; fallback funciona
- [x] Placeholders substituídos corretamente
- [x] Job diário marca faturas/parcelas atrasadas corretamente
- [x] Alerta de mensalidade chega no WhatsApp/e-mail do tenant (teste controlado)
- [x] Alerta de cobrança chega no WhatsApp/e-mail do customer, com config correta do tenant
- [x] Mesmo alerta não disparado duas vezes no mesmo dia
- [x] Falha de envio reprocessada com retry; erro final registrado
- [x] Endpoints de consulta de logs funcionam com filtros
**Observações:**
- **Separação real de processo web/worker**, como pedido no prompt: `src/main.ts`
(processo `app`, só HTTP, sem `ScheduleModule`/`BullModule`) e `src/worker.ts`
(processo `worker`, `NestFactory.createApplicationContext(WorkerModule)`, sem HTTP).
`WorkerModule` (`src/worker/`) é o único lugar que importa `ScheduleModule`/`BullModule`
— os dois `@Cron()` (`AlertSchedulerService`) e os dois processors do BullMQ
(`MensalidadeProcessor`/`CobrancaProcessor`) vivem só lá. Motivo de separar os crons do
processo web (não só "documentar", de fato implementado): se o `app` escalar
horizontalmente (várias réplicas), um `@Cron()` nele dispararia o mesmo job uma vez por
réplica; como o `worker` é quem tem os crons, escalar `worker`
(`docker compose up --scale worker=3`) paraleliza só o *processamento* das filas, não a
geração dos jobs. `docker-compose.yml`/`docker-compose.override.yml` ganharam um serviço
`worker` novo (mesma imagem do `app`, `command` diferente — `node dist/worker` em
produção, `npm run worker:dev` em dev). `nest build` já compila `src/worker.ts` para
`dist/worker.js` junto com o resto (não precisa de configuração extra no `nest-cli.json`);
`npm run worker:dev`/`worker` usam `nest start --entryFile worker`.
- Módulo novo `src/modules/alerts/` (web): CRUD de `alert_templates` em
`/master/alertas/templates` (`MasterGuard`) e `/tenant/alertas/templates`
(`TenantGuard`+`RolesGuard`+`@Roles('tenant_admin')`, como pedido), `GET
/master/alertas/logs`/`GET /tenant/alertas/logs`, mais os utilitários compartilhados com o
worker (`AlertTemplateResolverService` — fallback tenant→master por canal —,
`AlertMaintenanceService` — marcar atrasadas em lote —, `template-render.util.ts`,
`placeholders.ts`, `alert-format.util.ts`, `queue.constants.ts`). `AlertsModule` exporta
`AlertTemplateResolverService`/`AlertMaintenanceService` justamente para o `WorkerModule`
importar sem duplicar código — nenhum dos dois depende de nada HTTP-específico.
- **`alert_templates`/`alert_logs` não têm RLS habilitado** (não estão na lista de tabelas
isoladas por tenant da migration de RLS da Etapa 1) — diferente de
`customers`/`products_services`/`sales`/etc. Por isso todo o módulo (master e tenant) usa
`PrismaMasterService` com filtro manual de `tenant_id` em toda query, nunca
`TenantContextService`/RLS — mesmo padrão já registrado na Etapa 4 para o
`AlertDispatchService` (que também precisa funcionar fora de uma request HTTP, dentro do
worker). Isolamento entre tenants nos templates é 100% responsabilidade do código da
aplicação aqui (`tenant_id: actor.tenant_id` em todo `findFirst`/`findMany`/`update`) —
coberto por teste e2e dedicado (`alertas.e2e-spec.ts`, tenant B não enxerga/edita template
do tenant A pelo UUID exato, sempre 404).
- **Decisão de modelagem, registrada com o motivo:** um job da fila cobre *uma fatura/parcela
inteira* (todos os canais configurados), não um job por canal. Motivo:
`tenant_faturas.alerta_enviado_em`/`sale_payments.alerta_enviado_em` são um único timestamp
por linha (não um por canal) — são a fonte de verdade de idempotência que o schema oferece,
então "o alerta foi enviado hoje" só faz sentido como uma ação conceitualmente única que
pode sair por WhatsApp e/ou e-mail juntos. Dentro do job: cada canal é tentado
independentemente (`Promise.all`), um `alert_logs` é gravado por canal tentado — sucesso
grava na hora; se **pelo menos um** canal funcionar, `alerta_enviado_em` é marcado (o
alerta "chegou" de alguma forma) e o log do(s) canal(is) que falhou(aram) junto também é
gravado nesse momento (não tem retry nesse caminho, já que o job resolve como sucesso). Se
**nenhum** canal funcionar, nada é gravado ainda — o job lança erro (BullMQ agenda retry
com backoff) e só grava a falha definitiva em `alert_logs` quando esgota as tentativas (ver
próximo item).
- **Bug pego pelo teste e2e, corrigido antes de terminar a etapa:** a checagem de "essa é a
última tentativa" nos dois processors originalmente era
`job.attemptsMade >= job.opts.attempts` — errada. `job.attemptsMade` do BullMQ conta as
tentativas *anteriores* à execução atual (0 na primeira chamada de `process()`, não 1);
com essa fórmula, a condição nunca ficava verdadeira dentro de `process()` (só ficaria
visível depois, quando não há mais chamada nenhuma) e a falha final nunca era gravada em
`alert_logs`. Corrigido para `job.attemptsMade + 1 >= (job.opts.attempts ?? 1)`. Pego pelo
teste "falha em todos os canais é registrada em alert_logs só após esgotar as tentativas"
(`alertas.e2e-spec.ts`), que falhava com `alert_logs` vazio antes da correção — fica como
lembrete de que `job.attemptsMade` merece essa checagem com cuidado em qualquer novo uso do
BullMQ no projeto.
- **Retry com backoff exponencial** configurado nas opções do job em `AlertSchedulerService`
(`attempts`/`backoff.delay`, lidos de `ALERT_JOB_MAX_ATTEMPTS`/`ALERT_JOB_BACKOFF_MS` via
`ConfigService`, defaults 3/5000ms) — diferente do horário do cron (`@Cron()`), que precisa
ser lido direto de `process.env` porque decorators são avaliados na importação da classe,
antes do `ConfigModule` terminar de inicializar; como o deploy real injeta env vars pelo
ambiente do container (não um `.env` lido em runtime), isso é seguro. Documentado em
`AlertSchedulerService`.
- **Idempotência em duas camadas**, como pedido no prompt ("checar antes de enfileirar, e de
novo dentro do worker"): o produtor (`AlertSchedulerService`) só enfileira faturas/parcelas
com `alerta_enviado_em` nulo ou anterior a hoje, com `jobId` determinístico
(`mensalidade-<id>-<data>`/`cobranca-<id>-<data>`) — rodar o cron duas vezes no mesmo dia
não duplica o job no Redis; o processor re-checa `alerta_enviado_em` fresco do banco antes
de processar (cobre o caso de dois disparos manuais/duas instâncias do worker). Testado
rodando o job duas vezes seguidas no mesmo dia (`alertas.e2e-spec.ts`) — `alert_logs`
continua com a mesma quantidade de linhas.
- **`AlertMaintenanceService`** (`marcarFaturasAtrasadas`/`marcarParcelasAtrasadas`, chamado
no início dos dois crons) duplica, de propósito, uma lógica parecida com a de
`FaturasService.atualizarAtrasadas` (Etapa 3) e `SalePaymentsService.atualizarAtrasadas`
(Etapa 6) — não foi um refactor pra reusar essas duas porque nenhuma faz exatamente a mesma
coisa: a de faturas é cross-tenant mas amarrada a um `actor` humano de request HTTP; a de
parcelas é estritamente escopada a *um* tenant via RLS (`TenantContextService`). O job
precisa de "todos os tenants, sem usuário humano, chamado pelo cron" — `PrismaMasterService`
sem `TenantContextService` (correto aqui: é uma manutenção em lote entre tenants, não uma
consulta em nome de um tenant autenticado). Registrado no código e aqui pra não parecer
esquecimento. Auditoria dessas duas chamadas grava `usuario_tipo:'master', usuario_id: null`
(ação de sistema, sem ator humano) — primeira vez que `writeAuditLog` é chamado com
`usuario_id` nulo no projeto.
- Placeholders documentados em `src/modules/alerts/placeholders.ts`
(`PLACEHOLDERS_POR_TIPO`) para os 4 tipos do schema
(`mensalidade_tenant`/`cobranca_cliente`/`venda_confirmada`/`lembrete_vencimento`) — só os
dois primeiros têm job automatizado nesta etapa (`TIPOS_TEMPLATE_COM_JOB`), os outros dois
já têm CRUD e placeholders prontos pra uma automação futura (ex: e-mail imediato ao
confirmar uma venda). `renderTemplate()` (`template-render.util.ts`) é substituição simples
de `{{chave}}`, sem validar na criação do template se os placeholders usados existem na
lista documentada (um placeholder desconhecido só fica literal no texto, não é erro).
- `mensalidade_tenant` só o master define (`tenant_id` sempre `null`) — o tenant não tem
como criar/editar esse tipo (`CreateTenantAlertTemplateDto`/`UpdateTenantAlertTemplateDto`
usam `TIPOS_TEMPLATE_TENANT`, que exclui `mensalidade_tenant` via `IsIn`); faz sentido, é o
master cobrando o tenant pela própria mensalidade, o tenant não deveria poder mudar como é
notificado sobre a própria fatura em atraso.
- **Limitação de arquitetura documentada, não uma escolha de produto:** alertas de
`mensalidade_tenant` por WhatsApp usam a própria instância de WhatsApp *do tenant* (só
existe `tenant_whatsapp_instances`, não há conceito de "WhatsApp do master" no schema) —
na prática, o tenant recebe a notificação da própria mensalidade através do número de
WhatsApp que ele mesmo conectou, endereçado a `tenants.telefone`. Funciona (o mecanismo de
envio não se importa que remetente e destinatário sejam do mesmo tenant), mas é um
reaproveitamento do que já existia, não um "canal do master" de verdade — só funciona pra
tenants que já conectaram um WhatsApp. Por e-mail não há essa esquisitice:
`EmailService.resolverConfig` já cai pro `master_email_config` padrão quando o tenant não
tem SMTP próprio configurado (a maioria), então a mensalidade chega de fato pela plataforma.
Fica como ponto pra uma etapa futura considerar um "WhatsApp de notificações da
plataforma" dedicado, se o produto precisar.
- Teste e2e novo, `test/alertas.e2e-spec.ts` (14 testes), cobrindo todos os critérios de
aceite: CRUD de templates (master e tenant, e-mail exige assunto via `@ValidateIf`, tenant
não pode criar `mensalidade_tenant`, isolamento entre tenants sempre 404, soft delete),
job de mensalidade batendo fatura vencida com alerta por WhatsApp (via `FakeEvolutionServer`
local, mesmo padrão da Etapa 4) e e-mail (via `nodemailer` mockado) — conteúdo renderizado
conferido (`R$ 199,90`, data `05/01/2020`), marca `atrasado`+`alerta_enviado_em`, roda duas
vezes no mesmo dia sem duplicar, e um cenário de falha total (sem instância de WhatsApp +
e-mail rejeitado pelo mock) provando retry + log definitivo só após esgotar tentativas; job
de cobrança com dois tenants — um com template/canal próprio (sobrepõe o padrão do master) e
um sem template próprio (cai no padrão do master por e-mail) —, confirmando `template_id`
do log aponta pro template certo em cada caso; filtros de `GET .../alertas/logs`
(master por canal, tenant só enxerga os próprios); bloqueio de endpoint de tenant com token
master "puro" (403). Os dois processos (web e worker) são inicializados como duas
aplicações Nest independentes no mesmo processo de teste (`Test.createTestingModule({imports:
[AppModule]})` e `...{imports:[WorkerModule]})`, cada uma com sua própria conexão real a
Postgres/Redis) — mesma técnica de testar contra infraestrutura real já usada no resto da
suíte, só que replicada pros dois processos que existem de verdade em produção; uma função
auxiliar (`aguardarFilasOciosas`) faz polling de `queue.getJobCounts()` até as duas filas
esvaziarem antes de cada asserção. Rodado com `docker compose up -d postgres redis` +
`npm install` (novas dependências: `bullmq`, `@nestjs/bullmq`, `@nestjs/schedule` —
precisou `npm install` em vez de `npm ci`, que exige o lockfile já sincronizado) + `prisma
migrate deploy` (nenhuma migration nova — `alert_templates`/`alert_logs` já existiam desde
a Etapa 1) + `jest --config ./test/jest-e2e.json --runInBand` — **118/118 testes e2e
passando** (os 104 das Etapas 1-6 + os 14 novos). `npm run lint`, `npm run test` (unit) e
`npm run build` também passam sem erros (o build gerou `dist/worker.js` junto com
`dist/main.js`, sem configuração extra). Smoke test manual do processo worker de verdade
(`node dist/worker` contra Postgres/Redis reais) confirmou o boot completo — todos os
módulos inicializam, conecta no Redis, registra as duas filas — antes de escrever os testes
automatizados.
---
## Etapa 8 — Personalização Whitelabel e Ajustes Finais
- [x] Upload/remoção de logo e alteração de cor funcionam
- [x] Endpoint público de tema retorna logo/cor sem autenticação
- [x] CORS, Helmet e rate limiting revisados e ativos
- [x] Nenhum segredo aparece em log ou resposta de API
- [x] Swagger disponível cobrindo todos os módulos, com guards indicados
- [x] Build de produção do Docker sem dependências de desenvolvimento
- [x] Teste E2E do fluxo completo passa do início ao fim
- [x] README final consolidado (dev, deploy, migrations, RLS)
**Observações:**
- **Logo/cor/tema** — módulo novo `src/modules/tenant-profile/`:
`GET/POST(logo)/DELETE(logo)/PUT(cor) /tenant/perfil` (`TenantGuard`+
`RolesGuard`+`tenant_admin`) e `GET /public/tenants/:id/tema` (sem
guard nenhum, só `ThrottlerGuard` global). Upload via
`FileInterceptor('logo')` (multer, memory storage padrão) +
`ParseFilePipeBuilder` (`addFileTypeValidator`/`addMaxSizeValidator`,
2MB, png/jpg/svg) — ver desvio de `skipMagicNumbersValidation` abaixo.
Armazenamento atrás de uma interface (`StorageService`,
`src/common/storage/`) com uma única implementação por ora
(`LocalStorageService`, grava em `uploads/`, volume Docker nomeado em
produção) — trocar por S3/object storage numa etapa futura é só
implementar a interface de novo e trocar o provider registrado em
`TenantProfileModule`, nenhum outro código muda.
- **Decisão registrada, pedida explicitamente pelo prompt ("avaliar se
precisa de slug"):** o endpoint público de tema usa o `id` (UUID) do
tenant, não um `slug`. O schema fornecido não tem esse campo, e o login
deste projeto já resolve o tenant pelo e-mail do usuário (Etapa 2), não
por subdomínio — criar um `slug` (migration incremental) só faria
sentido se o produto ganhasse login por subdomínio, o que não existe em
nenhuma outra parte do sistema. Fica documentado aqui e no código
(`PublicTenantController`) como decisão consciente, não omissão.
- **Achado de segurança corrigido nesta etapa, não pedido explicitamente
pelo checklist mas descoberto ao revisá-lo ("nenhum segredo em log"):**
`AuthService.forgotPassword` (Etapa 2) logava o token de reset em texto
puro no `console.log` — um desvio deliberado da própria Etapa 2
("envio real de e-mail é etapa futura"), mas que nunca foi revisitado
depois que a Etapa 4 wireou envio de e-mail de verdade. Corrigido: agora
envia por e-mail de verdade
(`EmailService.enviar`/`enviarComConfigPadrao` — o segundo, novo nesta
etapa, resolve só a config padrão da plataforma, sem tenant nenhum
envolvido, pro caso de `master_admin` resetando a própria senha, onde
não existe `tenant_id`). Falha no envio (ex: SMTP não configurado) é
logada (sem o token) mas nunca muda a resposta genérica do endpoint —
continua sem vazar se o e-mail existe. `test/auth.e2e-spec.ts`
atualizado: em vez de espiar `console.log`, mocka `nodemailer` (mesmo
padrão já usado em `integracoes.e2e-spec.ts`) e extrai o token do corpo
do e-mail "enviado".
- **CORS + Helmet**: `app.use(helmet())` e `app.enableCors({origin:
CORS_ORIGINS.split(',') || false})` — vazio/ausente bloqueia toda
origem cross-site por padrão (nunca `origin: '*'`). Extraído pra
`src/security.util.ts` (`applySecurityConfig(app, configService)`) em
vez de ficar só dentro de `bootstrap()` em `main.ts`, porque os testes
e2e criam a aplicação via `Test.createTestingModule(...).
createNestApplication()`, que **não** passa por `bootstrap()` — sem
extrair, os testes que verificam esses cabeçalhos estariam validando
uma configuração que o app de teste nunca aplicava de verdade (falso
positivo). Só `test/perfil-whitelabel.e2e-spec.ts` e
`test/fluxo-completo.e2e-spec.ts` (specs novos desta etapa) chamam
`applySecurityConfig` — os specs das etapas anteriores não precisam
disso pra cobrir o que já testam, então não foram tocados (menor
superfície de mudança em teste já passando).
- **Rate limiting**: já estava ativo desde a Etapa 1
(`ThrottlerModule`/`ThrottlerGuard` global, `60 req/60s` por IP) e
reforçado desde a Etapa 2 nas rotas de login (`@Throttle`, `30 req/60s`,
camada extra além do bloqueio persistente por e-mail em
`login_attempts`) — nada novo implementado aqui, só confirmado: o
`ThrottlerGuard` é global (`APP_GUARD`), então vale automaticamente
pros endpoints novos desta etapa (incluindo o público `GET
/public/tenants/:id/tema`) sem precisar de nenhuma anotação extra.
- **Swagger**: `@nestjs/swagger` + o plugin oficial
(`nest-cli.json`→`compilerOptions.plugins`), que gera os schemas dos
DTOs automaticamente a partir dos tipos TS + decorators do
`class-validator` já existentes — **sem precisar anotar manualmente
`@ApiProperty()` em nenhum dos ~50 DTOs do projeto** (a alternativa
manual seria um esforço de dias, incompatível com o resto do escopo
desta etapa). `@ApiTags`/`@ApiBearerAuth('jwt')` adicionados em todos os
~20 controllers (script Python pontual, não commitado, só pra aplicar o
padrão mecanicamente sem erro manual — revisado depois via
`npm run lint`/`npm run build`, que pegou um bug real de inserção
no meio de um import multi-linha em `sales/dashboard.controller.ts`,
corrigido). `/docs` confirmado manualmente contra o app real: 57 rotas,
19 tags, security scheme `jwt` (Bearer) registrado — `auth.controller.ts`
não ganhou `@ApiBearerAuth` por rota (a maioria dos endpoints ali é
pública ou mistura guards por método, não por classe); fica como
refinamento menor de documentação, não afeta comportamento.
- **Paginação em todas as listagens**: já valia desde as Etapas 5-7 pra
`customers`/`products`/`sales`/`recebimentos`/`tenants`/`faturas`/
`alert_logs`. Auditoria desta etapa achou `alert_templates` (master e
tenant) sem paginação — corrigido, adicionado `page`/`limit` (máx. 100)
em `ListAlertTemplatesQueryDto` e nos dois services. **Decisão
consciente de não mecanizar em todas as listagens sem critério**:
`planos`, `evolution-config` e `master-email-config` (todas
master-only, curadas manualmente, realisticamente algumas unidades a
poucas dezenas de linhas) e `customer_contacts`/`whatsapp instancias`
(limitadas estruturalmente — contatos por customer tende a ser pequeno;
instâncias de WhatsApp são limitadas por `limite_instancias_whatsapp`
do plano) ficaram sem paginação, de propósito — adicionar ali seria
ceremônia sem ganho real de proteção contra consulta sem limite.
- **Auditoria de segredos hardcoded/DTOs sem validação** (checklist
desta etapa): `grep` por padrões de segredo hardcoded (`password =`,
`secret =`, `api_key =`) não achou nada; todo `@Body()`/`@Query()` de
todo controller do projeto usa uma classe DTO (nenhum `any`/objeto cru)
— `ValidationPipe` global (`whitelist`+`forbidNonWhitelisted`+
`transform`, desde a Etapa 1) garante que isso é realmente validado, não
só tipado.
- **Docker de produção**: `docker build --target production` testado de
verdade nesta sessão (não só lido) — imagem final sem
`typescript`/`ts-node`/`jest`/`eslint`/`@nestjs/cli` (checado via `ls
node_modules` dentro do container), ~180MB, container real rodando e
respondendo `/health` (200) contra Postgres/Redis reais, e o `worker`
(mesma imagem, `CMD` diferente) subindo e conectando nas duas filas.
Volume nomeado novo, `uploads_data:/app/uploads`, no serviço `app` do
`docker-compose.yml` — sem ele, o volume efêmero do container perderia
os logos enviados a cada recriação/deploy.
- **README consolidado**: seção nova "Deploy em produção" (segredos a
trocar, build, subir serviços, migrations — incluindo a confirmação de
que `prisma migrate deploy` já cobre RLS/policies/roles num passo só,
sem etapa separada —, verificação, volume de uploads) e "Motor de
alertas (worker)" (já escrita na Etapa 7). "Estrutura do projeto"
atualizada com os módulos/arquivos novos desta etapa.
- **Teste e2e do fluxo completo**, exatamente como pedido no prompt:
`test/fluxo-completo.e2e-spec.ts` — master cria plano+tenant → tenant
faz login com a senha provisória → cadastra customer e produto → faz
uma venda com 2 parcelas → confere o financeiro antes (delta zero,
tenant novo) → marca a 1ª parcela como paga → confere o financeiro
depois (recebido/a-receber refletindo o pagamento, parcela paga não
aparece mais em "próximas") → confere a venda em si também mostrando a
parcela como `pago`. De propósito só o caminho feliz — casos de
erro/borda já estão nos specs dedicados de cada etapa.
- Dependências novas: `@nestjs/swagger`, `helmet`, `@nestjs/bullmq`-style
DI (`ParseFilePipeBuilder` já vem do `@nestjs/common` existente),
`@types/multer` (dev). `npm install` (não `npm ci`) de novo, mesma razão
já registrada na Etapa 7 (lockfile precisa ser regravado quando
`package.json` ganha dependência nova).
- Validação completa desta sessão: `docker compose up -d postgres redis`
+ `npm install` + `prisma migrate deploy` (nenhuma migration nova) +
`npm run lint`/`npm run test`(unit)/`npm run test:e2e` — **132/132
testes e2e passando** (118 das Etapas 1-7 + 14 novos: 13 de
perfil/whitelabel + 1 do fluxo completo) — `npm run build`, e smoke
test manual contra o app real (`node dist/main`) e o worker real (`node
dist/worker`), incluindo verificação de headers HTTP reais (Helmet,
CORS permitido vs. bloqueado) via `curl`, além do build de produção
Docker completo (item acima).
**Este era o último item do backlog faseado — as 8 etapas de
`Etapas/00-README-ordem-das-etapas.md` estão concluídas.** O backend está
pronto para receber um frontend: todos os endpoints documentados em
`/docs`, RLS/isolamento multi-tenant testado em cada etapa que introduziu
tabela nova, motor de alertas automatizado rodando em processo próprio, e
137 testes automatizados (132 e2e contra Postgres/Redis reais + 5
unitários) passando. Pendências conhecidas, todas já documentadas nas
seções de Observações/Histórico de decisões acima quando surgiram (não
re-listadas aqui pra não divergir da fonte): login por e-mail sem
subdomínio (Etapa 2), Evolution API nunca testada contra uma instância
real (Etapa 4), WhatsApp de "mensalidade_tenant" reaproveitando a
instância do próprio tenant (Etapa 7), paginação deliberadamente ausente
em listagens pequenas/curadas (acima).
---
## Histórico de decisões e desvios do plano original
Use esta seção para registrar qualquer decisão tomada durante a
implementação que se desvie do que foi descrito nos prompts originais
(ex: mudança de biblioteca, ajuste de schema, corte de escopo
combinado com o usuário). Formato sugerido:
```
[DATA] Etapa X — descrição da decisão/desvio e motivo
```
[2026-08-12] Etapa 1 — `@types/supertest` atualizado de `^6.0.2` para `^7.0.3`
para casar com a versão de `supertest`/`superagent` de fato instalada pelo npm;
sem isso `npm run lint` falhava com erros de tipo em `test/health.e2e-spec.ts`.
Nenhum comportamento de runtime muda, só a versão dos `@types`.
[2026-08-13] Etapa 2 — Adicionada uma terceira connection string,
`DATABASE_URL_TENANT` (role `app_tenant`, sem `BYPASSRLS`), além da
`DATABASE_URL_MASTER` já prevista no prompt original. Motivo: a role de
`DATABASE_URL` (`seconectai`) é a superuser bootstrap do Postgres (criada
pelo `POSTGRES_USER` do docker-compose desde a Etapa 1) — e superusers
sempre ignoram Row Level Security, mesmo com as policies criadas na Etapa
1. Sem uma role de aplicação não-superuser dedicada (`app_tenant`), toda
query de `tenant_user` continuaria vendo dados de todos os tenants e o
isolamento por RLS seria só decorativo. As duas roles (`app_master` e
`app_tenant`) são criadas na migration manual
`prisma/migrations/20260813120000_auth_app_roles/`, com senhas
placeholder de dev documentadas em `.env.example` (mesmo padrão já usado
para `POSTGRES_PASSWORD`/`JWT_SECRET` — trocar em produção).
[2026-08-13] Etapa 2 — `email` de `tenant_users` é único só por tenant, não
globalmente (`UNIQUE(tenant_id, email)` no schema original). Login e
forgot-password por e-mail, sem mais contexto (ex: subdomínio/slug do
tenant), não têm como resolver com segurança um e-mail que colida entre
dois tenants diferentes. Decisão: tratar 0 ou >1 `tenant_users` com o
mesmo e-mail como credencial inválida, em vez de arriscar autenticar/
resetar a senha da conta errada. Fica como limitação conhecida — uma
etapa futura pode introduzir um identificador de tenant explícito no
login (subdomínio, slug) para eliminar essa ambiguidade.
[2026-08-13] Etapa 3 — `TenantGuard` passou a checar o status do tenant a
cada request (consulta a `tenants` via `PrismaMasterService`), não só no
login. O prompt desta etapa já pedia isso explicitamente ("checar status
do tenant no login e no TenantGuard"), mas vale registrar: sem essa
checagem, suspender/cancelar um tenant não bloqueava de fato quem já
tinha um access token válido (até 15 min de janela, com a config
padrão). `changeStatus` também revoga os refresh tokens ativos dos
usuários do tenant ao suspender/cancelar, como defesa em profundidade
extra (não pedida explicitamente, mesmo espírito da revogação de família
de tokens da Etapa 2).
[2026-08-13] Etapa 3 — `status` retirado do `UpdateTenantDto` (`PUT
/master/tenants/:id`), mesmo o prompt original listando `status` entre os
campos que esse endpoint atualiza. Toda transição de status passa
exclusivamente por `PATCH /master/tenants/:id/status`, o único caminho
que grava auditoria (`de`/`para`) e aplica bloqueio de acesso — permitir
mudar `status` também via PUT abriria um caminho para contornar essa
lógica.
[2026-08-13] Etapa 3 — `package.json`: `test:e2e` passou a rodar com
`--runInBand`. A partir desta etapa os testes e2e leem estado global do
painel master (contagem de tenants por status, MRR, faturas do mês
corrente); com mais de um arquivo de teste, o Jest roda os arquivos em
paralelo por padrão, o que criaria uma fonte real de flakiness entre
`auth.e2e-spec.ts` e `master.e2e-spec.ts`. Rodar em série elimina isso.
[2026-08-14] Etapa 4 — `AlertDispatchService` (e o `EmailService`/
`EvolutionApiClient` por baixo dele) usam `PrismaMasterService` com filtro
manual de `tenant_id`, não `TenantContextService`/RLS — única exceção ao
padrão RLS-first do resto do projeto. Motivo: este service é o "serviço
central de envio, preparando a Etapa 7" e vai ser chamado pelo worker
BullMQ da Etapa 7 fora de qualquer request HTTP/contexto de tenant
autenticado, onde `TenantContextService.getTx()` não tem como funcionar
(não há `AsyncLocalStorage` ativo sem uma request em andamento). `tenant_id`
nunca vem de input do usuário final nesse service (sempre do JWT via
controller, e futuramente do job da fila), então o filtro manual continua
seguro.
[2026-08-14] Etapa 4 — `AlertDispatchService.enviarWhatsapp` ganhou um
quarto parâmetro opcional, `instanceId?: string`, além dos três previstos
no prompt (`tenantId, numero, mensagem`). Sem ele, resolve a instância
`conectado` mais recente do tenant (uso futuro da Etapa 7); com ele, envia
por uma instância específica — necessário para
`POST /tenant/whatsapp/instancias/:id/testar-envio`, que testa uma
instância específica, não "uma instância qualquer do tenant", mantendo
ainda assim o pedido do prompt de reusar esse service nos endpoints de
teste de envio.
[2026-08-14] Etapa 4 — `ENCRYPTION_KEY` (nova variável de ambiente
obrigatória) é reduzida a 32 bytes via SHA-256 antes de virar a chave
AES-256-GCM usada pelo `EncryptionService`, em vez de exigir um valor
hex/base64 de exatamente 32 bytes. Mesma lógica de validação de
`JWT_SECRET` (`@MinLength(32)`, qualquer string forte serve) — evita o
usuário ter que gerar/colar uma chave num formato binário específico.
[2026-08-14] Etapa 4 — Não há instância real da Evolution API disponível
neste ambiente de desenvolvimento/CI. `EvolutionApiClient` foi implementado
contra a especificação pública documentada da API v2 e validado
ponta a ponta no teste e2e contra um servidor HTTP local que simula esse
contrato (`FakeEvolutionServer`, em `test/integracoes.e2e-spec.ts`), não
contra o serviço real. Confirmar contra uma instância real antes de operar
em produção.
[2026-08-14] Etapa 5 — Decisão explicitamente pedida pelo critério de
aceite ("master deve conseguir acessar em modo de suporte — decisão a
confirmar antes de implementar"), confirmada com o usuário: **o master
"puro" não acessa `/tenant/customers`/`/tenant/produtos`/contatos de
nenhum tenant.** Todos os controllers desta etapa usam só `TenantGuard`
(sem endpoint paralelo pro master). Se um modo de suporte para
diagnóstico/atendimento vier a ser necessário, deve ser uma etapa própria
(endpoints `/master/tenants/:tenantId/...` read-only, `MasterGuard` +
auditoria de cada acesso), não uma extensão das rotas `/tenant/*`
existentes.
[2026-08-14] Etapa 6 — Achado de segurança corrigido durante a
implementação, não uma escolha de produto: as views de dashboard
(`vw_resumo_vendas_semanal`/`mensal`/`vw_recebimentos`, criadas na Etapa
1) rodavam com os privilégios do *dono* da view (a role bootstrap
`seconectai`, superuser), que ignora Row Level Security — qualquer role
com `SELECT` na view (ex: `app_tenant`) leria dados de **todos os
tenants** através dela, mesmo com as policies de RLS corretas nas tabelas
por trás. Corrigido com `ALTER VIEW ... SET (security_invoker = true)`
nas três views (migration `20260815090000_dashboard_views_security_invoker`,
Postgres 15+). Sem essa correção, `GET /tenant/dashboard/resumo-mensal`
vazaria vendas de outros tenants — o teste e2e "dashboard do tenant B não
inclui a venda do tenant A" foi escrito justamente pra pegar essa
regressão se a migration for revertida por engano no futuro.
[2026-08-14] Etapa 6 — `GET /tenant/recebimentos` implementado com
`tx.sale_payments.findMany` (Prisma tipado) em vez de `vw_recebimentos`
como o prompt pedia literalmente. Motivo: a view só expõe `id,
tenant_id, customer_id, valor, vencimento, status, pago_em` — sem
`sale_id`/`numero_parcela`/`forma_pagamento`, úteis numa listagem real de
recebimentos — e `sale_payments` já tem RLS própria (não depende do fix
de `security_invoker` acima), então a query tipada dá o mesmo resultado
com mais segurança de tipo e sem reintroduzir SQL raw sem necessidade.
`security_invoker` foi aplicado em `vw_recebimentos` mesmo assim, por
robustez, mesmo sem uso direto no momento.
[2026-08-14] Etapa 6 — Vencimento das parcelas quando `numero_parcelas`
é informado (em vez da lista explícita de `{valor, vencimento}`): 30 em
30 dias a partir de `data_venda`, começando 30 dias depois dela. O
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.
[2026-08-14] Etapa 8 — Endpoint público de tema (`GET
/public/tenants/:id/tema`) usa o `id` (UUID) do tenant, não um `slug`. O
prompt desta etapa pedia avaliar se um campo `slug` era necessário; como
o schema fornecido não tem esse campo e o login do projeto já resolve o
tenant pelo e-mail do usuário (não por subdomínio, desde a Etapa 2),
criar um `slug` só faria sentido junto de um fluxo de login por
subdomínio que não existe em nenhuma outra parte do sistema — decisão
consciente de não adicionar, não uma omissão.
[2026-08-14] Etapa 8 — Achado de segurança corrigido, descoberto ao
revisar o checklist "nenhum segredo em log ou resposta de API":
`AuthService.forgotPassword` (Etapa 2) logava o token de reset de senha
em texto puro via `console.log` — desvio deliberado da própria Etapa 2
("envio real de e-mail é etapa futura"), nunca revisitado depois que a
Etapa 4 implementou envio de e-mail de verdade. Corrigido: agora envia
por e-mail via `EmailService` (`enviar`, se for `tenant_user`, ou
`enviarComConfigPadrao`, método novo, se for `master_admin` — não há
`tenant_id` nesse caso). Falha no envio nunca muda a resposta genérica do
endpoint (só loga um aviso, sem o token). `test/auth.e2e-spec.ts`
atualizado pra mockar `nodemailer` em vez de espiar `console.log`.
[2026-08-14] Etapa 8 — Upload de logo usa
`skipMagicNumbersValidation: true` no `FileTypeValidator`, validando só o
`mimetype` declarado pelo cliente — não os magic numbers do arquivo. O
comportamento padrão do Nest (sniffar magic numbers via `file-type`,
pacote ESM-only, importado dinamicamente) funciona no app real (`node
dist/main`), mas falha sempre dentro do Jest/ts-jest deste projeto (não
consegue completar o `import()` dinâmico de um pacote ESM-only de dentro
do contexto CJS de teste), inviabilizando testar um upload de sucesso.
Aceitável pro risco real de uma logo estática nunca processada/executada
no servidor; revisitar se o arquivo enviado passar a ser processado
(ex: resize) no backend no futuro.
[2026-08-14] Etapa 8 — Paginação adicionada em `alert_templates` (master
e tenant, únicas listagens sem `page`/`limit` entre as tabelas
potencialmente user-generated). Decisão consciente de **não** adicionar
em `planos`/`evolution-config`/`master-email-config` (master-only,
curadas manualmente, poucas linhas na prática) nem em
`customer_contacts`/`whatsapp instancias` (limitadas estruturalmente por
natureza do domínio ou por `limite_instancias_whatsapp` do plano) — só
pra cumprir a letra do checklist sem ganho real de proteção contra
consulta sem limite.