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>
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
- 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 | 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 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. - [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 emauth.e2e-spec.ts). Detalhe completo emEtapas/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ódulosrc/modules/equipe/(GET/POST /tenant/equipe,PATCH /tenant/equipe/:id, sótenant_admin, RLS viaTenantContextServicecomocustomers/productsda Etapa 5) — oGET /auth/tenant/usersdeste módulo continua exatamente como estava, só leitura/demonstração, não foi tocado. Também aditivo:PUT /tenant/perfil(nome_fantasia/razao_social) emsrc/modules/tenant-profile/, ao lado doPUT /tenant/perfil/corjá 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 — emEtapas/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_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:
- 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 noTenantsModuleda Etapa 3):SalesController/Serviceem/tenant/vendas,SalePaymentsController/Serviceem/tenant/recebimentoseTenantDashboardController/Serviceem/tenant/dashboard. Todos usam sóTenantGuard(semRolesGuard), 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 bootstrapseconectai, superuser) — não de quem consulta. Como superuser ignora RLS, qualquer role com sóSELECTna view (ex:app_tenant) conseguiria ler dados de todos os tenants através dela, mesmo com as policies de RLS corretas emsales/sale_paymentspor 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): comsecurity_invoker, a view roda com os privilégios de quem chamou, e a RLS desales/sale_paymentsvolta 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-mensalconsultam as views viatx.$queryRaw(comPrisma.sql/Prisma.join— sempre parametrizado, nunca concatenando string do usuário) dentro da mesma transação por-request (TenantContextService.getTx()), então oSET LOCAL app.current_tenant_idjá ativo na transação vale também para a query raw. Prisma não modela views nativamente sem o preview featureviews(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/recebimentosnão usavw_recebimentoscomo pedido literalmente — usatx.sale_payments.findMany(Prisma normal, tipado) comincludetrazendosale.numero_venda/sale.customer. A view só expõeid, tenant_id, customer_id, valor, vencimento, status, pago_em— semsale_id/numero_parcela/forma_pagamento, campos claramente úteis numa listagem de recebimentos de verdade. Comosale_paymentsjá tem RLS própria (via subquery emsale_id, não precisa do fix desecurity_invokeracima) 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_invokerfoi aplicado na view mesmo assim, por robustez (qualquer consulta futura direta a ela, viaprisma studioou uma etapa nova, já nasce segura). - Regras de transição de status de venda
(
orcamento→confirmada/cancelada,confirmada→concluida/cancelada,concluida/canceladaterminais) implementadas como uma tabela (TRANSICOES_VALIDAS) emsales.service.ts, mesmo espírito deTENANT_BLOCKED_STATUSESda 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 cancelasale_paymentscom statuspendenteouatrasado(o prompt cita só "parcelas pendentes", mas uma parcelaatrasadoé uma parcela pendente vencida — deixá-la pendurada numa venda cancelada não faz sentido). PUT /tenant/vendas/:id(só com a venda emorcamento) redefine itens e parcelas por inteiro (deleteMany+createManydentro 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 emaudit_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 comPrisma.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 denumero_parcelasparcelas 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 dedata_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_ide todoproduct_service_idsão resolvidos porfindUniquedentro da mesma transação com RLS ativa — se não existir ou não pertencer ao tenant, 404 do ponto de vista do banco viraBadRequestException(400, não 404, porque o problema está no corpo doPOST, 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
TenantRlsInterceptorjá envolve a request inteira numa única transação Prisma — criar venda + itens + parcelas nunca precisou de um$transactionaninhado (que nem seria possível: otxaqui já é umPrisma.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 comnumero_parcelas(divisão igual) e comparcelasexplícitas, desconto reduzindovalor_total, soma de parcelas divergente (400), desconto maior que os itens (400),numero_parcelas+parcelasjuntos 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 prasale_payments,PUTsó emorcamento(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ó vendaconfirmada/concluidaconta — uma venda emorcamentocriada 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 dosecurity_invokeracima — sem a migration, esse teste falha). Rodado comdocker compose up -d postgres redis+prisma migrate deploy(aplicou a migration nova desecurity_invokeralé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) enpm run buildtambém passam sem erros — o build pegou um erro real (TS4053, interfaces de retorno das rows do$queryRawnão exportadas,ResumoSemanalRow/ResumoMensalRow), corrigido exportando as duas interfaces. Limpeza doafterAlldo teste precisou apagarsalesexplicitamente antes detenants—sale_items/sale_paymentsnão têmON DELETE CASCADEdeproducts_servicesno schema (de propósito, é o que garante que produto usado numa venda não pode ser hard-deletado), então apagar um tenant cujassalesainda não foram removidas pode disparar o cascade deproducts_servicesantes do cascade desale_itemse 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(processoapp, só HTTP, semScheduleModule/BullModule) esrc/worker.ts(processoworker,NestFactory.createApplicationContext(WorkerModule), sem HTTP).WorkerModule(src/worker/) é o único lugar que importaScheduleModule/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 oappescalar horizontalmente (várias réplicas), um@Cron()nele dispararia o mesmo job uma vez por réplica; como oworkeré quem tem os crons, escalarworker(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.ymlganharam um serviçoworkernovo (mesma imagem doapp,commanddiferente —node dist/workerem produção,npm run worker:devem dev).nest buildjá compilasrc/worker.tsparadist/worker.jsjunto com o resto (não precisa de configuração extra nonest-cli.json);npm run worker:dev/workerusamnest start --entryFile worker. - Módulo novo
src/modules/alerts/(web): CRUD dealert_templatesem/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).AlertsModuleexportaAlertTemplateResolverService/AlertMaintenanceServicejustamente para oWorkerModuleimportar sem duplicar código — nenhum dos dois depende de nada HTTP-específico. alert_templates/alert_logsnão têm RLS habilitado (não estão na lista de tabelas isoladas por tenant da migration de RLS da Etapa 1) — diferente decustomers/products_services/sales/etc. Por isso todo o módulo (master e tenant) usaPrismaMasterServicecom filtro manual detenant_idem toda query, nuncaTenantContextService/RLS — mesmo padrão já registrado na Etapa 4 para oAlertDispatchService(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_idem todofindFirst/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_emsã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), umalert_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 emalert_logsquando 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.attemptsMadedo BullMQ conta as tentativas anteriores à execução atual (0 na primeira chamada deprocess(), não 1); com essa fórmula, a condição nunca ficava verdadeira dentro deprocess()(só ficaria visível depois, quando não há mais chamada nenhuma) e a falha final nunca era gravada emalert_logs. Corrigido parajob.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 comalert_logsvazio antes da correção — fica como lembrete de quejob.attemptsMademerece 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 deALERT_JOB_MAX_ATTEMPTS/ALERT_JOB_BACKOFF_MSviaConfigService, defaults 3/5000ms) — diferente do horário do cron (@Cron()), que precisa ser lido direto deprocess.envporque decorators são avaliados na importação da classe, antes doConfigModuleterminar de inicializar; como o deploy real injeta env vars pelo ambiente do container (não um.envlido em runtime), isso é seguro. Documentado emAlertSchedulerService. - 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 comalerta_enviado_emnulo ou anterior a hoje, comjobIddeterminí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-checaalerta_enviado_emfresco 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_logscontinua 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 deFaturasService.atualizarAtrasadas(Etapa 3) eSalePaymentsService.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 umactorhumano 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" —PrismaMasterServicesemTenantContextService(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 gravausuario_tipo:'master', usuario_id: null(ação de sistema, sem ator humano) — primeira vez quewriteAuditLogé chamado comusuario_idnulo 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_tenantsó o master define (tenant_idsemprenull) — o tenant não tem como criar/editar esse tipo (CreateTenantAlertTemplateDto/UpdateTenantAlertTemplateDtousamTIPOS_TEMPLATE_TENANT, que excluimensalidade_tenantviaIsIn); 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_tenantpor WhatsApp usam a própria instância de WhatsApp do tenant (só existetenant_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 atenants.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.resolverConfigjá cai promaster_email_configpadrã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 criarmensalidade_tenant, isolamento entre tenants sempre 404, soft delete), job de mensalidade batendo fatura vencida com alerta por WhatsApp (viaFakeEvolutionServerlocal, mesmo padrão da Etapa 4) e e-mail (vianodemailermockado) — conteúdo renderizado conferido (R$ 199,90, data05/01/2020), marcaatrasado+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) —, confirmandotemplate_iddo log aponta pro template certo em cada caso; filtros deGET .../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 dequeue.getJobCounts()até as duas filas esvaziarem antes de cada asserção. Rodado comdocker compose up -d postgres redis+npm install(novas dependências:bullmq,@nestjs/bullmq,@nestjs/schedule— precisounpm installem vez denpm ci, que exige o lockfile já sincronizado) +prisma migrate deploy(nenhuma migration nova —alert_templates/alert_logsjá 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) enpm run buildtambém passam sem erros (o build geroudist/worker.jsjunto comdist/main.js, sem configuração extra). Smoke test manual do processo worker de verdade (node dist/workercontra 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) eGET /public/tenants/:id/tema(sem guard nenhum, sóThrottlerGuardglobal). Upload viaFileInterceptor('logo')(multer, memory storage padrão) +ParseFilePipeBuilder(addFileTypeValidator/addMaxSizeValidator, 2MB, png/jpg/svg) — ver desvio deskipMagicNumbersValidationabaixo. Armazenamento atrás de uma interface (StorageService,src/common/storage/) com uma única implementação por ora (LocalStorageService, grava emuploads/, 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 emTenantProfileModule, 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 umslug. 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 umslug(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 noconsole.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 demaster_adminresetando a própria senha, onde não existetenant_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.tsatualizado: em vez de espiarconsole.log, mockanodemailer(mesmo padrão já usado emintegracoes.e2e-spec.ts) e extrai o token do corpo do e-mail "enviado". - CORS + Helmet:
app.use(helmet())eapp.enableCors({origin: CORS_ORIGINS.split(',') || false})— vazio/ausente bloqueia toda origem cross-site por padrão (nuncaorigin: '*'). Extraído prasrc/security.util.ts(applySecurityConfig(app, configService)) em vez de ficar só dentro debootstrap()emmain.ts, porque os testes e2e criam a aplicação viaTest.createTestingModule(...). createNestApplication(), que não passa porbootstrap()— 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.tsetest/fluxo-completo.e2e-spec.ts(specs novos desta etapa) chamamapplySecurityConfig— 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/ThrottlerGuardglobal,60 req/60spor 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 emlogin_attempts) — nada novo implementado aqui, só confirmado: oThrottlerGuardé global (APP_GUARD), então vale automaticamente pros endpoints novos desta etapa (incluindo o públicoGET /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 doclass-validatorjá 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 vianpm run lint/npm run build, que pegou um bug real de inserção no meio de um import multi-linha emsales/dashboard.controller.ts, corrigido)./docsconfirmado manualmente contra o app real: 57 rotas, 19 tags, security schemejwt(Bearer) registrado —auth.controller.tsnão ganhou@ApiBearerAuthpor 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 achoualert_templates(master e tenant) sem paginação — corrigido, adicionadopage/limit(máx. 100) emListAlertTemplatesQueryDtoe nos dois services. Decisão consciente de não mecanizar em todas as listagens sem critério:planos,evolution-configemaster-email-config(todas master-only, curadas manualmente, realisticamente algumas unidades a poucas dezenas de linhas) ecustomer_contacts/whatsapp instancias(limitadas estruturalmente — contatos por customer tende a ser pequeno; instâncias de WhatsApp são limitadas porlimite_instancias_whatsappdo 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):
greppor 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 (nenhumany/objeto cru) —ValidationPipeglobal (whitelist+forbidNonWhitelisted+transform, desde a Etapa 1) garante que isso é realmente validado, não só tipado. - Docker de produção:
docker build --target productiontestado de verdade nesta sessão (não só lido) — imagem final semtypescript/ts-node/jest/eslint/@nestjs/cli(checado vials node_modulesdentro do container), ~180MB, container real rodando e respondendo/health(200) contra Postgres/Redis reais, e oworker(mesma imagem,CMDdiferente) subindo e conectando nas duas filas. Volume nomeado novo,uploads_data:/app/uploads, no serviçoappdodocker-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 deployjá 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 comopago. 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 (ParseFilePipeBuilderjá vem do@nestjs/commonexistente),@types/multer(dev).npm install(nãonpm ci) de novo, mesma razão já registrada na Etapa 7 (lockfile precisa ser regravado quandopackage.jsonganha dependência nova). - Validação completa desta sessão:
docker compose up -d postgres redisnpm 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) viacurl, 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
- 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.
[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
- rodavam com os privilégios do dono da view (a role bootstrap
seconectai, superuser), que ignora Row Level Security — qualquer role comSELECTna 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 comALTER VIEW ... SET (security_invoker = true)nas três views (migration20260815090000_dashboard_views_security_invoker, Postgres 15+). Sem essa correção,GET /tenant/dashboard/resumo-mensalvazaria 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.