Adiciona os módulos customers (com contatos aninhados e regra de contato principal único) e products, ambos com TenantGuard/RLS, soft delete, limite de clientes do plano e SKU único por tenant (409 amigável). Atualiza o controle de progresso e cobre os critérios de aceite com test/tenant-customers-produtos.e2e-spec.ts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
46 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
- 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.
- 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.
- 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)
- 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.
- 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 | [ ] Não iniciada | |
| 7 | Motor de Alertas (BullMQ) | [ ] Não iniciada | |
| 8 | Personalização Whitelabel e Ajustes Finais | [ ] Não iniciada |
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 upsobeapp,postgreseredissem erroGET /healthretorna 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.exampledocumenta todas as variáveis necessáriasnpm run lintenpm run testrodam sem erro- README explica como subir o ambiente do zero
Observações:
- Estrutura NestJS,
ConfigModule/validação de env,PrismaService/PrismaModulee o módulohealth(controller + service + spec) já existiam de uma sessão anterior; esta sessão completou o restante da etapa:Dockerfilemulti-stage (stagesdeps,dev,build,prod-deps,production),docker-compose.yml,docker-compose.override.yml(hot-reload dev),.env.example,README.mde as migrations do Prisma. prisma/schema.prismajá era a tradução 1:1 do schema SQL fornecido. Construtos que o Prisma DSL não expressa nativamente (views de dashboard,CHECKconstraint dedia_vencimento_mensalidade, índices parciais e asCREATE POLICYde RLS) foram implementados em uma migration manual separada (prisma/migrations/20260812161100_rls_views_constraints/), aplicada comprisma migrate deploylogo após a migration inicial gerada automaticamente (prisma migrate dev --name init). Confirmado via\dt,\dv,\d+ tenantse\d+ customersno psql: 22 tabelas, 6 enums, 3 views, oCHECKe as 9 policies de isolamento por tenant.- As imagens Alpine (
node:20-alpine) precisaram deapk add opensslem todos os estágios que rodamprisma 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/supertestestava fixado em^6.0.2nopackage.json, mas onpm installresolveusupertest@7.2.2(comsuperagent@10.xcomo dependência) — uma combinação incompatível com os tipos da v6, quebrandonpm run lintcom erros de "Unsafe call"/"not callable" emtest/health.e2e-spec.ts. Corrigido atualizando para@types/supertest@^7.0.3e trocandoimport * as request from 'supertest'porimport request, { Response } from 'supertest'(a v7 dos tipos exige import default por causa da interop comesModuleInterop).package-lock.jsonfoi gerado nesta sessão (não existia antes);npm cino 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 hostalcanç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.ymlentregues 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
MasterGuardeTenantGuardbloqueiam corretamente rotas erradasRolesGuardbloqueia 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) etoken.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, roleapp_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 detenant_user— a role deDATABASE_URL(seconectai) é a superuser bootstrap do Postgres (criada peloPOSTGRES_USERdo docker-compose desde a Etapa 1), e superusers sempre ignoram RLS independente das policies existirem. Ambas as roles são criadas na migration manualprisma/migrations/20260813120000_auth_app_roles/. - Mecanismo de RLS por request:
TenantRlsInterceptor(registrado globalmente viaAPP_INTERCEPTOR) detecta requests autenticadas comotenant_usere, através deTenantContextService, abre uma transação Prisma na conexãoapp_tenantexecutandoSELECT set_config('app.current_tenant_id', '<tenant_id_do_jwt>', true)(equivalente aSET LOCAL, mas parametrizável — evita interpolar string na query). Otenant_idusado vem sempre do JWT da sessão (request.user.tenant_id, populado peloJwtStrategy/TenantGuard), nunca de body/query — verificado no teste de isolamento (endpointGET /auth/tenant/usersignora um?tenant_id=<outro-tenant>forçado na query string). - Requests master usam
PrismaMasterService(conexãoapp_master, bypassa RLS) — não passam peloTenantRlsInterceptor. - 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). emaildetenant_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/forgotsempre 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 adicionarpython3 make g++aos estágiosdepseprod-depsdoDockerfile(ausentes na imagem finalproduction, só usados para compilar durante o build).- Teste e2e
test/auth.e2e-spec.tscobre todos os critérios de aceite contra Postgres real (viadocker 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çartenant_idde outro tenant via query string) e bloqueio por força bruta (5 tentativas falhas → 429 mesmo com senha correta na 6ª). Rodado comdocker compose exec app npx prisma migrate deployseguido denpx jest --config ./test/jest-e2e.json— 21/21 testes passando.npm run lint,tsc --noEmitenpm run build(produção) também passam sem erros; smoke test manual viacurlcontra a imagemproductionreal confirmou login + guards funcionando fim a fim.
Etapa 3 — Painel Master: Tenants, Planos e Faturas
- CRUD de planos funcional e validado
- Criar tenant também cria o primeiro
tenant_adminfuncional - 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ódulosrc/modules/tenants/(TenantsController/TenantsServiceem/master/tenants,FaturasController/FaturasServiceem/master/faturas,DashboardController/DashboardServiceem/master/dashboard, todos agrupados no mesmoTenantsModule). Todos os controllers desta etapa usam@UseGuards(JwtAuthGuard, MasterGuard)a nível de classe e a conexãoPrismaMasterService(app_master, BYPASSRLS) —tenants/planos/tenant_faturas/audit_logsnã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 tantoPrismaMasterServicequanto otxde uma transação em andamento, seguindo o mesmo padrão de utilitário puro já usado emauth/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
statuscomo um dos campos que oPUT /master/tenants/:idatualiza. Implementado comstatusfora doUpdateTenantDto— toda transição de status passa exclusivamente porPATCH /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. Deixarstatuseditá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 oTenantGuard(auth/guards/tenant.guard.ts) passou a consultar o status do tenant a cada request (antes só populavarequest.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 atenantspor 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),changeStatustambém revoga todos os refresh tokens ativos dostenant_usersdo tenant ao suspender/cancelar. TENANT_BLOCKED_STATUSES(antes uma constante privada emauth.service.ts) foi exportado para ser a única fonte de verdade de quais status bloqueiam acesso, reusada tanto no login (Etapa 2) quanto noTenantGuard(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 complano_idapontando pra ele estatus != 'cancelado'(não sóstatus = 'ativo') — um tenanttrial/inadimplente/suspensotambé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/updatede tenant checamplano.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 dePOST /master/tenantsnão informaradmin.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 viapassword.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-mesusacreateMany({ skipDuplicates: true })em vez de checar linha a linha se já existe fatura pro(tenant_id, referencia)— oUNIQUE(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-atrasadassó rebaixa prainadimplentetenants que estejamativono momento — tenants jásuspenso/canceladocom fatura vencida viramatrasadona fatura, mas não voltam a virarinadimplente(não faria sentido reativar parcialmente um tenant cancelado só por causa de uma fatura antiga).- Dashboard (
GET /master/dashboard):mrrcalculado somandoplanos.valor_mensaldos tenantsativoem memória comPrisma.Decimal(não dá pra fazer umgroupBy/aggregatedo Prisma somando um campo de tabela relacionada em uma única query) — evita o erro de arredondamento de ponto flutuante que uma soma emnumberteria com valores monetários.total_recebido_mes/total_em_abertousamaggregate({_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 aenv.validation.tse.env.example, mesmo padrão das demais variáveis opcionais com default. package.json:test:e2epassou 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.tse o novomaster.e2e-spec.ts) rodando em paralelo (padrão do Jest), um teste que cria um tenantativonum arquivo poderia contaminar uma asserção de contagem global no outro.--runInBandroda 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 devalor_mensal/limites, exclusão bloqueada por tenant em uso vs. exclusão real quando não usado), criação de tenant com login dotenant_admingerado testado de ponta a ponta, listagem com filtro porstatus/busca, mudança de status bloqueando tanto login novo quanto um token de acesso já emitido (viaTenantGuard), geração de faturas idempotente, marcar-pago revertendoinadimplente→ativo(incluindo rejeição 409 ao tentar marcar pago de novo), atualizar-atrasadas propagando pratenants.status, dashboard (com asserções por delta) e bloqueio de todos os grupos de endpoint com token detenant_user. Rodado comdocker compose exec-equivalente (docker runnum container a partir da imagemdevdoDockerfile, ligado à mesma rede dodocker-compose.yml, já que a imagemdevcompila os bindings nativos doargon2pra musl/Alpine — o host deste ambiente é glibc e não tem acesso de rede pra build de imagem sem--network host) —npx prisma migrate statusconfirmou que as migrations da Etapa 1 já cobrem todo o schema desta etapa (planos/tenants/tenant_faturas/audit_logsjá existiam), então não houve migration nova.npm run lint,npx tsc --noEmit,npm run test(unit) enpm run buildpassam 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 viacurlconfirmando/health(200) e bloqueio de/master/planossem token (401) contra a imagemproductionreal.
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
EncryptionServiceusado de forma consistente para segredos
Observações:
- Módulos novos:
src/modules/evolution/(EvolutionController/EvolutionService/EvolutionApiClient, CRUD deevolution_api_configem/master/evolution-config+testar-conexao),src/modules/whatsapp/(WhatsappController/WhatsappService, instâncias em/tenant/whatsapp/instancias),src/modules/email-config/(MasterEmailConfigController/Serviceem/master/email-config,TenantEmailConfigController/Serviceem/tenant/email-config,EmailServicecomnodemailer) esrc/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'). EncryptionServicenovo emsrc/common/encryption.service.ts(AES-256-GCM,node:cryptonativo, sem dependência extra), registrado emEncryptionModule(@Global(), mesmo padrão doPrismaModule). Formato gravado nas colunas*_cripto:iv:authTag:conteudo(hex,:-separado). A chave deENCRYPTION_KEY(env, nova variável obrigatória,@MinLength(32)emenv.validation.ts, mesmo padrão deJWT_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_criptosã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_criptosempreundefinedno payload).- Decisão de arquitetura, para registrar com destaque:
AlertDispatchService(e, por baixo dele,EmailService) usamPrismaMasterServicecom filtro manual detenant_idem toda query, em vez deTenantContextService/RLS — diferente do resto do código tenant desta etapa (WhatsappService/TenantEmailConfigService, que usamtenantContext.getTx()normalmente, já quetenant_whatsapp_instances/tenant_email_configtê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 deAsyncLocalStorageativo (TenantContextService.getTx()lançaria "chamado fora de um contexto de tenant ativo"). UsarPrismaMasterServicedesde já evita ter que reescrever esse service na Etapa 7. Como esse service nunca recebetenant_idde 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(onodemailerpropriamente dito) vive num módulo próprio,EmailModule(src/modules/email-config/email.module.ts), separado doEmailConfigModule(CRUD). Motivo:AlertDispatchModuleprecisa doEmailService, eEmailConfigModuleprecisa doAlertDispatchService(endpointtestar-enviodo 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.enviarWhatsapprecebeu um quarto parâmetro opcional,instanceId?: string(assinatura do prompt previa sóenviarWhatsapp(tenantId, numero, mensagem)). SeminstanceId, resolve a instânciaconectadomais recente do tenant (comportamento que a Etapa 7 vai usar). CominstanceId, envia especificamente por aquela instância — usado porPOST /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 (apikeyno 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:httppuro, sem dependência nova) que implementa o mesmo contrato e roda na porta efêmera do próprio processo de teste, exercitando oEvolutionApiClientreal 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éStringlivre no schema (não enum do Prisma) — validado/mapeado a nível de aplicação emsrc/modules/whatsapp/whatsapp-status.util.ts(WhatsappInstanceStatus = 'desconectado' | 'conectando' | 'conectado' | 'erro',mapEvolutionState()traduz ostateda Evolution —open/connecting/close— pros valores em português pedidos no critério de aceite).- Tenants não têm campo
slugno schema. Nome de instância (tenant-<slug>-<sufixo>) gerado emsrc/modules/whatsapp/instance-name.util.tsa partir denome_fantasia(ourazao_socialcomo fallback), com uma tabela de substituição de acentos comuns em português (nãonormalize('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@uniquedo schema como último recurso. - Ao criar uma instância, o tenant não escolhe qual
evolution_api_configusar (a etapa não previu esse fluxo) —WhatsappServiceseleciona automaticamente a configativo=truemais antiga (orderBy: criado_em asc) e grava o vínculo emevolution_config_id. Assume-se, na prática, uma única config ativa por vez (mesmo espírito documentado no prompt paramaster_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 porAlertDispatchService.enviarEmaile, portanto, pelos dois endpoints detestar-envio) resolve o SMTP do tenant quando existetenant_email_configcomusar_config_padrao=false(e todos os campos preenchidos — senãoBadRequestExceptionexplícito); caso contrário, usa omaster_email_configativo=truemais recente (orderBy: criado_em desc— intencionalmente diferente doascda 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-configcomusar_config_padrao=falseexige todos os campos de SMTP via@ValidateIfdeclarativo no DTO (UpdateTenantEmailConfigDto); comusar_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-configsome com os campos de SMTP próprio quandousar_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 deevolution_api_configcomapi_key/smtp_senhanunca em texto puro (conferido tanto na resposta HTTP quanto decifrando o valor cru do banco viaEncryptionServicee comparando com o original),testar-conexaocontra oFakeEvolutionServer, criação de instância WhatsApp + QR code + mudança de status paraconectado, 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 demaster_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 (nodemailermockado viajest.mock('nodemailer')— sem SMTP real disponível neste ambiente — verificando que ofromusado bate com a config resolvida em cada modo) e bloqueio de todos os grupos de endpoint com o token errado (master vs. tenant). Rodado comdocker compose up -d postgres redis+prisma migrate deploy+jest --config ./test/jest-e2e.json --runInBandapontando pro Postgres vialocalhost(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 novosrc/common/encryption.service.spec.ts) enpm run buildtambé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) eaxios(cliente HTTP para a Evolution API) — adicionadas vianpm install, sem@nestjs/axios(o projeto não usa nenhum wrapper de HTTP client até agora;axiospuro, encapsulado só dentro deEvolutionApiClient, 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/(donoroot, resíduo de builds Docker rodados como root em sessão anterior) e um binário nativo deargon2compilado paramuslemnode_modules/argon2/build(também resíduo de build Docker anterior, teve precedência indevida sobre o prebuildglibccorreto deste host) bloqueandonpm install/tsc/os testes locais. Corrigido comchown(autorizado interativamente pelo usuário) e removendo obuild/nativo obsoleto — nenhuma mudança de código, só limpeza de artefatos de build. Também diferente da Etapa 1:docker buildfuncionou 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/Serviceem/tenant/customers,CustomerContactsController/Serviceaninhado em/tenant/customers/:customerId/contatos) esrc/modules/products/(ProductsController/Serviceem/tenant/produtos). Todos os controllers usam só@UseGuards(JwtAuthGuard, TenantGuard)— semRolesGuard/@Roles, como o prompt desta etapa pedia ("todos os endpoints usam TenantGuard") — qualquer papel detenant_user(tenant_admin/vendedor/financeiro/visualizador) acessa; papéis mais granulares por endpoint ficam para uma etapa futura, se necessário. Todos usamTenantContextService.getTx()(conexãoapp_tenant, RLS ativo) — nenhuma query manual filtrando portenant_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 (
TenantGuardrejeita qualquer token que não sejatenant_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 porMasterGuarde com log de auditoria de cada acesso — não reaproveitar as rotas/tenant/*. - Limite de
limite_customersdo plano: contado só entre customersativo=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 viaPrismaMasterService(tenants.findUnique({include: {plano:true}})), mesmo padrão já usado emWhatsappServicepro limite de instâncias (Etapa 4) —limite_customersNULL= ilimitado, não bloqueia. - Regra de contato principal único (
customer_contacts.principal): aplicada com umupdateManydesmarcando o(s) principal(is) anterior(es) do mesmocustomer_id, seguido docreate/updatedo 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$transactionaninhada (que nem seria possível:txaqui já é umPrisma.TransactionClient, que não expõe$transaction). Comportamento explícito verificado em teste: criar dois contatos seguidos comprincipal:truedeixa só o segundo como principal. - SKU duplicado no mesmo tenant → 409:
products_servicestem@@unique([tenant_id, sku])no schema; o service tenta ocreate/updatedentro de umtry/catche traduzPrisma.PrismaClientKnownRequestErrorcomcode === 'P2002'paraConflictExceptioncom mensagem citando o SKU. Não filtra porerror.meta.targetincluir'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); comoskué a única constraintUNIQUEda 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 emcustomer_idjá quecustomer_contactsnão temtenant_iddireto). 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ãoapp_tenantcomapp.current_tenant_iddo 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 comativo=falsevia query direta ao Prisma), bloqueio ao estourarlimite_customersdo 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 detipo/valor_venda > 0, SKU duplicado 409 vs. SKU igual permitido entre tenants diferentes, filtro portipo/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 comdocker compose up -d postgres redis+prisma migrate deploy(nenhuma migration nova —customers/customer_contacts/products_servicesjá existiam desde a Etapa 1) +jest --config ./test/jest-e2e.json --runInBandapontando pro Postgres vialocalhost— 74/74 testes e2e passando (os 54 das Etapas 1-4 + os 20 novos desta etapa).npm run lint,npm run test(unit) enpm run buildtambém passam sem erros. Ambiente deste host não tem Node/npm nativo (só Docker) — dependências instaladas viadocker run node:20-alpinecompython3 make g++adicionados (necessários pro binding nativo doargon2, mesmo motivo já documentado na Etapa 1/4); rede bridge padrão do Docker funcionou nesta sessão (sem precisar--network hostpranpm install/pull de imagens, só pro container de teste alcançar o Postgres publicado pelodocker-composevialocalhost).
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:
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:
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:
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
- Sem uma role de aplicação não-superuser dedicada (
app_tenant), toda query detenant_usercontinuaria vendo dados de todos os tenants e o isolamento por RLS seria só decorativo. As duas roles (app_mastereapp_tenant) são criadas na migration manualprisma/migrations/20260813120000_auth_app_roles/, com senhas placeholder de dev documentadas em.env.example(mesmo padrão já usado paraPOSTGRES_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.