first commit

This commit is contained in:
2026-08-14 17:35:24 -03:00
commit 04472fcd2b
118 changed files with 19173 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
# ============================================================
# SeConectai Vendas — variáveis de ambiente
# Copie este arquivo para `.env` e ajuste os valores.
# ============================================================
# Ambiente da aplicação: development | production | test
NODE_ENV=development
# Porta HTTP exposta pela API (também usada pelo docker-compose para o mapeamento de porta)
PORT=3000
# --- Postgres --------------------------------------------------
# Credenciais usadas pelo container do Postgres (docker-compose.yml)
POSTGRES_USER=seconectai
POSTGRES_PASSWORD=seconectai
POSTGRES_DB=seconectai_vendas
POSTGRES_PORT=5432
# String de conexão usada pelo Prisma. Dentro do docker-compose, o host é o
# nome do serviço (`postgres`); rodando a API fora do compose, use `localhost`.
DATABASE_URL=postgresql://seconectai:seconectai@postgres:5432/seconectai_vendas?schema=public
# Connection string da role `app_master` (LOGIN + BYPASSRLS, criada na
# migration `20260813120000_auth_app_roles`), usada pelo painel master para
# enxergar dados de todos os tenants sem passar pelas policies de RLS.
DATABASE_URL_MASTER=postgresql://app_master:app_master_changeme@postgres:5432/seconectai_vendas?schema=public
# Connection string da role `app_tenant` (LOGIN comum, sem BYPASSRLS),
# usada para toda query autenticada como tenant_user, dentro da transação
# que executa `SET LOCAL app.current_tenant_id` — é essa role que faz as
# policies de RLS valerem de fato (a role de DATABASE_URL é dona das
# tabelas e, por ser a superuser bootstrap do Postgres, sempre ignora RLS).
DATABASE_URL_TENANT=postgresql://app_tenant:app_tenant_changeme@postgres:5432/seconectai_vendas?schema=public
# Em produção, troque as senhas acima com `ALTER ROLE app_master/app_tenant
# WITH PASSWORD '...'` e atualize as URLs de acordo — mesma lógica de
# POSTGRES_PASSWORD/JWT_SECRET: os valores default aqui são só para dev.
# --- Redis -------------------------------------------------------
REDIS_HOST=redis
REDIS_PORT=6379
# --- Autenticação --------------------------------------------------
# Segredo usado para assinar os JWTs de acesso/refresh (mínimo 32 caracteres)
JWT_SECRET=troque-este-valor-por-um-segredo-forte-com-32-caracteres-ou-mais
# Expiração do access token (formato aceito pela lib `ms`, ex: 15m, 1h)
JWT_ACCESS_EXPIRES_IN=15m
# Expiração do refresh token e do token de reset de senha, em dias/horas
REFRESH_TOKEN_EXPIRES_IN_DAYS=30
PASSWORD_RESET_EXPIRES_IN_HOURS=1
# Bloqueio de força bruta: nº de tentativas falhas e janela (minutos) até bloquear
LOGIN_MAX_ATTEMPTS=5
LOGIN_LOCKOUT_WINDOW_MINUTES=15
# --- Painel Master ------------------------------------------------
# Dias de trial concedidos a um tenant recém-criado (tenants.trial_expira_em)
TENANT_TRIAL_DIAS=14
# --- Integrações (Evolution API / SMTP) ----------------------------
# Chave usada para cifrar segredos em repouso (AES-256-GCM): api_key da
# Evolution API (evolution_api_config.api_key_cripto) e senha SMTP
# (master_email_config.smtp_senha_cripto / tenant_email_config.smtp_senha_cripto).
# Mínimo 32 caracteres; internamente é reduzida a 32 bytes via SHA-256 antes
# de virar a chave AES-256, então qualquer string longa o suficiente serve.
# Nunca reutilize o valor de exemplo em produção — trocar invalida todos os
# segredos já cifrados no banco (seria necessário recriá-los).
ENCRYPTION_KEY=troque-este-valor-por-um-segredo-forte-com-32-caracteres-ou-mais
+24
View File
@@ -0,0 +1,24 @@
# dependencies
node_modules/
# build
dist/
coverage/
# env
.env
.env.*.local
# logs
*.log
npm-debug.log*
# IDE
.vscode/
.idea/
# OS
.DS_Store
# uploads (etapa 8 — armazenamento local de logos)
uploads/
+5
View File
@@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}
+49
View File
@@ -0,0 +1,49 @@
# syntax=docker/dockerfile:1
# ---- deps: instala todas as dependências (inclui dev, precisa pra build) ----
FROM node:20-alpine AS deps
# python3/make/g++: precisos para compilar o binding nativo do `argon2`
# (não há prebuild musl/Alpine para todas as versões/arquiteturas).
RUN apk add --no-cache openssl python3 make g++
WORKDIR /app
COPY package*.json ./
COPY prisma ./prisma
RUN npm ci
RUN npx prisma generate
# ---- dev: usado pelo docker-compose.override.yml, com hot-reload ----
FROM deps AS dev
WORKDIR /app
COPY . .
CMD ["npm", "run", "dev"]
# ---- build: compila TypeScript -> dist ----
FROM node:20-alpine AS build
RUN apk add --no-cache openssl
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/prisma ./prisma
COPY . .
RUN npm run build
# ---- prod-deps: reinstala só dependências de produção ----
FROM node:20-alpine AS prod-deps
RUN apk add --no-cache openssl python3 make g++
WORKDIR /app
COPY package*.json ./
COPY prisma ./prisma
RUN npm ci --omit=dev
RUN npx prisma generate
# ---- production: imagem final enxuta ----
FROM node:20-alpine AS production
RUN apk add --no-cache openssl
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/prisma ./prisma
COPY package*.json ./
EXPOSE 3000
CMD ["node", "dist/main"]
+57
View File
@@ -0,0 +1,57 @@
# SeConectai Vendas — Prompts de Build Faseado
Este pacote contém 8 prompts sequenciais e autocontidos para entregar a uma
IA de geração de código (Claude Code, Cursor, Windsurf etc). Cada arquivo
pode ser colado como um prompt único — eles já trazem o contexto necessário
para não depender de memória de conversa anterior.
## Ordem obrigatória
1. `01-setup-projeto.md` — estrutura base, Docker Compose, Postgres, Prisma
2. `02-autenticacao-rls.md` — login master/tenant, JWT, RLS
3. `03-painel-master-tenants.md` — CRUD de tenants, planos, faturas
4. `04-integracoes-evolution-email.md` — Evolution API, instâncias WhatsApp, SMTP
5. `05-painel-tenant-customers-produtos.md` — CRUD de clientes e produtos/serviços
6. `06-vendas-dashboards.md` — vendas, parcelas, resumos
7. `07-motor-de-alertas.md` — BullMQ, templates, disparo e logs
8. `08-personalizacao-whitelabel.md` — logo/cor por tenant, ajustes finais
## Arquivo de controle de progresso (obrigatório)
`09-controle-de-progresso.md` é o estado oficial do projeto. Regras:
- **Sempre envie esse arquivo junto com o prompt de cada etapa.**
- A IA deve lê-lo antes de começar e recusar iniciar uma etapa se a
anterior não estiver 100% concluída na checklist.
- Ao final de cada etapa, a IA deve devolver o arquivo atualizado (itens
marcados, status geral e observações preenchidas) — é esse arquivo
atualizado que você guarda e reenvia na próxima etapa/sessão.
- Sem esse arquivo, uma IA nova (em outra sessão) não tem como saber o
que já foi feito — por isso ele substitui a necessidade de repetir todo
o histórico da conversa.
## Como usar cada prompt
- Rode nessa ordem — cada etapa assume que a anterior está pronta e
funcionando (migrations aplicadas, testes passando).
- Ao final de cada etapa, confira o checklist de "Critérios de aceite"
antes de avançar para a próxima. Se algo não for atendido, peça correção
antes de seguir.
- O arquivo `seconectai-vendas-schema.sql` é a fonte de verdade do modelo
de dados — qualquer etapa que crie migration deve refletir exatamente
essas tabelas (a IA pode adaptar para sintaxe Prisma, mas a estrutura,
nomes de campo e tipos devem ser preservados).
- Cada prompt já repete o essencial do contexto (stack, convenções), então
eles funcionam mesmo em uma conversa nova/sessão nova com a IA.
## Stack fixada em todas as etapas
- Node.js 20+ / TypeScript
- NestJS (modular por domínio)
- PostgreSQL 16 + Prisma ORM
- Redis + BullMQ (a partir da etapa 7)
- Docker + Docker Compose
- Autenticação: JWT (access curto) + refresh token com rotação, senha com
argon2id
- Multi-tenancy: banco único, isolamento por `tenant_id` + Row Level
Security do Postgres
+115
View File
@@ -0,0 +1,115 @@
# Etapa 1/8 — Setup do Projeto
## Contexto do projeto
Estou construindo o **SeConectai Vendas**: um CRM/controle de vendas
multi-tenant. Existe um painel **master** (eu, dono da plataforma, que
vendo o acesso ao sistema) e painéis de **tenant** (meus clientes, que
usam o sistema para cadastrar os próprios clientes/vendas).
Stack definida:
- Node.js 20+ / TypeScript
- NestJS (organizado em módulos por domínio)
- PostgreSQL 16 + Prisma ORM
- Redis (usado a partir de uma etapa futura, mas já deve subir no compose)
- Docker + Docker Compose para todo o ambiente (dev e produção)
- Multi-tenancy: banco único, isolamento por `tenant_id` + Row Level
Security (RLS) do Postgres
Esta é a **Etapa 1 de 8** de um build faseado. Cada etapa será entregue
separadamente. Nesta etapa, quero **apenas a fundação do projeto** —
nenhuma feature de negócio ainda.
## Objetivo desta etapa
Criar a estrutura base do repositório, ambiente Docker funcional, conexão
com banco de dados via Prisma, e o schema inicial do banco já migrado.
## Tarefas
1. **Estrutura do projeto NestJS**
- Criar projeto NestJS em TypeScript com estrutura modular:
`src/modules/<dominio>/...` (os domínios virão nas próximas etapas —
por ora, criar só `src/modules/health/` com um endpoint
`GET /health` que retorna status da API e status da conexão com o
banco).
- Configurar ESLint + Prettier com regras razoáveis para TypeScript.
- Configurar variáveis de ambiente via `.env` (usar `@nestjs/config`),
com um `.env.example` documentando todas as chaves necessárias.
2. **Docker Compose**
- Criar `docker-compose.yml` com os serviços:
- `app` (build a partir de um `Dockerfile` multi-stage — stage de
build e stage de produção enxuto)
- `postgres` (Postgres 16, volume nomeado para persistência, healthcheck)
- `redis` (Redis 7, volume nomeado, healthcheck) — não será usado
ainda, mas deixar disponível
- Criar `docker-compose.override.yml` (ou perfil `dev`) com hot-reload
do backend para desenvolvimento local.
- Garantir que `docker compose up` sobe tudo e a API responde em
`GET /health` com status 200.
3. **Banco de dados e Prisma**
- Inicializar Prisma (`prisma/schema.prisma`) e traduzir **exatamente**
o schema SQL abaixo para o formato Prisma, preservando nomes de
tabelas/campos, tipos, enums, defaults, unique constraints e índices.
- Não implemente ainda a lógica de RLS na aplicação (isso é da Etapa
2) — mas a migration gerada deve incluir os comandos
`ALTER TABLE ... ENABLE ROW LEVEL SECURITY` e as `CREATE POLICY`
definidos no schema, via uma migration SQL raw do Prisma (Prisma não
suporta RLS nativamente, então usar `prisma migrate dev` seguido de
uma migration manual `.sql` para as policies).
- Rodar a migration inicial e confirmar que todas as tabelas, enums,
views e policies foram criadas corretamente no Postgres do
`docker-compose`.
Schema de referência (colar aqui o conteúdo completo do arquivo
`seconectai-vendas-schema.sql` fornecido separadamente — use-o como
fonte de verdade).
4. **Scripts utilitários**
- `package.json` com scripts: `dev`, `build`, `start:prod`,
`lint`, `test`, `prisma:migrate`, `prisma:generate`, `prisma:studio`.
- `README.md` inicial explicando como subir o projeto localmente
(`docker compose up`, variáveis de ambiente necessárias, como rodar
migrations).
5. **Testes**
- Configurar Jest (já vem com NestJS) e criar um teste simples do
endpoint `/health`.
## Estrutura de pastas esperada
```
seconectai-vendas/
├── docker-compose.yml
├── docker-compose.override.yml
├── Dockerfile
├── .env.example
├── package.json
├── prisma/
│ ├── schema.prisma
│ └── migrations/
├── src/
│ ├── main.ts
│ ├── app.module.ts
│ ├── config/
│ └── modules/
│ └── health/
│ ├── health.module.ts
│ ├── health.controller.ts
│ └── health.service.ts
└── test/
```
## Critérios de aceite
- [ ] `docker compose up` sobe `app`, `postgres` e `redis` sem erro
- [ ] `GET /health` retorna 200 com status da API e status da conexão com
o banco
- [ ] Todas as tabelas, enums, views e índices do schema fornecido existem
no banco após a migration (conferir com `\dt` e `\dv` no psql)
- [ ] As policies de RLS estão criadas (`\d+ customers` mostra a policy)
- [ ] `.env.example` documenta todas as variáveis necessárias
- [ ] `npm run lint` e `npm run test` rodam sem erro
- [ ] README explica como subir o ambiente do zero
+115
View File
@@ -0,0 +1,115 @@
# Etapa 2/8 — Autenticação, Autorização e Row Level Security
## Contexto do projeto
Continuação do **SeConectai Vendas** (CRM/vendas multi-tenant). A Etapa 1
já entregou: projeto NestJS + Docker Compose + Postgres/Prisma com o
schema completo migrado (incluindo as tabelas `master_admins`,
`tenant_users`, `auth_refresh_tokens`, `password_reset_tokens`,
`login_attempts` e as policies de RLS).
Existem dois tipos de usuário:
- **master_admins**: você, dono da plataforma — acesso a todos os tenants.
- **tenant_users**: usuários de cada tenant (cliente seu), com papéis
(`tenant_admin`, `vendedor`, `financeiro`, `visualizador`), restritos
aos próprios dados via RLS.
## Objetivo desta etapa
Implementar login, emissão/renovação de tokens, reset de senha,
mecanismo de definição do `tenant_id` da sessão no Postgres (RLS) e
proteção contra força bruta — para ambos os tipos de usuário.
## Tarefas
1. **Módulo de autenticação (`src/modules/auth/`)**
- `POST /auth/master/login` — email + senha → valida contra
`master_admins.senha_hash` (argon2id) → retorna access token (JWT,
expiração curta, ex: 15 min) + refresh token (opaco, guardado como
hash em `auth_refresh_tokens`, expiração longa, ex: 30 dias).
- `POST /auth/tenant/login` — email + senha + (implícito) tenant
resolvido pelo email único dentro do tenant → mesma lógica, usando
`tenant_users`.
- `POST /auth/refresh` — recebe refresh token, valida hash e
expiração/revogação, **rotaciona** (invalida o antigo, emite um
novo) e retorna novo par de tokens.
- `POST /auth/logout` — revoga o refresh token atual.
- `POST /auth/password/forgot` — gera token em
`password_reset_tokens` (hash, expiração curta, ex: 1h) e dispara
e-mail (por ora, apenas logar o link/token no console — o envio real
de e-mail é de uma etapa futura).
- `POST /auth/password/reset` — valida token, atualiza `senha_hash`,
marca token como usado, revoga todos os refresh tokens ativos do
usuário.
2. **JWT payload**
- Incluir no access token: `sub` (id do usuário), `tipo`
(`master`|`tenant_user`), `tenant_id` (null para master), `role`
(para tenant_user).
- Implementar `JwtStrategy` (Passport) validando assinatura e
expiração.
3. **Guards e decorators**
- `@CurrentUser()` — decorator para extrair o usuário autenticado do
request.
- `MasterGuard` — permite apenas `tipo === 'master'`.
- `TenantGuard` — permite apenas `tipo === 'tenant_user'`, e injeta
`tenant_id` no contexto da request.
- `RolesGuard` + `@Roles('tenant_admin', ...)` — restringe endpoints
por papel dentro do tenant.
4. **Aplicação do RLS via Prisma**
- Implementar um mecanismo (middleware/interceptor do Nest, executado
por request) que, para requests autenticadas como `tenant_user`,
abre uma transação Prisma e executa
`SET LOCAL app.current_tenant_id = '<tenant_id>'` antes de qualquer
query — garantindo que a policy de RLS do Postgres filtre os dados
automaticamente.
- Para requests como `master`, usar uma conexão/role de banco com
`BYPASS ROW LEVEL SECURITY` (criar essa role na migration, se ainda
não existir, com `CREATE ROLE app_master BYPASSRLS LOGIN ...` —
documentar isso no `.env.example` como uma segunda connection string
opcional, `DATABASE_URL_MASTER`).
- Escrever um teste de integração que prove o isolamento: criar dois
tenants, um usuário em cada, confirmar que a query de um nunca
retorna dado do outro mesmo manipulando o `tenant_id` de outras
formas (ex: tentando passar um `tenant_id` diferente no body de uma
requisição — o `tenant_id` da sessão deve **sempre** vir do token,
nunca de input do usuário).
5. **Proteção contra força bruta**
- Toda tentativa de login (sucesso ou falha) grava em
`login_attempts`.
- Após N tentativas falhas (configurável, ex: 5) para o mesmo e-mail
em uma janela de tempo (ex: 15 min), bloquear novas tentativas com
429 e mensagem clara, mesmo que a senha esteja correta.
- Rate limiting geral no endpoint de login (ex: `@nestjs/throttler`).
6. **Segurança de senha**
- Hash com argon2id (biblioteca `argon2`), parâmetros de custo
documentados no código.
- Validação de força mínima da senha no cadastro/reset (tamanho
mínimo, não permitir senhas triviais).
- Refresh tokens e reset tokens **nunca** armazenados em texto puro —
sempre hash (ex: sha256) antes de gravar no banco.
## Critérios de aceite
- [ ] Login master e login tenant funcionam e retornam access+refresh
token válidos
- [ ] Refresh token rotaciona corretamente; token antigo usado após
rotação é rejeitado
- [ ] Logout revoga o refresh token
- [ ] Fluxo de esqueci minha senha gera token, valida expiração/uso único
- [ ] `MasterGuard` e `TenantGuard` bloqueiam corretamente rotas erradas
(testar com token de tenant tentando acessar rota master e
vice-versa)
- [ ] `RolesGuard` bloqueia usuário `vendedor` tentando acessar rota
restrita a `tenant_admin`
- [ ] Teste de integração prova isolamento entre tenants via RLS (dado de
um tenant nunca vaza para outro, mesmo forçando `tenant_id` no
payload da requisição)
- [ ] Após 5 tentativas de login falhas, novas tentativas são bloqueadas
temporariamente
- [ ] Todas as senhas no banco estão como hash argon2id (nenhuma em texto
puro em nenhuma tabela)
+98
View File
@@ -0,0 +1,98 @@
# Etapa 3/8 — Painel Master: Tenants, Planos e Faturas
## Contexto do projeto
Continuação do **SeConectai Vendas**. Etapas anteriores entregaram: setup
do projeto (Docker/Prisma/schema) e autenticação completa (login
master/tenant, JWT, RLS, guards `MasterGuard`/`TenantGuard`/`RolesGuard`).
Esta etapa implementa a parte do **painel master** relacionada à gestão
comercial da própria plataforma: cadastro dos tenants (seus clientes),
planos vendidos, e cobrança da mensalidade deles.
Todos os endpoints desta etapa são protegidos por `MasterGuard`.
## Objetivo desta etapa
CRUD completo de `planos` e `tenants`, geração e controle de
`tenant_faturas` (mensalidades), e endpoints de consulta para o dashboard
do master (quantos tenants ativos, inadimplentes, faturamento previsto
etc).
## Tarefas
1. **Módulo `planos` (`src/modules/planos/`)**
- CRUD completo (`GET/POST/PUT/DELETE /master/planos`).
- Validação: `valor_mensal` > 0; limites (`limite_customers`,
`limite_usuarios`, `limite_instancias_whatsapp`) opcionais, mas se
informados devem ser > 0.
- Não permitir excluir plano em uso por algum tenant ativo — apenas
desativar (`ativo = false`).
2. **Módulo `tenants` (`src/modules/tenants/`)**
- `POST /master/tenants` — cria tenant com status inicial `trial`,
associa a um plano, define `trial_expira_em` (ex: hoje + 14 dias,
configurável), e **cria automaticamente o primeiro usuário
`tenant_admin`** desse tenant (nome, e-mail e senha provisória
informados no payload, ou senha gerada e enviada por e-mail — por
ora, retornar a senha provisória na resposta, o envio de e-mail é de
etapa futura).
- `GET /master/tenants` — listagem com filtros por `status`, busca por
nome/documento, paginação.
- `GET /master/tenants/:id` — detalhe do tenant, incluindo plano,
últimas faturas, quantidade de usuários/customers/vendas
(agregados).
- `PUT /master/tenants/:id` — atualizar dados cadastrais, plano,
status.
- `PATCH /master/tenants/:id/status` — transição explícita de status
(`ativo`, `suspenso`, `cancelado`, `inadimplente`), registrando em
`audit_logs`. Regra: mudar para `suspenso` ou `cancelado` deve
também **bloquear login** dos `tenant_users` daquele tenant (checar
status do tenant no login e no `TenantGuard`).
3. **Módulo `tenant_faturas` (dentro de `tenants` ou módulo próprio)**
- Geração automática mensal: um job (pode ser um endpoint manual por
enquanto, `POST /master/faturas/gerar-mes`, já preparando estrutura
para virar cron na Etapa 7) que cria, para cada tenant `ativo`, uma
linha em `tenant_faturas` referente ao mês corrente, com
`vencimento` calculado a partir de `dia_vencimento_mensalidade`,
evitando duplicar (respeitar o `UNIQUE(tenant_id, referencia)`).
- `GET /master/faturas` — listagem com filtros por status, tenant,
período.
- `PATCH /master/faturas/:id/marcar-pago` — marca fatura como paga
(`pago_em = now()`), e se o tenant estava `inadimplente`, volta para
`ativo`.
- Regra: se uma fatura passa do vencimento sem pagamento, marcar como
`atrasado` (pode ser endpoint manual por ora, `POST
/master/faturas/atualizar-atrasadas`, que também vira job na Etapa
7) e mudar `tenants.status` para `inadimplente`.
4. **Dashboard do master**
- `GET /master/dashboard` retornando: total de tenants por status,
MRR (soma de `valor_mensal` dos planos dos tenants ativos), total
recebido no mês corrente, total em aberto (`tenant_faturas` com
status `pendente` ou `atrasado`).
5. **Auditoria**
- Toda criação/alteração de tenant, plano ou fatura grava uma linha em
`audit_logs` (`usuario_tipo='master'`, `acao`, `entidade`,
`entidade_id`, `detalhes` em JSON com o diff relevante).
## Critérios de aceite
- [ ] CRUD de planos funcional e validado
- [ ] Criar tenant também cria o primeiro `tenant_admin` funcional (login
testado)
- [ ] Listagem de tenants com filtro por status e busca funciona
- [ ] Mudar status do tenant para `suspenso`/`cancelado` bloqueia login
dos usuários daquele tenant (testar tentativa de login após
suspensão)
- [ ] Geração de faturas do mês não duplica fatura já existente para o
mesmo tenant/referência
- [ ] Marcar fatura como paga reverte tenant `inadimplente` → `ativo`
- [ ] Marcar fatura como atrasada muda tenant para `inadimplente`
- [ ] Dashboard retorna números corretos (conferir manualmente com dados
de teste)
- [ ] Todas as ações relevantes aparecem em `audit_logs`
- [ ] Todos os endpoints desta etapa são inacessíveis com token de
tenant_user (testar)
+85
View File
@@ -0,0 +1,85 @@
# Etapa 4/8 — Integrações: Evolution API (WhatsApp) e E-mail
## Contexto do projeto
Continuação do **SeConectai Vendas**. Já existem: setup do projeto, auth
completa com RLS, e o CRUD de tenants/planos/faturas no painel master.
Esta etapa conecta a infraestrutura de mensageria: cadastro da Evolution
API (nível master), criação de instâncias de WhatsApp por tenant, e
configuração de SMTP (padrão da plataforma e por tenant). **O disparo
automático de alertas fica para a Etapa 7** — aqui o objetivo é deixar a
infraestrutura de envio pronta e testável manualmente.
## Tarefas
1. **Módulo `evolution` — nível master (`src/modules/evolution/`)**
- CRUD de `evolution_api_config` (`POST/GET/PUT/DELETE
/master/evolution-config`), protegido por `MasterGuard`.
- **Nunca** retornar `api_key_cripto` em texto puro nas respostas da
API — mascarar (ex: mostrar só os últimos 4 caracteres).
- Implementar criptografia AES-256-GCM para `api_key_cripto` (chave
vinda de variável de ambiente `ENCRYPTION_KEY`, nunca hardcoded).
Criar um `EncryptionService` reutilizável (vai servir também para
`smtp_senha_cripto`).
- `POST /master/evolution-config/:id/testar-conexao` — faz uma chamada
simples à Evolution API (ex: endpoint de status/versão) para
validar que a `base_url`/`api_key` cadastradas funcionam.
2. **Módulo `whatsapp` — nível tenant**
- `POST /tenant/whatsapp/instancias` (`TenantGuard`, role
`tenant_admin`) — cria uma instância na Evolution API (nome único
sugerido automaticamente, ex: `tenant-<slug>-<uuid curto>`), grava
em `tenant_whatsapp_instances` com status `desconectado`.
- `GET /tenant/whatsapp/instancias/:id/qrcode` — solicita o QR code
de conexão à Evolution API e retorna para o front exibir; atualiza
`qr_code_gerado_em`.
- `GET /tenant/whatsapp/instancias/:id/status` — consulta status atual
na Evolution API e sincroniza com o campo `status` local
(desconectado/conectando/conectado/erro).
- `DELETE /tenant/whatsapp/instancias/:id` — desconecta e remove a
instância na Evolution API e localmente.
- `POST /tenant/whatsapp/instancias/:id/testar-envio` — envia uma
mensagem de teste para um número informado, útil para o tenant
validar a conexão antes de confiar no sistema.
- Respeitar `limite_instancias_whatsapp` do plano do tenant ao criar
nova instância (retornar erro claro se o limite foi atingido).
3. **Módulo `email-config`**
- `master_email_config`: CRUD simples (`MasterGuard`) — geralmente só
um registro ativo, mas manter como tabela para permitir trocar
provedor sem downtime.
- `tenant_email_config`: `GET/PUT /tenant/email-config`
(`TenantGuard`, role `tenant_admin`) — se `usar_config_padrao=true`,
ocultar/ignorar os campos de SMTP próprio; se `false`, exigir os
campos de SMTP preenchidos.
- `POST /tenant/email-config/testar-envio` — envia e-mail de teste
para um endereço informado, usando a config resolvida (própria ou
padrão).
- Implementar `EmailService` central que resolve automaticamente qual
SMTP usar (do tenant ou o padrão) e usa `nodemailer`.
4. **Serviço central de envio (preparando a Etapa 7)**
- Criar `AlertDispatchService` com dois métodos:
`enviarWhatsapp(tenantId, numero, mensagem)` e
`enviarEmail(tenantId, destinatario, assunto, corpo)`. Ambos apenas
executam o envio (sem lógica de "quando enviar" — isso é da Etapa
7) e retornam sucesso/erro. Usar esse service nos endpoints de
"testar envio" acima, para já validar que funciona ponta a ponta.
## Critérios de aceite
- [ ] Master consegue cadastrar config da Evolution API e testar conexão
- [ ] `api_key` e senha SMTP nunca aparecem em texto puro em nenhuma
resposta de API (conferir payloads)
- [ ] Tenant consegue criar instância WhatsApp, obter QR code, escanear e
ver status mudar para `conectado`
- [ ] Criar instância além do limite do plano retorna erro claro
- [ ] Teste de envio de WhatsApp funciona (mensagem chega no número de
teste)
- [ ] Tenant consegue configurar SMTP próprio ou usar o padrão da
plataforma
- [ ] Teste de envio de e-mail funciona nos dois modos (SMTP próprio e
padrão)
- [ ] `EncryptionService` usado de forma consistente para todos os
segredos armazenados
@@ -0,0 +1,69 @@
# Etapa 5/8 — Painel Tenant: Clientes, Produtos e Serviços
## Contexto do projeto
Continuação do **SeConectai Vendas**. Já existem: setup, auth com RLS,
painel master (tenants/planos/faturas), e integrações de WhatsApp/e-mail.
Esta etapa é o núcleo operacional do **painel do tenant**: cadastro dos
clientes dele (empresas que ele atende) e do catálogo de produtos e
serviços que ele vende. Todos os endpoints usam `TenantGuard` — o
`tenant_id` vem sempre do token, nunca de input do usuário.
## Tarefas
1. **Módulo `customers` (`src/modules/customers/`)**
- CRUD completo (`GET/POST/PUT/DELETE /tenant/customers`), com
`TenantGuard`.
- Campos obrigatórios: `razao_social`. Demais opcionais conforme
schema (`documento`, `email`, `telefone_whatsapp`, endereço etc).
- Validar `documento` como CPF ou CNPJ (formato, não precisa validar
dígito verificador na primeira versão — deixar comentário no código
indicando que pode evoluir).
- `GET /tenant/customers` — listagem com busca por nome/documento,
filtro por `ativo`, paginação.
- Respeitar `limite_customers` do plano do tenant (bloquear criação
além do limite, com mensagem clara).
- Soft delete: `DELETE` não remove a linha, apenas seta `ativo=false`
(histórico de vendas depende desses registros).
2. **Submódulo `customer_contacts`**
- CRUD aninhado em `customers` (`/tenant/customers/:customerId/contatos`).
- Regra: apenas um contato pode ser `principal=true` por customer —
ao marcar um novo como principal, desmarcar o anterior
automaticamente (numa transação).
3. **Módulo `products_services` (`src/modules/products/`)**
- CRUD completo (`GET/POST/PUT/DELETE /tenant/produtos`), com
`TenantGuard`.
- `tipo` obrigatório (`produto` ou `servico`); `valor_venda`
obrigatório e > 0.
- `sku` único por tenant quando informado (respeitar constraint do
banco, retornar erro 409 amigável em caso de conflito).
- `GET /tenant/produtos` — listagem com filtro por `tipo`, `ativo`,
busca por nome/sku.
- Soft delete igual a customers (`ativo=false`) — produto usado em
venda antiga não pode sumir do histórico.
4. **Testes de RLS aplicados a esta etapa**
- Confirmar (teste automatizado) que um tenant não consegue, por
nenhum endpoint criado aqui, ler/editar/excluir customer ou produto
de outro tenant — mesmo sabendo o UUID exato do registro alheio
(deve retornar 404, não 403, para não vazar existência do recurso).
## Critérios de aceite
- [ ] CRUD de customers funcional, com soft delete e busca
- [ ] Criar customer além do `limite_customers` do plano retorna erro
claro
- [ ] CRUD de contatos aninhado funciona, e a regra de contato principal
único é respeitada
- [ ] CRUD de produtos/serviços funcional, com soft delete e busca
- [ ] SKU duplicado no mesmo tenant retorna erro 409 amigável; SKU igual
em tenants diferentes é permitido
- [ ] Teste automatizado confirma que tentar acessar customer/produto de
outro tenant retorna 404
- [ ] Todos os endpoints desta etapa são inacessíveis com token master
"puro" sem contexto de tenant (ou documentar explicitamente se o
master deve conseguir acessar em modo de suporte — decisão a
confirmar antes de implementar)
+85
View File
@@ -0,0 +1,85 @@
# Etapa 6/8 — Vendas, Parcelas e Dashboards
## Contexto do projeto
Continuação do **SeConectai Vendas**. Já existem: setup, auth com RLS,
painel master, integrações Evolution/e-mail, e CRUD de customers e
produtos/serviços no painel do tenant.
Esta etapa implementa o fluxo de vendas propriamente dito: registrar uma
venda com itens, gerar parcelas a receber, e expor os dashboards de
acompanhamento (resumo semanal/mensal, total recebido, total a receber).
## Tarefas
1. **Módulo `sales` (`src/modules/sales/`)**
- `POST /tenant/vendas` — cria venda (`sales`) com seus itens
(`sale_items`) em uma única transação:
- Recebe `customer_id`, lista de itens
(`product_service_id`, `quantidade`, `valor_unitario` — permitir
sobrescrever o valor do catálogo caso tenha desconto pontual),
`desconto` geral, `observacoes`, `data_venda`.
- Calcula `valor_total` de cada item e da venda (soma dos itens
menos desconto).
- Recebe também a definição das parcelas (`sale_payments`): número
de parcelas, ou lista explícita de `{valor, vencimento}` — validar
que a soma das parcelas bate com `valor_total`.
- Validar que `customer_id` e todos os `product_service_id`
pertencem ao tenant autenticado (RLS já ajuda, mas validar
explicitamente para retornar erro de negócio claro em vez de erro
de banco).
- `GET /tenant/vendas` — listagem com filtros por `status`,
`customer_id`, período (`data_venda`), paginação.
- `GET /tenant/vendas/:id` — detalhe completo (itens + parcelas).
- `PATCH /tenant/vendas/:id/status` — transição de status
(`orcamento` → `confirmada` → `concluida`, ou `cancelada`).
Cancelar uma venda deve cancelar também as parcelas pendentes
(`sale_payments.status = 'cancelado'`).
- `PUT /tenant/vendas/:id` — editar venda em status `orcamento`
apenas (venda `confirmada` não deve ser editável em itens/valores —
só cancelada e recriada, para preservar histórico financeiro).
2. **Módulo `sale_payments` (parcelas)**
- `GET /tenant/recebimentos` — listagem de parcelas com filtros por
`status`, período de vencimento, `customer_id`; usar a view
`vw_recebimentos` como base.
- `PATCH /tenant/recebimentos/:id/marcar-pago` — marca parcela como
paga (`pago_em = now()`, `forma_pagamento`).
- Endpoint (preparando Etapa 7): `POST
/tenant/recebimentos/atualizar-atrasadas` — marca como `atrasado`
toda parcela `pendente` com `vencimento` no passado (vira job
agendado na Etapa 7, aqui só o endpoint manual).
3. **Dashboard do tenant**
- `GET /tenant/dashboard/resumo-semanal` e
`GET /tenant/dashboard/resumo-mensal` — usando as views
`vw_resumo_vendas_semanal` / `vw_resumo_vendas_mensal`, com filtro
de período opcional.
- `GET /tenant/dashboard/financeiro` — retorna: total recebido no mês
corrente, total a receber (pendente + atrasado), total atrasado
isolado (para destacar visualmente), próximas N parcelas a vencer.
4. **Regras de consistência**
- Toda alteração relevante em `sales`/`sale_payments` grava em
`audit_logs` (`usuario_tipo='tenant_user'`, incluindo `usuario_id` e
`tenant_id`).
- Usar transações do Prisma em toda operação que mexe em mais de uma
tabela (venda + itens + parcelas) para evitar estado inconsistente.
## Critérios de aceite
- [ ] Criar venda com itens e parcelas funciona em uma única transação;
falha em qualquer parte reverte tudo
- [ ] Soma das parcelas é validada contra o valor total da venda
- [ ] Cancelar venda cancela as parcelas pendentes associadas
- [ ] Venda confirmada não pode ser editada em itens/valores (só via
cancelamento)
- [ ] Marcar parcela como paga reflete corretamente no dashboard
financeiro
- [ ] Dashboards semanal/mensal retornam números batendo com os dados de
teste inseridos manualmente
- [ ] Endpoint de atualizar atrasadas muda status corretamente das
parcelas vencidas
- [ ] Todas as queries usam RLS corretamente — venda de um tenant nunca
aparece para outro (reforçar teste de isolamento desta etapa
também)
+89
View File
@@ -0,0 +1,89 @@
# Etapa 7/8 — Motor de Alertas (BullMQ)
## Contexto do projeto
Continuação do **SeConectai Vendas**. Já existem: setup, auth com RLS,
painel master completo, integrações Evolution API/e-mail com serviço
central de envio (`AlertDispatchService`), CRUD de customers/produtos, e
o módulo completo de vendas/parcelas/dashboards.
Esta é a etapa que **automatiza** o que até agora só existia como
endpoint manual: verificação de vencimentos e disparo de alertas, nos
dois níveis do sistema (master→tenant e tenant→customer).
## Tarefas
1. **Infraestrutura de filas**
- Configurar `@nestjs/bullmq` conectado ao Redis já disponível no
`docker-compose`.
- Criar duas filas: `alertas-mensalidade` (master→tenant) e
`alertas-cobranca` (tenant→customer).
- Criar um processo/worker separado do processo web principal (pode
ser o mesmo container em dev, mas documentar como se separaria em
produção — ex: `main.ts` vs `worker.ts`, ou um segundo serviço no
`docker-compose` chamado `worker` rodando o mesmo build com um
comando diferente).
2. **Módulo `alert-templates`**
- CRUD de `alert_templates`:
- Nível master (`MasterGuard`): templates padrão da plataforma
(`tenant_id = null`), tipo `mensalidade_tenant`.
- Nível tenant (`TenantGuard`, role `tenant_admin`): templates
próprios, tipo `cobranca_cliente` (e outros tipos que fizerem
sentido, ex: `venda_confirmada`, `lembrete_vencimento`). Se o
tenant não tiver template próprio para um tipo/canal, usar o
template padrão do master como fallback.
- Implementar renderização de placeholders (`{{nome}}`, `{{valor}}`,
`{{vencimento}}`, etc — listar todos os placeholders suportados por
tipo de template em um arquivo de constantes, documentado).
3. **Job diário: `mensalidade_tenant`**
- Cron (ex: todo dia às 8h, horário configurável via env) que:
1. Roda a lógica equivalente ao endpoint
`POST /master/faturas/atualizar-atrasadas` (marca faturas
vencidas como `atrasado`, tenant vira `inadimplente`).
2. Para toda `tenant_faturas` com vencimento em N dias (configurável,
ex: 3 dias antes) ou já vencida e ainda sem alerta hoje, enfileira
um job na fila `alertas-mensalidade` com os dados necessários.
- Worker da fila `alertas-mensalidade`: busca template
(`mensalidade_tenant`, canal `whatsapp` e/ou `email` conforme
configurado), renderiza, chama `AlertDispatchService`, grava
resultado em `alert_logs`, atualiza `tenant_faturas.alerta_enviado_em`.
4. **Job diário: `cobranca_cliente`** (por tenant)
- Mesmo padrão do job acima, mas iterando `sale_payments` de todos os
tenants ativos: marca atrasadas, enfileira alerta para parcelas
próximas do vencimento ou já atrasadas (respeitando
`alerta_enviado_em` para não notificar duas vezes no mesmo dia).
- Usa a instância WhatsApp e config de e-mail **do tenant dono da
venda**, não a do master.
5. **Idempotência e resiliência**
- Garantir que um mesmo job não dispare o mesmo alerta duas vezes
(checar `alerta_enviado_em` antes de enfileirar, e novamente antes
de enviar, dentro do worker, para lidar com concorrência).
- Configurar retry com backoff exponencial no BullMQ para falhas
transitórias de envio (ex: Evolution API fora do ar), com limite de
tentativas (ex: 3) e log do erro final em `alert_logs.erro`.
- Endpoint de observabilidade: `GET /master/alertas/logs` e
`GET /tenant/alertas/logs` para consultar `alert_logs` com filtros
(canal, status, período).
## Critérios de aceite
- [ ] Worker processa as duas filas de forma independente do processo
web (documentado como escalar workers separadamente)
- [ ] Template do tenant sobrepõe o padrão do master quando existe; usa
fallback quando não existe
- [ ] Placeholders são substituídos corretamente na renderização
- [ ] Job diário marca faturas/parcelas atrasadas corretamente
- [ ] Alerta de mensalidade chega no WhatsApp/e-mail do tenant em teste
controlado (vencimento simulado para "hoje")
- [ ] Alerta de cobrança chega no WhatsApp/e-mail do customer, usando a
instância/config do tenant correto (testar com 2 tenants
diferentes)
- [ ] Mesmo alerta não é disparado duas vezes no mesmo dia (testar
rodando o job duas vezes seguidas)
- [ ] Falha de envio é reprocessada com retry e, após esgotar tentativas,
fica registrada em `alert_logs` com erro
- [ ] Endpoints de consulta de logs funcionam com os filtros esperados
+87
View File
@@ -0,0 +1,87 @@
# Etapa 8/8 — Personalização (Whitelabel) e Ajustes Finais
## Contexto do projeto
Etapa final do **SeConectai Vendas**. Todo o backend de negócio já está
pronto: auth com RLS, painel master (tenants/planos/faturas), integrações
WhatsApp/e-mail, customers/produtos, vendas/parcelas/dashboards, e o
motor de alertas automatizado.
Esta etapa cobre a personalização visual por tenant (whitelabel) e uma
revisão geral de segurança/qualidade antes de considerar o backend pronto
para receber um frontend.
## Tarefas
1. **Upload e gestão de logo**
- `POST /tenant/perfil/logo` (`TenantGuard`, role `tenant_admin`) —
recebe upload de imagem (multipart), valida tipo (`png`/`jpg`/`svg`)
e tamanho máximo (ex: 2MB), armazena (definir estratégia: volume
Docker local para MVP, com estrutura pronta para trocar por S3/
object storage depois — abstrair atrás de um `StorageService` com
interface clara) e atualiza `tenants.logo_url`.
- `DELETE /tenant/perfil/logo` — remove logo, volta ao padrão da
plataforma.
- `PUT /tenant/perfil/cor` — atualiza `tenants.cor_primaria` (validar
formato hex `#RRGGBB`).
- `GET /tenant/perfil` — retorna dados de personalização atuais
(logo_url, cor_primaria, nome_fantasia) para o frontend montar o
tema.
- Endpoint público (sem auth, ou com auth leve): `GET
/public/tenants/:slugOuId/tema` — para a tela de login do tenant já
carregar logo/cor antes do usuário autenticar (avaliar se precisa
de um campo `slug` único e amigável em `tenants`; se sim, criar
migration incremental para adicionar essa coluna).
2. **Revisão de segurança geral**
- Checklist a validar/corrigir no código existente:
- Rate limiting está ativo em todos os endpoints de auth e nos
endpoints públicos.
- CORS configurado explicitamente (não `*`) — origem(ns) do
frontend via variável de ambiente.
- Helmet (`@nestjs/helmet` ou `helmet` direto) configurado para
headers de segurança padrão.
- Nenhum segredo (API keys, senhas SMTP, `ENCRYPTION_KEY`,
`JWT_SECRET`) hardcoded ou logado em texto puro em nenhum lugar do
código.
- Todos os inputs de API validados via DTOs com `class-validator`
(checar que nenhum endpoint aceita payload sem validação).
- Paginação obrigatória (com limite máximo) em todas as listagens,
para evitar consultas sem limite.
3. **Documentação da API**
- Configurar Swagger/OpenAPI (`@nestjs/swagger`) documentando todos os
endpoints das 8 etapas, agrupados por módulo/tag, com exemplos de
request/response e indicação clara de quais exigem `MasterGuard` vs
`TenantGuard`.
4. **Ambiente de produção**
- Revisar `Dockerfile` e `docker-compose.yml` para produção:
- Imagem final enxuta (multi-stage, sem devDependencies).
- Variáveis sensíveis via secrets/env, nunca commitadas.
- Healthchecks configurados em todos os serviços.
- Documentar no README o processo de deploy (build, migrations em
produção, rodar migration de policies RLS, subir worker
separadamente do web).
5. **Testes finais de ponta a ponta**
- Escrever um teste E2E que cobre o fluxo completo mínimo: master
cria tenant → tenant faz login → cadastra customer e produto → faz
uma venda com parcelas → marca uma parcela como paga → confere
dashboard financeiro refletindo o pagamento.
## Critérios de aceite
- [ ] Upload/remoção de logo e alteração de cor funcionam e refletem em
`GET /tenant/perfil`
- [ ] Endpoint público de tema retorna logo/cor sem exigir autenticação
- [ ] CORS, Helmet e rate limiting revisados e ativos
- [ ] Nenhum segredo aparece em log ou resposta de API (revisão manual)
- [ ] Swagger disponível (ex: `/docs`) cobrindo todos os módulos, com
guards indicados
- [ ] Build de produção do Docker gera imagem sem dependências de
desenvolvimento
- [ ] Teste E2E do fluxo completo passa do início ao fim
- [ ] README final consolidado: como rodar em dev, como fazer deploy em
produção, como rodar migrations (incluindo policies de RLS) em um
ambiente novo
+601
View File
@@ -0,0 +1,601 @@
# SeConectai Vendas — Controle de Progresso
Este arquivo é o **estado oficial do projeto**. Ele deve ser mantido
atualizado pela IA que está construindo o sistema e consultado antes de
qualquer nova etapa começar.
## Instruções obrigatórias para a IA
1. **Antes de iniciar qualquer etapa**, peça este arquivo ao usuário (se
ele não for fornecido junto com o prompt da etapa) e leia a seção
"Status Geral" para confirmar que a etapa anterior está 100% concluída.
2. **Não inicie uma etapa** se a etapa anterior tiver qualquer item de
critério de aceite sem marcar. Se estiver incompleta, avise o usuário
e pergunte se deve terminar a etapa pendente antes de avançar.
3. **Ao concluir uma etapa**, marque com `[x]` cada item da checklist
correspondente (só marque o que foi de fato implementado e testado —
não marque por otimismo) e atualize:
- A seção "Status Geral" (etapa concluída, data)
- O campo "Observações" da etapa, se houver algo relevante para as
próximas etapas (ex: decisões tomadas, desvios do prompt original,
pendências técnicas deixadas para depois)
4. **Devolva este arquivo atualizado** ao usuário ao final de cada etapa,
junto com o restante da entrega, para que ele possa repassar na
próxima sessão/etapa.
5. Se o usuário pedir para pular uma etapa ou mudar a ordem, isso deve
ficar explicitamente registrado na seção "Observações" da etapa
afetada, com o motivo.
## Status Geral
| # | Etapa | Status | Concluída em |
|---|-------|--------|---------------|
| 1 | Setup do Projeto | Concluída | 2026-08-12 |
| 2 | Autenticação e RLS | Concluída | 2026-08-13 |
| 3 | Painel Master (tenants/planos/faturas) | Concluída | 2026-08-13 |
| 4 | Integrações Evolution API + E-mail | Concluída | 2026-08-14 |
| 5 | Painel Tenant (clientes/produtos) | [ ] Não iniciada | |
| 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
- [x] `docker compose up` sobe `app`, `postgres` e `redis` sem erro
- [x] `GET /health` retorna 200 com status da API e status da conexão com o banco
- [x] Todas as tabelas, enums, views e índices do schema fornecido existem no banco após a migration
- [x] As policies de RLS estão criadas
- [x] `.env.example` documenta todas as variáveis necessárias
- [x] `npm run lint` e `npm run test` rodam sem erro
- [x] README explica como subir o ambiente do zero
**Observações:**
- Estrutura NestJS, `ConfigModule`/validação de env, `PrismaService`/`PrismaModule`
e o módulo `health` (controller + service + spec) já existiam de uma sessão
anterior; esta sessão completou o restante da etapa: `Dockerfile` multi-stage
(stages `deps`, `dev`, `build`, `prod-deps`, `production`), `docker-compose.yml`,
`docker-compose.override.yml` (hot-reload dev), `.env.example`, `README.md` e as
migrations do Prisma.
- `prisma/schema.prisma` já era a tradução 1:1 do schema SQL fornecido. Construtos
que o Prisma DSL não expressa nativamente (views de dashboard, `CHECK` constraint
de `dia_vencimento_mensalidade`, índices parciais e as `CREATE POLICY` de RLS)
foram implementados em uma migration manual separada
(`prisma/migrations/20260812161100_rls_views_constraints/`), aplicada com
`prisma migrate deploy` logo após a migration inicial gerada automaticamente
(`prisma migrate dev --name init`). Confirmado via `\dt`, `\dv`, `\d+ tenants` e
`\d+ customers` no psql: 22 tabelas, 6 enums, 3 views, o `CHECK` e as 9 policies
de isolamento por tenant.
- As imagens Alpine (`node:20-alpine`) precisaram de `apk add openssl` em todos os
estágios que rodam `prisma generate`/`node`, pois o Prisma não detecta
libssl/openssl no Alpine puro e cai num engine binário incompatível (aviso
"Prisma failed to detect the libssl/openssl version").
- `@types/supertest` estava fixado em `^6.0.2` no `package.json`, mas o `npm
install` resolveu `supertest@7.2.2` (com `superagent@10.x` como dependência) —
uma combinação incompatível com os tipos da v6, quebrando `npm run lint` com
erros de "Unsafe call"/"not callable" em `test/health.e2e-spec.ts`. Corrigido
atualizando para `@types/supertest@^7.0.3` e trocando `import * as request from
'supertest'` por `import request, { Response } from 'supertest'` (a v7 dos tipos
exige import default por causa da interop com `esModuleInterop`).
- `package-lock.json` foi gerado nesta sessão (não existia antes); `npm ci` no
Dockerfile depende dele.
- Ambiente de execução não tinha Node/npm no host, e os containers Docker deste
sandbox não têm acesso de rede via bridge padrão (bloqueado no nível do host) —
só o modo `--network host` alcança a internet. Isso só afetou os comandos usados
para *verificar* a etapa nesta sessão (`docker build --network host`, `docker
run --network host ... npm install`); `Dockerfile`/`docker-compose.yml`
entregues não dependem disso e devem funcionar normalmente em qualquer ambiente
Docker com rede padrão.
---
## Etapa 2 — Autenticação, Autorização e Row Level Security
- [x] Login master e login tenant funcionam e retornam access+refresh token válidos
- [x] Refresh token rotaciona corretamente; token antigo rejeitado após rotação
- [x] Logout revoga o refresh token
- [x] Fluxo de esqueci minha senha gera token, valida expiração/uso único
- [x] `MasterGuard` e `TenantGuard` bloqueiam corretamente rotas erradas
- [x] `RolesGuard` bloqueia usuário sem permissão de papel
- [x] Teste de integração prova isolamento entre tenants via RLS
- [x] Bloqueio temporário após tentativas de login falhas
- [x] Todas as senhas no banco estão como hash argon2id
**Observações:**
- Módulo `src/modules/auth/` completo: `AuthController`/`AuthService`
(`master/login`, `tenant/login`, `refresh`, `logout`, `password/forgot`,
`password/reset`), `JwtStrategy` (passport-jwt), guards (`JwtAuthGuard`,
`MasterGuard`, `TenantGuard`, `RolesGuard`), decorators (`@CurrentUser()`,
`@Roles(...)`), `password.util.ts` (argon2id, parâmetros OWASP 2024:
memoryCost 19456/timeCost 2/parallelism 1, + validação de força mínima) e
`token.util.ts` (refresh/reset tokens opacos, guardados só como hash
sha256 — nunca em texto puro).
- **Desvio do prompt original, registrado em detalhe no histórico abaixo:**
o prompt só previa uma segunda connection string (`DATABASE_URL_MASTER`,
role `app_master BYPASSRLS`) para o master. Foi necessário criar também
uma terceira role/connection string (`app_tenant`/`DATABASE_URL_TENANT`)
para que o RLS realmente filtre as queries de `tenant_user` — a role de
`DATABASE_URL` (`seconectai`) é a superuser bootstrap do Postgres
(criada pelo `POSTGRES_USER` do docker-compose desde a Etapa 1), e
superusers sempre ignoram RLS independente das policies existirem.
Ambas as roles são criadas na migration manual
`prisma/migrations/20260813120000_auth_app_roles/`.
- Mecanismo de RLS por request: `TenantRlsInterceptor` (registrado
globalmente via `APP_INTERCEPTOR`) detecta requests autenticadas como
`tenant_user` e, através de `TenantContextService`, abre uma transação
Prisma na conexão `app_tenant` executando
`SELECT set_config('app.current_tenant_id', '<tenant_id_do_jwt>', true)`
(equivalente a `SET LOCAL`, mas parametrizável — evita interpolar string
na query). O `tenant_id` usado vem **sempre** do JWT da sessão
(`request.user.tenant_id`, populado pelo `JwtStrategy`/`TenantGuard`),
nunca de body/query — verificado no teste de isolamento (endpoint
`GET /auth/tenant/users` ignora um `?tenant_id=<outro-tenant>` forçado
na query string).
- Requests master usam `PrismaMasterService` (conexão `app_master`,
bypassa RLS) — não passam pelo `TenantRlsInterceptor`.
- Endpoints de demonstração/utilidade adicionados para exercitar os guards
e o RLS de ponta a ponta (não fazem parte do prompt original, mas são
necessários para provar os critérios de aceite via HTTP real, e ficam
como funcionalidade útil para as próximas etapas): `GET /auth/me`
(qualquer usuário autenticado), `GET /auth/master/admins` (só master),
`GET /auth/tenant/users` (só `tenant_admin`, lista usuários do próprio
tenant via RLS).
- `email` de `tenant_users` é único só por tenant (`UNIQUE(tenant_id,
email)`, não globalmente) — login/forgot-password por e-mail, sem mais
contexto, não conseguem resolver com segurança um e-mail que colida
entre dois tenants distintos. Decisão: nesse caso (0 ou >1 tenant_user
com o e-mail informado), tratar como credencial inválida em vez de
arriscar autenticar na conta errada (`AuthService.uniqueTenantUserByEmail`).
Fica registrado como limitação conhecida para uma etapa futura resolver
(ex: login por subdomínio/slug do tenant).
- `POST /auth/password/forgot` sempre responde com a mesma mensagem
genérica, exista ou não a conta — prática padrão para não vazar quais
e-mails estão cadastrados (não pedido explicitamente no prompt, mas não
conflita com nenhum critério de aceite).
- Reuso de um refresh token já rotacionado (revogado) é tratado como
indício de roubo de token: além de rejeitar a tentativa, revoga toda a
família de tokens ativos daquele usuário (defesa em profundidade, além
do pedido mínimo do prompt).
- `argon2` é um módulo nativo (binding C++) sem prebuild musl/Alpine para
esta versão — precisou adicionar `python3 make g++` aos estágios `deps`
e `prod-deps` do `Dockerfile` (ausentes na imagem final `production`,
só usados para compilar durante o build).
- Teste e2e `test/auth.e2e-spec.ts` cobre todos os critérios de aceite
contra Postgres real (via `docker compose`): login (master/tenant,
sucesso/falha), rotação de refresh token (incluindo o cenário de reuso
de token revogado revogando a família toda), logout, forgot/reset
(token único-uso, expiração, senha fraca rejeitada), os quatro guards
(`JwtAuthGuard`/`MasterGuard`/`TenantGuard`/`RolesGuard`) nos dois
sentidos, isolamento por RLS (dois tenants reais, tentativa de forçar
`tenant_id` de outro tenant via query string) e bloqueio por força
bruta (5 tentativas falhas → 429 mesmo com senha correta na 6ª).
Rodado com `docker compose exec app npx prisma migrate deploy` seguido
de `npx jest --config ./test/jest-e2e.json` — 21/21 testes passando.
`npm run lint`, `tsc --noEmit` e `npm run build` (produção) também
passam sem erros; smoke test manual via `curl` contra a imagem
`production` real confirmou login + guards funcionando fim a fim.
---
## Etapa 3 — Painel Master: Tenants, Planos e Faturas
- [x] CRUD de planos funcional e validado
- [x] Criar tenant também cria o primeiro `tenant_admin` funcional
- [x] Listagem de tenants com filtro por status e busca funciona
- [x] Suspender/cancelar tenant bloqueia login dos usuários dele
- [x] Geração de faturas do mês não duplica fatura existente
- [x] Marcar fatura como paga reverte tenant `inadimplente` → `ativo`
- [x] Marcar fatura como atrasada muda tenant para `inadimplente`
- [x] Dashboard master retorna números corretos
- [x] Ações relevantes aparecem em `audit_logs`
- [x] Endpoints inacessíveis com token de tenant_user
**Observações:**
- Módulo `src/modules/planos/` (`PlanosController`/`PlanosService`, CRUD completo em
`/master/planos`) e módulo `src/modules/tenants/` (`TenantsController`/`TenantsService`
em `/master/tenants`, `FaturasController`/`FaturasService` em `/master/faturas`,
`DashboardController`/`DashboardService` em `/master/dashboard`, todos agrupados no
mesmo `TenantsModule`). Todos os controllers desta etapa usam
`@UseGuards(JwtAuthGuard, MasterGuard)` a nível de classe e a conexão `PrismaMasterService`
(`app_master`, BYPASSRLS) — `tenants`/`planos`/`tenant_faturas`/`audit_logs` não têm RLS
habilitado (só tabelas por-tenant têm), mas a conexão master é usada por consistência
arquitetural com a Etapa 2.
- Auditoria centralizada em `src/common/audit-log.util.ts` (`writeAuditLog(client, params)`,
função simples — não um serviço injetável — para poder receber tanto `PrismaMasterService`
quanto o `tx` de uma transação em andamento, seguindo o mesmo padrão de utilitário puro já
usado em `auth/password.util.ts`/`token.util.ts`). Toda operação de escrita desta etapa
(criar/atualizar plano, excluir/desativar plano, criar tenant, atualizar tenant, mudar
status, gerar faturas do mês, marcar fatura paga, atualizar atrasadas) grava a auditoria
**dentro da mesma transação** da operação principal, para que auditoria e efeito nunca
fiquem dessincronizados.
- **Desvio do prompt original:** o prompt lista `status` como um dos campos que o `PUT
/master/tenants/:id` atualiza. Implementado com `status` **fora** do `UpdateTenantDto` —
toda transição de status passa exclusivamente por `PATCH /master/tenants/:id/status`
(`TenantsService.changeStatus`), que é o único caminho que grava a auditoria da mudança
(`de`/`para`) e aplica o bloqueio de acesso. Deixar `status` editável também via PUT
abriria um caminho para mudar o status sem passar por essa lógica.
- **Desvio/extensão do prompt original, relevante para segurança:** o prompt pedia bloquear
login após suspender/cancelar um tenant ("checar status do tenant no login e no
`TenantGuard`"). Login já checava (Etapa 2); nesta etapa o `TenantGuard`
(`auth/guards/tenant.guard.ts`) passou a consultar o status do tenant a cada request
(antes só populava `request.tenant_id`, sem checar nada) — um access token emitido antes
da suspensão continua criptograficamente válido até expirar (15 min por padrão), então sem
essa checagem o usuário suspenso continuaria acessando a API normalmente até o token
expirar. Isso adiciona uma consulta a `tenants` por request autenticada como tenant_user
(não cacheada por ora — aceitável no volume atual, pode virar ponto de otimização numa
etapa futura). Como defesa em profundidade adicional (mesmo espírito da revogação de
família de refresh tokens da Etapa 2), `changeStatus` também revoga todos os refresh
tokens ativos dos `tenant_users` do tenant ao suspender/cancelar.
- `TENANT_BLOCKED_STATUSES` (antes uma constante privada em `auth.service.ts`) foi exportado
para ser a única fonte de verdade de quais status bloqueiam acesso, reusada tanto no login
(Etapa 2) quanto no `TenantGuard` (Etapa 3) — evita as duas listas divergirem no futuro.
- Exclusão de plano (`DELETE /master/planos/:id`): interpretado "em uso por algum tenant
ativo" como qualquer tenant com `plano_id` apontando pra ele e `status != 'cancelado'`
(não só `status = 'ativo'`) — um tenant `trial`/`inadimplente`/`suspenso` também depende do
plano estar íntegro. Nesse caso a exclusão é convertida em desativação (`ativo = false`,
plano continua existindo e servindo os tenants já associados) e a resposta indica
`{deletado:false, desativado:true}`; sem tenants associados (exceto cancelados), a linha é
excluída de fato (`{deletado:true, desativado:false}`). Um plano desativado não pode ser
associado a tenants novos (`create`/`update` de tenant checam `plano.ativo`), mas tenants
já associados a ele continuam funcionando normalmente (não há "downgrade forçado").
- Senha provisória do primeiro `tenant_admin`: se o payload de `POST /master/tenants` não
informar `admin.senha`, uma é gerada (`tenants/provisional-password.util.ts`,
`randomBytes(9).toString('base64url')`) e devolvida em texto puro **só na resposta desse
request** (nunca fica logada nem persistida em texto puro — só o hash argon2id via
`password.util.ts`, igual ao resto do sistema). Envio por e-mail é a Etapa 4
(Integrações), como o prompt já previa.
- `POST /master/faturas/gerar-mes` usa `createMany({ skipDuplicates: true })` em vez de
checar linha a linha se já existe fatura pro `(tenant_id, referencia)` — o
`UNIQUE(tenant_id, referencia)` do schema garante a idempotência a nível de banco; chamar
de novo no mesmo mês (ou re-chamar manualmente antes da automação virar cron na Etapa 7) é
seguro.
- `POST /master/faturas/atualizar-atrasadas` só rebaixa pra `inadimplente` tenants que
estejam `ativo` no momento — tenants já `suspenso`/`cancelado` com fatura vencida viram
`atrasado` na fatura, mas não voltam a virar `inadimplente` (não faria sentido reativar
parcialmente um tenant cancelado só por causa de uma fatura antiga).
- Dashboard (`GET /master/dashboard`): `mrr` calculado somando `planos.valor_mensal` dos
tenants `ativo` em memória com `Prisma.Decimal` (não dá pra fazer um `groupBy`/`aggregate`
do Prisma somando um campo de tabela relacionada em uma única query) — evita o erro de
arredondamento de ponto flutuante que uma soma em `number` teria com valores monetários.
`total_recebido_mes`/`total_em_aberto` usam `aggregate({_sum})` diretamente. Todos os
valores voltam formatados com `.toFixed(2)`.
- Variável de ambiente nova: `TENANT_TRIAL_DIAS` (default 14, dias de trial concedidos na
criação do tenant), adicionada a `env.validation.ts` e `.env.example`, mesmo padrão das
demais variáveis opcionais com default.
- `package.json`: `test:e2e` passou a rodar com `--runInBand`. Motivo: a partir desta etapa
os testes e2e leem estado **global** do painel master (contagem de tenants por status,
MRR, faturas do mês corrente) — com múltiplos arquivos de teste (`auth.e2e-spec.ts` e o
novo `master.e2e-spec.ts`) rodando em paralelo (padrão do Jest), um teste que cria um
tenant `ativo` num arquivo poderia contaminar uma asserção de contagem global no outro.
`--runInBand` roda os arquivos em série, eliminando essa fonte de flakiness — mesmo assim,
as asserções mais sensíveis (dashboard) comparam o valor antes/depois da operação testada
(delta), em vez do valor absoluto, para não depender de nenhum estado pré-existente no
banco.
- Teste e2e novo, `test/master.e2e-spec.ts`, cobrindo todos os critérios de aceite: CRUD de
planos (validação de `valor_mensal`/limites, exclusão bloqueada por tenant em uso vs.
exclusão real quando não usado), criação de tenant com login do `tenant_admin` gerado
testado de ponta a ponta, listagem com filtro por `status`/busca, mudança de status
bloqueando tanto login novo quanto um token de acesso já emitido (via `TenantGuard`),
geração de faturas idempotente, marcar-pago revertendo `inadimplente`→`ativo` (incluindo
rejeição 409 ao tentar marcar pago de novo), atualizar-atrasadas propagando pra
`tenants.status`, dashboard (com asserções por delta) e bloqueio de todos os grupos de
endpoint com token de `tenant_user`. Rodado com `docker compose exec`-equivalente (`docker
run` num container a partir da imagem `dev` do `Dockerfile`, ligado à mesma rede do
`docker-compose.yml`, já que a imagem `dev` compila os bindings nativos do `argon2` pra
musl/Alpine — o host deste ambiente é glibc e não tem acesso de rede pra build de imagem
sem `--network host`) — `npx prisma migrate status` confirmou que as migrations da Etapa 1
já cobrem todo o schema desta etapa (`planos`/`tenants`/`tenant_faturas`/`audit_logs` já
existiam), então não houve migration nova. `npm run lint`, `npx tsc --noEmit`, `npm run
test` (unit) e `npm run build` passam sem erros; `npx jest --config ./test/jest-e2e.json
--runInBand` — 43/43 testes passando (os 21 da Etapa 2 + 1 do health + 21 novos desta
etapa). Build de produção (`docker build --target production`) também verificado, com
smoke test manual via `curl` confirmando `/health` (200) e bloqueio de `/master/planos`
sem token (401) contra a imagem `production` real.
---
## Etapa 4 — Integrações: Evolution API e E-mail
- [x] Master cadastra config da Evolution API e testa conexão
- [x] `api_key`/senha SMTP nunca aparecem em texto puro nas respostas
- [x] Tenant cria instância WhatsApp, obtém QR code, conecta
- [x] Criar instância além do limite do plano retorna erro claro
- [x] Teste de envio de WhatsApp funciona
- [x] Tenant configura SMTP próprio ou usa o padrão da plataforma
- [x] Teste de envio de e-mail funciona nos dois modos
- [x] `EncryptionService` usado de forma consistente para segredos
**Observações:**
- Módulos novos: `src/modules/evolution/` (`EvolutionController`/`EvolutionService`/`EvolutionApiClient`,
CRUD de `evolution_api_config` em `/master/evolution-config` + `testar-conexao`),
`src/modules/whatsapp/` (`WhatsappController`/`WhatsappService`, instâncias em
`/tenant/whatsapp/instancias`), `src/modules/email-config/` (`MasterEmailConfigController`/`Service`
em `/master/email-config`, `TenantEmailConfigController`/`Service` em `/tenant/email-config`,
`EmailService` com `nodemailer`) e `src/modules/alert-dispatch/` (`AlertDispatchService`, preparando a
Etapa 7). Todos os controllers master usam `@UseGuards(JwtAuthGuard, MasterGuard)` +
`PrismaMasterService`, mesmo padrão da Etapa 3; os controllers tenant usam
`@UseGuards(JwtAuthGuard, TenantGuard, RolesGuard)` + `@Roles('tenant_admin')`.
- `EncryptionService` novo em `src/common/encryption.service.ts` (AES-256-GCM, `node:crypto` nativo, sem
dependência extra), registrado em `EncryptionModule` (`@Global()`, mesmo padrão do `PrismaModule`).
Formato gravado nas colunas `*_cripto`: `iv:authTag:conteudo` (hex, `:`-separado). A chave de
`ENCRYPTION_KEY` (env, nova variável obrigatória, `@MinLength(32)` em `env.validation.ts`, mesmo padrão
de `JWT_SECRET`) é reduzida a 32 bytes via SHA-256 antes de virar a chave AES-256 — assim qualquer
string longa o suficiente serve, sem exigir um valor hex/base64 de tamanho exato. Mascaramento para
exibição (`src/common/mask.util.ts`, `maskSecret`) sempre usa largura fixa de asteriscos + últimos 4
caracteres do valor decifrado — nunca revela o tamanho real do segredo. `api_key_cripto`/
`smtp_senha_cripto` são explicitamente excluídos do objeto de resposta (destructuring) em todo
endpoint que retorna essas entidades — conferido em teste e2e (`api_key_cripto`/`smtp_senha_cripto`
sempre `undefined` no payload).
- **Decisão de arquitetura, para registrar com destaque:** `AlertDispatchService` (e, por baixo dele,
`EmailService`) usam `PrismaMasterService` com filtro manual de `tenant_id` em toda query, em vez de
`TenantContextService`/RLS — diferente do resto do código tenant desta etapa
(`WhatsappService`/`TenantEmailConfigService`, que usam `tenantContext.getTx()` normalmente, já que
`tenant_whatsapp_instances`/`tenant_email_config` têm RLS habilitado desde a migration da Etapa 2).
Motivo: `AlertDispatchService` é explicitamente "o serviço central de envio, preparando a Etapa 7" — o
worker BullMQ da Etapa 7 vai chamá-lo fora de qualquer request HTTP, sem JWT/tenant autenticado e,
portanto, sem nenhum contexto de `AsyncLocalStorage` ativo (`TenantContextService.getTx()` lançaria
"chamado fora de um contexto de tenant ativo"). Usar `PrismaMasterService` desde já evita ter que
reescrever esse service na Etapa 7. Como esse service nunca recebe `tenant_id` de input do usuário
final (sempre do JWT via controller, e futuramente do job da fila), o filtro manual é seguro, mas fica
registrado aqui porque é a única exceção ao padrão RLS-first do resto do projeto.
- **Decisão de wiring do Nest, sem impacto de comportamento:** `EmailService` (o `nodemailer`
propriamente dito) vive num módulo próprio, `EmailModule` (`src/modules/email-config/email.module.ts`),
separado do `EmailConfigModule` (CRUD). Motivo: `AlertDispatchModule` precisa do `EmailService`, e
`EmailConfigModule` precisa do `AlertDispatchService` (endpoint `testar-envio` do tenant) — um único
módulo cobrindo as duas coisas criaria um ciclo de imports (`EmailConfigModule` → `AlertDispatchModule`
→ `EmailConfigModule`). Grafo final, sem ciclos: `EvolutionModule`/`EmailModule` → `AlertDispatchModule`
→ `WhatsappModule`/`EmailConfigModule`.
- **Desvio do prompt original, pequeno e documentado:** `AlertDispatchService.enviarWhatsapp` recebeu um
quarto parâmetro opcional, `instanceId?: string` (assinatura do prompt previa só
`enviarWhatsapp(tenantId, numero, mensagem)`). Sem `instanceId`, resolve a instância `conectado` mais
recente do tenant (comportamento que a Etapa 7 vai usar). Com `instanceId`, envia especificamente por
aquela instância — usado por `POST /tenant/whatsapp/instancias/:id/testar-envio`, que por natureza
precisa testar uma instância específica (não "uma instância qualquer do tenant"), mantendo ainda assim
o pedido do prompt de "usar esse service nos endpoints de testar envio".
- `EvolutionApiClient` (`src/modules/evolution/evolution-api.client.ts`) implementado contra a
especificação pública REST da Evolution API v2 (`apikey` no header, `/instance/create`,
`/instance/connect/:name`, `/instance/connectionState/:name`, `/instance/logout|delete/:name`,
`/message/sendText/:name`) — mesma versão (`2.3`) já assumida como default no schema
(`evolution_api_config.versao`). **Não há uma instância real da Evolution API disponível neste ambiente
de desenvolvimento/CI** (sem internet dedicada a isso nem servidor de referência) para validar contra o
serviço de verdade — mitigado escrevendo, no teste e2e, um servidor HTTP real e local
(`FakeEvolutionServer`, `node:http` puro, sem dependência nova) que implementa o mesmo contrato e roda
na porta efêmera do próprio processo de teste, exercitando o `EvolutionApiClient` real ponta a ponta
(não mockado) contra um servidor de verdade. Fica como pendência natural confirmar contra uma instância
real da Evolution API antes de operar em produção — mesmo espírito da limitação de rede Docker já
registrada na Etapa 1.
- `tenant_whatsapp_instances.status` é `String` livre no schema (não enum do Prisma) — validado/mapeado a
nível de aplicação em `src/modules/whatsapp/whatsapp-status.util.ts`
(`WhatsappInstanceStatus = 'desconectado' | 'conectando' | 'conectado' | 'erro'`,
`mapEvolutionState()` traduz o `state` da Evolution — `open`/`connecting`/`close` — pros valores em
português pedidos no critério de aceite).
- Tenants não têm campo `slug` no schema. Nome de instância (`tenant-<slug>-<sufixo>`) gerado em
`src/modules/whatsapp/instance-name.util.ts` a partir de `nome_fantasia` (ou `razao_social` como
fallback), com uma tabela de substituição de acentos comuns em português (não
`normalize('NFD')`/regex de marcas diacríticas — evitado por instabilidade de encoding observada ao
gravar esse regex específico neste ambiente) + sufixo hex aleatório de 4 bytes. Unicidade é checada
antes do insert (até 5 tentativas) e reforçada pela constraint `@unique` do schema como último recurso.
- Ao criar uma instância, o tenant não escolhe qual `evolution_api_config` usar (a etapa não previu esse
fluxo) — `WhatsappService` seleciona automaticamente a config `ativo=true` mais antiga
(`orderBy: criado_em asc`) e grava o vínculo em `evolution_config_id`. Assume-se, na prática, uma única
config ativa por vez (mesmo espírito documentado no prompt para `master_email_config`: "geralmente só
um registro ativo, mas mantido como tabela"). Fica como ponto a revisitar se o produto precisar de
múltiplas Evolution APIs simultâneas roteadas por critério de negócio.
- `EmailService.resolverConfig` (usado por `AlertDispatchService.enviarEmail` e, portanto, pelos dois
endpoints de `testar-envio`) resolve o SMTP do tenant quando existe `tenant_email_config` com
`usar_config_padrao=false` (e todos os campos preenchidos — senão `BadRequestException` explícito);
caso contrário, usa o `master_email_config` `ativo=true` mais recente
(`orderBy: criado_em desc` — intencionalmente diferente do `asc` da Evolution config, para permitir
trocar o SMTP padrão da plataforma sem precisar excluir o registro antigo, só desativando o velho e
criando um novo).
- `PUT /tenant/email-config` com `usar_config_padrao=false` exige todos os campos de SMTP via
`@ValidateIf` declarativo no DTO (`UpdateTenantEmailConfigDto`); com `usar_config_padrao=true`, os
campos de SMTP são ignorados pelo service mesmo se enviados (não sobrescreve nem apaga o que já estava
salvo) — permite ao tenant alternar entre padrão/próprio sem perder a config própria já cadastrada.
`GET /tenant/email-config` some com os campos de SMTP próprio quando `usar_config_padrao=true` (só
devolve `{usar_config_padrao:true}`), como pedido no prompt ("ocultar/ignorar os campos").
- Teste e2e novo, `test/integracoes.e2e-spec.ts` (11 testes), cobrindo todos os critérios de aceite:
CRUD de `evolution_api_config` com `api_key`/`smtp_senha` nunca em texto puro (conferido tanto na
resposta HTTP quanto decifrando o valor cru do banco via `EncryptionService` e comparando com o
original), `testar-conexao` contra o `FakeEvolutionServer`, criação de instância WhatsApp + QR code +
mudança de status para `conectado`, limite do plano bloqueando a instância excedente, teste de envio de
WhatsApp nos dois cenários (sucesso e falha simulada pela Evolution API, sempre HTTP 201 com
`{sucesso, erro?}` — nunca lança erro HTTP, como pedido no prompt), remoção de instância, isolamento
por RLS entre tenants (acessar instância de outro tenant retorna 404), CRUD de `master_email_config`,
tenant usando padrão vs. SMTP próprio (incluindo rejeição 400 sem os campos obrigatórios), teste de
e-mail nos dois modos (`nodemailer` mockado via `jest.mock('nodemailer')` — sem SMTP real disponível
neste ambiente — verificando que o `from` usado bate com a config resolvida em cada modo) e bloqueio de
todos os grupos de endpoint com o token errado (master vs. tenant). Rodado com `docker compose up -d
postgres redis` + `prisma migrate deploy` + `jest --config ./test/jest-e2e.json --runInBand` apontando
pro Postgres via `localhost` (porta mapeada do compose) — **54/54 testes e2e passando** (os 43 das
Etapas 1-3 + os 11 novos). `npm run lint`, `npx tsc --noEmit`, `npm run test` (unit, incluindo novo
`src/common/encryption.service.spec.ts`) e `npm run build` também passam sem erros. Build de produção
Docker (`docker build --target production`) verificado com sucesso, com smoke test manual confirmando
todas as rotas novas mapeadas no boot e `/health` (200) / endpoints protegidos sem token (401) contra a
imagem real.
- Duas dependências novas: `nodemailer`/`@types/nodemailer` (envio de e-mail) e `axios` (cliente HTTP
para a Evolution API) — adicionadas via `npm install`, sem `@nestjs/axios` (o projeto não usa nenhum
wrapper de HTTP client até agora; `axios` puro, encapsulado só dentro de `EvolutionApiClient`, seguiu o
mesmo espírito minimalista das dependências já escolhidas nas etapas anteriores).
- Nada relacionado ao código desta etapa, mas registrado por transparência: o ambiente local usado nesta
sessão tinha `node_modules/@types`, `dist/` (dono `root`, resíduo de builds Docker rodados como root em
sessão anterior) e um binário nativo de `argon2` compilado para `musl` em `node_modules/argon2/build`
(também resíduo de build Docker anterior, teve precedência indevida sobre o prebuild `glibc` correto
deste host) bloqueando `npm install`/`tsc`/os testes locais. Corrigido com `chown` (autorizado
interativamente pelo usuário) e removendo o `build/` nativo obsoleto — nenhuma mudança de código, só
limpeza de artefatos de build. Também diferente da Etapa 1: `docker build` funcionou nesta sessão com a
rede bridge padrão, sem precisar de `--network host`.
---
## Etapa 5 — Painel Tenant: Clientes, Produtos e Serviços
- [ ] 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:**
---
## 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
1. Sem uma role de aplicação não-superuser dedicada (`app_tenant`), toda
query de `tenant_user` continuaria vendo dados de todos os tenants e o
isolamento por RLS seria só decorativo. As duas roles (`app_master` e
`app_tenant`) são criadas na migration manual
`prisma/migrations/20260813120000_auth_app_roles/`, com senhas
placeholder de dev documentadas em `.env.example` (mesmo padrão já usado
para `POSTGRES_PASSWORD`/`JWT_SECRET` — trocar em produção).
[2026-08-13] Etapa 2 — `email` de `tenant_users` é único só por tenant, não
globalmente (`UNIQUE(tenant_id, email)` no schema original). Login e
forgot-password por e-mail, sem mais contexto (ex: subdomínio/slug do
tenant), não têm como resolver com segurança um e-mail que colida entre
dois tenants diferentes. Decisão: tratar 0 ou >1 `tenant_users` com o
mesmo e-mail como credencial inválida, em vez de arriscar autenticar/
resetar a senha da conta errada. Fica como limitação conhecida — uma
etapa futura pode introduzir um identificador de tenant explícito no
login (subdomínio, slug) para eliminar essa ambiguidade.
[2026-08-13] Etapa 3 — `TenantGuard` passou a checar o status do tenant a
cada request (consulta a `tenants` via `PrismaMasterService`), não só no
login. O prompt desta etapa já pedia isso explicitamente ("checar status
do tenant no login e no TenantGuard"), mas vale registrar: sem essa
checagem, suspender/cancelar um tenant não bloqueava de fato quem já
tinha um access token válido (até 15 min de janela, com a config
padrão). `changeStatus` também revoga os refresh tokens ativos dos
usuários do tenant ao suspender/cancelar, como defesa em profundidade
extra (não pedida explicitamente, mesmo espírito da revogação de família
de tokens da Etapa 2).
[2026-08-13] Etapa 3 — `status` retirado do `UpdateTenantDto` (`PUT
/master/tenants/:id`), mesmo o prompt original listando `status` entre os
campos que esse endpoint atualiza. Toda transição de status passa
exclusivamente por `PATCH /master/tenants/:id/status`, o único caminho
que grava auditoria (`de`/`para`) e aplica bloqueio de acesso — permitir
mudar `status` também via PUT abriria um caminho para contornar essa
lógica.
[2026-08-13] Etapa 3 — `package.json`: `test:e2e` passou a rodar com
`--runInBand`. A partir desta etapa os testes e2e leem estado global do
painel master (contagem de tenants por status, MRR, faturas do mês
corrente); com mais de um arquivo de teste, o Jest roda os arquivos em
paralelo por padrão, o que criaria uma fonte real de flakiness entre
`auth.e2e-spec.ts` e `master.e2e-spec.ts`. Rodar em série elimina isso.
[2026-08-14] Etapa 4 — `AlertDispatchService` (e o `EmailService`/
`EvolutionApiClient` por baixo dele) usam `PrismaMasterService` com filtro
manual de `tenant_id`, não `TenantContextService`/RLS — única exceção ao
padrão RLS-first do resto do projeto. Motivo: este service é o "serviço
central de envio, preparando a Etapa 7" e vai ser chamado pelo worker
BullMQ da Etapa 7 fora de qualquer request HTTP/contexto de tenant
autenticado, onde `TenantContextService.getTx()` não tem como funcionar
(não há `AsyncLocalStorage` ativo sem uma request em andamento). `tenant_id`
nunca vem de input do usuário final nesse service (sempre do JWT via
controller, e futuramente do job da fila), então o filtro manual continua
seguro.
[2026-08-14] Etapa 4 — `AlertDispatchService.enviarWhatsapp` ganhou um
quarto parâmetro opcional, `instanceId?: string`, além dos três previstos
no prompt (`tenantId, numero, mensagem`). Sem ele, resolve a instância
`conectado` mais recente do tenant (uso futuro da Etapa 7); com ele, envia
por uma instância específica — necessário para
`POST /tenant/whatsapp/instancias/:id/testar-envio`, que testa uma
instância específica, não "uma instância qualquer do tenant", mantendo
ainda assim o pedido do prompt de reusar esse service nos endpoints de
teste de envio.
[2026-08-14] Etapa 4 — `ENCRYPTION_KEY` (nova variável de ambiente
obrigatória) é reduzida a 32 bytes via SHA-256 antes de virar a chave
AES-256-GCM usada pelo `EncryptionService`, em vez de exigir um valor
hex/base64 de exatamente 32 bytes. Mesma lógica de validação de
`JWT_SECRET` (`@MinLength(32)`, qualquer string forte serve) — evita o
usuário ter que gerar/colar uma chave num formato binário específico.
[2026-08-14] Etapa 4 — Não há instância real da Evolution API disponível
neste ambiente de desenvolvimento/CI. `EvolutionApiClient` foi implementado
contra a especificação pública documentada da API v2 e validado
ponta a ponta no teste e2e contra um servidor HTTP local que simula esse
contrato (`FakeEvolutionServer`, em `test/integracoes.e2e-spec.ts`), não
contra o serviço real. Confirmar contra uma instância real antes de operar
em produção.
+118
View File
@@ -0,0 +1,118 @@
# SeConectai Vendas — Arquitetura
CRM/controle de vendas multi-tenant, com um painel **master** (você, dono da
plataforma) e painéis de **tenant** (seus clientes, que por sua vez cadastram
os próprios clientes/vendas).
## 1. Modelo de dados (resumo)
```
master_admins → você e sua equipe (login do painel master)
evolution_api_config → conexão(ões) com a Evolution API 2.3
master_email_config → SMTP padrão da plataforma
planos → planos de assinatura que você vende
tenants → seus clientes (quem compra a plataforma)
tenant_faturas → mensalidades que o tenant te deve
tenant_users → usuários/login de cada tenant
tenant_whatsapp_instances → instância Evolution API de cada tenant
tenant_email_config → SMTP próprio do tenant (opcional)
alert_templates → mensagens (WhatsApp/e-mail) customizáveis
alert_logs → auditoria de todo alerta enviado
customers → clientes (empresas) de cada tenant
customer_contacts → contatos dentro dessas empresas
products_services → catálogo de produto/serviço por tenant
sales / sale_items / sale_payments → vendas, itens e parcelas a receber
audit_logs → trilha de auditoria geral
```
Arquivo completo: `seconectai-vendas-schema.sql`.
## 2. Estratégia de multi-tenancy
Banco único, todas as tabelas de dado operacional carregam `tenant_id`, com
**Row Level Security (RLS)** do Postgres isolando cada tenant. A aplicação
define `SET app.current_tenant_id = '<uuid>'` na conexão/transação do
usuário logado; o Postgres bloqueia qualquer leitura/escrita fora do tenant
automaticamente — mesmo que haja um bug na query da aplicação.
Por que essa abordagem em vez de "um schema por tenant" ou "um banco por
tenant": com um único Postgres em Docker, migrations, backup e escala
operacional ficam muito mais simples, e RLS já dá o isolamento de segurança
que schema-per-tenant tentaria resolver com mais complexidade operacional.
O usuário master (você) usa uma role de banco separada com `BYPASS ROW LEVEL
SECURITY`, para poder ver/gerenciar todos os tenants.
## 3. Stack sugerida
- **Backend:** Node.js + TypeScript, com NestJS (organização em módulos por
domínio: auth, tenants, customers, products, sales, alerts) ou Fastify se
quiser algo mais enxuto. Node se encaixa bem no seu workflow atual
(n8n, Evolution API, JS/TS já no dia a dia).
- **ORM:** Prisma — migrations versionadas, tipagem forte, e dá pra injetar
o `SET app.current_tenant_id` por transação com `$transaction`.
- **Banco:** PostgreSQL 16.
- **Fila/agendamento:** Redis + BullMQ, para dois tipos de job recorrente:
- varredura diária de vencimentos (`tenant_faturas` e `sale_payments`)
disparando alertas antes/no dia do vencimento;
- envio assíncrono de mensagens (não travar a request do usuário
esperando resposta da Evolution API/SMTP).
- **Autenticação:** JWT de curta duração (access token) + refresh token com
rotação, guardado com hash (nunca em texto puro) na tabela
`auth_refresh_tokens`. Senha com `argon2id` (preferível a bcrypt hoje).
- **Deploy:** Docker Compose com serviços `app`, `postgres`, `redis` — a
Evolution API pode rodar no mesmo compose ou em servidor separado (você
já mantém instância própria, então só precisa cadastrar a `base_url` +
`api_key` em `evolution_api_config`).
## 4. Segurança — pontos que valem atenção na implementação
- Hash de senha: `argon2id`, nunca MD5/SHA puro.
- Segredos (API key da Evolution, senha SMTP) criptografados em repouso
(AES-256-GCM) com chave fora do banco (variável de ambiente/secret
manager), não só "hash" — porque esses precisam ser recuperados em texto
puro para uso.
- Rate limiting no login (a tabela `login_attempts` já dá suporte a isso) e
bloqueio temporário após N tentativas falhas.
- 2FA (TOTP) pelo menos para `master_admins`, opcional para `tenant_admin`.
- RLS como camada de defesa adicional — mesmo com bug de aplicação, tenant A
não acessa dado de tenant B.
- HTTPS obrigatório, cookies de refresh token com `httpOnly` + `Secure` +
`SameSite=Strict`.
- Logs de auditoria (`audit_logs`, `alert_logs`) para rastrear quem fez o
quê — importante já que múltiplos tenants e usuários mexem no sistema.
## 5. Fluxo de alertas (o motor comercial do sistema)
Dois níveis, mesma mecânica:
1. **Master → Tenant:** job diário verifica `tenant_faturas` com vencimento
próximo/vencido → gera mensagem a partir de `alert_templates`
(`tipo='mensalidade_tenant'`) → envia via WhatsApp (Evolution API,
instância própria do master) e/ou e-mail (`master_email_config`) →
grava em `alert_logs`.
2. **Tenant → Customer:** mesmo job (ou outro, escopado por tenant) verifica
`sale_payments` pendentes/atrasados de cada tenant → usa a instância
Evolution do próprio tenant (`tenant_whatsapp_instances`) e o SMTP dele
(ou o padrão da plataforma, se `usar_config_padrao=true`) → envia e
registra em `alert_logs`.
Isso te dá, de graça, o mecanismo pra cobrar a própria mensalidade da
plataforma (item que você pediu explicitamente) usando a mesma
infraestrutura que o tenant usa pra cobrar os clientes dele.
## 6. Próximos passos sugeridos
Seguindo seu formato usual de build faseado (prompts sequenciais com
critério de aceite por etapa), a ordem natural seria:
1. Setup do projeto (Docker Compose, Postgres, Prisma, estrutura NestJS)
2. Auth (master + tenant_user, JWT/refresh, RLS aplicado)
3. CRUD de tenants + planos + faturas (painel master)
4. Cadastro Evolution API + instâncias WhatsApp por tenant
5. CRUD de customers, products_services (painel tenant)
6. Vendas (sales/sale_items/sale_payments) + dashboards (views já prontas)
7. Motor de alertas (BullMQ + templates + logs)
8. Personalização visual (logo/cor por tenant)
Posso detalhar qualquer uma dessas fases em um prompt de vibe coding
próprio, se quiser seguir esse fluxo.
+451
View File
@@ -0,0 +1,451 @@
-- ============================================================
-- SeConectAI Vendas — Schema PostgreSQL (multi-tenant CRM/Vendas)
-- ============================================================
-- Estratégia de multi-tenancy: banco único, isolado por tenant_id
-- + Row Level Security (RLS). Mais simples de operar em Docker/
-- Docker Compose do que "schema por tenant" e escala bem até
-- centenas de tenants num único Postgres.
-- ============================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
-- ============================================================
-- 1. NÍVEL MASTER (você — dono da plataforma)
-- ============================================================
CREATE TABLE master_admins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
nome VARCHAR(150) NOT NULL,
email VARCHAR(150) UNIQUE NOT NULL,
senha_hash TEXT NOT NULL, -- argon2id ou bcrypt cost>=12
ativo BOOLEAN NOT NULL DEFAULT TRUE,
mfa_secret TEXT, -- TOTP opcional, recomendado p/ master
ultimo_login TIMESTAMPTZ,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now(),
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Conexão(ões) com a Evolution API 2.3, cadastrada pelo master.
-- Pode haver mais de um servidor Evolution (ex: para balancear carga).
CREATE TABLE evolution_api_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
nome VARCHAR(100) NOT NULL, -- "Servidor Principal"
base_url VARCHAR(255) NOT NULL,
api_key_cripto TEXT NOT NULL, -- criptografar em repouso (app-level, ex: AES-256-GCM)
versao VARCHAR(20) NOT NULL DEFAULT '2.3',
ativo BOOLEAN NOT NULL DEFAULT TRUE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Config SMTP padrão da plataforma (fallback quando o tenant não
-- tem SMTP próprio cadastrado)
CREATE TABLE master_email_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
smtp_host VARCHAR(150) NOT NULL,
smtp_porta INTEGER NOT NULL,
smtp_usuario VARCHAR(150) NOT NULL,
smtp_senha_cripto TEXT NOT NULL,
email_remetente VARCHAR(150) NOT NULL,
ativo BOOLEAN NOT NULL DEFAULT TRUE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE planos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
nome VARCHAR(100) NOT NULL,
descricao TEXT,
valor_mensal NUMERIC(10,2) NOT NULL,
limite_customers INTEGER, -- NULL = ilimitado
limite_usuarios INTEGER,
limite_instancias_whatsapp INTEGER NOT NULL DEFAULT 1,
ativo BOOLEAN NOT NULL DEFAULT TRUE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================
-- 2. TENANTS — seus clientes que compram/usam a plataforma
-- ============================================================
CREATE TYPE tenant_status AS ENUM ('trial', 'ativo', 'inadimplente', 'suspenso', 'cancelado');
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
razao_social VARCHAR(200) NOT NULL,
nome_fantasia VARCHAR(200),
documento VARCHAR(20), -- CNPJ/CPF
email_contato VARCHAR(150) NOT NULL,
telefone VARCHAR(20),
logo_url TEXT, -- personalização visual (whitelabel)
cor_primaria VARCHAR(7), -- "#1a73e8"
plano_id UUID REFERENCES planos(id),
status tenant_status NOT NULL DEFAULT 'trial',
dia_vencimento_mensalidade SMALLINT NOT NULL DEFAULT 10
CHECK (dia_vencimento_mensalidade BETWEEN 1 AND 28),
trial_expira_em TIMESTAMPTZ,
criado_por UUID REFERENCES master_admins(id),
criado_em TIMESTAMPTZ NOT NULL DEFAULT now(),
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_tenants_status ON tenants(status);
-- Faturas da mensalidade (você cobrando o tenant)
CREATE TYPE cobranca_status AS ENUM ('pendente', 'pago', 'atrasado', 'cancelado');
CREATE TABLE tenant_faturas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
referencia VARCHAR(7) NOT NULL, -- '2026-08'
valor NUMERIC(10,2) NOT NULL,
vencimento DATE NOT NULL,
pago_em TIMESTAMPTZ,
status cobranca_status NOT NULL DEFAULT 'pendente',
alerta_enviado_em TIMESTAMPTZ,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(tenant_id, referencia)
);
CREATE INDEX idx_tenant_faturas_venc
ON tenant_faturas(vencimento) WHERE status IN ('pendente', 'atrasado');
-- ============================================================
-- 3. USUÁRIOS E AUTENTICAÇÃO
-- ============================================================
CREATE TYPE user_role AS ENUM ('tenant_admin', 'vendedor', 'financeiro', 'visualizador');
CREATE TABLE tenant_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
nome VARCHAR(150) NOT NULL,
email VARCHAR(150) NOT NULL,
senha_hash TEXT NOT NULL,
role user_role NOT NULL DEFAULT 'vendedor',
ativo BOOLEAN NOT NULL DEFAULT TRUE,
mfa_secret TEXT,
ultimo_login TIMESTAMPTZ,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(tenant_id, email)
);
CREATE INDEX idx_tenant_users_tenant ON tenant_users(tenant_id);
-- Refresh tokens (JWT access token curto + refresh token com rotação)
CREATE TABLE auth_refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
usuario_tipo VARCHAR(20) NOT NULL, -- 'master' | 'tenant_user'
usuario_id UUID NOT NULL,
token_hash TEXT NOT NULL, -- nunca guardar o token em texto puro
expira_em TIMESTAMPTZ NOT NULL,
revogado BOOLEAN NOT NULL DEFAULT FALSE,
ip_origem INET,
user_agent TEXT,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_refresh_tokens_usuario ON auth_refresh_tokens(usuario_tipo, usuario_id);
CREATE TABLE password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
usuario_tipo VARCHAR(20) NOT NULL,
usuario_id UUID NOT NULL,
token_hash TEXT NOT NULL,
expira_em TIMESTAMPTZ NOT NULL,
usado BOOLEAN NOT NULL DEFAULT FALSE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Registro de tentativas de login (para rate-limit/bloqueio de força bruta)
CREATE TABLE login_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(150) NOT NULL,
usuario_tipo VARCHAR(20) NOT NULL,
sucesso BOOLEAN NOT NULL,
ip_origem INET,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_login_attempts_email_data ON login_attempts(email, criado_em);
-- ============================================================
-- 4. INTEGRAÇÕES POR TENANT (WhatsApp / E-mail / Templates)
-- ============================================================
CREATE TABLE tenant_whatsapp_instances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
evolution_config_id UUID REFERENCES evolution_api_config(id),
instance_name VARCHAR(100) NOT NULL UNIQUE, -- nome único na Evolution API
numero_conectado VARCHAR(20),
status VARCHAR(30) NOT NULL DEFAULT 'desconectado',
-- desconectado | conectando | conectado | erro
qr_code_gerado_em TIMESTAMPTZ,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE tenant_email_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL UNIQUE REFERENCES tenants(id) ON DELETE CASCADE,
smtp_host VARCHAR(150),
smtp_porta INTEGER,
smtp_usuario VARCHAR(150),
smtp_senha_cripto TEXT,
email_remetente VARCHAR(150),
usar_config_padrao BOOLEAN NOT NULL DEFAULT TRUE, -- usa master_email_config se true
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Templates de mensagens (nível master = padrão global; nível tenant = customizado)
CREATE TABLE alert_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, -- NULL = template padrão da plataforma
tipo VARCHAR(50) NOT NULL,
-- 'mensalidade_tenant' | 'cobranca_cliente' | 'venda_confirmada' | 'lembrete_vencimento'
canal VARCHAR(20) NOT NULL, -- 'whatsapp' | 'email'
assunto VARCHAR(200), -- só para email
corpo TEXT NOT NULL, -- placeholders: {{nome}}, {{valor}}, {{vencimento}}
ativo BOOLEAN NOT NULL DEFAULT TRUE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Log/auditoria de todo alerta disparado (master->tenant e tenant->customer)
CREATE TABLE alert_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
destinatario_tipo VARCHAR(20) NOT NULL, -- 'tenant' | 'customer'
destinatario_id UUID NOT NULL, -- id do tenant ou do customer
canal VARCHAR(20) NOT NULL, -- whatsapp | email
template_id UUID REFERENCES alert_templates(id),
conteudo_enviado TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'enviado', -- enviado | falhou | entregue | lido
erro TEXT,
enviado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_alert_logs_tenant ON alert_logs(tenant_id, enviado_em);
-- ============================================================
-- 5. CLIENTES DO TENANT ("customers" — empresas atendidas)
-- ============================================================
CREATE TABLE customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
razao_social VARCHAR(200) NOT NULL,
nome_fantasia VARCHAR(200),
documento VARCHAR(20),
email VARCHAR(150),
telefone_whatsapp VARCHAR(20),
endereco TEXT,
cidade VARCHAR(100),
uf CHAR(2),
observacoes TEXT,
ativo BOOLEAN NOT NULL DEFAULT TRUE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now(),
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_customers_tenant ON customers(tenant_id);
CREATE TABLE customer_contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
nome VARCHAR(150) NOT NULL,
cargo VARCHAR(100),
email VARCHAR(150),
telefone_whatsapp VARCHAR(20),
principal BOOLEAN NOT NULL DEFAULT FALSE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================
-- 6. PRODUTOS E SERVIÇOS DO TENANT
-- ============================================================
CREATE TYPE item_tipo AS ENUM ('produto', 'servico');
CREATE TABLE products_services (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
tipo item_tipo NOT NULL,
nome VARCHAR(200) NOT NULL,
descricao TEXT,
sku VARCHAR(50),
valor_venda NUMERIC(12,2) NOT NULL,
valor_custo NUMERIC(12,2),
unidade VARCHAR(20) NOT NULL DEFAULT 'un', -- un, hora, mês...
recorrente BOOLEAN NOT NULL DEFAULT FALSE, -- ex: serviço de mensalidade
ativo BOOLEAN NOT NULL DEFAULT TRUE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now(),
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(tenant_id, sku)
);
CREATE INDEX idx_products_tenant ON products_services(tenant_id);
-- ============================================================
-- 7. VENDAS
-- ============================================================
CREATE TYPE venda_status AS ENUM ('orcamento', 'confirmada', 'cancelada', 'concluida');
CREATE TABLE sales (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
customer_id UUID NOT NULL REFERENCES customers(id),
vendedor_id UUID REFERENCES tenant_users(id),
numero_venda SERIAL,
status venda_status NOT NULL DEFAULT 'confirmada',
valor_total NUMERIC(12,2) NOT NULL DEFAULT 0,
desconto NUMERIC(12,2) NOT NULL DEFAULT 0,
observacoes TEXT,
data_venda DATE NOT NULL DEFAULT CURRENT_DATE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now(),
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_sales_tenant_data ON sales(tenant_id, data_venda);
CREATE INDEX idx_sales_customer ON sales(customer_id);
CREATE TABLE sale_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sale_id UUID NOT NULL REFERENCES sales(id) ON DELETE CASCADE,
product_service_id UUID NOT NULL REFERENCES products_services(id),
quantidade NUMERIC(10,2) NOT NULL DEFAULT 1,
valor_unitario NUMERIC(12,2) NOT NULL,
valor_total NUMERIC(12,2) NOT NULL
);
CREATE INDEX idx_sale_items_sale ON sale_items(sale_id);
-- Parcelas/recebimentos: contas a receber do tenant junto ao customer
CREATE TYPE parcela_status AS ENUM ('pendente', 'pago', 'atrasado', 'cancelado');
CREATE TABLE sale_payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sale_id UUID NOT NULL REFERENCES sales(id) ON DELETE CASCADE,
numero_parcela SMALLINT NOT NULL DEFAULT 1,
valor NUMERIC(12,2) NOT NULL,
vencimento DATE NOT NULL,
pago_em TIMESTAMPTZ,
forma_pagamento VARCHAR(30), -- pix, boleto, cartao, dinheiro
status parcela_status NOT NULL DEFAULT 'pendente',
alerta_enviado_em TIMESTAMPTZ,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_sale_payments_venc
ON sale_payments(vencimento) WHERE status IN ('pendente', 'atrasado');
-- ============================================================
-- 8. AUDITORIA GERAL
-- ============================================================
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
usuario_tipo VARCHAR(20) NOT NULL, -- master | tenant_user
usuario_id UUID,
acao VARCHAR(100) NOT NULL, -- login, criar_venda, excluir_cliente...
entidade VARCHAR(50),
entidade_id UUID,
detalhes JSONB,
ip_origem INET,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_audit_tenant_data ON audit_logs(tenant_id, criado_em);
-- ============================================================
-- 9. VIEWS DE DASHBOARD (usadas no painel do tenant)
-- ============================================================
CREATE VIEW vw_resumo_vendas_semanal AS
SELECT
s.tenant_id,
date_trunc('week', s.data_venda)::date AS semana,
COUNT(*) AS qtd_vendas,
SUM(s.valor_total) AS total_vendido
FROM sales s
WHERE s.status IN ('confirmada', 'concluida')
GROUP BY s.tenant_id, date_trunc('week', s.data_venda);
CREATE VIEW vw_resumo_vendas_mensal AS
SELECT
s.tenant_id,
date_trunc('month', s.data_venda)::date AS mes,
COUNT(*) AS qtd_vendas,
SUM(s.valor_total) AS total_vendido
FROM sales s
WHERE s.status IN ('confirmada', 'concluida')
GROUP BY s.tenant_id, date_trunc('month', s.data_venda);
CREATE VIEW vw_recebimentos AS
SELECT
sp.id,
s.tenant_id,
s.customer_id,
sp.valor,
sp.vencimento,
sp.status,
sp.pago_em
FROM sale_payments sp
JOIN sales s ON s.id = sp.sale_id;
-- ============================================================
-- 10. ROW LEVEL SECURITY — isolamento entre tenants
-- ============================================================
-- A aplicação deve definir, por conexão/transação:
-- SET app.current_tenant_id = '<uuid-do-tenant-logado>';
-- Usuários master (que enxergam todos os tenants) usam uma role
-- de banco separada (ex: app_master) que faz BYPASS RLS, ou uma
-- policy adicional que libera quando app.is_master = 'true'.
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE customer_contacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE products_services ENABLE ROW LEVEL SECURITY;
ALTER TABLE sales ENABLE ROW LEVEL SECURITY;
ALTER TABLE sale_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE sale_payments ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_users ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_whatsapp_instances ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_email_config ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_customers ON customers
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_products ON products_services
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_sales ON sales
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_tenant_users ON tenant_users
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_whatsapp ON tenant_whatsapp_instances
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_email_config ON tenant_email_config
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- sale_items e customer_contacts não têm tenant_id direto: isolar via join
CREATE POLICY tenant_isolation_sale_items ON sale_items
USING (sale_id IN (
SELECT id FROM sales
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid
));
CREATE POLICY tenant_isolation_sale_payments ON sale_payments
USING (sale_id IN (
SELECT id FROM sales
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid
));
CREATE POLICY tenant_isolation_customer_contacts ON customer_contacts
USING (customer_id IN (
SELECT id FROM customers
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid
));
-- Fim do schema
+172
View File
@@ -0,0 +1,172 @@
# SeConectai Vendas
CRM/controle de vendas multi-tenant. Um painel **master** (dono da
plataforma) administra **tenants** (clientes), que por sua vez cadastram
seus próprios clientes ("customers"), produtos/serviços e vendas.
Stack: Node.js 20 + TypeScript, NestJS, PostgreSQL 16 + Prisma, Redis,
Docker/Docker Compose. Multi-tenancy via banco único, isolado por
`tenant_id` + Row Level Security (RLS) do Postgres.
## Subindo o ambiente do zero
1. Copie o arquivo de variáveis de ambiente e ajuste se necessário:
```bash
cp .env.example .env
```
2. Suba os containers (Postgres, Redis e a API com hot-reload em modo dev):
```bash
docker compose up
```
Isso builda a imagem `app` (stage `dev`), sobe `postgres` e `redis` com
healthcheck, e só inicia a API depois que as duas dependências estiverem
saudáveis. `docker-compose.override.yml` é aplicado automaticamente e monta
o código-fonte local no container, então alterações em `src/` reiniciam a
API sozinhas (`npm run dev` / `nest start --watch`).
3. Rode as migrations do Prisma (tabelas, enums, índices) e a migration
manual de RLS/views/checks:
```bash
docker compose exec app npx prisma migrate deploy
```
Em desenvolvimento, para criar uma nova migration a partir de mudanças no
`schema.prisma`:
```bash
docker compose exec app npx prisma migrate dev
```
4. Confirme que a API está de pé:
```bash
curl http://localhost:3000/health
```
Deve retornar `200` com `{"status":"ok","database":"up",...}`.
## Rodando sem Docker (opcional)
Requer Node.js 20+, um Postgres 16 e um Redis acessíveis localmente.
```bash
npm install
npm run prisma:generate
npm run prisma:migrate
npm run dev
```
## Variáveis de ambiente
Veja [.env.example](.env.example) — todas as chaves usadas pela aplicação e
pelo `docker-compose.yml` (credenciais do Postgres, `DATABASE_URL` e
variantes, Redis, `JWT_SECRET` e demais parâmetros de autenticação) estão
documentadas lá.
## Autenticação
`src/modules/auth/` implementa login/refresh/logout/reset de senha para os
dois tipos de usuário:
- `POST /auth/master/login` e `POST /auth/tenant/login` — e-mail+senha →
access token (JWT, 15 min por padrão) + refresh token opaco (rotacionado
a cada uso, guardado só como hash sha256 em `auth_refresh_tokens`).
- `POST /auth/refresh` — rotaciona o refresh token; reuso de um token já
rotacionado é tratado como indício de roubo e revoga toda a família de
tokens do usuário.
- `POST /auth/logout` — revoga o refresh token informado.
- `POST /auth/password/forgot` / `POST /auth/password/reset` — gera token
de reset (hash, expiração curta, uso único); por ora o token é só logado
no console (envio de e-mail real é da Etapa 4).
- Senhas com `argon2id` (parâmetros OWASP 2024). Bloqueio de força bruta
por e-mail após `LOGIN_MAX_ATTEMPTS` falhas em `LOGIN_LOCKOUT_WINDOW_MINUTES`
(tabela `login_attempts`), mais um rate limit por IP nas rotas de login
(`@nestjs/throttler`).
- `JwtAuthGuard`, `MasterGuard`, `TenantGuard`, `RolesGuard` (+
`@Roles(...)`) e o decorator `@CurrentUser()` — ver
`GET /auth/me`, `GET /auth/master/admins` e `GET /auth/tenant/users` em
`auth.controller.ts` para exemplos de uso de cada guard.
## Migrations e RLS
O schema em `prisma/schema.prisma` é a tradução 1:1 de
`Etapas/seconectai-vendas-schema.sql` (fonte de verdade). Construtos que o
Prisma DSL não expressa nativamente — views de dashboard, `CHECK`
constraints e as `CREATE POLICY` de Row Level Security — vivem numa
migration manual em `prisma/migrations/<timestamp>_rls_views_constraints/`,
aplicada junto com as migrations geradas automaticamente pelo Prisma
(`prisma migrate deploy` roda todas em ordem).
Uma segunda migration manual
(`prisma/migrations/20260813120000_auth_app_roles/`) cria duas roles de
banco usadas em runtime:
- **`app_master`** (`LOGIN` + `BYPASSRLS`, conectada via
`DATABASE_URL_MASTER`) — usada pelo painel master, que precisa enxergar
dados de todos os tenants.
- **`app_tenant`** (`LOGIN` comum, sem `BYPASSRLS`, conectada via
`DATABASE_URL_TENANT`) — usada por toda query autenticada como
`tenant_user`.
Isso é necessário porque a role de `DATABASE_URL` (`seconectai`, dona das
tabelas) é a superuser bootstrap do Postgres criada pelo `POSTGRES_USER`
do docker-compose — e superusers **sempre** ignoram Row Level Security,
mesmo com as policies criadas. Sem uma role não-superuser dedicada, o
isolamento por tenant seria só decorativo.
Por request, o `TenantRlsInterceptor` (registrado globalmente) identifica
requests autenticadas como `tenant_user` e, via `TenantContextService`,
abre uma transação na conexão `app_tenant` executando:
```sql
SELECT set_config('app.current_tenant_id', '<uuid-do-tenant-logado>', true); -- equivalente a SET LOCAL
```
sempre usando o `tenant_id` do JWT da sessão — nunca um valor vindo de
input do usuário (body/query). Serviços que precisam rodar dentro desse
contexto usam `TenantContextService.getTx()` (ver
`AuthController.listTenantUsers` como exemplo). Requests master usam
`PrismaMasterService` (conexão `app_master`, sem passar pelas policies).
## Scripts
| Script | Descrição |
|----------------------------|-----------------------------------------------------|
| `npm run dev` | Inicia a API em modo watch |
| `npm run build` | Compila TypeScript para `dist/` |
| `npm run start:prod` | Roda a build de produção (`dist/main.js`) |
| `npm run lint` | ESLint (com `--fix`) |
| `npm run test` | Testes unitários (Jest) |
| `npm run test:e2e` | Testes end-to-end (Jest + Supertest) |
| `npm run prisma:generate` | Gera o Prisma Client |
| `npm run prisma:migrate` | Cria/aplica migration em desenvolvimento |
| `npm run prisma:migrate:deploy` | Aplica migrations pendentes (produção/CI) |
| `npm run prisma:studio` | Abre o Prisma Studio |
## Estrutura do projeto
```
src/
├── main.ts
├── app.module.ts
├── config/ # ConfigModule, validação de env, PrismaService/PrismaMasterService/
│ # PrismaTenantService, TenantContextService (mecanismo de RLS por request)
└── modules/
├── health/ # GET /health
└── auth/ # login/refresh/logout/reset, guards, decorators, JwtStrategy
prisma/
├── schema.prisma
└── migrations/
Etapas/ # Prompts e controle de progresso do build faseado
```
## Progresso do build
Este projeto é construído em etapas. O estado oficial de cada etapa (o que
já foi concluído, o que falta, decisões tomadas) fica em
[`Etapas/09-controle-de-progresso.md`](Etapas/09-controle-de-progresso.md).
+12
View File
@@ -0,0 +1,12 @@
services:
app:
build:
target: dev
command: npm run dev
environment:
NODE_ENV: development
volumes:
- .:/app
- /app/node_modules
healthcheck:
disable: true
+55
View File
@@ -0,0 +1,55 @@
services:
app:
build:
context: .
target: production
restart: unless-stopped
ports:
- '${PORT:-3000}:3000'
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ['CMD', 'node', '-e', "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-seconectai}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-seconectai}
POSTGRES_DB: ${POSTGRES_DB:-seconectai_vendas}
ports:
- '${POSTGRES_PORT:-5432}:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-seconectai} -d ${POSTGRES_DB:-seconectai_vendas}']
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- '${REDIS_PORT:-6379}:6379'
volumes:
- redis_data:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres_data:
redis_data:
+34
View File
@@ -0,0 +1,34 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs', 'dist/**', 'node_modules/**', 'coverage/**'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
},
},
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+10319
View File
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
{
"name": "seconectai-vendas",
"version": "0.1.0",
"description": "SeConectai Vendas — CRM/controle de vendas multi-tenant",
"license": "UNLICENSED",
"private": true,
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"dev": "nest start --watch",
"start": "nest start",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json --runInBand",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:deploy": "prisma migrate deploy",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0",
"argon2": "^0.45.1",
"axios": "^1.19.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"nodemailer": "^9.0.5",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^20.17.10",
"@types/nodemailer": "^8.0.1",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.0.3",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"globals": "^15.14.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"prisma": "^5.22.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.2",
"typescript-eslint": "^8.18.1"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
@@ -0,0 +1,451 @@
-- CreateEnum
CREATE TYPE "tenant_status" AS ENUM ('trial', 'ativo', 'inadimplente', 'suspenso', 'cancelado');
-- CreateEnum
CREATE TYPE "cobranca_status" AS ENUM ('pendente', 'pago', 'atrasado', 'cancelado');
-- CreateEnum
CREATE TYPE "user_role" AS ENUM ('tenant_admin', 'vendedor', 'financeiro', 'visualizador');
-- CreateEnum
CREATE TYPE "item_tipo" AS ENUM ('produto', 'servico');
-- CreateEnum
CREATE TYPE "venda_status" AS ENUM ('orcamento', 'confirmada', 'cancelada', 'concluida');
-- CreateEnum
CREATE TYPE "parcela_status" AS ENUM ('pendente', 'pago', 'atrasado', 'cancelado');
-- CreateTable
CREATE TABLE "master_admins" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"nome" VARCHAR(150) NOT NULL,
"email" VARCHAR(150) NOT NULL,
"senha_hash" TEXT NOT NULL,
"ativo" BOOLEAN NOT NULL DEFAULT true,
"mfa_secret" TEXT,
"ultimo_login" TIMESTAMPTZ(6),
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"atualizado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "master_admins_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "evolution_api_config" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"nome" VARCHAR(100) NOT NULL,
"base_url" VARCHAR(255) NOT NULL,
"api_key_cripto" TEXT NOT NULL,
"versao" VARCHAR(20) NOT NULL DEFAULT '2.3',
"ativo" BOOLEAN NOT NULL DEFAULT true,
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "evolution_api_config_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "master_email_config" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"smtp_host" VARCHAR(150) NOT NULL,
"smtp_porta" INTEGER NOT NULL,
"smtp_usuario" VARCHAR(150) NOT NULL,
"smtp_senha_cripto" TEXT NOT NULL,
"email_remetente" VARCHAR(150) NOT NULL,
"ativo" BOOLEAN NOT NULL DEFAULT true,
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "master_email_config_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "planos" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"nome" VARCHAR(100) NOT NULL,
"descricao" TEXT,
"valor_mensal" DECIMAL(10,2) NOT NULL,
"limite_customers" INTEGER,
"limite_usuarios" INTEGER,
"limite_instancias_whatsapp" INTEGER NOT NULL DEFAULT 1,
"ativo" BOOLEAN NOT NULL DEFAULT true,
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "planos_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenants" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"razao_social" VARCHAR(200) NOT NULL,
"nome_fantasia" VARCHAR(200),
"documento" VARCHAR(20),
"email_contato" VARCHAR(150) NOT NULL,
"telefone" VARCHAR(20),
"logo_url" TEXT,
"cor_primaria" VARCHAR(7),
"plano_id" UUID,
"status" "tenant_status" NOT NULL DEFAULT 'trial',
"dia_vencimento_mensalidade" SMALLINT NOT NULL DEFAULT 10,
"trial_expira_em" TIMESTAMPTZ(6),
"criado_por" UUID,
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"atualizado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tenants_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenant_faturas" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"tenant_id" UUID NOT NULL,
"referencia" VARCHAR(7) NOT NULL,
"valor" DECIMAL(10,2) NOT NULL,
"vencimento" DATE NOT NULL,
"pago_em" TIMESTAMPTZ(6),
"status" "cobranca_status" NOT NULL DEFAULT 'pendente',
"alerta_enviado_em" TIMESTAMPTZ(6),
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tenant_faturas_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenant_users" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"tenant_id" UUID NOT NULL,
"nome" VARCHAR(150) NOT NULL,
"email" VARCHAR(150) NOT NULL,
"senha_hash" TEXT NOT NULL,
"role" "user_role" NOT NULL DEFAULT 'vendedor',
"ativo" BOOLEAN NOT NULL DEFAULT true,
"mfa_secret" TEXT,
"ultimo_login" TIMESTAMPTZ(6),
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tenant_users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "auth_refresh_tokens" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"usuario_tipo" VARCHAR(20) NOT NULL,
"usuario_id" UUID NOT NULL,
"token_hash" TEXT NOT NULL,
"expira_em" TIMESTAMPTZ(6) NOT NULL,
"revogado" BOOLEAN NOT NULL DEFAULT false,
"ip_origem" INET,
"user_agent" TEXT,
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "auth_refresh_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "password_reset_tokens" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"usuario_tipo" VARCHAR(20) NOT NULL,
"usuario_id" UUID NOT NULL,
"token_hash" TEXT NOT NULL,
"expira_em" TIMESTAMPTZ(6) NOT NULL,
"usado" BOOLEAN NOT NULL DEFAULT false,
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "login_attempts" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"email" VARCHAR(150) NOT NULL,
"usuario_tipo" VARCHAR(20) NOT NULL,
"sucesso" BOOLEAN NOT NULL,
"ip_origem" INET,
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "login_attempts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenant_whatsapp_instances" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"tenant_id" UUID NOT NULL,
"evolution_config_id" UUID,
"instance_name" VARCHAR(100) NOT NULL,
"numero_conectado" VARCHAR(20),
"status" VARCHAR(30) NOT NULL DEFAULT 'desconectado',
"qr_code_gerado_em" TIMESTAMPTZ(6),
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tenant_whatsapp_instances_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenant_email_config" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"tenant_id" UUID NOT NULL,
"smtp_host" VARCHAR(150),
"smtp_porta" INTEGER,
"smtp_usuario" VARCHAR(150),
"smtp_senha_cripto" TEXT,
"email_remetente" VARCHAR(150),
"usar_config_padrao" BOOLEAN NOT NULL DEFAULT true,
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tenant_email_config_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "alert_templates" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"tenant_id" UUID,
"tipo" VARCHAR(50) NOT NULL,
"canal" VARCHAR(20) NOT NULL,
"assunto" VARCHAR(200),
"corpo" TEXT NOT NULL,
"ativo" BOOLEAN NOT NULL DEFAULT true,
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "alert_templates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "alert_logs" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"tenant_id" UUID,
"destinatario_tipo" VARCHAR(20) NOT NULL,
"destinatario_id" UUID NOT NULL,
"canal" VARCHAR(20) NOT NULL,
"template_id" UUID,
"conteudo_enviado" TEXT,
"status" VARCHAR(20) NOT NULL DEFAULT 'enviado',
"erro" TEXT,
"enviado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "alert_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "customers" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"tenant_id" UUID NOT NULL,
"razao_social" VARCHAR(200) NOT NULL,
"nome_fantasia" VARCHAR(200),
"documento" VARCHAR(20),
"email" VARCHAR(150),
"telefone_whatsapp" VARCHAR(20),
"endereco" TEXT,
"cidade" VARCHAR(100),
"uf" CHAR(2),
"observacoes" TEXT,
"ativo" BOOLEAN NOT NULL DEFAULT true,
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"atualizado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "customers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "customer_contacts" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"customer_id" UUID NOT NULL,
"nome" VARCHAR(150) NOT NULL,
"cargo" VARCHAR(100),
"email" VARCHAR(150),
"telefone_whatsapp" VARCHAR(20),
"principal" BOOLEAN NOT NULL DEFAULT false,
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "customer_contacts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "products_services" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"tenant_id" UUID NOT NULL,
"tipo" "item_tipo" NOT NULL,
"nome" VARCHAR(200) NOT NULL,
"descricao" TEXT,
"sku" VARCHAR(50),
"valor_venda" DECIMAL(12,2) NOT NULL,
"valor_custo" DECIMAL(12,2),
"unidade" VARCHAR(20) NOT NULL DEFAULT 'un',
"recorrente" BOOLEAN NOT NULL DEFAULT false,
"ativo" BOOLEAN NOT NULL DEFAULT true,
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"atualizado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "products_services_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sales" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"tenant_id" UUID NOT NULL,
"customer_id" UUID NOT NULL,
"vendedor_id" UUID,
"numero_venda" SERIAL NOT NULL,
"status" "venda_status" NOT NULL DEFAULT 'confirmada',
"valor_total" DECIMAL(12,2) NOT NULL DEFAULT 0,
"desconto" DECIMAL(12,2) NOT NULL DEFAULT 0,
"observacoes" TEXT,
"data_venda" DATE NOT NULL DEFAULT CURRENT_TIMESTAMP,
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"atualizado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sales_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sale_items" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"sale_id" UUID NOT NULL,
"product_service_id" UUID NOT NULL,
"quantidade" DECIMAL(10,2) NOT NULL DEFAULT 1,
"valor_unitario" DECIMAL(12,2) NOT NULL,
"valor_total" DECIMAL(12,2) NOT NULL,
CONSTRAINT "sale_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sale_payments" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"sale_id" UUID NOT NULL,
"numero_parcela" SMALLINT NOT NULL DEFAULT 1,
"valor" DECIMAL(12,2) NOT NULL,
"vencimento" DATE NOT NULL,
"pago_em" TIMESTAMPTZ(6),
"forma_pagamento" VARCHAR(30),
"status" "parcela_status" NOT NULL DEFAULT 'pendente',
"alerta_enviado_em" TIMESTAMPTZ(6),
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sale_payments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"tenant_id" UUID,
"usuario_tipo" VARCHAR(20) NOT NULL,
"usuario_id" UUID,
"acao" VARCHAR(100) NOT NULL,
"entidade" VARCHAR(50),
"entidade_id" UUID,
"detalhes" JSONB,
"ip_origem" INET,
"criado_em" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "master_admins_email_key" ON "master_admins"("email");
-- CreateIndex
CREATE INDEX "idx_tenants_status" ON "tenants"("status");
-- CreateIndex
CREATE UNIQUE INDEX "tenant_faturas_tenant_id_referencia_key" ON "tenant_faturas"("tenant_id", "referencia");
-- CreateIndex
CREATE INDEX "idx_tenant_users_tenant" ON "tenant_users"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "tenant_users_tenant_id_email_key" ON "tenant_users"("tenant_id", "email");
-- CreateIndex
CREATE INDEX "idx_refresh_tokens_usuario" ON "auth_refresh_tokens"("usuario_tipo", "usuario_id");
-- CreateIndex
CREATE INDEX "idx_login_attempts_email_data" ON "login_attempts"("email", "criado_em");
-- CreateIndex
CREATE UNIQUE INDEX "tenant_whatsapp_instances_instance_name_key" ON "tenant_whatsapp_instances"("instance_name");
-- CreateIndex
CREATE UNIQUE INDEX "tenant_email_config_tenant_id_key" ON "tenant_email_config"("tenant_id");
-- CreateIndex
CREATE INDEX "idx_alert_logs_tenant" ON "alert_logs"("tenant_id", "enviado_em");
-- CreateIndex
CREATE INDEX "idx_customers_tenant" ON "customers"("tenant_id");
-- CreateIndex
CREATE INDEX "idx_products_tenant" ON "products_services"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "products_services_tenant_id_sku_key" ON "products_services"("tenant_id", "sku");
-- CreateIndex
CREATE INDEX "idx_sales_tenant_data" ON "sales"("tenant_id", "data_venda");
-- CreateIndex
CREATE INDEX "idx_sales_customer" ON "sales"("customer_id");
-- CreateIndex
CREATE INDEX "idx_sale_items_sale" ON "sale_items"("sale_id");
-- CreateIndex
CREATE INDEX "idx_audit_tenant_data" ON "audit_logs"("tenant_id", "criado_em");
-- AddForeignKey
ALTER TABLE "tenants" ADD CONSTRAINT "tenants_plano_id_fkey" FOREIGN KEY ("plano_id") REFERENCES "planos"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenants" ADD CONSTRAINT "tenants_criado_por_fkey" FOREIGN KEY ("criado_por") REFERENCES "master_admins"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_faturas" ADD CONSTRAINT "tenant_faturas_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_users" ADD CONSTRAINT "tenant_users_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_whatsapp_instances" ADD CONSTRAINT "tenant_whatsapp_instances_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_whatsapp_instances" ADD CONSTRAINT "tenant_whatsapp_instances_evolution_config_id_fkey" FOREIGN KEY ("evolution_config_id") REFERENCES "evolution_api_config"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_email_config" ADD CONSTRAINT "tenant_email_config_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "alert_templates" ADD CONSTRAINT "alert_templates_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "alert_logs" ADD CONSTRAINT "alert_logs_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "alert_logs" ADD CONSTRAINT "alert_logs_template_id_fkey" FOREIGN KEY ("template_id") REFERENCES "alert_templates"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "customers" ADD CONSTRAINT "customers_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "customer_contacts" ADD CONSTRAINT "customer_contacts_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "products_services" ADD CONSTRAINT "products_services_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sales" ADD CONSTRAINT "sales_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sales" ADD CONSTRAINT "sales_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sales" ADD CONSTRAINT "sales_vendedor_id_fkey" FOREIGN KEY ("vendedor_id") REFERENCES "tenant_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sale_items" ADD CONSTRAINT "sale_items_sale_id_fkey" FOREIGN KEY ("sale_id") REFERENCES "sales"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sale_items" ADD CONSTRAINT "sale_items_product_service_id_fkey" FOREIGN KEY ("product_service_id") REFERENCES "products_services"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sale_payments" ADD CONSTRAINT "sale_payments_sale_id_fkey" FOREIGN KEY ("sale_id") REFERENCES "sales"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,119 @@
-- Migration manual: construtos que o Prisma DSL não expressa nativamente.
-- Fonte de verdade: Etapas/seconectai-vendas-schema.sql
-- Contém: extensão pgcrypto, CHECK constraint, índices parciais, views de
-- dashboard e Row Level Security (ENABLE + CREATE POLICY) para isolamento
-- entre tenants.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ============================================================
-- CHECK constraints
-- ============================================================
ALTER TABLE "tenants"
ADD CONSTRAINT "tenants_dia_vencimento_mensalidade_check"
CHECK ("dia_vencimento_mensalidade" BETWEEN 1 AND 28);
-- ============================================================
-- Índices parciais
-- ============================================================
CREATE INDEX "idx_tenant_faturas_venc"
ON "tenant_faturas"("vencimento")
WHERE "status" IN ('pendente', 'atrasado');
CREATE INDEX "idx_sale_payments_venc"
ON "sale_payments"("vencimento")
WHERE "status" IN ('pendente', 'atrasado');
-- ============================================================
-- Views de dashboard (painel do tenant)
-- ============================================================
CREATE VIEW "vw_resumo_vendas_semanal" AS
SELECT
s.tenant_id,
date_trunc('week', s.data_venda)::date AS semana,
COUNT(*) AS qtd_vendas,
SUM(s.valor_total) AS total_vendido
FROM sales s
WHERE s.status IN ('confirmada', 'concluida')
GROUP BY s.tenant_id, date_trunc('week', s.data_venda);
CREATE VIEW "vw_resumo_vendas_mensal" AS
SELECT
s.tenant_id,
date_trunc('month', s.data_venda)::date AS mes,
COUNT(*) AS qtd_vendas,
SUM(s.valor_total) AS total_vendido
FROM sales s
WHERE s.status IN ('confirmada', 'concluida')
GROUP BY s.tenant_id, date_trunc('month', s.data_venda);
CREATE VIEW "vw_recebimentos" AS
SELECT
sp.id,
s.tenant_id,
s.customer_id,
sp.valor,
sp.vencimento,
sp.status,
sp.pago_em
FROM sale_payments sp
JOIN sales s ON s.id = sp.sale_id;
-- ============================================================
-- Row Level Security — isolamento entre tenants
-- ============================================================
-- A aplicação deve definir, por conexão/transação:
-- SET app.current_tenant_id = '<uuid-do-tenant-logado>';
-- Usuários master (que enxergam todos os tenants) usam uma role de banco
-- separada (ex: app_master) que faz BYPASS RLS, ou uma policy adicional
-- que libera quando app.is_master = 'true'. Implementado na Etapa 2.
ALTER TABLE "customers" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "customer_contacts" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "products_services" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "sales" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "sale_items" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "sale_payments" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "tenant_users" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "tenant_whatsapp_instances" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "tenant_email_config" ENABLE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation_customers" ON "customers"
USING ("tenant_id" = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY "tenant_isolation_products" ON "products_services"
USING ("tenant_id" = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY "tenant_isolation_sales" ON "sales"
USING ("tenant_id" = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY "tenant_isolation_tenant_users" ON "tenant_users"
USING ("tenant_id" = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY "tenant_isolation_whatsapp" ON "tenant_whatsapp_instances"
USING ("tenant_id" = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY "tenant_isolation_email_config" ON "tenant_email_config"
USING ("tenant_id" = current_setting('app.current_tenant_id', true)::uuid);
-- sale_items e customer_contacts não têm tenant_id direto: isolar via join
CREATE POLICY "tenant_isolation_sale_items" ON "sale_items"
USING ("sale_id" IN (
SELECT id FROM sales
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid
));
CREATE POLICY "tenant_isolation_sale_payments" ON "sale_payments"
USING ("sale_id" IN (
SELECT id FROM sales
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid
));
CREATE POLICY "tenant_isolation_customer_contacts" ON "customer_contacts"
USING ("customer_id" IN (
SELECT id FROM customers
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid
));
@@ -0,0 +1,53 @@
-- Migration manual: roles de aplicação usadas pela Etapa 2 (Autenticação/RLS).
--
-- Por que duas roles novas em vez de reaproveitar a role do DATABASE_URL
-- (`seconectai`, dono das tabelas): a role criada pelo `POSTGRES_USER` do
-- docker-compose é a role bootstrap do initdb, portanto é SUPERUSER — e
-- superusers do Postgres **sempre** ignoram Row Level Security, mesmo com
-- `ENABLE ROW LEVEL SECURITY`/`FORCE ROW LEVEL SECURITY` nas tabelas. Se a
-- aplicação continuasse consultando o banco com essa role em runtime, o
-- isolamento por tenant seria só decorativo. Por isso:
--
-- * `app_master` — LOGIN + BYPASSRLS, mas SEM ser superuser. Usada via
-- `DATABASE_URL_MASTER` pelo painel master (enxerga todos os tenants),
-- como pedido no prompt da etapa.
-- * `app_tenant` — LOGIN comum, SEM BYPASSRLS e SEM ser superuser. Usada
-- via `DATABASE_URL_TENANT` para toda query autenticada como
-- tenant_user, dentro da transação que executa
-- `SET LOCAL app.current_tenant_id`; é essa role que faz as policies de
-- RLS (`prisma/migrations/20260812161100_rls_views_constraints`) valerem
-- de fato. (Desvio registrado em Etapas/09-controle-de-progresso.md: o
-- prompt original só citava `DATABASE_URL_MASTER`.)
--
-- `DATABASE_URL` (role `seconectai`) continua sendo usada só para
-- migrations e para o `PrismaService` "admin" (health check, bootstrap de
-- login antes de existir um tenant_id de sessão — ver AuthService).
--
-- As senhas abaixo são placeholders de desenvolvimento, no mesmo espírito
-- de `POSTGRES_PASSWORD`/`JWT_SECRET` em `.env.example`: troque-as em
-- produção com `ALTER ROLE ... WITH PASSWORD '...'` e atualize
-- `DATABASE_URL_MASTER`/`DATABASE_URL_TENANT` de acordo.
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'app_master') THEN
CREATE ROLE app_master WITH LOGIN PASSWORD 'app_master_changeme' NOSUPERUSER BYPASSRLS;
END IF;
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'app_tenant') THEN
CREATE ROLE app_tenant WITH LOGIN PASSWORD 'app_tenant_changeme' NOSUPERUSER NOBYPASSRLS;
END IF;
END
$$;
GRANT USAGE ON SCHEMA public TO app_master, app_tenant;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_master, app_tenant;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_master, app_tenant;
-- Cobre tabelas/sequences criadas por migrations futuras (etapas 3+),
-- desde que aplicadas com a mesma role que rodou esta migration.
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_master, app_tenant;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO app_master, app_tenant;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+438
View File
@@ -0,0 +1,438 @@
// SeConectai Vendas — Prisma schema
// Traduzido 1:1 a partir de `Etapas/seconectai-vendas-schema.sql` (fonte de verdade).
// Constructos que o Prisma DSL não expressa nativamente (views, índices parciais,
// CHECK constraints e as policies de RLS) são criados por uma migration manual
// em `prisma/migrations/<timestamp>_rls_views_constraints/migration.sql`.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============================================================
// ENUMS
// ============================================================
enum tenant_status {
trial
ativo
inadimplente
suspenso
cancelado
}
enum cobranca_status {
pendente
pago
atrasado
cancelado
}
enum user_role {
tenant_admin
vendedor
financeiro
visualizador
}
enum item_tipo {
produto
servico
}
enum venda_status {
orcamento
confirmada
cancelada
concluida
}
enum parcela_status {
pendente
pago
atrasado
cancelado
}
// ============================================================
// 1. NÍVEL MASTER
// ============================================================
model master_admins {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
nome String @db.VarChar(150)
email String @unique @db.VarChar(150)
senha_hash String
ativo Boolean @default(true)
mfa_secret String?
ultimo_login DateTime? @db.Timestamptz(6)
criado_em DateTime @default(now()) @db.Timestamptz(6)
atualizado_em DateTime @default(now()) @db.Timestamptz(6)
tenants_criados tenants[]
}
model evolution_api_config {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
nome String @db.VarChar(100)
base_url String @db.VarChar(255)
api_key_cripto String
versao String @default("2.3") @db.VarChar(20)
ativo Boolean @default(true)
criado_em DateTime @default(now()) @db.Timestamptz(6)
instancias tenant_whatsapp_instances[]
}
model master_email_config {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
smtp_host String @db.VarChar(150)
smtp_porta Int
smtp_usuario String @db.VarChar(150)
smtp_senha_cripto String
email_remetente String @db.VarChar(150)
ativo Boolean @default(true)
criado_em DateTime @default(now()) @db.Timestamptz(6)
}
model planos {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
nome String @db.VarChar(100)
descricao String?
valor_mensal Decimal @db.Decimal(10, 2)
limite_customers Int?
limite_usuarios Int?
limite_instancias_whatsapp Int @default(1)
ativo Boolean @default(true)
criado_em DateTime @default(now()) @db.Timestamptz(6)
tenants tenants[]
}
// ============================================================
// 2. TENANTS
// ============================================================
model tenants {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
razao_social String @db.VarChar(200)
nome_fantasia String? @db.VarChar(200)
documento String? @db.VarChar(20)
email_contato String @db.VarChar(150)
telefone String? @db.VarChar(20)
logo_url String?
cor_primaria String? @db.VarChar(7)
plano_id String? @db.Uuid
status tenant_status @default(trial)
dia_vencimento_mensalidade Int @default(10) @db.SmallInt
trial_expira_em DateTime? @db.Timestamptz(6)
criado_por String? @db.Uuid
criado_em DateTime @default(now()) @db.Timestamptz(6)
atualizado_em DateTime @default(now()) @db.Timestamptz(6)
plano planos? @relation(fields: [plano_id], references: [id])
criador master_admins? @relation(fields: [criado_por], references: [id])
faturas tenant_faturas[]
usuarios tenant_users[]
whatsapp_instances tenant_whatsapp_instances[]
email_config tenant_email_config?
alert_templates alert_templates[]
alert_logs alert_logs[]
customers customers[]
produtos products_services[]
vendas sales[]
audit_logs audit_logs[]
@@index([status], map: "idx_tenants_status")
}
model tenant_faturas {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
tenant_id String @db.Uuid
referencia String @db.VarChar(7)
valor Decimal @db.Decimal(10, 2)
vencimento DateTime @db.Date
pago_em DateTime? @db.Timestamptz(6)
status cobranca_status @default(pendente)
alerta_enviado_em DateTime? @db.Timestamptz(6)
criado_em DateTime @default(now()) @db.Timestamptz(6)
tenant tenants @relation(fields: [tenant_id], references: [id], onDelete: Cascade)
@@unique([tenant_id, referencia])
}
// ============================================================
// 3. USUÁRIOS E AUTENTICAÇÃO
// ============================================================
model tenant_users {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
tenant_id String @db.Uuid
nome String @db.VarChar(150)
email String @db.VarChar(150)
senha_hash String
role user_role @default(vendedor)
ativo Boolean @default(true)
mfa_secret String?
ultimo_login DateTime? @db.Timestamptz(6)
criado_em DateTime @default(now()) @db.Timestamptz(6)
tenant tenants @relation(fields: [tenant_id], references: [id], onDelete: Cascade)
vendas sales[]
@@unique([tenant_id, email])
@@index([tenant_id], map: "idx_tenant_users_tenant")
}
model auth_refresh_tokens {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
usuario_tipo String @db.VarChar(20)
usuario_id String @db.Uuid
token_hash String
expira_em DateTime @db.Timestamptz(6)
revogado Boolean @default(false)
ip_origem String? @db.Inet
user_agent String?
criado_em DateTime @default(now()) @db.Timestamptz(6)
@@index([usuario_tipo, usuario_id], map: "idx_refresh_tokens_usuario")
}
model password_reset_tokens {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
usuario_tipo String @db.VarChar(20)
usuario_id String @db.Uuid
token_hash String
expira_em DateTime @db.Timestamptz(6)
usado Boolean @default(false)
criado_em DateTime @default(now()) @db.Timestamptz(6)
}
model login_attempts {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
email String @db.VarChar(150)
usuario_tipo String @db.VarChar(20)
sucesso Boolean
ip_origem String? @db.Inet
criado_em DateTime @default(now()) @db.Timestamptz(6)
@@index([email, criado_em], map: "idx_login_attempts_email_data")
}
// ============================================================
// 4. INTEGRAÇÕES POR TENANT
// ============================================================
model tenant_whatsapp_instances {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
tenant_id String @db.Uuid
evolution_config_id String? @db.Uuid
instance_name String @unique @db.VarChar(100)
numero_conectado String? @db.VarChar(20)
status String @default("desconectado") @db.VarChar(30)
qr_code_gerado_em DateTime? @db.Timestamptz(6)
criado_em DateTime @default(now()) @db.Timestamptz(6)
tenant tenants @relation(fields: [tenant_id], references: [id], onDelete: Cascade)
evolution_config evolution_api_config? @relation(fields: [evolution_config_id], references: [id])
}
model tenant_email_config {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
tenant_id String @unique @db.Uuid
smtp_host String? @db.VarChar(150)
smtp_porta Int?
smtp_usuario String? @db.VarChar(150)
smtp_senha_cripto String?
email_remetente String? @db.VarChar(150)
usar_config_padrao Boolean @default(true)
criado_em DateTime @default(now()) @db.Timestamptz(6)
tenant tenants @relation(fields: [tenant_id], references: [id], onDelete: Cascade)
}
model alert_templates {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
tenant_id String? @db.Uuid
tipo String @db.VarChar(50)
canal String @db.VarChar(20)
assunto String? @db.VarChar(200)
corpo String
ativo Boolean @default(true)
criado_em DateTime @default(now()) @db.Timestamptz(6)
tenant tenants? @relation(fields: [tenant_id], references: [id], onDelete: Cascade)
alert_logs alert_logs[]
}
model alert_logs {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
tenant_id String? @db.Uuid
destinatario_tipo String @db.VarChar(20)
destinatario_id String @db.Uuid
canal String @db.VarChar(20)
template_id String? @db.Uuid
conteudo_enviado String?
status String @default("enviado") @db.VarChar(20)
erro String?
enviado_em DateTime @default(now()) @db.Timestamptz(6)
tenant tenants? @relation(fields: [tenant_id], references: [id], onDelete: Cascade)
template alert_templates? @relation(fields: [template_id], references: [id])
@@index([tenant_id, enviado_em], map: "idx_alert_logs_tenant")
}
// ============================================================
// 5. CLIENTES DO TENANT ("customers")
// ============================================================
model customers {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
tenant_id String @db.Uuid
razao_social String @db.VarChar(200)
nome_fantasia String? @db.VarChar(200)
documento String? @db.VarChar(20)
email String? @db.VarChar(150)
telefone_whatsapp String? @db.VarChar(20)
endereco String?
cidade String? @db.VarChar(100)
uf String? @db.Char(2)
observacoes String?
ativo Boolean @default(true)
criado_em DateTime @default(now()) @db.Timestamptz(6)
atualizado_em DateTime @default(now()) @db.Timestamptz(6)
tenant tenants @relation(fields: [tenant_id], references: [id], onDelete: Cascade)
contatos customer_contacts[]
vendas sales[]
@@index([tenant_id], map: "idx_customers_tenant")
}
model customer_contacts {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
customer_id String @db.Uuid
nome String @db.VarChar(150)
cargo String? @db.VarChar(100)
email String? @db.VarChar(150)
telefone_whatsapp String? @db.VarChar(20)
principal Boolean @default(false)
criado_em DateTime @default(now()) @db.Timestamptz(6)
customer customers @relation(fields: [customer_id], references: [id], onDelete: Cascade)
}
// ============================================================
// 6. PRODUTOS E SERVIÇOS DO TENANT
// ============================================================
model products_services {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
tenant_id String @db.Uuid
tipo item_tipo
nome String @db.VarChar(200)
descricao String?
sku String? @db.VarChar(50)
valor_venda Decimal @db.Decimal(12, 2)
valor_custo Decimal? @db.Decimal(12, 2)
unidade String @default("un") @db.VarChar(20)
recorrente Boolean @default(false)
ativo Boolean @default(true)
criado_em DateTime @default(now()) @db.Timestamptz(6)
atualizado_em DateTime @default(now()) @db.Timestamptz(6)
tenant tenants @relation(fields: [tenant_id], references: [id], onDelete: Cascade)
itens_venda sale_items[]
@@unique([tenant_id, sku])
@@index([tenant_id], map: "idx_products_tenant")
}
// ============================================================
// 7. VENDAS
// ============================================================
model sales {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
tenant_id String @db.Uuid
customer_id String @db.Uuid
vendedor_id String? @db.Uuid
numero_venda Int @default(autoincrement())
status venda_status @default(confirmada)
valor_total Decimal @default(0) @db.Decimal(12, 2)
desconto Decimal @default(0) @db.Decimal(12, 2)
observacoes String?
data_venda DateTime @default(now()) @db.Date
criado_em DateTime @default(now()) @db.Timestamptz(6)
atualizado_em DateTime @default(now()) @db.Timestamptz(6)
tenant tenants @relation(fields: [tenant_id], references: [id], onDelete: Cascade)
customer customers @relation(fields: [customer_id], references: [id])
vendedor tenant_users? @relation(fields: [vendedor_id], references: [id])
itens sale_items[]
parcelas sale_payments[]
@@index([tenant_id, data_venda], map: "idx_sales_tenant_data")
@@index([customer_id], map: "idx_sales_customer")
}
model sale_items {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
sale_id String @db.Uuid
product_service_id String @db.Uuid
quantidade Decimal @default(1) @db.Decimal(10, 2)
valor_unitario Decimal @db.Decimal(12, 2)
valor_total Decimal @db.Decimal(12, 2)
sale sales @relation(fields: [sale_id], references: [id], onDelete: Cascade)
produto products_services @relation(fields: [product_service_id], references: [id])
@@index([sale_id], map: "idx_sale_items_sale")
}
model sale_payments {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
sale_id String @db.Uuid
numero_parcela Int @default(1) @db.SmallInt
valor Decimal @db.Decimal(12, 2)
vencimento DateTime @db.Date
pago_em DateTime? @db.Timestamptz(6)
forma_pagamento String? @db.VarChar(30)
status parcela_status @default(pendente)
alerta_enviado_em DateTime? @db.Timestamptz(6)
criado_em DateTime @default(now()) @db.Timestamptz(6)
sale sales @relation(fields: [sale_id], references: [id], onDelete: Cascade)
}
// ============================================================
// 8. AUDITORIA GERAL
// ============================================================
model audit_logs {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
tenant_id String? @db.Uuid
usuario_tipo String @db.VarChar(20)
usuario_id String? @db.Uuid
acao String @db.VarChar(100)
entidade String? @db.VarChar(50)
entidade_id String? @db.Uuid
detalhes Json?
ip_origem String? @db.Inet
criado_em DateTime @default(now()) @db.Timestamptz(6)
tenant tenants? @relation(fields: [tenant_id], references: [id], onDelete: SetNull)
@@index([tenant_id, criado_em], map: "idx_audit_tenant_data")
}
+37
View File
@@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { EncryptionModule } from './common/encryption.module';
import { validate } from './config/env.validation';
import { PrismaModule } from './config/prisma.module';
import { AlertDispatchModule } from './modules/alert-dispatch/alert-dispatch.module';
import { AuthModule } from './modules/auth/auth.module';
import { EmailConfigModule } from './modules/email-config/email-config.module';
import { EvolutionModule } from './modules/evolution/evolution.module';
import { HealthModule } from './modules/health/health.module';
import { PlanosModule } from './modules/planos/planos.module';
import { TenantsModule } from './modules/tenants/tenants.module';
import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validate,
}),
ThrottlerModule.forRoot([{ name: 'default', ttl: 60_000, limit: 60 }]),
PrismaModule,
EncryptionModule,
AuthModule,
HealthModule,
PlanosModule,
TenantsModule,
EvolutionModule,
AlertDispatchModule,
EmailConfigModule,
WhatsappModule,
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}
+19
View File
@@ -0,0 +1,19 @@
import { Prisma, PrismaClient } from '@prisma/client';
type AuditableClient = PrismaClient | Prisma.TransactionClient;
export interface AuditLogParams {
usuario_tipo: 'master' | 'tenant_user';
usuario_id?: string | null;
tenant_id?: string | null;
acao: string;
entidade?: string;
entidade_id?: string | null;
detalhes?: Prisma.InputJsonValue;
ip_origem?: string;
}
/** Grava uma linha em `audit_logs`. Passe o `tx` da transação em andamento para que a auditoria só valha se o resto da operação for confirmado junto. */
export function writeAuditLog(client: AuditableClient, params: AuditLogParams) {
return client.audit_logs.create({ data: params });
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { EncryptionService } from './encryption.service';
@Global()
@Module({
providers: [EncryptionService],
exports: [EncryptionService],
})
export class EncryptionModule {}
+34
View File
@@ -0,0 +1,34 @@
import { ConfigService } from '@nestjs/config';
import { EncryptionService } from './encryption.service';
describe('EncryptionService', () => {
const buildService = (key = 'chave-de-teste-com-mais-de-32-caracteres') =>
new EncryptionService({ get: () => key } as unknown as ConfigService);
it('decifra um valor cifrado de volta ao texto original', () => {
const service = buildService();
const cifrado = service.encrypt('minha-api-key-secreta');
expect(cifrado).not.toBe('minha-api-key-secreta');
expect(service.decrypt(cifrado)).toBe('minha-api-key-secreta');
});
it('gera saídas diferentes para o mesmo texto (IV aleatório)', () => {
const service = buildService();
expect(service.encrypt('mesmo-valor')).not.toBe(service.encrypt('mesmo-valor'));
});
it('falha ao decifrar com uma chave diferente da usada para cifrar', () => {
const cifrado = buildService('chave-a-com-mais-de-32-caracteres').encrypt('segredo');
const outraChave = buildService('chave-b-com-mais-de-32-caracteres-tambem');
expect(() => outraChave.decrypt(cifrado)).toThrow();
});
it('lança erro na inicialização se ENCRYPTION_KEY não estiver definida', () => {
expect(
() => new EncryptionService({ get: () => undefined } as unknown as ConfigService),
).toThrow(/ENCRYPTION_KEY/);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
/**
* Cifra/decifra segredos em repouso (api_key da Evolution API, senha SMTP)
* com AES-256-GCM. A chave vem de `ENCRYPTION_KEY` (env, nunca hardcoded) e
* é reduzida a 32 bytes via SHA-256 — assim `ENCRYPTION_KEY` pode ser
* qualquer string longa o suficiente (mesmo padrão de validação de
* `JWT_SECRET`), sem exigir um valor hex/base64 de tamanho exato.
*/
@Injectable()
export class EncryptionService {
private readonly key: Buffer;
constructor(configService: ConfigService) {
const secret = configService.get<string>('ENCRYPTION_KEY');
if (!secret) {
throw new Error(
'ENCRYPTION_KEY não definida. Ela é obrigatória para cifrar segredos como api_key ' +
'da Evolution API e senha SMTP (ver .env.example).',
);
}
this.key = createHash('sha256').update(secret).digest();
}
/** Retorna `iv:authTag:conteudo` (hex), pronto para gravar em uma coluna `*_cripto`. */
encrypt(plainText: string): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, this.key, iv);
const encrypted = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return [iv, authTag, encrypted].map((buffer) => buffer.toString('hex')).join(':');
}
decrypt(cipherText: string): string {
const [ivHex, authTagHex, dataHex] = cipherText.split(':');
if (!ivHex || !authTagHex || !dataHex) {
throw new Error('Formato de valor cifrado inválido');
}
const decipher = createDecipheriv(ALGORITHM, this.key, Buffer.from(ivHex, 'hex'));
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
return Buffer.concat([decipher.update(Buffer.from(dataHex, 'hex')), decipher.final()]).toString(
'utf8',
);
}
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Mascara um segredo em texto puro para exibição em resposta de API: nunca
* revela o tamanho real do valor (largura fixa de asteriscos) e mostra só
* os últimos 4 caracteres, para o usuário reconhecer qual segredo é sem
* conseguir reconstruí-lo.
*/
export function maskSecret(plainText: string): string {
const ultimosQuatro = plainText.slice(-4);
return `${'*'.repeat(8)}${ultimosQuatro}`;
}
+76
View File
@@ -0,0 +1,76 @@
import { plainToInstance } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, MinLength, validateSync } from 'class-validator';
class EnvironmentVariables {
@IsIn(['development', 'production', 'test'])
@IsOptional()
NODE_ENV?: string;
@IsInt()
@IsOptional()
PORT?: number;
@IsString()
DATABASE_URL: string;
@IsString()
@IsOptional()
DATABASE_URL_MASTER?: string;
@IsString()
@IsOptional()
DATABASE_URL_TENANT?: string;
@IsString()
@MinLength(32)
JWT_SECRET: string;
@IsString()
@MinLength(32)
ENCRYPTION_KEY: string;
@IsString()
@IsOptional()
REDIS_HOST?: string;
@IsInt()
@IsOptional()
REDIS_PORT?: number;
@IsString()
@IsOptional()
JWT_ACCESS_EXPIRES_IN?: string;
@IsInt()
@IsOptional()
REFRESH_TOKEN_EXPIRES_IN_DAYS?: number;
@IsInt()
@IsOptional()
PASSWORD_RESET_EXPIRES_IN_HOURS?: number;
@IsInt()
@IsOptional()
LOGIN_MAX_ATTEMPTS?: number;
@IsInt()
@IsOptional()
LOGIN_LOCKOUT_WINDOW_MINUTES?: number;
@IsInt()
@IsOptional()
TENANT_TRIAL_DIAS?: number;
}
export function validate(config: Record<string, unknown>) {
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
enableImplicitConversion: true,
});
const errors = validateSync(validatedConfig, { skipMissingProperties: false });
if (errors.length > 0) {
throw new Error(errors.toString());
}
return validatedConfig;
}
+27
View File
@@ -0,0 +1,27 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaClient } from '@prisma/client';
/**
* Conectada via `DATABASE_URL_MASTER` (role `app_master`, LOGIN + BYPASSRLS).
* Usada pelo painel master, que precisa enxergar dados de todos os tenants
* sem passar pelas policies de RLS.
*/
@Injectable()
export class PrismaMasterService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor(configService: ConfigService) {
super({
datasourceUrl:
configService.get<string>('DATABASE_URL_MASTER') ??
configService.get<string>('DATABASE_URL'),
});
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
+32
View File
@@ -0,0 +1,32 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaClient } from '@prisma/client';
/**
* Conectada via `DATABASE_URL_TENANT` (role `app_tenant`, sem BYPASSRLS).
* Toda query de request autenticada como tenant_user deve passar por essa
* conexão, dentro de uma transação com `SET LOCAL app.current_tenant_id`
* (ver `TenantContextService`) — é essa role não-superuser que faz as
* policies de RLS valerem de fato.
*/
@Injectable()
export class PrismaTenantService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor(configService: ConfigService) {
const url = configService.get<string>('DATABASE_URL_TENANT');
if (!url) {
throw new Error(
'DATABASE_URL_TENANT não definida. Ela é obrigatória: sem ela, o isolamento por ' +
'tenant via Row Level Security não é aplicado (ver .env.example).',
);
}
super({ datasourceUrl: url });
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { PrismaMasterService } from './prisma-master.service';
import { PrismaTenantService } from './prisma-tenant.service';
import { PrismaService } from './prisma.service';
import { TenantContextService } from './tenant-context.service';
@Global()
@Module({
providers: [PrismaService, PrismaMasterService, PrismaTenantService, TenantContextService],
exports: [PrismaService, PrismaMasterService, PrismaTenantService, TenantContextService],
})
export class PrismaModule {}
+13
View File
@@ -0,0 +1,13 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
+49
View File
@@ -0,0 +1,49 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaTenantService } from './prisma-tenant.service';
interface TenantStore {
tenantId: string;
tx: Prisma.TransactionClient;
}
/**
* Implementa o mecanismo de RLS por request: abre uma transação na conexão
* `app_tenant` (sem BYPASSRLS), executa `SET LOCAL app.current_tenant_id`
* dentro dela via `set_config(..., true)`, e disponibiliza essa transação
* para o resto do request via AsyncLocalStorage — nunca a partir de um
* `tenant_id` vindo de input do usuário, sempre do `tenant_id` já resolvido
* pelo token (ver `TenantRlsInterceptor`).
*/
@Injectable()
export class TenantContextService {
private readonly als = new AsyncLocalStorage<TenantStore>();
constructor(private readonly prismaTenant: PrismaTenantService) {}
async run<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
return this.prismaTenant.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, true)`;
return this.als.run({ tenantId, tx }, fn);
});
}
getTx(): Prisma.TransactionClient {
const store = this.als.getStore();
if (!store) {
throw new Error('TenantContextService.getTx() chamado fora de um contexto de tenant ativo');
}
return store.tx;
}
getTenantId(): string {
const store = this.als.getStore();
if (!store) {
throw new Error(
'TenantContextService.getTenantId() chamado fora de um contexto de tenant ativo',
);
}
return store.tenantId;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
const port = configService.get<number>('PORT', 3000);
await app.listen(port);
}
void bootstrap();
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { EmailModule } from '../email-config/email.module';
import { EvolutionModule } from '../evolution/evolution.module';
import { AlertDispatchService } from './alert-dispatch.service';
@Module({
imports: [EvolutionModule, EmailModule],
providers: [AlertDispatchService],
exports: [AlertDispatchService],
})
export class AlertDispatchModule {}
@@ -0,0 +1,102 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { EncryptionService } from '../../common/encryption.service';
import { PrismaMasterService } from '../../config/prisma-master.service';
import { EmailService } from '../email-config/email.service';
import { EvolutionApiClient } from '../evolution/evolution-api.client';
export interface DispatchResult {
sucesso: boolean;
erro?: string;
}
/**
* Serviço central de envio, preparando a Etapa 7 (motor de alertas): só
* executa o envio de um WhatsApp/e-mail já resolvido, sem nenhuma lógica de
* "quando enviar" — isso fica para o worker BullMQ da Etapa 7. Usa
* `PrismaMasterService` (nunca `TenantContextService`/RLS) porque este
* service precisa funcionar tanto dentro de uma request HTTP (endpoints de
* "testar envio" desta etapa) quanto, futuramente, dentro de um worker de
* fila sem nenhum contexto de request/tenant ativo — por isso todo acesso a
* tabela por-tenant aqui filtra `tenant_id` manualmente.
*/
@Injectable()
export class AlertDispatchService {
constructor(
private readonly prismaMaster: PrismaMasterService,
private readonly encryptionService: EncryptionService,
private readonly evolutionApiClient: EvolutionApiClient,
private readonly emailService: EmailService,
) {}
/**
* `instanceId` é opcional: quando omitido (uso futuro pela Etapa 7),
* resolve a instância conectada mais recente do tenant. Quando informado
* (usado pelo endpoint `POST /tenant/whatsapp/instancias/:id/testar-envio`),
* envia especificamente por aquela instância, para validar a conexão dela.
*/
async enviarWhatsapp(
tenantId: string,
numero: string,
mensagem: string,
instanceId?: string,
): Promise<DispatchResult> {
try {
const instance = instanceId
? await this.prismaMaster.tenant_whatsapp_instances.findFirst({
where: { id: instanceId, tenant_id: tenantId },
})
: await this.prismaMaster.tenant_whatsapp_instances.findFirst({
where: { tenant_id: tenantId, status: 'conectado' },
orderBy: { criado_em: 'desc' },
});
if (!instance) {
throw new BadRequestException(
instanceId
? 'Instância de WhatsApp não encontrada'
: 'Nenhuma instância de WhatsApp conectada para este tenant',
);
}
if (!instance.evolution_config_id) {
throw new BadRequestException(
'Instância de WhatsApp sem configuração da Evolution API associada',
);
}
const config = await this.prismaMaster.evolution_api_config.findUnique({
where: { id: instance.evolution_config_id },
});
if (!config) {
throw new BadRequestException('Configuração da Evolution API associada não encontrada');
}
await this.evolutionApiClient.enviarMensagem(
{
base_url: config.base_url,
api_key: this.encryptionService.decrypt(config.api_key_cripto),
},
instance.instance_name,
numero,
mensagem,
);
return { sucesso: true };
} catch (error) {
return { sucesso: false, erro: (error as Error).message };
}
}
async enviarEmail(
tenantId: string,
destinatario: string,
assunto: string,
corpo: string,
): Promise<DispatchResult> {
try {
await this.emailService.enviar(tenantId, destinatario, assunto, corpo);
return { sucesso: true };
} catch (error) {
return { sucesso: false, erro: (error as Error).message };
}
}
}
+119
View File
@@ -0,0 +1,119 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Req, UseGuards } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { Request } from 'express';
import { PrismaMasterService } from '../../config/prisma-master.service';
import { TenantContextService } from '../../config/tenant-context.service';
import { AuthService, TokenPair } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator';
import { Roles } from './decorators/roles.decorator';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { LoginDto } from './dto/login.dto';
import { LogoutDto } from './dto/logout.dto';
import { RefreshDto } from './dto/refresh.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { MasterGuard } from './guards/master.guard';
import { RolesGuard } from './guards/roles.guard';
import { TenantGuard } from './guards/tenant.guard';
import { JwtPayload } from './types/jwt-payload.type';
// Rate limit por IP nas rotas de login, como segunda camada além do
// bloqueio persistente por e-mail (`login_attempts`, ver
// AuthService.assertNotLockedOut — esse sim é o mecanismo de força bruta
// pedido no critério de aceite, com limite bem mais estrito). Este aqui é
// só uma rede de segurança contra um IP martelando várias contas
// diferentes; por isso o limite é generoso (não deve incomodar um IP
// legítimo, ex: escritório atrás de NAT com vários usuários).
const LOGIN_THROTTLE = { default: { limit: 30, ttl: 60_000 } };
function requestMeta(req: Request) {
return { ip: req.ip, userAgent: req.get('user-agent') };
}
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly prismaMaster: PrismaMasterService,
private readonly tenantContext: TenantContextService,
) {}
@HttpCode(HttpStatus.OK)
@Throttle(LOGIN_THROTTLE)
@Post('master/login')
loginMaster(@Body() dto: LoginDto, @Req() req: Request): Promise<TokenPair> {
return this.authService.loginMaster(dto, requestMeta(req));
}
@HttpCode(HttpStatus.OK)
@Throttle(LOGIN_THROTTLE)
@Post('tenant/login')
loginTenant(@Body() dto: LoginDto, @Req() req: Request): Promise<TokenPair> {
return this.authService.loginTenant(dto, requestMeta(req));
}
@HttpCode(HttpStatus.OK)
@Post('refresh')
refresh(@Body() dto: RefreshDto, @Req() req: Request): Promise<TokenPair> {
return this.authService.refresh(dto.refresh_token, requestMeta(req));
}
@HttpCode(HttpStatus.NO_CONTENT)
@Post('logout')
async logout(@Body() dto: LogoutDto): Promise<void> {
await this.authService.logout(dto.refresh_token);
}
@HttpCode(HttpStatus.OK)
@Post('password/forgot')
forgotPassword(@Body() dto: ForgotPasswordDto): Promise<{ message: string }> {
return this.authService.forgotPassword(dto);
}
@HttpCode(HttpStatus.OK)
@Post('password/reset')
resetPassword(@Body() dto: ResetPasswordDto): Promise<{ message: string }> {
return this.authService.resetPassword(dto);
}
/** Qualquer usuário autenticado (master ou tenant_user) — demonstra @CurrentUser()/JwtAuthGuard. */
@UseGuards(JwtAuthGuard)
@Get('me')
me(@CurrentUser() user: JwtPayload): JwtPayload {
return user;
}
/** Só master — demonstra MasterGuard + conexão BYPASSRLS (app_master). */
@UseGuards(JwtAuthGuard, MasterGuard)
@Get('master/admins')
listMasterAdmins() {
return this.prismaMaster.master_admins.findMany({
select: {
id: true,
nome: true,
email: true,
ativo: true,
ultimo_login: true,
criado_em: true,
},
orderBy: { criado_em: 'asc' },
});
}
/**
* Só tenant_admin — demonstra TenantGuard + RolesGuard + o mecanismo de
* RLS (TenantRlsInterceptor/TenantContextService, conexão app_tenant).
* `tenant_id` de query é ignorado de propósito: o isolamento vem sempre
* da sessão (token), nunca de input do usuário.
*/
@UseGuards(JwtAuthGuard, TenantGuard, RolesGuard)
@Roles('tenant_admin')
@Get('tenant/users')
listTenantUsers() {
const tx = this.tenantContext.getTx();
return tx.tenant_users.findMany({
select: { id: true, nome: true, email: true, role: true, ativo: true, criado_em: true },
orderBy: { criado_em: 'asc' },
});
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { MasterGuard } from './guards/master.guard';
import { RolesGuard } from './guards/roles.guard';
import { TenantGuard } from './guards/tenant.guard';
import { TenantRlsInterceptor } from './interceptors/tenant-rls.interceptor';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
}),
}),
],
controllers: [AuthController],
providers: [
AuthService,
JwtStrategy,
JwtAuthGuard,
MasterGuard,
TenantGuard,
RolesGuard,
{ provide: APP_INTERCEPTOR, useClass: TenantRlsInterceptor },
],
exports: [AuthService],
})
export class AuthModule {}
+321
View File
@@ -0,0 +1,321 @@
import { HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { user_role } from '@prisma/client';
import { PrismaService } from '../../config/prisma.service';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { LoginDto } from './dto/login.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { assertPasswordStrength, hashPassword, verifyPassword } from './password.util';
import { expiresInToSeconds } from './time.util';
import { generateOpaqueToken, hashToken } from './token.util';
import { JwtPayload, UserType } from './types/jwt-payload.type';
export interface TokenPair {
access_token: string;
refresh_token: string;
token_type: 'Bearer';
expires_in: number;
}
export interface RequestMeta {
ip?: string;
userAgent?: string;
}
/** Status de tenant que bloqueiam login/acesso dos seus `tenant_users` — reutilizado pelo `TenantGuard` para bloquear em toda request, não só no login. */
export const TENANT_BLOCKED_STATUSES = new Set(['suspenso', 'cancelado']);
@Injectable()
export class AuthService {
private readonly accessTokenExpiresIn: string;
private readonly refreshTokenExpiresInDays: number;
private readonly passwordResetExpiresInHours: number;
private readonly loginMaxAttempts: number;
private readonly loginLockoutWindowMinutes: number;
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {
this.accessTokenExpiresIn = this.configService.get<string>('JWT_ACCESS_EXPIRES_IN', '15m');
this.refreshTokenExpiresInDays = this.configService.get<number>(
'REFRESH_TOKEN_EXPIRES_IN_DAYS',
30,
);
this.passwordResetExpiresInHours = this.configService.get<number>(
'PASSWORD_RESET_EXPIRES_IN_HOURS',
1,
);
this.loginMaxAttempts = this.configService.get<number>('LOGIN_MAX_ATTEMPTS', 5);
this.loginLockoutWindowMinutes = this.configService.get<number>(
'LOGIN_LOCKOUT_WINDOW_MINUTES',
15,
);
}
async loginMaster(dto: LoginDto, meta: RequestMeta): Promise<TokenPair> {
await this.assertNotLockedOut(dto.email, 'master');
const admin = await this.prisma.master_admins.findUnique({ where: { email: dto.email } });
const senhaOk = admin?.ativo ? await verifyPassword(admin.senha_hash, dto.senha) : false;
await this.recordLoginAttempt(dto.email, 'master', senhaOk, meta.ip);
if (!admin || !admin.ativo || !senhaOk) {
throw new UnauthorizedException('E-mail ou senha inválidos');
}
await this.prisma.master_admins.update({
where: { id: admin.id },
data: { ultimo_login: new Date() },
});
return this.issueTokenPair({ tipo: 'master', usuario_id: admin.id, tenant_id: null }, meta);
}
async loginTenant(dto: LoginDto, meta: RequestMeta): Promise<TokenPair> {
await this.assertNotLockedOut(dto.email, 'tenant_user');
// `email` é único por tenant (UNIQUE(tenant_id, email)), não
// globalmente. Se o mesmo e-mail existir em mais de um tenant não há
// como resolver a ambiguidade com segurança apenas com email+senha —
// trata como credencial inválida em vez de arriscar logar na conta
// errada.
const usuario = await this.uniqueTenantUserByEmail(dto.email);
const senhaOk = usuario?.ativo ? await verifyPassword(usuario.senha_hash, dto.senha) : false;
await this.recordLoginAttempt(dto.email, 'tenant_user', senhaOk, meta.ip);
if (!usuario || !usuario.ativo || !senhaOk) {
throw new UnauthorizedException('E-mail ou senha inválidos');
}
const tenant = await this.prisma.tenants.findUnique({ where: { id: usuario.tenant_id } });
if (!tenant || TENANT_BLOCKED_STATUSES.has(tenant.status)) {
throw new UnauthorizedException('Acesso bloqueado para este tenant');
}
await this.prisma.tenant_users.update({
where: { id: usuario.id },
data: { ultimo_login: new Date() },
});
return this.issueTokenPair(
{
tipo: 'tenant_user',
usuario_id: usuario.id,
tenant_id: usuario.tenant_id,
role: usuario.role,
},
meta,
);
}
async refresh(refreshToken: string, meta: RequestMeta): Promise<TokenPair> {
const tokenHash = hashToken(refreshToken);
const stored = await this.prisma.auth_refresh_tokens.findFirst({
where: { token_hash: tokenHash },
});
if (!stored || stored.revogado || stored.expira_em < new Date()) {
if (stored?.revogado) {
// Reuso de um refresh token já rotacionado é indício de roubo de
// token: revoga todos os tokens ativos desse usuário por precaução.
await this.prisma.auth_refresh_tokens.updateMany({
where: {
usuario_tipo: stored.usuario_tipo,
usuario_id: stored.usuario_id,
revogado: false,
},
data: { revogado: true },
});
}
throw new UnauthorizedException('Refresh token inválido ou expirado');
}
await this.prisma.auth_refresh_tokens.update({
where: { id: stored.id },
data: { revogado: true },
});
const tipo = stored.usuario_tipo as UserType;
if (tipo === 'master') {
const admin = await this.prisma.master_admins.findUnique({
where: { id: stored.usuario_id },
});
if (!admin || !admin.ativo) {
throw new UnauthorizedException('Usuário inválido');
}
return this.issueTokenPair({ tipo: 'master', usuario_id: admin.id, tenant_id: null }, meta);
}
const usuario = await this.prisma.tenant_users.findUnique({ where: { id: stored.usuario_id } });
if (!usuario || !usuario.ativo) {
throw new UnauthorizedException('Usuário inválido');
}
return this.issueTokenPair(
{
tipo: 'tenant_user',
usuario_id: usuario.id,
tenant_id: usuario.tenant_id,
role: usuario.role,
},
meta,
);
}
async logout(refreshToken: string): Promise<void> {
const tokenHash = hashToken(refreshToken);
await this.prisma.auth_refresh_tokens.updateMany({
where: { token_hash: tokenHash, revogado: false },
data: { revogado: true },
});
}
async forgotPassword(dto: ForgotPasswordDto): Promise<{ message: string }> {
// Resposta genérica sempre igual (exista ou não a conta) para não
// revelar quais e-mails estão cadastrados.
const generic = {
message: 'Se o e-mail existir, as instruções de redefinição foram enviadas.',
};
const usuario =
dto.usuario_tipo === 'master'
? await this.prisma.master_admins.findUnique({ where: { email: dto.email } })
: await this.uniqueTenantUserByEmail(dto.email);
if (!usuario || !usuario.ativo) {
return generic;
}
const token = generateOpaqueToken();
const expira_em = new Date(Date.now() + this.passwordResetExpiresInHours * 60 * 60 * 1000);
await this.prisma.password_reset_tokens.create({
data: {
usuario_tipo: dto.usuario_tipo,
usuario_id: usuario.id,
token_hash: hashToken(token),
expira_em,
},
});
// Envio real de e-mail é de uma etapa futura (Etapa 4 — Integrações);
// por ora, só loga o token/link no console, como pedido no prompt desta etapa.
console.log(
`[password-reset] usuario_tipo=${dto.usuario_tipo} usuario_id=${usuario.id} ` +
`token=${token} expira_em=${expira_em.toISOString()}`,
);
return generic;
}
async resetPassword(dto: ResetPasswordDto): Promise<{ message: string }> {
assertPasswordStrength(dto.nova_senha);
const tokenHash = hashToken(dto.token);
const registro = await this.prisma.password_reset_tokens.findFirst({
where: { token_hash: tokenHash },
});
if (!registro || registro.usado || registro.expira_em < new Date()) {
throw new UnauthorizedException('Token de redefinição inválido ou expirado');
}
const senha_hash = await hashPassword(dto.nova_senha);
await this.prisma.$transaction(async (tx) => {
await tx.password_reset_tokens.update({ where: { id: registro.id }, data: { usado: true } });
if (registro.usuario_tipo === 'master') {
await tx.master_admins.update({ where: { id: registro.usuario_id }, data: { senha_hash } });
} else {
await tx.tenant_users.update({ where: { id: registro.usuario_id }, data: { senha_hash } });
}
await tx.auth_refresh_tokens.updateMany({
where: {
usuario_tipo: registro.usuario_tipo,
usuario_id: registro.usuario_id,
revogado: false,
},
data: { revogado: true },
});
});
return { message: 'Senha redefinida com sucesso.' };
}
private async uniqueTenantUserByEmail(email: string) {
const candidatos = await this.prisma.tenant_users.findMany({ where: { email } });
return candidatos.length === 1 ? candidatos[0] : undefined;
}
private async issueTokenPair(
params: { tipo: UserType; usuario_id: string; tenant_id: string | null; role?: user_role },
meta: RequestMeta,
): Promise<TokenPair> {
const payload: JwtPayload = {
sub: params.usuario_id,
tipo: params.tipo,
tenant_id: params.tenant_id,
...(params.role ? { role: params.role } : {}),
};
const access_token = await this.jwtService.signAsync(payload, {
// `expiresIn` aceita string ("15m"/"1h"/...), mas o tipo `StringValue`
// da lib `ms` só cobre literais fixos — aqui o valor vem de env/config.
expiresIn: this.accessTokenExpiresIn as unknown as number,
});
const refresh_token = generateOpaqueToken();
const expira_em = new Date(Date.now() + this.refreshTokenExpiresInDays * 24 * 60 * 60 * 1000);
await this.prisma.auth_refresh_tokens.create({
data: {
usuario_tipo: params.tipo,
usuario_id: params.usuario_id,
token_hash: hashToken(refresh_token),
expira_em,
ip_origem: meta.ip,
user_agent: meta.userAgent,
},
});
return {
access_token,
refresh_token,
token_type: 'Bearer',
expires_in: expiresInToSeconds(this.accessTokenExpiresIn),
};
}
private async assertNotLockedOut(email: string, usuario_tipo: UserType): Promise<void> {
const desde = new Date(Date.now() - this.loginLockoutWindowMinutes * 60 * 1000);
const falhas = await this.prisma.login_attempts.count({
where: { email, usuario_tipo, sucesso: false, criado_em: { gte: desde } },
});
if (falhas >= this.loginMaxAttempts) {
throw new HttpException(
`Muitas tentativas de login falhas para este e-mail. Tente novamente em até ${this.loginLockoutWindowMinutes} minutos.`,
HttpStatus.TOO_MANY_REQUESTS,
);
}
}
private async recordLoginAttempt(
email: string,
usuario_tipo: UserType,
sucesso: boolean,
ip?: string,
): Promise<void> {
await this.prisma.login_attempts.create({
data: { email, usuario_tipo, sucesso, ip_origem: ip },
});
}
}
@@ -0,0 +1,7 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AuthenticatedRequest } from '../types/authenticated-request.type';
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
return request.user;
});
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { user_role } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: user_role[]) => SetMetadata(ROLES_KEY, roles);
@@ -0,0 +1,10 @@
import { IsEmail, IsIn } from 'class-validator';
import { UserType } from '../types/jwt-payload.type';
export class ForgotPasswordDto {
@IsEmail()
email: string;
@IsIn(['master', 'tenant_user'])
usuario_tipo: UserType;
}
+10
View File
@@ -0,0 +1,10 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
@MinLength(1)
senha: string;
}
+6
View File
@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class LogoutDto {
@IsString()
refresh_token: string;
}
+6
View File
@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class RefreshDto {
@IsString()
refresh_token: string;
}
@@ -0,0 +1,10 @@
import { IsString, MinLength } from 'class-validator';
export class ResetPasswordDto {
@IsString()
token: string;
@IsString()
@MinLength(10)
nova_senha: string;
}
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
+16
View File
@@ -0,0 +1,16 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { AuthenticatedRequest } from '../types/authenticated-request.type';
/** Permite apenas requests autenticadas como `master` (deve rodar depois de JwtAuthGuard). */
@Injectable()
export class MasterGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
if (request.user?.tipo !== 'master') {
throw new ForbiddenException('Rota restrita ao painel master');
}
return true;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { user_role } from '@prisma/client';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { AuthenticatedRequest } from '../types/authenticated-request.type';
/** Restringe por papel dentro do tenant (`@Roles('tenant_admin', ...)`). Sem decorator, libera. */
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<user_role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const role = request.user?.role;
if (!role || !requiredRoles.includes(role)) {
throw new ForbiddenException('Papel do usuário não tem permissão para esta rota');
}
return true;
}
}
+39
View File
@@ -0,0 +1,39 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { PrismaMasterService } from '../../../config/prisma-master.service';
import { TENANT_BLOCKED_STATUSES } from '../auth.service';
import { AuthenticatedRequest } from '../types/authenticated-request.type';
/**
* Permite apenas requests autenticadas como `tenant_user` (deve rodar
* depois de JwtAuthGuard), bloqueia se o tenant estiver `suspenso`/
* `cancelado` (checado a cada request, não só no login — um access token
* emitido antes da suspensão continua válido até expirar, então o corte de
* acesso não pode depender só da checagem feita em `AuthService.loginTenant`)
* e expõe `tenant_id` no contexto da request — o `TenantRlsInterceptor` usa
* esse mesmo `request.user.tenant_id` (nunca um valor vindo de body/query)
* para abrir a transação com RLS.
*/
@Injectable()
export class TenantGuard implements CanActivate {
constructor(private readonly prismaMaster: PrismaMasterService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
if (request.user?.tipo !== 'tenant_user' || !request.user.tenant_id) {
throw new ForbiddenException('Rota restrita a usuários de tenant');
}
const tenant = await this.prismaMaster.tenants.findUnique({
where: { id: request.user.tenant_id },
select: { status: true },
});
if (!tenant || TENANT_BLOCKED_STATUSES.has(tenant.status)) {
throw new ForbiddenException('Acesso bloqueado para este tenant');
}
request.tenant_id = request.user.tenant_id;
return true;
}
}
@@ -0,0 +1,32 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { from, Observable, firstValueFrom } from 'rxjs';
import { TenantContextService } from '../../../config/tenant-context.service';
import { AuthenticatedRequest } from '../types/authenticated-request.type';
/**
* Roda em toda request HTTP (registrado globalmente). Para requests
* autenticadas como `tenant_user`, envolve o handler numa transação Prisma
* na conexão `app_tenant` com `SET LOCAL app.current_tenant_id` (via
* `TenantContextService`), usando **sempre** o `tenant_id` do token
* (`request.user`, populado pelo JwtStrategy/guards) — nunca um valor de
* input do usuário. Requests master ou não autenticadas passam direto.
*/
@Injectable()
export class TenantRlsInterceptor implements NestInterceptor {
constructor(private readonly tenantContext: TenantContextService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
if (context.getType() !== 'http') {
return next.handle();
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const user = request.user;
if (!user || user.tipo !== 'tenant_user' || !user.tenant_id) {
return next.handle();
}
return from(this.tenantContext.run(user.tenant_id, () => firstValueFrom(next.handle())));
}
}
+47
View File
@@ -0,0 +1,47 @@
import { BadRequestException } from '@nestjs/common';
import * as argon2 from 'argon2';
// Parâmetros OWASP (2024) para argon2id em contexto de login web: 19 MiB de
// memória, 2 iterações, paralelismo 1.
const ARGON2_OPTIONS: argon2.HashOptions = {
type: argon2.argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
};
export function hashPassword(password: string): Promise<string> {
return argon2.hash(password, ARGON2_OPTIONS);
}
export function verifyPassword(hash: string, password: string): Promise<boolean> {
return argon2.verify(hash, password);
}
const MIN_LENGTH = 10;
const TRIVIAL_PASSWORDS = new Set([
'12345678',
'123456789',
'1234567890',
'password',
'password1',
'password123',
'senha1234',
'senha12345',
'senha123456',
'qwertyuiop',
'11111111',
'00000000',
'admin1234',
'letmein123',
]);
export function assertPasswordStrength(password: string): void {
if (password.length < MIN_LENGTH) {
throw new BadRequestException(`A senha deve ter no mínimo ${MIN_LENGTH} caracteres`);
}
if (TRIVIAL_PASSWORDS.has(password.toLowerCase())) {
throw new BadRequestException('Senha muito comum/trivial, escolha outra');
}
}
@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JwtPayload } from '../types/jwt-payload.type';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
});
}
validate(payload: JwtPayload): JwtPayload {
return payload;
}
}
+10
View File
@@ -0,0 +1,10 @@
const UNIT_SECONDS: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };
/** Converte strings simples do tipo "15m"/"1h"/"30d" (mesmo formato aceito por JwtModule) em segundos. */
export function expiresInToSeconds(value: string): number {
const match = /^(\d+)(s|m|h|d)$/.exec(value.trim());
if (!match) {
throw new Error(`Formato de expiração inválido: "${value}" (use algo como "15m", "1h", "30d")`);
}
return Number(match[1]) * UNIT_SECONDS[match[2]];
}
+9
View File
@@ -0,0 +1,9 @@
import { createHash, randomBytes } from 'node:crypto';
export function generateOpaqueToken(): string {
return randomBytes(48).toString('base64url');
}
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
@@ -0,0 +1,8 @@
import { Request } from 'express';
import { JwtPayload } from './jwt-payload.type';
export interface AuthenticatedRequest extends Request {
user: JwtPayload;
/** Preenchido pelo TenantGuard a partir de `user.tenant_id` — nunca de input do usuário. */
tenant_id?: string;
}
@@ -0,0 +1,10 @@
import { user_role } from '@prisma/client';
export type UserType = 'master' | 'tenant_user';
export interface JwtPayload {
sub: string;
tipo: UserType;
tenant_id: string | null;
role?: user_role;
}
@@ -0,0 +1,24 @@
import { IsEmail, IsInt, IsString, Max, Min, MinLength } from 'class-validator';
export class CreateMasterEmailConfigDto {
@IsString()
@MinLength(2)
smtp_host: string;
@IsInt()
@Min(1)
@Max(65535)
smtp_porta: number;
@IsString()
@MinLength(1)
smtp_usuario: string;
/** Texto puro na entrada — cifrado (AES-256-GCM) antes de gravar em `smtp_senha_cripto`. */
@IsString()
@MinLength(1)
smtp_senha: string;
@IsEmail()
email_remetente: string;
}
@@ -0,0 +1,6 @@
import { IsEmail } from 'class-validator';
export class TestarEnvioEmailDto {
@IsEmail()
destinatario: string;
}
@@ -0,0 +1,42 @@
import {
IsBoolean,
IsEmail,
IsInt,
IsOptional,
IsString,
Max,
Min,
MinLength,
} from 'class-validator';
export class UpdateMasterEmailConfigDto {
@IsOptional()
@IsString()
@MinLength(2)
smtp_host?: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(65535)
smtp_porta?: number;
@IsOptional()
@IsString()
@MinLength(1)
smtp_usuario?: string;
/** Texto puro na entrada — cifrado (AES-256-GCM) antes de gravar em `smtp_senha_cripto`. */
@IsOptional()
@IsString()
@MinLength(1)
smtp_senha?: string;
@IsOptional()
@IsEmail()
email_remetente?: string;
@IsOptional()
@IsBoolean()
ativo?: boolean;
}
@@ -0,0 +1,47 @@
import {
IsBoolean,
IsEmail,
IsInt,
IsString,
Max,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
/**
* `usar_config_padrao=true`: ignora os campos de SMTP próprio (mesmo que
* enviados, o service não os persiste). `usar_config_padrao=false`: exige
* todos os campos de SMTP preenchidos (validado declarativamente via
* `@ValidateIf`).
*/
export class UpdateTenantEmailConfigDto {
@IsBoolean()
usar_config_padrao: boolean;
@ValidateIf((o: UpdateTenantEmailConfigDto) => o.usar_config_padrao === false)
@IsString()
@MinLength(2)
smtp_host?: string;
@ValidateIf((o: UpdateTenantEmailConfigDto) => o.usar_config_padrao === false)
@IsInt()
@Min(1)
@Max(65535)
smtp_porta?: number;
@ValidateIf((o: UpdateTenantEmailConfigDto) => o.usar_config_padrao === false)
@IsString()
@MinLength(1)
smtp_usuario?: string;
/** Texto puro na entrada — cifrado (AES-256-GCM) antes de gravar em `smtp_senha_cripto`. */
@ValidateIf((o: UpdateTenantEmailConfigDto) => o.usar_config_padrao === false)
@IsString()
@MinLength(1)
smtp_senha?: string;
@ValidateIf((o: UpdateTenantEmailConfigDto) => o.usar_config_padrao === false)
@IsEmail()
email_remetente?: string;
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AlertDispatchModule } from '../alert-dispatch/alert-dispatch.module';
import { MasterEmailConfigController } from './master-email-config.controller';
import { MasterEmailConfigService } from './master-email-config.service';
import { TenantEmailConfigController } from './tenant-email-config.controller';
import { TenantEmailConfigService } from './tenant-email-config.service';
@Module({
imports: [AlertDispatchModule],
controllers: [MasterEmailConfigController, TenantEmailConfigController],
providers: [MasterEmailConfigService, TenantEmailConfigService],
})
export class EmailConfigModule {}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { EmailService } from './email.service';
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}
+100
View File
@@ -0,0 +1,100 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { createTransport } from 'nodemailer';
import { EncryptionService } from '../../common/encryption.service';
import { PrismaMasterService } from '../../config/prisma-master.service';
interface ResolvedEmailConfig {
smtp_host: string;
smtp_porta: number;
smtp_usuario: string;
smtp_senha: string;
email_remetente: string;
}
/**
* Resolve automaticamente qual SMTP usar para um tenant (próprio ou o
* padrão da plataforma) e envia via `nodemailer`. Fica num módulo próprio
* (`EmailModule`), separado do CRUD de `email-config`, porque o
* `AlertDispatchService` (Etapa 7) depende deste service e o CRUD de
* `email-config` depende do `AlertDispatchService` (endpoints de
* "testar envio") — um único módulo combinando os dois criaria um ciclo de
* imports entre `EmailConfigModule` e `AlertDispatchModule`.
*/
@Injectable()
export class EmailService {
constructor(
private readonly prismaMaster: PrismaMasterService,
private readonly encryptionService: EncryptionService,
) {}
async resolverConfig(tenantId: string): Promise<ResolvedEmailConfig> {
const tenantConfig = await this.prismaMaster.tenant_email_config.findUnique({
where: { tenant_id: tenantId },
});
if (tenantConfig && !tenantConfig.usar_config_padrao) {
if (
!tenantConfig.smtp_host ||
!tenantConfig.smtp_porta ||
!tenantConfig.smtp_usuario ||
!tenantConfig.smtp_senha_cripto ||
!tenantConfig.email_remetente
) {
throw new BadRequestException('Configuração de SMTP própria do tenant está incompleta');
}
return {
smtp_host: tenantConfig.smtp_host,
smtp_porta: tenantConfig.smtp_porta,
smtp_usuario: tenantConfig.smtp_usuario,
smtp_senha: this.encryptionService.decrypt(tenantConfig.smtp_senha_cripto),
email_remetente: tenantConfig.email_remetente,
};
}
const padrao = await this.prismaMaster.master_email_config.findFirst({
where: { ativo: true },
orderBy: { criado_em: 'desc' },
});
if (!padrao) {
throw new BadRequestException(
'Nenhuma configuração de e-mail padrão da plataforma está ativa',
);
}
return {
smtp_host: padrao.smtp_host,
smtp_porta: padrao.smtp_porta,
smtp_usuario: padrao.smtp_usuario,
smtp_senha: this.encryptionService.decrypt(padrao.smtp_senha_cripto),
email_remetente: padrao.email_remetente,
};
}
async enviar(
tenantId: string,
destinatario: string,
assunto: string,
corpo: string,
): Promise<void> {
const config = await this.resolverConfig(tenantId);
const transporter = createTransport({
host: config.smtp_host,
port: config.smtp_porta,
secure: config.smtp_porta === 465,
auth: { user: config.smtp_usuario, pass: config.smtp_senha },
});
try {
await transporter.sendMail({
from: config.email_remetente,
to: destinatario,
subject: assunto,
html: corpo,
});
} catch (error) {
throw new BadRequestException(`Falha ao enviar e-mail: ${(error as Error).message}`);
}
}
}
@@ -0,0 +1,67 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Put,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { MasterGuard } from '../auth/guards/master.guard';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CreateMasterEmailConfigDto } from './dto/create-master-email-config.dto';
import { UpdateMasterEmailConfigDto } from './dto/update-master-email-config.dto';
import { MasterEmailConfigService } from './master-email-config.service';
@UseGuards(JwtAuthGuard, MasterGuard)
@Controller('master/email-config')
export class MasterEmailConfigController {
constructor(private readonly masterEmailConfigService: MasterEmailConfigService) {}
@Post()
create(
@Body() dto: CreateMasterEmailConfigDto,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.masterEmailConfigService.create(dto, user, req.ip);
}
@Get()
findAll() {
return this.masterEmailConfigService.findAll();
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.masterEmailConfigService.findOne(id);
}
@Put(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateMasterEmailConfigDto,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.masterEmailConfigService.update(id, dto, user, req.ip);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.masterEmailConfigService.remove(id, user, req.ip);
}
}
@@ -0,0 +1,120 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { master_email_config } from '@prisma/client';
import { writeAuditLog } from '../../common/audit-log.util';
import { EncryptionService } from '../../common/encryption.service';
import { maskSecret } from '../../common/mask.util';
import { PrismaMasterService } from '../../config/prisma-master.service';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CreateMasterEmailConfigDto } from './dto/create-master-email-config.dto';
import { UpdateMasterEmailConfigDto } from './dto/update-master-email-config.dto';
@Injectable()
export class MasterEmailConfigService {
constructor(
private readonly prismaMaster: PrismaMasterService,
private readonly encryptionService: EncryptionService,
) {}
/** Nunca retorna `smtp_senha_cripto` — só uma versão mascarada (últimos 4 caracteres). */
private sanitize(config: master_email_config) {
const { smtp_senha_cripto, ...rest } = config;
return { ...rest, smtp_senha: maskSecret(this.encryptionService.decrypt(smtp_senha_cripto)) };
}
private async findRaw(id: string): Promise<master_email_config> {
const config = await this.prismaMaster.master_email_config.findUnique({ where: { id } });
if (!config) {
throw new NotFoundException('Configuração de e-mail padrão não encontrada');
}
return config;
}
async create(dto: CreateMasterEmailConfigDto, actor: JwtPayload, ip?: string) {
return this.prismaMaster.$transaction(async (tx) => {
const config = await tx.master_email_config.create({
data: {
smtp_host: dto.smtp_host,
smtp_porta: dto.smtp_porta,
smtp_usuario: dto.smtp_usuario,
smtp_senha_cripto: this.encryptionService.encrypt(dto.smtp_senha),
email_remetente: dto.email_remetente,
},
});
await writeAuditLog(tx, {
usuario_tipo: 'master',
usuario_id: actor.sub,
acao: 'criar_master_email_config',
entidade: 'master_email_config',
entidade_id: config.id,
detalhes: { smtp_host: dto.smtp_host, email_remetente: dto.email_remetente },
ip_origem: ip,
});
return this.sanitize(config);
});
}
async findAll() {
const configs = await this.prismaMaster.master_email_config.findMany({
orderBy: { criado_em: 'asc' },
});
return configs.map((config) => this.sanitize(config));
}
async findOne(id: string) {
return this.sanitize(await this.findRaw(id));
}
async update(id: string, dto: UpdateMasterEmailConfigDto, actor: JwtPayload, ip?: string) {
await this.findRaw(id);
return this.prismaMaster.$transaction(async (tx) => {
const config = await tx.master_email_config.update({
where: { id },
data: {
...(dto.smtp_host !== undefined ? { smtp_host: dto.smtp_host } : {}),
...(dto.smtp_porta !== undefined ? { smtp_porta: dto.smtp_porta } : {}),
...(dto.smtp_usuario !== undefined ? { smtp_usuario: dto.smtp_usuario } : {}),
...(dto.smtp_senha !== undefined
? { smtp_senha_cripto: this.encryptionService.encrypt(dto.smtp_senha) }
: {}),
...(dto.email_remetente !== undefined ? { email_remetente: dto.email_remetente } : {}),
...(dto.ativo !== undefined ? { ativo: dto.ativo } : {}),
},
});
await writeAuditLog(tx, {
usuario_tipo: 'master',
usuario_id: actor.sub,
acao: 'atualizar_master_email_config',
entidade: 'master_email_config',
entidade_id: id,
detalhes: {
...dto,
smtp_senha: dto.smtp_senha !== undefined ? '***alterada***' : undefined,
},
ip_origem: ip,
});
return this.sanitize(config);
});
}
async remove(id: string, actor: JwtPayload, ip?: string) {
await this.findRaw(id);
await this.prismaMaster.$transaction(async (tx) => {
await tx.master_email_config.delete({ where: { id } });
await writeAuditLog(tx, {
usuario_tipo: 'master',
usuario_id: actor.sub,
acao: 'excluir_master_email_config',
entidade: 'master_email_config',
entidade_id: id,
ip_origem: ip,
});
});
}
}
@@ -0,0 +1,37 @@
import { Body, Controller, Get, Post, Put, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { TenantGuard } from '../auth/guards/tenant.guard';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { TestarEnvioEmailDto } from './dto/testar-envio-email.dto';
import { UpdateTenantEmailConfigDto } from './dto/update-tenant-email-config.dto';
import { TenantEmailConfigService } from './tenant-email-config.service';
@UseGuards(JwtAuthGuard, TenantGuard, RolesGuard)
@Roles('tenant_admin')
@Controller('tenant/email-config')
export class TenantEmailConfigController {
constructor(private readonly tenantEmailConfigService: TenantEmailConfigService) {}
@Get()
obter() {
return this.tenantEmailConfigService.obter();
}
@Put()
atualizar(
@Body() dto: UpdateTenantEmailConfigDto,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.tenantEmailConfigService.atualizar(dto, user, req.ip);
}
@Post('testar-envio')
testarEnvio(@Body() dto: TestarEnvioEmailDto, @CurrentUser() user: JwtPayload) {
return this.tenantEmailConfigService.testarEnvio(user, dto);
}
}
@@ -0,0 +1,90 @@
import { Injectable } from '@nestjs/common';
import { EncryptionService } from '../../common/encryption.service';
import { maskSecret } from '../../common/mask.util';
import { TenantContextService } from '../../config/tenant-context.service';
import { AlertDispatchService } from '../alert-dispatch/alert-dispatch.service';
import { writeAuditLog } from '../../common/audit-log.util';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { TestarEnvioEmailDto } from './dto/testar-envio-email.dto';
import { UpdateTenantEmailConfigDto } from './dto/update-tenant-email-config.dto';
const ASSUNTO_TESTE = 'Teste de configuração de e-mail — SeConectai Vendas';
const CORPO_TESTE =
'Este é um e-mail de teste enviado pelo SeConectai Vendas para validar sua configuração de SMTP.';
@Injectable()
export class TenantEmailConfigService {
constructor(
private readonly tenantContext: TenantContextService,
private readonly encryptionService: EncryptionService,
private readonly alertDispatchService: AlertDispatchService,
) {}
/** Quando usando o padrão da plataforma, oculta os campos de SMTP próprio (podem estar desatualizados). */
async obter() {
const tx = this.tenantContext.getTx();
const config = await tx.tenant_email_config.findUnique({
where: { tenant_id: this.tenantContext.getTenantId() },
});
if (!config || config.usar_config_padrao) {
return { usar_config_padrao: true };
}
return {
usar_config_padrao: false,
smtp_host: config.smtp_host,
smtp_porta: config.smtp_porta,
smtp_usuario: config.smtp_usuario,
smtp_senha: config.smtp_senha_cripto
? maskSecret(this.encryptionService.decrypt(config.smtp_senha_cripto))
: undefined,
email_remetente: config.email_remetente,
};
}
async atualizar(dto: UpdateTenantEmailConfigDto, actor: JwtPayload, ip?: string) {
const tenantId = actor.tenant_id as string;
const tx = this.tenantContext.getTx();
const campos = dto.usar_config_padrao
? { usar_config_padrao: true as const }
: {
usar_config_padrao: false as const,
smtp_host: dto.smtp_host as string,
smtp_porta: dto.smtp_porta as number,
smtp_usuario: dto.smtp_usuario as string,
smtp_senha_cripto: this.encryptionService.encrypt(dto.smtp_senha as string),
email_remetente: dto.email_remetente as string,
};
const config = await tx.tenant_email_config.upsert({
where: { tenant_id: tenantId },
create: { tenant_id: tenantId, ...campos },
update: campos,
});
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: tenantId,
acao: 'atualizar_tenant_email_config',
entidade: 'tenant_email_config',
entidade_id: config.id,
detalhes: { usar_config_padrao: dto.usar_config_padrao },
ip_origem: ip,
});
return this.obter();
}
testarEnvio(actor: JwtPayload, dto: TestarEnvioEmailDto) {
const tenantId = actor.tenant_id as string;
return this.alertDispatchService.enviarEmail(
tenantId,
dto.destinatario,
ASSUNTO_TESTE,
CORPO_TESTE,
);
}
}
@@ -0,0 +1,20 @@
import { IsOptional, IsString, IsUrl, MinLength } from 'class-validator';
export class CreateEvolutionConfigDto {
@IsString()
@MinLength(2)
nome: string;
@IsUrl({ protocols: ['http', 'https'], require_tld: false, require_protocol: true })
base_url: string;
/** Texto puro na entrada — cifrado (AES-256-GCM) antes de gravar em `api_key_cripto`. */
@IsString()
@MinLength(8)
api_key: string;
@IsOptional()
@IsString()
@MinLength(1)
versao?: string;
}
@@ -0,0 +1,27 @@
import { IsBoolean, IsOptional, IsString, IsUrl, MinLength } from 'class-validator';
export class UpdateEvolutionConfigDto {
@IsOptional()
@IsString()
@MinLength(2)
nome?: string;
@IsOptional()
@IsUrl({ protocols: ['http', 'https'], require_tld: false, require_protocol: true })
base_url?: string;
/** Texto puro na entrada — cifrado (AES-256-GCM) antes de gravar em `api_key_cripto`. */
@IsOptional()
@IsString()
@MinLength(8)
api_key?: string;
@IsOptional()
@IsString()
@MinLength(1)
versao?: string;
@IsOptional()
@IsBoolean()
ativo?: boolean;
}
@@ -0,0 +1,167 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import axios, { AxiosInstance, Method } from 'axios';
export interface EvolutionCredentials {
base_url: string;
api_key: string;
}
export interface EvolutionConnectionInfo {
status_http: number;
versao?: string;
mensagem?: string;
}
export type EvolutionConnectionState = 'open' | 'connecting' | 'close' | 'desconhecido';
export interface EvolutionQrCode {
base64?: string;
pairing_code?: string;
}
interface EvolutionVersionResponse {
version?: string;
message?: string;
}
interface EvolutionQrCodeResponse {
base64?: string;
pairingCode?: string;
}
interface EvolutionConnectionStateResponse {
state?: string;
instance?: { state?: string };
}
/**
* Wrapper fino sobre a API REST da Evolution API (WhatsApp), contra a
* especificação pública v2 (`apikey` no header, `/instance/*`,
* `/message/sendText/:instance`). Não há acesso a um servidor Evolution
* real neste ambiente de desenvolvimento (sem internet/instância dedicada)
* — ver Observações da Etapa 4 no controle de progresso.
*/
@Injectable()
export class EvolutionApiClient {
private http(creds: EvolutionCredentials): AxiosInstance {
return axios.create({
baseURL: creds.base_url.replace(/\/+$/, ''),
timeout: 15_000,
headers: { apikey: creds.api_key },
validateStatus: () => true,
});
}
private async request<T = unknown>(
http: AxiosInstance,
method: Method,
url: string,
data?: unknown,
) {
try {
return await http.request<T>({ method, url, data });
} catch (error) {
throw new ServiceUnavailableException(
`Falha ao comunicar com a Evolution API: ${(error as Error).message}`,
);
}
}
async testarConexao(creds: EvolutionCredentials): Promise<EvolutionConnectionInfo> {
const response = await this.request<EvolutionVersionResponse>(this.http(creds), 'GET', '/');
if (response.status >= 400) {
throw new ServiceUnavailableException(
`Evolution API respondeu com status ${response.status} ao testar conexão`,
);
}
return {
status_http: response.status,
versao: response.data?.version,
mensagem: response.data?.message,
};
}
async criarInstancia(creds: EvolutionCredentials, instanceName: string): Promise<void> {
const response = await this.request(this.http(creds), 'POST', '/instance/create', {
instanceName,
qrcode: true,
integration: 'WHATSAPP-BAILEYS',
});
if (response.status >= 400) {
throw new ServiceUnavailableException(
`Evolution API recusou a criação da instância (status ${response.status})`,
);
}
}
async gerarQrCode(creds: EvolutionCredentials, instanceName: string): Promise<EvolutionQrCode> {
const response = await this.request<EvolutionQrCodeResponse>(
this.http(creds),
'GET',
`/instance/connect/${encodeURIComponent(instanceName)}`,
);
if (response.status >= 400) {
throw new ServiceUnavailableException(
`Evolution API não retornou QR code (status ${response.status})`,
);
}
return {
base64: response.data?.base64,
pairing_code: response.data?.pairingCode,
};
}
async consultarStatus(
creds: EvolutionCredentials,
instanceName: string,
): Promise<EvolutionConnectionState> {
const response = await this.request<EvolutionConnectionStateResponse>(
this.http(creds),
'GET',
`/instance/connectionState/${encodeURIComponent(instanceName)}`,
);
if (response.status >= 400) {
return 'desconhecido';
}
const state = response.data?.instance?.state ?? response.data?.state;
return state === 'open' || state === 'connecting' || state === 'close' ? state : 'desconhecido';
}
async removerInstancia(creds: EvolutionCredentials, instanceName: string): Promise<void> {
const http = this.http(creds);
await this.request(
http,
'DELETE',
`/instance/logout/${encodeURIComponent(instanceName)}`,
).catch(() => undefined);
const response = await this.request(
http,
'DELETE',
`/instance/delete/${encodeURIComponent(instanceName)}`,
);
if (response.status >= 400 && response.status !== 404) {
throw new ServiceUnavailableException(
`Evolution API recusou a remoção da instância (status ${response.status})`,
);
}
}
async enviarMensagem(
creds: EvolutionCredentials,
instanceName: string,
numero: string,
mensagem: string,
): Promise<void> {
const response = await this.request(
this.http(creds),
'POST',
`/message/sendText/${encodeURIComponent(instanceName)}`,
{ number: numero, text: mensagem },
);
if (response.status >= 400) {
throw new ServiceUnavailableException(
`Evolution API recusou o envio da mensagem (status ${response.status})`,
);
}
}
}
@@ -0,0 +1,76 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Put,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { MasterGuard } from '../auth/guards/master.guard';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CreateEvolutionConfigDto } from './dto/create-evolution-config.dto';
import { UpdateEvolutionConfigDto } from './dto/update-evolution-config.dto';
import { EvolutionService } from './evolution.service';
@UseGuards(JwtAuthGuard, MasterGuard)
@Controller('master/evolution-config')
export class EvolutionController {
constructor(private readonly evolutionService: EvolutionService) {}
@Post()
create(
@Body() dto: CreateEvolutionConfigDto,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.evolutionService.create(dto, user, req.ip);
}
@Get()
findAll() {
return this.evolutionService.findAll();
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.evolutionService.findOne(id);
}
@Put(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateEvolutionConfigDto,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.evolutionService.update(id, dto, user, req.ip);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.evolutionService.remove(id, user, req.ip);
}
@Post(':id/testar-conexao')
testarConexao(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.evolutionService.testarConexao(id, user, req.ip);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { EvolutionApiClient } from './evolution-api.client';
import { EvolutionController } from './evolution.controller';
import { EvolutionService } from './evolution.service';
@Module({
controllers: [EvolutionController],
providers: [EvolutionApiClient, EvolutionService],
exports: [EvolutionApiClient],
})
export class EvolutionModule {}
+151
View File
@@ -0,0 +1,151 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, evolution_api_config } from '@prisma/client';
import { writeAuditLog } from '../../common/audit-log.util';
import { EncryptionService } from '../../common/encryption.service';
import { maskSecret } from '../../common/mask.util';
import { PrismaMasterService } from '../../config/prisma-master.service';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CreateEvolutionConfigDto } from './dto/create-evolution-config.dto';
import { UpdateEvolutionConfigDto } from './dto/update-evolution-config.dto';
import { EvolutionApiClient } from './evolution-api.client';
@Injectable()
export class EvolutionService {
constructor(
private readonly prismaMaster: PrismaMasterService,
private readonly encryptionService: EncryptionService,
private readonly evolutionApiClient: EvolutionApiClient,
) {}
/** Nunca retorna `api_key_cripto` — só uma versão mascarada (últimos 4 caracteres). */
private sanitize(config: evolution_api_config) {
const { api_key_cripto, ...rest } = config;
return { ...rest, api_key: maskSecret(this.encryptionService.decrypt(api_key_cripto)) };
}
private async findRaw(id: string): Promise<evolution_api_config> {
const config = await this.prismaMaster.evolution_api_config.findUnique({ where: { id } });
if (!config) {
throw new NotFoundException('Configuração da Evolution API não encontrada');
}
return config;
}
async create(dto: CreateEvolutionConfigDto, actor: JwtPayload, ip?: string) {
return this.prismaMaster.$transaction(async (tx) => {
const config = await tx.evolution_api_config.create({
data: {
nome: dto.nome,
base_url: dto.base_url,
api_key_cripto: this.encryptionService.encrypt(dto.api_key),
...(dto.versao !== undefined ? { versao: dto.versao } : {}),
},
});
await writeAuditLog(tx, {
usuario_tipo: 'master',
usuario_id: actor.sub,
acao: 'criar_evolution_config',
entidade: 'evolution_api_config',
entidade_id: config.id,
detalhes: { nome: dto.nome, base_url: dto.base_url },
ip_origem: ip,
});
return this.sanitize(config);
});
}
async findAll() {
const configs = await this.prismaMaster.evolution_api_config.findMany({
orderBy: { criado_em: 'asc' },
});
return configs.map((config) => this.sanitize(config));
}
async findOne(id: string) {
return this.sanitize(await this.findRaw(id));
}
async update(id: string, dto: UpdateEvolutionConfigDto, actor: JwtPayload, ip?: string) {
await this.findRaw(id);
return this.prismaMaster.$transaction(async (tx) => {
const config = await tx.evolution_api_config.update({
where: { id },
data: {
...(dto.nome !== undefined ? { nome: dto.nome } : {}),
...(dto.base_url !== undefined ? { base_url: dto.base_url } : {}),
...(dto.api_key !== undefined
? { api_key_cripto: this.encryptionService.encrypt(dto.api_key) }
: {}),
...(dto.versao !== undefined ? { versao: dto.versao } : {}),
...(dto.ativo !== undefined ? { ativo: dto.ativo } : {}),
},
});
await writeAuditLog(tx, {
usuario_tipo: 'master',
usuario_id: actor.sub,
acao: 'atualizar_evolution_config',
entidade: 'evolution_api_config',
entidade_id: id,
detalhes: {
...dto,
api_key: dto.api_key !== undefined ? '***alterada***' : undefined,
},
ip_origem: ip,
});
return this.sanitize(config);
});
}
/** Bloqueia exclusão se alguma instância de WhatsApp ainda referenciar esta config. */
async remove(id: string, actor: JwtPayload, ip?: string) {
await this.findRaw(id);
const emUso = await this.prismaMaster.tenant_whatsapp_instances.count({
where: { evolution_config_id: id },
});
if (emUso > 0) {
throw new BadRequestException(
'Configuração em uso por instância(s) de WhatsApp — remova ou migre as instâncias antes de excluir',
);
}
await this.prismaMaster.$transaction(async (tx) => {
await tx.evolution_api_config.delete({ where: { id } });
await writeAuditLog(tx, {
usuario_tipo: 'master',
usuario_id: actor.sub,
acao: 'excluir_evolution_config',
entidade: 'evolution_api_config',
entidade_id: id,
ip_origem: ip,
});
});
}
async testarConexao(id: string, actor: JwtPayload, ip?: string) {
const config = await this.findRaw(id);
const resultado = await this.evolutionApiClient.testarConexao({
base_url: config.base_url,
api_key: this.encryptionService.decrypt(config.api_key_cripto),
});
await writeAuditLog(this.prismaMaster, {
usuario_tipo: 'master',
usuario_id: actor.sub,
acao: 'testar_conexao_evolution',
entidade: 'evolution_api_config',
entidade_id: id,
detalhes: resultado as unknown as Prisma.InputJsonValue,
ip_origem: ip,
});
return resultado;
}
}
@@ -0,0 +1,35 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
describe('HealthController', () => {
let controller: HealthController;
const checkMock = jest.fn().mockResolvedValue({
status: 'ok',
timestamp: new Date().toISOString(),
database: 'up',
});
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [HealthController],
providers: [
{
provide: HealthService,
useValue: { check: checkMock },
},
],
}).compile();
controller = module.get<HealthController>(HealthController);
});
it('deve retornar status ok e database up', async () => {
const result = await controller.check();
expect(checkMock).toHaveBeenCalled();
expect(result.status).toBe('ok');
expect(result.database).toBe('up');
expect(result.timestamp).toBeDefined();
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { HealthService, HealthStatus } from './health.service';
@Controller('health')
export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get()
check(): Promise<HealthStatus> {
return this.healthService.check();
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
providers: [HealthService],
})
export class HealthModule {}
+32
View File
@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../config/prisma.service';
export interface HealthStatus {
status: 'ok' | 'error';
timestamp: string;
database: 'up' | 'down';
}
@Injectable()
export class HealthService {
constructor(private readonly prisma: PrismaService) {}
async check(): Promise<HealthStatus> {
const database = await this.checkDatabase();
return {
status: database === 'up' ? 'ok' : 'error',
timestamp: new Date().toISOString(),
database,
};
}
private async checkDatabase(): Promise<'up' | 'down'> {
try {
await this.prisma.$queryRaw`SELECT 1`;
return 'up';
} catch {
return 'down';
}
}
}
@@ -0,0 +1,30 @@
import { IsInt, IsNumber, IsOptional, IsPositive, IsString, Min, MinLength } from 'class-validator';
export class CreatePlanoDto {
@IsString()
@MinLength(2)
nome: string;
@IsOptional()
@IsString()
descricao?: string;
@IsNumber()
@IsPositive()
valor_mensal: number;
@IsOptional()
@IsInt()
@Min(1)
limite_customers?: number;
@IsOptional()
@IsInt()
@Min(1)
limite_usuarios?: number;
@IsOptional()
@IsInt()
@Min(1)
limite_instancias_whatsapp?: number;
}
@@ -0,0 +1,9 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional } from 'class-validator';
export class ListPlanosQueryDto {
@IsOptional()
@Transform(({ value }) => (value === undefined ? undefined : value === 'true' || value === true))
@IsBoolean()
ativo?: boolean;
}
@@ -0,0 +1,45 @@
import {
IsBoolean,
IsInt,
IsNumber,
IsOptional,
IsPositive,
IsString,
Min,
MinLength,
} from 'class-validator';
export class UpdatePlanoDto {
@IsOptional()
@IsString()
@MinLength(2)
nome?: string;
@IsOptional()
@IsString()
descricao?: string;
@IsOptional()
@IsNumber()
@IsPositive()
valor_mensal?: number;
@IsOptional()
@IsInt()
@Min(1)
limite_customers?: number;
@IsOptional()
@IsInt()
@Min(1)
limite_usuarios?: number;
@IsOptional()
@IsInt()
@Min(1)
limite_instancias_whatsapp?: number;
@IsOptional()
@IsBoolean()
ativo?: boolean;
}
+62
View File
@@ -0,0 +1,62 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { MasterGuard } from '../auth/guards/master.guard';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CreatePlanoDto } from './dto/create-plano.dto';
import { ListPlanosQueryDto } from './dto/list-planos.query.dto';
import { UpdatePlanoDto } from './dto/update-plano.dto';
import { PlanosService } from './planos.service';
@UseGuards(JwtAuthGuard, MasterGuard)
@Controller('master/planos')
export class PlanosController {
constructor(private readonly planosService: PlanosService) {}
@Post()
create(@Body() dto: CreatePlanoDto, @CurrentUser() user: JwtPayload, @Req() req: Request) {
return this.planosService.create(dto, user, req.ip);
}
@Get()
findAll(@Query() query: ListPlanosQueryDto) {
return this.planosService.findAll(query);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.planosService.findOne(id);
}
@Put(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePlanoDto,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.planosService.update(id, dto, user, req.ip);
}
@Delete(':id')
remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.planosService.remove(id, user, req.ip);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PlanosController } from './planos.controller';
import { PlanosService } from './planos.service';
@Module({
controllers: [PlanosController],
providers: [PlanosService],
exports: [PlanosService],
})
export class PlanosModule {}
+122
View File
@@ -0,0 +1,122 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { writeAuditLog } from '../../common/audit-log.util';
import { PrismaMasterService } from '../../config/prisma-master.service';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CreatePlanoDto } from './dto/create-plano.dto';
import { ListPlanosQueryDto } from './dto/list-planos.query.dto';
import { UpdatePlanoDto } from './dto/update-plano.dto';
@Injectable()
export class PlanosService {
constructor(private readonly prismaMaster: PrismaMasterService) {}
async create(dto: CreatePlanoDto, actor: JwtPayload, ip?: string) {
return this.prismaMaster.$transaction(async (tx) => {
const plano = await tx.planos.create({ data: dto });
await writeAuditLog(tx, {
usuario_tipo: 'master',
usuario_id: actor.sub,
acao: 'criar_plano',
entidade: 'planos',
entidade_id: plano.id,
detalhes: dto as unknown as Prisma.InputJsonValue,
ip_origem: ip,
});
return plano;
});
}
findAll(query: ListPlanosQueryDto) {
return this.prismaMaster.planos.findMany({
where: query.ativo === undefined ? {} : { ativo: query.ativo },
orderBy: { criado_em: 'asc' },
});
}
async findOne(id: string) {
const plano = await this.prismaMaster.planos.findUnique({ where: { id } });
if (!plano) {
throw new NotFoundException('Plano não encontrado');
}
return plano;
}
async update(id: string, dto: UpdatePlanoDto, actor: JwtPayload, ip?: string) {
await this.findOne(id);
return this.prismaMaster.$transaction(async (tx) => {
const plano = await tx.planos.update({ where: { id }, data: dto });
await writeAuditLog(tx, {
usuario_tipo: 'master',
usuario_id: actor.sub,
acao: 'atualizar_plano',
entidade: 'planos',
entidade_id: id,
detalhes: dto as unknown as Prisma.InputJsonValue,
ip_origem: ip,
});
return plano;
});
}
/**
* Exclusão real só é permitida se nenhum tenant não-cancelado estiver
* usando o plano — caso contrário, apenas desativa (`ativo = false`),
* como pedido no critério de aceite ("não permitir excluir plano em uso
* por algum tenant ativo").
*/
async remove(id: string, actor: JwtPayload, ip?: string) {
const plano = await this.findOne(id);
const emUso = await this.prismaMaster.tenants.count({
where: { plano_id: id, status: { not: 'cancelado' } },
});
if (emUso > 0) {
if (!plano.ativo) {
throw new ConflictException(
'Plano em uso por tenant(s) ativo(s) e já está desativado — não pode ser excluído',
);
}
const desativado = await this.prismaMaster.$transaction(async (tx) => {
const atualizado = await tx.planos.update({ where: { id }, data: { ativo: false } });
await writeAuditLog(tx, {
usuario_tipo: 'master',
usuario_id: actor.sub,
acao: 'desativar_plano_em_uso',
entidade: 'planos',
entidade_id: id,
detalhes: { motivo: 'plano em uso por tenant(s) ativo(s); exclusão bloqueada' },
ip_origem: ip,
});
return atualizado;
});
return { deletado: false, desativado: true, plano: desativado };
}
await this.prismaMaster.$transaction(async (tx) => {
await tx.planos.delete({ where: { id } });
await writeAuditLog(tx, {
usuario_tipo: 'master',
usuario_id: actor.sub,
acao: 'excluir_plano',
entidade: 'planos',
entidade_id: id,
detalhes: { nome: plano.nome },
ip_origem: ip,
});
});
return { deletado: true, desativado: false, plano: null };
}
}
@@ -0,0 +1,15 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { MasterGuard } from '../auth/guards/master.guard';
import { DashboardService } from './dashboard.service';
@UseGuards(JwtAuthGuard, MasterGuard)
@Controller('master/dashboard')
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
@Get()
resumo() {
return this.dashboardService.resumo();
}
}
+63
View File
@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import { Prisma, tenant_status } from '@prisma/client';
import { PrismaMasterService } from '../../config/prisma-master.service';
const STATUSES: tenant_status[] = ['trial', 'ativo', 'inadimplente', 'suspenso', 'cancelado'];
function inicioDoMes(data: Date): Date {
return new Date(Date.UTC(data.getUTCFullYear(), data.getUTCMonth(), 1));
}
@Injectable()
export class DashboardService {
constructor(private readonly prismaMaster: PrismaMasterService) {}
async resumo() {
const porStatusRaw = await this.prismaMaster.tenants.groupBy({
by: ['status'],
_count: { _all: true },
});
const tenants_por_status = Object.fromEntries(
STATUSES.map((status) => [
status,
porStatusRaw.find((r) => r.status === status)?._count._all ?? 0,
]),
);
// MRR = soma do valor_mensal do plano dos tenants ativos. Não dá pra
// fazer isso com um `aggregate`/`groupBy` do Prisma (soma um campo de
// tabela relacionada), então busca os planos dos tenants ativos e soma
// em memória com `Prisma.Decimal` (evita erro de arredondamento de
// ponto flutuante em valores monetários).
const tenantsAtivos = await this.prismaMaster.tenants.findMany({
where: { status: 'ativo', plano_id: { not: null } },
select: { plano: { select: { valor_mensal: true } } },
});
const mrr = tenantsAtivos.reduce(
(acc, t) => acc.plus(t.plano?.valor_mensal ?? new Prisma.Decimal(0)),
new Prisma.Decimal(0),
);
const inicioMesAtual = inicioDoMes(new Date());
const inicioProximoMes = new Date(
Date.UTC(inicioMesAtual.getUTCFullYear(), inicioMesAtual.getUTCMonth() + 1, 1),
);
const recebidoMes = await this.prismaMaster.tenant_faturas.aggregate({
_sum: { valor: true },
where: { status: 'pago', pago_em: { gte: inicioMesAtual, lt: inicioProximoMes } },
});
const emAberto = await this.prismaMaster.tenant_faturas.aggregate({
_sum: { valor: true },
where: { status: { in: ['pendente', 'atrasado'] } },
});
return {
tenants_por_status,
mrr: mrr.toFixed(2),
total_recebido_mes: (recebidoMes._sum.valor ?? new Prisma.Decimal(0)).toFixed(2),
total_em_aberto: (emAberto._sum.valor ?? new Prisma.Decimal(0)).toFixed(2),
};
}
}
@@ -0,0 +1,62 @@
import { Type } from 'class-transformer';
import {
IsEmail,
IsInt,
IsObject,
IsOptional,
IsString,
IsUUID,
Max,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
export class CreateTenantAdminDto {
@IsString()
@MinLength(2)
nome: string;
@IsEmail()
email: string;
/** Se omitida, uma senha provisória é gerada e devolvida na resposta (envio por e-mail fica para uma etapa futura). */
@IsOptional()
@IsString()
senha?: string;
}
export class CreateTenantDto {
@IsString()
@MinLength(2)
razao_social: string;
@IsOptional()
@IsString()
nome_fantasia?: string;
@IsOptional()
@IsString()
documento?: string;
@IsEmail()
email_contato: string;
@IsOptional()
@IsString()
telefone?: string;
@IsUUID()
plano_id: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(28)
dia_vencimento_mensalidade?: number;
@IsObject()
@ValidateNested()
@Type(() => CreateTenantAdminDto)
admin: CreateTenantAdminDto;
}
@@ -0,0 +1,8 @@
import { IsOptional, Matches } from 'class-validator';
export class GerarFaturasMesDto {
/** Referência no formato YYYY-MM. Se omitida, usa o mês corrente. */
@IsOptional()
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/, { message: 'referencia deve estar no formato YYYY-MM' })
referencia?: string;
}
@@ -0,0 +1,33 @@
import { cobranca_status } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsUUID, Matches, Max, Min } from 'class-validator';
const STATUSES: cobranca_status[] = ['pendente', 'pago', 'atrasado', 'cancelado'];
export class ListFaturasQueryDto {
@IsOptional()
@IsIn(STATUSES)
status?: cobranca_status;
@IsOptional()
@IsUUID()
tenant_id?: string;
/** Referência exata no formato YYYY-MM. */
@IsOptional()
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/, { message: 'referencia deve estar no formato YYYY-MM' })
referencia?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
@@ -0,0 +1,28 @@
import { tenant_status } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
const STATUSES: tenant_status[] = ['trial', 'ativo', 'inadimplente', 'suspenso', 'cancelado'];
export class ListTenantsQueryDto {
@IsOptional()
@IsIn(STATUSES)
status?: tenant_status;
@IsOptional()
@IsString()
busca?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
@@ -0,0 +1,9 @@
import { IsIn } from 'class-validator';
import { tenant_status } from '@prisma/client';
const TRANSICOES_PERMITIDAS: tenant_status[] = ['ativo', 'inadimplente', 'suspenso', 'cancelado'];
export class UpdateTenantStatusDto {
@IsIn(TRANSICOES_PERMITIDAS)
status: tenant_status;
}
@@ -0,0 +1,40 @@
import { IsEmail, IsInt, IsOptional, IsString, IsUUID, Max, Min, MinLength } from 'class-validator';
/**
* Atualização de dados cadastrais/plano do tenant. `status` fica de fora de
* propósito — toda transição de status passa por `PATCH /:id/status`, que é
* o único caminho que aplica bloqueio de login e grava a auditoria da
* mudança (ver Observações da Etapa 3 no controle de progresso).
*/
export class UpdateTenantDto {
@IsOptional()
@IsString()
@MinLength(2)
razao_social?: string;
@IsOptional()
@IsString()
nome_fantasia?: string;
@IsOptional()
@IsString()
documento?: string;
@IsOptional()
@IsEmail()
email_contato?: string;
@IsOptional()
@IsString()
telefone?: string;
@IsOptional()
@IsUUID()
plano_id?: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(28)
dia_vencimento_mensalidade?: number;
}
+54
View File
@@ -0,0 +1,54 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { MasterGuard } from '../auth/guards/master.guard';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { GerarFaturasMesDto } from './dto/gerar-faturas-mes.dto';
import { ListFaturasQueryDto } from './dto/list-faturas.query.dto';
import { FaturasService } from './faturas.service';
@UseGuards(JwtAuthGuard, MasterGuard)
@Controller('master/faturas')
export class FaturasController {
constructor(private readonly faturasService: FaturasService) {}
@HttpCode(HttpStatus.OK)
@Post('gerar-mes')
gerarMes(@Body() dto: GerarFaturasMesDto, @CurrentUser() user: JwtPayload, @Req() req: Request) {
return this.faturasService.gerarMes(dto, user, req.ip);
}
@Get()
findAll(@Query() query: ListFaturasQueryDto) {
return this.faturasService.findAll(query);
}
@Patch(':id/marcar-pago')
marcarPago(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.faturasService.marcarPago(id, user, req.ip);
}
@HttpCode(HttpStatus.OK)
@Post('atualizar-atrasadas')
atualizarAtrasadas(@CurrentUser() user: JwtPayload, @Req() req: Request) {
return this.faturasService.atualizarAtrasadas(user, req.ip);
}
}

Some files were not shown because too many files have changed in this diff Show More