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