first commit
This commit is contained in:
@@ -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
@@ -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/
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100
|
||||
}
|
||||
+49
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
+10319
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { EncryptionService } from './encryption.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EncryptionService],
|
||||
exports: [EncryptionService],
|
||||
})
|
||||
export class EncryptionModule {}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
senha: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class LogoutDto {
|
||||
@IsString()
|
||||
refresh_token: string;
|
||||
}
|
||||
@@ -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') {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]];
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EmailService } from './email.service';
|
||||
|
||||
@Module({
|
||||
providers: [EmailService],
|
||||
exports: [EmailService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user