Files
SeConectai_Vendas/Etapas/09-controle-de-progresso.md
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

85 KiB

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

  • docker compose up sobe app, postgres e redis sem erro
  • GET /health retorna 200 com status da API e status da conexão com o banco
  • Todas as tabelas, enums, views e índices do schema fornecido existem no banco após a migration
  • As policies de RLS estão criadas
  • .env.example documenta todas as variáveis necessárias
  • npm run lint e npm run test rodam sem erro
  • 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

  • Login master e login tenant funcionam e retornam access+refresh token válidos
  • Refresh token rotaciona corretamente; token antigo rejeitado após rotação
  • Logout revoga o refresh token
  • Fluxo de esqueci minha senha gera token, valida expiração/uso único
  • MasterGuard e TenantGuard bloqueiam corretamente rotas erradas
  • RolesGuard bloqueia usuário sem permissão de papel
  • Teste de integração prova isolamento entre tenants via RLS
  • Bloqueio temporário após tentativas de login falhas
  • 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

  • CRUD de planos funcional e validado
  • Criar tenant também cria o primeiro tenant_admin funcional
  • Listagem de tenants com filtro por status e busca funciona
  • Suspender/cancelar tenant bloqueia login dos usuários dele
  • Geração de faturas do mês não duplica fatura existente
  • Marcar fatura como paga reverte tenant inadimplente → ativo
  • Marcar fatura como atrasada muda tenant para inadimplente
  • Dashboard master retorna números corretos
  • Ações relevantes aparecem em audit_logs
  • 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

  • Master cadastra config da Evolution API e testa conexão
  • api_key/senha SMTP nunca aparecem em texto puro nas respostas
  • Tenant cria instância WhatsApp, obtém QR code, conecta
  • Criar instância além do limite do plano retorna erro claro
  • Teste de envio de WhatsApp funciona
  • Tenant configura SMTP próprio ou usa o padrão da plataforma
  • Teste de envio de e-mail funciona nos dois modos
  • 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

  • CRUD de customers funcional, com soft delete e busca
  • Criar customer além do limite do plano retorna erro claro
  • CRUD de contatos aninhado funciona; contato principal único respeitado
  • CRUD de produtos/serviços funcional, com soft delete e busca
  • SKU duplicado no mesmo tenant retorna erro 409; permitido entre tenants
  • Teste confirma que acessar recurso de outro tenant retorna 404
  • 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

  • Criar venda com itens e parcelas funciona em transação única
  • Soma das parcelas validada contra o valor total da venda
  • Cancelar venda cancela as parcelas pendentes associadas
  • Venda confirmada não editável em itens/valores
  • Marcar parcela como paga reflete no dashboard financeiro
  • Dashboards semanal/mensal batem com dados de teste
  • Endpoint de atualizar atrasadas funciona
  • 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)

  • 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

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

  • Upload/remoção de logo e alteração de cor funcionam
  • Endpoint público de tema retorna logo/cor sem autenticação
  • CORS, Helmet e rate limiting revisados e ativos
  • Nenhum segredo aparece em log ou resposta de API
  • Swagger disponível cobrindo todos os módulos, com guards indicados
  • Build de produção do Docker sem dependências de desenvolvimento
  • Teste E2E do fluxo completo passa do início ao fim
  • 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.