Etapa 8 — Personalização whitelabel e ajustes finais

Adiciona upload/remoção de logo e cor primária por tenant (módulo
tenant-profile, armazenamento local atrás de uma interface StorageService
trocável por object storage depois) e um endpoint público de tema para a
tela de login carregar antes da autenticação.

Revisão de segurança: Helmet e CORS explícito (extraídos para
security.util.ts, reutilizado pelos testes e2e que verificam os
cabeçalhos), Swagger em /docs cobrindo todos os módulos via o plugin
oficial, paginação em alert_templates. Corrige um achado real: o reset de
senha logava o token em texto puro desde a Etapa 2 — agora envia por
e-mail de verdade.

Build de produção do Docker validado (imagem sem devDependencies, app e
worker rodando contra Postgres/Redis reais) e README consolidado com o
processo de deploy. Fecha o backlog com um teste e2e do fluxo completo:
master cria tenant → login → customer/produto → venda com parcelas →
parcela paga → dashboard financeiro reflete o pagamento.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 23:38:37 -03:00
co-authored by Claude Sonnet 5
parent d6d7d5fa61
commit f9738e54da
48 changed files with 1470 additions and 64 deletions
+7
View File
@@ -83,3 +83,10 @@ ALERT_DIAS_ANTECEDENCIA_COBRANCA=3
# do backoff exponencial (tentativa N espera ALERT_JOB_BACKOFF_MS * 2^(N-1))
ALERT_JOB_MAX_ATTEMPTS=3
ALERT_JOB_BACKOFF_MS=5000
# --- CORS -----------------------------------------------------------
# Origens (frontend) autorizadas a chamar a API via browser, separadas por
# vírgula (sem espaço) — nunca "*". Vazio/ausente = nenhuma origem cross-site
# autorizada (chamadas server-to-server e ferramentas como curl/Postman não
# passam pelo CORS do browser, então não são afetadas).
CORS_ORIGINS=http://localhost:5173,http://localhost:3001
+200 -9
View File
@@ -37,7 +37,7 @@ qualquer nova etapa começar.
| 5 | Painel Tenant (clientes/produtos) | Concluída | 2026-08-14 |
| 6 | Vendas, Parcelas e Dashboards | Concluída | 2026-08-14 |
| 7 | Motor de Alertas (BullMQ) | Concluída | 2026-08-14 |
| 8 | Personalização Whitelabel e Ajustes Finais | [ ] Não iniciada | |
| 8 | Personalização Whitelabel e Ajustes Finais | Concluída | 2026-08-14 |
Valores possíveis para "Status": `Não iniciada`, `Em andamento`,
`Concluída`, `Concluída com pendências` (usar este último se algo ficou
@@ -848,17 +848,165 @@ propositalmente para depois — detalhar em 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)
- [x] Upload/remoção de logo e alteração de cor funcionam
- [x] Endpoint público de tema retorna logo/cor sem autenticação
- [x] CORS, Helmet e rate limiting revisados e ativos
- [x] Nenhum segredo aparece em log ou resposta de API
- [x] Swagger disponível cobrindo todos os módulos, com guards indicados
- [x] Build de produção do Docker sem dependências de desenvolvimento
- [x] Teste E2E do fluxo completo passa do início ao fim
- [x] README final consolidado (dev, deploy, migrations, RLS)
**Observações:**
- **Logo/cor/tema** — módulo novo `src/modules/tenant-profile/`:
`GET/POST(logo)/DELETE(logo)/PUT(cor) /tenant/perfil` (`TenantGuard`+
`RolesGuard`+`tenant_admin`) e `GET /public/tenants/:id/tema` (sem
guard nenhum, só `ThrottlerGuard` global). Upload via
`FileInterceptor('logo')` (multer, memory storage padrão) +
`ParseFilePipeBuilder` (`addFileTypeValidator`/`addMaxSizeValidator`,
2MB, png/jpg/svg) — ver desvio de `skipMagicNumbersValidation` abaixo.
Armazenamento atrás de uma interface (`StorageService`,
`src/common/storage/`) com uma única implementação por ora
(`LocalStorageService`, grava em `uploads/`, volume Docker nomeado em
produção) — trocar por S3/object storage numa etapa futura é só
implementar a interface de novo e trocar o provider registrado em
`TenantProfileModule`, nenhum outro código muda.
- **Decisão registrada, pedida explicitamente pelo prompt ("avaliar se
precisa de slug"):** o endpoint público de tema usa o `id` (UUID) do
tenant, não um `slug`. O schema fornecido não tem esse campo, e o login
deste projeto já resolve o tenant pelo e-mail do usuário (Etapa 2), não
por subdomínio — criar um `slug` (migration incremental) só faria
sentido se o produto ganhasse login por subdomínio, o que não existe em
nenhuma outra parte do sistema. Fica documentado aqui e no código
(`PublicTenantController`) como decisão consciente, não omissão.
- **Achado de segurança corrigido nesta etapa, não pedido explicitamente
pelo checklist mas descoberto ao revisá-lo ("nenhum segredo em log"):**
`AuthService.forgotPassword` (Etapa 2) logava o token de reset em texto
puro no `console.log` — um desvio deliberado da própria Etapa 2
("envio real de e-mail é etapa futura"), mas que nunca foi revisitado
depois que a Etapa 4 wireou envio de e-mail de verdade. Corrigido: agora
envia por e-mail de verdade
(`EmailService.enviar`/`enviarComConfigPadrao` — o segundo, novo nesta
etapa, resolve só a config padrão da plataforma, sem tenant nenhum
envolvido, pro caso de `master_admin` resetando a própria senha, onde
não existe `tenant_id`). Falha no envio (ex: SMTP não configurado) é
logada (sem o token) mas nunca muda a resposta genérica do endpoint —
continua sem vazar se o e-mail existe. `test/auth.e2e-spec.ts`
atualizado: em vez de espiar `console.log`, mocka `nodemailer` (mesmo
padrão já usado em `integracoes.e2e-spec.ts`) e extrai o token do corpo
do e-mail "enviado".
- **CORS + Helmet**: `app.use(helmet())` e `app.enableCors({origin:
CORS_ORIGINS.split(',') || false})` — vazio/ausente bloqueia toda
origem cross-site por padrão (nunca `origin: '*'`). Extraído pra
`src/security.util.ts` (`applySecurityConfig(app, configService)`) em
vez de ficar só dentro de `bootstrap()` em `main.ts`, porque os testes
e2e criam a aplicação via `Test.createTestingModule(...).
createNestApplication()`, que **não** passa por `bootstrap()` — sem
extrair, os testes que verificam esses cabeçalhos estariam validando
uma configuração que o app de teste nunca aplicava de verdade (falso
positivo). Só `test/perfil-whitelabel.e2e-spec.ts` e
`test/fluxo-completo.e2e-spec.ts` (specs novos desta etapa) chamam
`applySecurityConfig` — os specs das etapas anteriores não precisam
disso pra cobrir o que já testam, então não foram tocados (menor
superfície de mudança em teste já passando).
- **Rate limiting**: já estava ativo desde a Etapa 1
(`ThrottlerModule`/`ThrottlerGuard` global, `60 req/60s` por IP) e
reforçado desde a Etapa 2 nas rotas de login (`@Throttle`, `30 req/60s`,
camada extra além do bloqueio persistente por e-mail em
`login_attempts`) — nada novo implementado aqui, só confirmado: o
`ThrottlerGuard` é global (`APP_GUARD`), então vale automaticamente
pros endpoints novos desta etapa (incluindo o público `GET
/public/tenants/:id/tema`) sem precisar de nenhuma anotação extra.
- **Swagger**: `@nestjs/swagger` + o plugin oficial
(`nest-cli.json`→`compilerOptions.plugins`), que gera os schemas dos
DTOs automaticamente a partir dos tipos TS + decorators do
`class-validator` já existentes — **sem precisar anotar manualmente
`@ApiProperty()` em nenhum dos ~50 DTOs do projeto** (a alternativa
manual seria um esforço de dias, incompatível com o resto do escopo
desta etapa). `@ApiTags`/`@ApiBearerAuth('jwt')` adicionados em todos os
~20 controllers (script Python pontual, não commitado, só pra aplicar o
padrão mecanicamente sem erro manual — revisado depois via
`npm run lint`/`npm run build`, que pegou um bug real de inserção
no meio de um import multi-linha em `sales/dashboard.controller.ts`,
corrigido). `/docs` confirmado manualmente contra o app real: 57 rotas,
19 tags, security scheme `jwt` (Bearer) registrado — `auth.controller.ts`
não ganhou `@ApiBearerAuth` por rota (a maioria dos endpoints ali é
pública ou mistura guards por método, não por classe); fica como
refinamento menor de documentação, não afeta comportamento.
- **Paginação em todas as listagens**: já valia desde as Etapas 5-7 pra
`customers`/`products`/`sales`/`recebimentos`/`tenants`/`faturas`/
`alert_logs`. Auditoria desta etapa achou `alert_templates` (master e
tenant) sem paginação — corrigido, adicionado `page`/`limit` (máx. 100)
em `ListAlertTemplatesQueryDto` e nos dois services. **Decisão
consciente de não mecanizar em todas as listagens sem critério**:
`planos`, `evolution-config` e `master-email-config` (todas
master-only, curadas manualmente, realisticamente algumas unidades a
poucas dezenas de linhas) e `customer_contacts`/`whatsapp instancias`
(limitadas estruturalmente — contatos por customer tende a ser pequeno;
instâncias de WhatsApp são limitadas por `limite_instancias_whatsapp`
do plano) ficaram sem paginação, de propósito — adicionar ali seria
ceremônia sem ganho real de proteção contra consulta sem limite.
- **Auditoria de segredos hardcoded/DTOs sem validação** (checklist
desta etapa): `grep` por padrões de segredo hardcoded (`password =`,
`secret =`, `api_key =`) não achou nada; todo `@Body()`/`@Query()` de
todo controller do projeto usa uma classe DTO (nenhum `any`/objeto cru)
— `ValidationPipe` global (`whitelist`+`forbidNonWhitelisted`+
`transform`, desde a Etapa 1) garante que isso é realmente validado, não
só tipado.
- **Docker de produção**: `docker build --target production` testado de
verdade nesta sessão (não só lido) — imagem final sem
`typescript`/`ts-node`/`jest`/`eslint`/`@nestjs/cli` (checado via `ls
node_modules` dentro do container), ~180MB, container real rodando e
respondendo `/health` (200) contra Postgres/Redis reais, e o `worker`
(mesma imagem, `CMD` diferente) subindo e conectando nas duas filas.
Volume nomeado novo, `uploads_data:/app/uploads`, no serviço `app` do
`docker-compose.yml` — sem ele, o volume efêmero do container perderia
os logos enviados a cada recriação/deploy.
- **README consolidado**: seção nova "Deploy em produção" (segredos a
trocar, build, subir serviços, migrations — incluindo a confirmação de
que `prisma migrate deploy` já cobre RLS/policies/roles num passo só,
sem etapa separada —, verificação, volume de uploads) e "Motor de
alertas (worker)" (já escrita na Etapa 7). "Estrutura do projeto"
atualizada com os módulos/arquivos novos desta etapa.
- **Teste e2e do fluxo completo**, exatamente como pedido no prompt:
`test/fluxo-completo.e2e-spec.ts` — master cria plano+tenant → tenant
faz login com a senha provisória → cadastra customer e produto → faz
uma venda com 2 parcelas → confere o financeiro antes (delta zero,
tenant novo) → marca a 1ª parcela como paga → confere o financeiro
depois (recebido/a-receber refletindo o pagamento, parcela paga não
aparece mais em "próximas") → confere a venda em si também mostrando a
parcela como `pago`. De propósito só o caminho feliz — casos de
erro/borda já estão nos specs dedicados de cada etapa.
- Dependências novas: `@nestjs/swagger`, `helmet`, `@nestjs/bullmq`-style
DI (`ParseFilePipeBuilder` já vem do `@nestjs/common` existente),
`@types/multer` (dev). `npm install` (não `npm ci`) de novo, mesma razão
já registrada na Etapa 7 (lockfile precisa ser regravado quando
`package.json` ganha dependência nova).
- Validação completa desta sessão: `docker compose up -d postgres redis`
+ `npm install` + `prisma migrate deploy` (nenhuma migration nova) +
`npm run lint`/`npm run test`(unit)/`npm run test:e2e` — **132/132
testes e2e passando** (118 das Etapas 1-7 + 14 novos: 13 de
perfil/whitelabel + 1 do fluxo completo) — `npm run build`, e smoke
test manual contra o app real (`node dist/main`) e o worker real (`node
dist/worker`), incluindo verificação de headers HTTP reais (Helmet,
CORS permitido vs. bloqueado) via `curl`, além do build de produção
Docker completo (item acima).
**Este era o último item do backlog faseado — as 8 etapas de
`Etapas/00-README-ordem-das-etapas.md` estão concluídas.** O backend está
pronto para receber um frontend: todos os endpoints documentados em
`/docs`, RLS/isolamento multi-tenant testado em cada etapa que introduziu
tabela nova, motor de alertas automatizado rodando em processo próprio, e
137 testes automatizados (132 e2e contra Postgres/Redis reais + 5
unitários) passando. Pendências conhecidas, todas já documentadas nas
seções de Observações/Histórico de decisões acima quando surgiram (não
re-listadas aqui pra não divergir da fonte): login por e-mail sem
subdomínio (Etapa 2), Evolution API nunca testada contra uma instância
real (Etapa 4), WhatsApp de "mensalidade_tenant" reaproveitando a
instância do próprio tenant (Etapa 7), paginação deliberadamente ausente
em listagens pequenas/curadas (acima).
---
## Histórico de decisões e desvios do plano original
@@ -1047,3 +1195,46 @@ e-mail não há esse problema, já cai pro `master_email_config` padrão
quando o tenant não tem SMTP próprio. Fica como ponto para uma etapa
futura considerar um WhatsApp de notificações dedicado da plataforma, se
o produto precisar.
[2026-08-14] Etapa 8 — Endpoint público de tema (`GET
/public/tenants/:id/tema`) usa o `id` (UUID) do tenant, não um `slug`. O
prompt desta etapa pedia avaliar se um campo `slug` era necessário; como
o schema fornecido não tem esse campo e o login do projeto já resolve o
tenant pelo e-mail do usuário (não por subdomínio, desde a Etapa 2),
criar um `slug` só faria sentido junto de um fluxo de login por
subdomínio que não existe em nenhuma outra parte do sistema — decisão
consciente de não adicionar, não uma omissão.
[2026-08-14] Etapa 8 — Achado de segurança corrigido, descoberto ao
revisar o checklist "nenhum segredo em log ou resposta de API":
`AuthService.forgotPassword` (Etapa 2) logava o token de reset de senha
em texto puro via `console.log` — desvio deliberado da própria Etapa 2
("envio real de e-mail é etapa futura"), nunca revisitado depois que a
Etapa 4 implementou envio de e-mail de verdade. Corrigido: agora envia
por e-mail via `EmailService` (`enviar`, se for `tenant_user`, ou
`enviarComConfigPadrao`, método novo, se for `master_admin` — não há
`tenant_id` nesse caso). Falha no envio nunca muda a resposta genérica do
endpoint (só loga um aviso, sem o token). `test/auth.e2e-spec.ts`
atualizado pra mockar `nodemailer` em vez de espiar `console.log`.
[2026-08-14] Etapa 8 — Upload de logo usa
`skipMagicNumbersValidation: true` no `FileTypeValidator`, validando só o
`mimetype` declarado pelo cliente — não os magic numbers do arquivo. O
comportamento padrão do Nest (sniffar magic numbers via `file-type`,
pacote ESM-only, importado dinamicamente) funciona no app real (`node
dist/main`), mas falha sempre dentro do Jest/ts-jest deste projeto (não
consegue completar o `import()` dinâmico de um pacote ESM-only de dentro
do contexto CJS de teste), inviabilizando testar um upload de sucesso.
Aceitável pro risco real de uma logo estática nunca processada/executada
no servidor; revisitar se o arquivo enviado passar a ser processado
(ex: resize) no backend no futuro.
[2026-08-14] Etapa 8 — Paginação adicionada em `alert_templates` (master
e tenant, únicas listagens sem `page`/`limit` entre as tabelas
potencialmente user-generated). Decisão consciente de **não** adicionar
em `planos`/`evolution-config`/`master-email-config` (master-only,
curadas manualmente, poucas linhas na prática) nem em
`customer_contacts`/`whatsapp instancias` (limitadas estruturalmente por
natureza do domínio ou por `limite_instancias_whatsapp` do plano) — só
pra cumprir a letra do checklist sem ganho real de proteção contra
consulta sem limite.
+70 -11
View File
@@ -63,6 +63,57 @@ npm run prisma:migrate
npm run dev
```
## Deploy em produção
1. **Segredos**: copie `.env.example` para `.env` (ou injete as mesmas
variáveis via secrets do orquestrador — nunca commite o `.env`) e troque
todos os valores marcados como "changeme"/exemplo:
`POSTGRES_PASSWORD`, as senhas de `DATABASE_URL_MASTER`/
`DATABASE_URL_TENANT` (roles `app_master`/`app_tenant` — troque com
`ALTER ROLE ... WITH PASSWORD '...'` no Postgres e atualize as duas
connection strings de acordo), `JWT_SECRET` e `ENCRYPTION_KEY` (mínimo 32
caracteres cada). Trocar `ENCRYPTION_KEY` depois que já existem segredos
cifrados no banco (API keys, senha SMTP) invalida todos eles — só troque
nessa hora se também for recriá-los. Configure `CORS_ORIGINS` com a(s)
origem(ns) real(is) do frontend (nunca deixe vazio em produção se o
frontend for acessado via browser).
2. **Build**: `docker compose build` (sem `docker-compose.override.yml`,
que só se aplica automaticamente quando presente/em dev) builda a
imagem `production` — multi-stage, sem devDependencies (`prod-deps` usa
`npm ci --omit=dev`) — para os serviços `app` e `worker`.
3. **Subir os serviços**: `docker compose up -d` — sobe `postgres`/`redis`
(com healthcheck), e só inicia `app`/`worker` depois que as duas
dependências estiverem saudáveis (`depends_on: condition:
service_healthy`). `app` expõe HTTP (`PORT`, default 3000) e tem
healthcheck próprio batendo em `/health`; `worker` não expõe porta (sem
HTTP) e roda os crons/filas do motor de alertas — ver seção própria
abaixo. Os dois escalam de forma independente (`docker compose up -d
--scale worker=3`, por exemplo) sem duplicar o disparo dos crons.
4. **Migrations, incluindo RLS**: `docker compose exec app npx prisma
migrate deploy` aplica **todas** as migrations pendentes em ordem — as
geradas automaticamente pelo Prisma e as manuais (RLS/policies/views,
roles `app_master`/`app_tenant`, `security_invoker` das views de
dashboard) são só pastas em `prisma/migrations/`, então não existe um
passo separado "aplicar RLS": um único `migrate deploy` já cobre tudo,
nessa mesma ordem, em qualquer ambiente novo.
5. **Verificação**: `curl https://<host>/health` (200,
`{"status":"ok","database":"up"}`), `https://<host>/docs` (Swagger,
todos os módulos documentados com o guard exigido por cada rota) e
confira nos logs do `worker` que as duas filas (`alertas-mensalidade`/
`alertas-cobranca`) inicializaram.
6. **Uploads de logo**: `docker-compose.yml` já monta um volume nomeado
(`uploads_data:/app/uploads`) pro serviço `app`, então logos enviados via
`POST /tenant/perfil/logo` sobrevivem a recriações do container. Trocar
por object storage (S3 etc) numa etapa futura é só implementar
`StorageService` (`src/common/storage/storage.service.ts`) de novo e
trocar o provider registrado em `TenantProfileModule` — nenhum outro
código muda.
## Variáveis de ambiente
Veja [.env.example](.env.example) — todas as chaves usadas pela aplicação e
@@ -191,23 +242,31 @@ configuráveis via `ALERT_CRON_MENSALIDADE`/`ALERT_CRON_COBRANCA`,
```
src/
├── main.ts # processo web (HTTP)
├── worker.ts # processo worker (crons + filas BullMQ, sem HTTP)
├── app.module.ts # módulos do processo web
├── config/ # ConfigModule, validação de env, PrismaService/PrismaMasterService/
│ # PrismaTenantService, TenantContextService (mecanismo de RLS por request)
├── worker/ # WorkerModule, AlertSchedulerService (crons), processors do BullMQ
├── main.ts # processo web (HTTP)
├── worker.ts # processo worker (crons + filas BullMQ, sem HTTP)
├── app.module.ts # módulos do processo web
├── security.util.ts # Helmet/CORS/arquivos estáticos — chamado por main.ts e por
│ # testes e2e que verificam esses cabeçalhos
├── common/storage/ # StorageService (interface) + LocalStorageService (Etapa 8)
├── config/ # ConfigModule, validação de env, PrismaService/PrismaMasterService/
│ # PrismaTenantService, TenantContextService (mecanismo de RLS por request)
├── worker/ # WorkerModule, AlertSchedulerService (crons), processors do BullMQ
└── modules/
├── health/ # GET /health
├── auth/ # login/refresh/logout/reset, guards, decorators, JwtStrategy
└── alerts/ # CRUD de alert_templates, GET .../alertas/logs, resolver de fallback
# e placeholders (usado pelo módulo web e pelo worker)
├── health/ # GET /health
├── auth/ # login/refresh/logout/reset, guards, decorators, JwtStrategy
├── tenant-profile/ # GET/PUT/POST/DELETE /tenant/perfil (logo/cor), GET /public/tenants/:id/tema
└── alerts/ # CRUD de alert_templates, GET .../alertas/logs, resolver de fallback
# e placeholders (usado pelo módulo web e pelo worker)
prisma/
├── schema.prisma
└── migrations/
Etapas/ # Prompts e controle de progresso do build faseado
Etapas/ # Prompts e controle de progresso do build faseado
```
Documentação interativa de todos os endpoints (Swagger/OpenAPI, gerado via
`@nestjs/swagger` + o plugin do `nest-cli.json` — não precisa de
`@ApiProperty()` manual em cada DTO): `/docs` (`/docs-json` pro spec bruto).
## Progresso do build
Este projeto é construído em etapas. O estado oficial de cada etapa (o que
+6
View File
@@ -8,6 +8,11 @@ services:
- '${PORT:-3000}:3000'
env_file:
- .env
volumes:
# Logos de tenant (Etapa 8, `LocalStorageService`) — volume nomeado
# para sobreviver a recriações do container. Trocar por object
# storage (S3 etc) numa etapa futura elimina a necessidade disso.
- uploads_data:/app/uploads
depends_on:
postgres:
condition: service_healthy
@@ -72,3 +77,4 @@ services:
volumes:
postgres_data:
redis_data:
uploads_data:
+2 -1
View File
@@ -3,6 +3,7 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"plugins": ["@nestjs/swagger"]
}
}
+99 -1
View File
@@ -17,6 +17,7 @@
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^4.1.2",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0",
"argon2": "^0.45.1",
@@ -24,6 +25,7 @@
"bullmq": "^5.34.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"helmet": "^7.1.0",
"nodemailer": "^9.0.5",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
@@ -37,6 +39,7 @@
"@nestjs/testing": "^10.4.15",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/multer": "^1.4.12",
"@types/node": "^20.17.10",
"@types/nodemailer": "^8.0.1",
"@types/passport-jwt": "^4.0.1",
@@ -1755,6 +1758,12 @@
"node": ">=8"
}
},
"node_modules/@microsoft/tsdoc": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz",
"integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==",
"license": "MIT"
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
@@ -2095,6 +2104,26 @@
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/mapped-types": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz",
"integrity": "sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
"class-transformer": "^0.4.0 || ^0.5.0",
"class-validator": "^0.13.0 || ^0.14.0",
"reflect-metadata": "^0.1.12 || ^0.2.0"
},
"peerDependenciesMeta": {
"class-transformer": {
"optional": true
},
"class-validator": {
"optional": true
}
}
},
"node_modules/@nestjs/passport": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz",
@@ -2164,6 +2193,51 @@
"dev": true,
"license": "MIT"
},
"node_modules/@nestjs/swagger": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz",
"integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==",
"license": "MIT",
"dependencies": {
"@microsoft/tsdoc": "^0.15.0",
"@nestjs/mapped-types": "2.0.5",
"js-yaml": "4.1.0",
"lodash": "4.17.21",
"path-to-regexp": "3.3.0",
"swagger-ui-dist": "5.17.14"
},
"peerDependencies": {
"@fastify/static": "^6.0.0 || ^7.0.0",
"@nestjs/common": "^9.0.0 || ^10.0.0",
"@nestjs/core": "^9.0.0 || ^10.0.0",
"class-transformer": "*",
"class-validator": "*",
"reflect-metadata": "^0.1.12 || ^0.2.0"
},
"peerDependenciesMeta": {
"@fastify/static": {
"optional": true
},
"class-transformer": {
"optional": true
},
"class-validator": {
"optional": true
}
}
},
"node_modules/@nestjs/swagger/node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@nestjs/testing": {
"version": "10.4.22",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.22.tgz",
@@ -2665,6 +2739,16 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "1.4.13",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz",
"integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -3605,7 +3689,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/array-flatten": {
@@ -6043,6 +6126,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz",
"integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -9533,6 +9625,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.17.14",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz",
"integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==",
"license": "Apache-2.0"
},
"node_modules/symbol-observable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",
+3
View File
@@ -33,6 +33,7 @@
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^4.1.2",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0",
"argon2": "^0.45.1",
@@ -40,6 +41,7 @@
"bullmq": "^5.34.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"helmet": "^7.1.0",
"nodemailer": "^9.0.5",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
@@ -53,6 +55,7 @@
"@nestjs/testing": "^10.4.15",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/multer": "^1.4.12",
"@types/node": "^20.17.10",
"@types/nodemailer": "^8.0.1",
"@types/passport-jwt": "^4.0.1",
+2
View File
@@ -15,6 +15,7 @@ import { HealthModule } from './modules/health/health.module';
import { PlanosModule } from './modules/planos/planos.module';
import { ProductsModule } from './modules/products/products.module';
import { SalesModule } from './modules/sales/sales.module';
import { TenantProfileModule } from './modules/tenant-profile/tenant-profile.module';
import { TenantsModule } from './modules/tenants/tenants.module';
import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
@@ -39,6 +40,7 @@ import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
ProductsModule,
SalesModule,
AlertsModule,
TenantProfileModule,
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
@@ -0,0 +1,26 @@
import { mkdirSync, promises as fs } from 'node:fs';
import { basename, join } from 'node:path';
import { Injectable } from '@nestjs/common';
import { StorageService } from './storage.service';
/** Implementação `StorageService` pro MVP: um diretório local (`uploads/`, montado como volume Docker em produção — ver `docker-compose.yml`). */
@Injectable()
export class LocalStorageService implements StorageService {
private readonly diretorio = join(process.cwd(), 'uploads');
constructor() {
mkdirSync(this.diretorio, { recursive: true });
}
async salvar(nomeArquivo: string, conteudo: Buffer): Promise<string> {
await fs.writeFile(join(this.diretorio, nomeArquivo), conteudo);
return `/uploads/${nomeArquivo}`;
}
async remover(url: string): Promise<void> {
// `basename` corta qualquer diretório embutido na URL antes de montar o
// caminho local — a URL sempre vem de um valor que nós mesmos geramos e
// gravamos no banco, mas não custa não confiar cegamente nela.
await fs.rm(join(this.diretorio, basename(url)), { force: true });
}
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Interface de armazenamento de arquivos, pensada pra poder trocar
* `LocalStorageService` (volume Docker local, suficiente pro MVP) por uma
* implementação de object storage (S3 e afins) numa etapa futura sem tocar
* nos services que a usam — troca de uma linha no módulo que registra o
* provider (`TenantProfileModule`).
*/
export interface StorageService {
/** Grava `conteudo` sob `nomeArquivo` e devolve a URL pública (relativa) do arquivo salvo. */
salvar(nomeArquivo: string, conteudo: Buffer): Promise<string>;
/** Remove o arquivo apontado por uma URL previamente devolvida por `salvar`. Idempotente — não lança se o arquivo já não existir. */
remover(url: string): Promise<void>;
}
+4
View File
@@ -84,6 +84,10 @@ class EnvironmentVariables {
@IsInt()
@IsOptional()
ALERT_JOB_BACKOFF_MS?: number;
@IsString()
@IsOptional()
CORS_ORIGINS?: string;
}
export function validate(config: Record<string, unknown>) {
+20 -1
View File
@@ -1,12 +1,17 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { applySecurityConfig } from './security.util';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const configService = app.get(ConfigService);
applySecurityConfig(app, configService);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
@@ -15,6 +20,20 @@ async function bootstrap() {
}),
);
const swaggerConfig = new DocumentBuilder()
.setTitle('SeConectai Vendas API')
.setDescription(
'CRM/controle de vendas multi-tenant. Endpoints agrupados por módulo — ' +
'`master/*` exige `MasterGuard`, `tenant/*` exige `TenantGuard` ' +
'(alguns também `RolesGuard`/`tenant_admin`, indicado por endpoint), ' +
'`public/*` não exige autenticação.',
)
.setVersion('1.0')
.addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'jwt')
.build();
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('docs', app, swaggerDocument);
const port = configService.get<number>('PORT', 3000);
await app.listen(port);
}
@@ -1,5 +1,5 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsIn, IsOptional } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
import {
CANAIS_TEMPLATE,
CanalTemplate,
@@ -20,4 +20,17 @@ export class ListAlertTemplatesQueryDto {
@Transform(({ value }) => (value === undefined ? undefined : value === 'true' || value === true))
@IsBoolean()
ativo?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
@@ -3,8 +3,11 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { MasterGuard } from '../auth/guards/master.guard';
import { ListAlertLogsMasterQueryDto } from './dto/list-alert-logs.query.dto';
import { MasterAlertLogsService } from './master-alert-logs.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, MasterGuard)
@ApiTags('master/alertas')
@ApiBearerAuth('jwt')
@Controller('master/alertas/logs')
export class MasterAlertLogsController {
constructor(private readonly masterAlertLogsService: MasterAlertLogsService) {}
@@ -20,8 +20,11 @@ import { CreateMasterAlertTemplateDto } from './dto/create-master-alert-template
import { ListAlertTemplatesQueryDto } from './dto/list-alert-templates.query.dto';
import { UpdateMasterAlertTemplateDto } from './dto/update-master-alert-template.dto';
import { MasterAlertTemplatesService } from './master-alert-templates.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, MasterGuard)
@ApiTags('master/alertas')
@ApiBearerAuth('jwt')
@Controller('master/alertas/templates')
export class MasterAlertTemplatesController {
constructor(private readonly masterAlertTemplatesService: MasterAlertTemplatesService) {}
@@ -29,16 +29,28 @@ export class MasterAlertTemplatesService {
});
}
findAll(query: ListAlertTemplatesQueryDto) {
return this.prismaMaster.alert_templates.findMany({
where: {
tenant_id: null,
...(query.tipo ? { tipo: query.tipo } : {}),
...(query.canal ? { canal: query.canal } : {}),
...(query.ativo === undefined ? {} : { ativo: query.ativo }),
},
orderBy: { criado_em: 'desc' },
});
async findAll(query: ListAlertTemplatesQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const where: Prisma.alert_templatesWhereInput = {
tenant_id: null,
...(query.tipo ? { tipo: query.tipo } : {}),
...(query.canal ? { canal: query.canal } : {}),
...(query.ativo === undefined ? {} : { ativo: query.ativo }),
};
const [data, total] = await this.prismaMaster.$transaction([
this.prismaMaster.alert_templates.findMany({
where,
orderBy: { criado_em: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prismaMaster.alert_templates.count({ where }),
]);
return { data, meta: { total, page, limit } };
}
async findOne(id: string) {
@@ -5,8 +5,11 @@ import { TenantGuard } from '../auth/guards/tenant.guard';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { ListAlertLogsTenantQueryDto } from './dto/list-alert-logs.query.dto';
import { TenantAlertLogsService } from './tenant-alert-logs.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard)
@ApiTags('tenant/alertas')
@ApiBearerAuth('jwt')
@Controller('tenant/alertas/logs')
export class TenantAlertLogsController {
constructor(private readonly tenantAlertLogsService: TenantAlertLogsService) {}
@@ -22,9 +22,12 @@ import { CreateTenantAlertTemplateDto } from './dto/create-tenant-alert-template
import { ListAlertTemplatesQueryDto } from './dto/list-alert-templates.query.dto';
import { UpdateTenantAlertTemplateDto } from './dto/update-tenant-alert-template.dto';
import { TenantAlertTemplatesService } from './tenant-alert-templates.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard, RolesGuard)
@Roles('tenant_admin')
@ApiTags('tenant/alertas')
@ApiBearerAuth('jwt')
@Controller('tenant/alertas/templates')
export class TenantAlertTemplatesController {
constructor(private readonly tenantAlertTemplatesService: TenantAlertTemplatesService) {}
@@ -43,16 +43,28 @@ export class TenantAlertTemplatesService {
});
}
findAll(query: ListAlertTemplatesQueryDto, actor: JwtPayload) {
return this.prismaMaster.alert_templates.findMany({
where: {
tenant_id: actor.tenant_id,
...(query.tipo ? { tipo: query.tipo } : {}),
...(query.canal ? { canal: query.canal } : {}),
...(query.ativo === undefined ? {} : { ativo: query.ativo }),
},
orderBy: { criado_em: 'desc' },
});
async findAll(query: ListAlertTemplatesQueryDto, actor: JwtPayload) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const where: Prisma.alert_templatesWhereInput = {
tenant_id: actor.tenant_id,
...(query.tipo ? { tipo: query.tipo } : {}),
...(query.canal ? { canal: query.canal } : {}),
...(query.ativo === undefined ? {} : { ativo: query.ativo }),
};
const [data, total] = await this.prismaMaster.$transaction([
this.prismaMaster.alert_templates.findMany({
where,
orderBy: { criado_em: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prismaMaster.alert_templates.count({ where }),
]);
return { data, meta: { total, page, limit } };
}
async findOne(id: string, actor: JwtPayload) {
+2
View File
@@ -16,6 +16,7 @@ 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';
import { ApiTags } from '@nestjs/swagger';
// Rate limit por IP nas rotas de login, como segunda camada além do
// bloqueio persistente por e-mail (`login_attempts`, ver
@@ -30,6 +31,7 @@ function requestMeta(req: Request) {
return { ip: req.ip, userAgent: req.get('user-agent') };
}
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(
+2
View File
@@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { EmailModule } from '../email-config/email.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@@ -22,6 +23,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
secret: configService.get<string>('JWT_SECRET'),
}),
}),
EmailModule,
],
controllers: [AuthController],
providers: [
+29 -7
View File
@@ -1,8 +1,15 @@
import { HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
import {
HttpException,
HttpStatus,
Injectable,
Logger,
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 { EmailService } from '../email-config/email.service';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { LoginDto } from './dto/login.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
@@ -28,6 +35,7 @@ export const TENANT_BLOCKED_STATUSES = new Set(['suspenso', 'cancelado']);
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly accessTokenExpiresIn: string;
private readonly refreshTokenExpiresInDays: number;
private readonly passwordResetExpiresInHours: number;
@@ -38,6 +46,7 @@ export class AuthService {
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly emailService: EmailService,
) {
this.accessTokenExpiresIn = this.configService.get<string>('JWT_ACCESS_EXPIRES_IN', '15m');
this.refreshTokenExpiresInDays = this.configService.get<number>(
@@ -203,13 +212,26 @@ export class AuthService {
},
});
// 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.
const assunto = 'Redefinição de senha — SeConectai Vendas';
const corpo =
`Uma redefinição de senha foi solicitada para esta conta. Use o código abaixo em até ` +
`${this.passwordResetExpiresInHours}h para criar uma nova senha:<br><br>${token}<br><br>` +
`Se você não solicitou isso, ignore este e-mail.`;
console.log(
`[password-reset] usuario_tipo=${dto.usuario_tipo} usuario_id=${usuario.id} ` +
`token=${token} expira_em=${expira_em.toISOString()}`,
);
try {
if (dto.usuario_tipo === 'master') {
await this.emailService.enviarComConfigPadrao(usuario.email, assunto, corpo);
} else if ('tenant_id' in usuario) {
await this.emailService.enviar(usuario.tenant_id, usuario.email, assunto, corpo);
}
} catch (error) {
// Nunca deixa uma falha de envio (ex: SMTP não configurado) mudar a
// resposta genérica — só registra pra alguém de operação perceber
// (sem logar o token, que é o segredo em si).
this.logger.warn(
`Falha ao enviar e-mail de redefinição de senha (usuario_id=${usuario.id}): ${(error as Error).message}`,
);
}
return generic;
}
@@ -18,8 +18,11 @@ import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CustomerContactsService } from './customer-contacts.service';
import { CreateContactDto } from './dto/create-contact.dto';
import { UpdateContactDto } from './dto/update-contact.dto';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard)
@ApiTags('tenant/customers')
@ApiBearerAuth('jwt')
@Controller('tenant/customers/:customerId/contatos')
export class CustomerContactsController {
constructor(private readonly customerContactsService: CustomerContactsService) {}
@@ -20,8 +20,11 @@ import { CustomersService } from './customers.service';
import { CreateCustomerDto } from './dto/create-customer.dto';
import { ListCustomersQueryDto } from './dto/list-customers.query.dto';
import { UpdateCustomerDto } from './dto/update-customer.dto';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard)
@ApiTags('tenant/customers')
@ApiBearerAuth('jwt')
@Controller('tenant/customers')
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}
+23 -4
View File
@@ -52,6 +52,11 @@ export class EmailService {
};
}
return this.resolverConfigPadrao();
}
/** Só a config padrão da plataforma — usado quando não há tenant nenhum envolvido (ex: reset de senha de `master_admin`). */
async resolverConfigPadrao(): Promise<ResolvedEmailConfig> {
const padrao = await this.prismaMaster.master_email_config.findFirst({
where: { ativo: true },
orderBy: { criado_em: 'desc' },
@@ -71,14 +76,12 @@ export class EmailService {
};
}
async enviar(
tenantId: string,
private async enviarComConfig(
config: ResolvedEmailConfig,
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,
@@ -97,4 +100,20 @@ export class EmailService {
throw new BadRequestException(`Falha ao enviar e-mail: ${(error as Error).message}`);
}
}
async enviar(
tenantId: string,
destinatario: string,
assunto: string,
corpo: string,
): Promise<void> {
const config = await this.resolverConfig(tenantId);
await this.enviarComConfig(config, destinatario, assunto, corpo);
}
/** Envia usando sempre a config padrão da plataforma, sem nenhum tenant envolvido. */
async enviarComConfigPadrao(destinatario: string, assunto: string, corpo: string): Promise<void> {
const config = await this.resolverConfigPadrao();
await this.enviarComConfig(config, destinatario, assunto, corpo);
}
}
@@ -20,8 +20,11 @@ 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';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, MasterGuard)
@ApiTags('master/email-config')
@ApiBearerAuth('jwt')
@Controller('master/email-config')
export class MasterEmailConfigController {
constructor(private readonly masterEmailConfigService: MasterEmailConfigService) {}
@@ -9,9 +9,12 @@ 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';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard, RolesGuard)
@Roles('tenant_admin')
@ApiTags('tenant/email-config')
@ApiBearerAuth('jwt')
@Controller('tenant/email-config')
export class TenantEmailConfigController {
constructor(private readonly tenantEmailConfigService: TenantEmailConfigService) {}
@@ -20,8 +20,11 @@ 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';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, MasterGuard)
@ApiTags('master/evolution-config')
@ApiBearerAuth('jwt')
@Controller('master/evolution-config')
export class EvolutionController {
constructor(private readonly evolutionService: EvolutionService) {}
+2
View File
@@ -1,6 +1,8 @@
import { Controller, Get } from '@nestjs/common';
import { HealthService, HealthStatus } from './health.service';
import { ApiTags } from '@nestjs/swagger';
@ApiTags('health')
@Controller('health')
export class HealthController {
constructor(private readonly healthService: HealthService) {}
+3
View File
@@ -20,8 +20,11 @@ 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';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, MasterGuard)
@ApiTags('master/planos')
@ApiBearerAuth('jwt')
@Controller('master/planos')
export class PlanosController {
constructor(private readonly planosService: PlanosService) {}
@@ -20,8 +20,11 @@ import { CreateProductDto } from './dto/create-product.dto';
import { ListProductsQueryDto } from './dto/list-products.query.dto';
import { UpdateProductDto } from './dto/update-product.dto';
import { ProductsService } from './products.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard)
@ApiTags('tenant/produtos')
@ApiBearerAuth('jwt')
@Controller('tenant/produtos')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@@ -1,4 +1,5 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { TenantGuard } from '../auth/guards/tenant.guard';
import { TenantDashboardService } from './dashboard.service';
@@ -8,6 +9,8 @@ import {
} from './dto/dashboard-periodo.query.dto';
@UseGuards(JwtAuthGuard, TenantGuard)
@ApiTags('tenant/dashboard')
@ApiBearerAuth('jwt')
@Controller('tenant/dashboard')
export class TenantDashboardController {
constructor(private readonly dashboardService: TenantDashboardService) {}
@@ -18,8 +18,11 @@ import { JwtPayload } from '../auth/types/jwt-payload.type';
import { ListRecebimentosQueryDto } from './dto/list-recebimentos.query.dto';
import { MarcarPagoDto } from './dto/marcar-pago.dto';
import { SalePaymentsService } from './sale-payments.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard)
@ApiTags('tenant/recebimentos')
@ApiBearerAuth('jwt')
@Controller('tenant/recebimentos')
export class SalePaymentsController {
constructor(private readonly salePaymentsService: SalePaymentsService) {}
+3
View File
@@ -21,8 +21,11 @@ import { CreateSaleDto } from './dto/create-sale.dto';
import { ListSalesQueryDto } from './dto/list-sales.query.dto';
import { UpdateSaleDto } from './dto/update-sale.dto';
import { SalesService } from './sales.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard)
@ApiTags('tenant/vendas')
@ApiBearerAuth('jwt')
@Controller('tenant/vendas')
export class SalesController {
constructor(private readonly salesService: SalesService) {}
@@ -0,0 +1,6 @@
import { Matches } from 'class-validator';
export class UpdateCorDto {
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'cor_primaria deve estar no formato hex "#RRGGBB"' })
cor_primaria: string;
}
@@ -0,0 +1,19 @@
import { UnsupportedMediaTypeException } from '@nestjs/common';
const EXTENSAO_POR_MIME: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/svg+xml': 'svg',
};
export const MIME_TYPES_LOGO_PERMITIDOS = Object.keys(EXTENSAO_POR_MIME);
export function extensaoPorMime(mimeType: string): string {
const extensao = EXTENSAO_POR_MIME[mimeType];
if (!extensao) {
throw new UnsupportedMediaTypeException(
`Tipo de arquivo "${mimeType}" não suportado — use PNG, JPG ou SVG`,
);
}
return extensao;
}
@@ -0,0 +1,40 @@
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { PrismaMasterService } from '../../config/prisma-master.service';
/**
* Sem nenhum guard — de propósito: a tela de login do tenant precisa
* carregar logo/cor antes do usuário se autenticar. Ainda protegido pelo
* `ThrottlerGuard` global (rate limiting vale pra endpoint público também).
*
* Usa o `id` (UUID) do tenant, não um `slug` — o schema fornecido não tem
* esse campo, e o fluxo de login deste projeto já resolve o tenant pelo
* e-mail do usuário (Etapa 2), não por subdomínio/slug. Adicionar um `slug`
* exigiria uma migration incremental só para um cenário (login por
* subdomínio) que não existe em nenhuma outra parte do sistema — fica
* registrado como decisão consciente no controle de progresso, não uma
* omissão.
*/
@ApiTags('public')
@Controller('public/tenants')
export class PublicTenantController {
constructor(private readonly prismaMaster: PrismaMasterService) {}
@Get(':id/tema')
async tema(@Param('id', ParseUUIDPipe) id: string) {
const tenant = await this.prismaMaster.tenants.findUnique({
where: { id },
select: { nome_fantasia: true, razao_social: true, logo_url: true, cor_primaria: true },
});
if (!tenant) {
throw new NotFoundException('Tenant não encontrado');
}
return {
nome: tenant.nome_fantasia ?? tenant.razao_social,
logo_url: tenant.logo_url,
cor_primaria: tenant.cor_primaria,
};
}
}
@@ -0,0 +1,84 @@
import {
Body,
Controller,
Delete,
Get,
HttpStatus,
ParseFilePipeBuilder,
Put,
Post,
Req,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { TenantGuard } from '../auth/guards/tenant.guard';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { UpdateCorDto } from './dto/update-cor.dto';
import { TenantProfileService } from './tenant-profile.service';
const TAMANHO_MAXIMO_LOGO_BYTES = 2 * 1024 * 1024; // 2MB
@ApiTags('tenant/perfil')
@ApiBearerAuth('jwt')
@UseGuards(JwtAuthGuard, TenantGuard, RolesGuard)
@Roles('tenant_admin')
@Controller('tenant/perfil')
export class TenantProfileController {
constructor(private readonly tenantProfileService: TenantProfileService) {}
@Get()
obter(@CurrentUser() user: JwtPayload) {
return this.tenantProfileService.obter(user);
}
@Post('logo')
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: { type: 'object', properties: { logo: { type: 'string', format: 'binary' } } },
})
@UseInterceptors(FileInterceptor('logo'))
uploadLogo(
@UploadedFile(
new ParseFilePipeBuilder()
// `skipMagicNumbersValidation`: sem isso, o `FileTypeValidator` sniffa os
// magic numbers via `file-type` (pacote ESM-only, importado dinamicamente
// por baixo) — funciona no app real (`node dist/main`), mas o Jest/ts-jest
// deste projeto não carrega esse `import()` dinâmico de dentro de um
// contexto CJS de teste, fazendo a validação falhar sempre (mesmo pra um
// PNG válido). Validar só o `mimetype` declarado é suficiente pro risco
// real aqui (logo estática, nunca processada/executada no servidor).
.addFileTypeValidator({
fileType: /^image\/(png|jpeg|svg\+xml)$/,
skipMagicNumbersValidation: true,
})
.addMaxSizeValidator({ maxSize: TAMANHO_MAXIMO_LOGO_BYTES })
.build({
errorHttpStatusCode: HttpStatus.BAD_REQUEST,
fileIsRequired: true,
}),
)
file: Express.Multer.File,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.tenantProfileService.atualizarLogo(file, user, req.ip);
}
@Delete('logo')
removerLogo(@CurrentUser() user: JwtPayload, @Req() req: Request) {
return this.tenantProfileService.removerLogo(user, req.ip);
}
@Put('cor')
atualizarCor(@Body() dto: UpdateCorDto, @CurrentUser() user: JwtPayload, @Req() req: Request) {
return this.tenantProfileService.atualizarCor(dto, user, req.ip);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { LocalStorageService } from '../../common/storage/local-storage.service';
import { PublicTenantController } from './public-tenant.controller';
import { TenantProfileController } from './tenant-profile.controller';
import { TenantProfileService } from './tenant-profile.service';
@Module({
controllers: [TenantProfileController, PublicTenantController],
providers: [TenantProfileService, LocalStorageService],
})
export class TenantProfileModule {}
@@ -0,0 +1,116 @@
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { LocalStorageService } from '../../common/storage/local-storage.service';
import { writeAuditLog } from '../../common/audit-log.util';
import { PrismaMasterService } from '../../config/prisma-master.service';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { UpdateCorDto } from './dto/update-cor.dto';
import { extensaoPorMime } from './logo-mime.util';
/**
* `tenants` não tem RLS habilitado (nem precisaria: master também edita
* essa tabela) — usa `PrismaMasterService` com `where: { id: actor.tenant_id }`
* explícito em toda operação, mesmo padrão já usado por outros services que
* tocam tabelas sem RLS (`AlertDispatchService`, módulo `alerts`).
*/
@Injectable()
export class TenantProfileService {
constructor(
private readonly prismaMaster: PrismaMasterService,
private readonly storage: LocalStorageService,
) {}
async obter(actor: JwtPayload) {
return this.prismaMaster.tenants.findUniqueOrThrow({
where: { id: actor.tenant_id as string },
select: { logo_url: true, cor_primaria: true, nome_fantasia: true, razao_social: true },
});
}
async atualizarLogo(file: Express.Multer.File, actor: JwtPayload, ip?: string) {
const tenantId = actor.tenant_id as string;
const tenant = await this.prismaMaster.tenants.findUniqueOrThrow({ where: { id: tenantId } });
const nomeArquivo = `${tenantId}-${randomUUID()}.${extensaoPorMime(file.mimetype)}`;
const logoUrl = await this.storage.salvar(nomeArquivo, file.buffer);
const atualizado = await this.prismaMaster.$transaction(async (tx) => {
const resultado = await tx.tenants.update({
where: { id: tenantId },
data: { logo_url: logoUrl, atualizado_em: new Date() },
});
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: tenantId,
acao: 'atualizar_logo_tenant',
entidade: 'tenants',
entidade_id: tenantId,
ip_origem: ip,
});
return resultado;
});
if (tenant.logo_url) {
await this.storage.remover(tenant.logo_url);
}
return { logo_url: atualizado.logo_url };
}
async removerLogo(actor: JwtPayload, ip?: string) {
const tenantId = actor.tenant_id as string;
const tenant = await this.prismaMaster.tenants.findUniqueOrThrow({ where: { id: tenantId } });
const atualizado = await this.prismaMaster.$transaction(async (tx) => {
const resultado = await tx.tenants.update({
where: { id: tenantId },
data: { logo_url: null, atualizado_em: new Date() },
});
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: tenantId,
acao: 'remover_logo_tenant',
entidade: 'tenants',
entidade_id: tenantId,
ip_origem: ip,
});
return resultado;
});
if (tenant.logo_url) {
await this.storage.remover(tenant.logo_url);
}
return { logo_url: atualizado.logo_url };
}
async atualizarCor(dto: UpdateCorDto, actor: JwtPayload, ip?: string) {
const tenantId = actor.tenant_id as string;
return this.prismaMaster.$transaction(async (tx) => {
const atualizado = await tx.tenants.update({
where: { id: tenantId },
data: { cor_primaria: dto.cor_primaria, atualizado_em: new Date() },
});
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: tenantId,
acao: 'atualizar_cor_tenant',
entidade: 'tenants',
entidade_id: tenantId,
detalhes: { cor_primaria: dto.cor_primaria },
ip_origem: ip,
});
return { cor_primaria: atualizado.cor_primaria };
});
}
}
@@ -2,8 +2,11 @@ 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';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, MasterGuard)
@ApiTags('master/dashboard')
@ApiBearerAuth('jwt')
@Controller('master/dashboard')
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
@@ -20,8 +20,11 @@ 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';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, MasterGuard)
@ApiTags('master/faturas')
@ApiBearerAuth('jwt')
@Controller('master/faturas')
export class FaturasController {
constructor(private readonly faturasService: FaturasService) {}
@@ -21,8 +21,11 @@ import { ListTenantsQueryDto } from './dto/list-tenants.query.dto';
import { UpdateTenantDto } from './dto/update-tenant.dto';
import { UpdateTenantStatusDto } from './dto/update-tenant-status.dto';
import { TenantsService } from './tenants.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, MasterGuard)
@ApiTags('master/tenants')
@ApiBearerAuth('jwt')
@Controller('master/tenants')
export class TenantsController {
constructor(private readonly tenantsService: TenantsService) {}
@@ -18,9 +18,12 @@ import { TenantGuard } from '../auth/guards/tenant.guard';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { TestarEnvioWhatsappDto } from './dto/testar-envio-whatsapp.dto';
import { WhatsappService } from './whatsapp.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard, RolesGuard)
@Roles('tenant_admin')
@ApiTags('tenant/whatsapp')
@ApiBearerAuth('jwt')
@Controller('tenant/whatsapp/instancias')
export class WhatsappController {
constructor(private readonly whatsappService: WhatsappService) {}
+34
View File
@@ -0,0 +1,34 @@
import { join } from 'node:path';
import { ConfigService } from '@nestjs/config';
import { NestExpressApplication } from '@nestjs/platform-express';
import helmet from 'helmet';
/**
* Helmet + CORS + arquivos estáticos de `uploads/` — extraído de `main.ts`
* pra poder ser chamado também a partir de um `INestApplication` de teste
* (`Test.createTestingModule(...).createNestApplication()`), que não passa
* por `bootstrap()`. Sem isso, testes e2e que verificam esses cabeçalhos
* estariam testando uma configuração que a aplicação real nunca aplicaria
* de fato (falso positivo).
*/
export function applySecurityConfig(
app: NestExpressApplication,
configService: ConfigService,
): void {
app.use(helmet());
// Vazio/ausente = nenhuma origem cross-site autorizada (server-to-server e
// ferramentas como curl/Postman não passam pelo CORS do browser, então não
// são bloqueadas por isso). Nunca `origin: '*'`.
const corsOrigins = configService
.get<string>('CORS_ORIGINS', '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
app.enableCors({ origin: corsOrigins.length > 0 ? corsOrigins : false });
// Logo do tenant (`POST /tenant/perfil/logo`, Etapa 8) — servido como
// arquivo estático público, sem autenticação (a tela de login do tenant
// precisa carregar o logo antes do usuário autenticar).
app.useStaticAssets(join(process.cwd(), 'uploads'), { prefix: '/uploads/' });
}
+40 -8
View File
@@ -6,6 +6,14 @@ import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/config/prisma.service';
import { hashPassword } from '../src/modules/auth/password.util';
// O reset de senha (Etapa 8) manda o token por e-mail de verdade — sem SMTP
// real disponível neste ambiente, mocka `nodemailer` (mesmo padrão de
// `integracoes.e2e-spec.ts`) e extrai o token do corpo do e-mail "enviado".
const mockSendMail = jest.fn().mockResolvedValue({ messageId: 'fake-message-id' });
jest.mock('nodemailer', () => ({
createTransport: jest.fn(() => ({ sendMail: mockSendMail })),
}));
interface TokenPair {
access_token: string;
refresh_token: string;
@@ -32,6 +40,7 @@ describe('Auth (e2e)', () => {
let tenantAAdminId: string;
let tenantAVendedorId: string;
let tenantBAdminId: string;
const masterEmailConfigIds: string[] = [];
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
@@ -115,6 +124,7 @@ describe('Auth (e2e)', () => {
await prisma.login_attempts.deleteMany({ where: { email: { in: emails } } });
await prisma.tenant_users.deleteMany({ where: { tenant_id: { in: [tenantAId, tenantBId] } } });
await prisma.tenants.deleteMany({ where: { id: { in: [tenantAId, tenantBId] } } });
await prisma.master_email_config.deleteMany({ where: { id: { in: masterEmailConfigIds } } });
await prisma.master_admins.deleteMany({ where: { id: masterId } });
await app.close();
@@ -249,21 +259,43 @@ describe('Auth (e2e)', () => {
});
describe('esqueci minha senha / reset', () => {
beforeAll(async () => {
// Reset de senha manda o token por e-mail de verdade (Etapa 8) — sem
// config de e-mail padrão ativa, `forgotPassword` engoliria o erro de
// envio (nunca deixa a falta de SMTP vazar informação pela resposta
// genérica) e nenhum e-mail chegaria a ser "enviado" pro mock capturar.
const masterLogin = await request(httpServer)
.post('/auth/master/login')
.send({ email: masterEmail, senha })
.expect(200);
const masterToken = (masterLogin.body as TokenPair).access_token;
const config = await request(httpServer)
.post('/master/email-config')
.set('Authorization', `Bearer ${masterToken}`)
.send({
smtp_host: 'smtp.exemplo.com',
smtp_porta: 587,
smtp_usuario: 'no-reply',
smtp_senha: 'senha-smtp-secreta',
email_remetente: 'no-reply@seconectai.com.br',
})
.expect(201);
masterEmailConfigIds.push((config.body as { id: string }).id);
});
it('gera token de uso único, com expiração, e permite redefinir a senha', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
mockSendMail.mockClear();
await request(httpServer)
.post('/auth/password/forgot')
.send({ email: tenantAVendedorEmail, usuario_tipo: 'tenant_user' })
.expect(200);
const logLine = consoleSpy.mock.calls
.map((args) => String(args[0]))
.find((line) => line.includes('token='));
consoleSpy.mockRestore();
expect(logLine).toBeDefined();
const token = /token=(\S+)/.exec(logLine ?? '')?.[1];
expect(mockSendMail).toHaveBeenCalledTimes(1);
const [emailEnviado] = mockSendMail.mock.calls[0] as [{ html: string }];
const corpoEmail = String(emailEnviado.html);
const token = /(?<![\w-])[\w-]{20,}(?![\w-])/.exec(corpoEmail)?.[0];
expect(token).toBeTruthy();
const novaSenha = 'NovaSenhaForte#26';
+208
View File
@@ -0,0 +1,208 @@
import { randomUUID } from 'node:crypto';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestExpressApplication } from '@nestjs/platform-express';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/config/prisma.service';
import { applySecurityConfig } from '../src/security.util';
import { hashPassword } from '../src/modules/auth/password.util';
interface TokenPair {
access_token: string;
refresh_token: string;
}
/**
* Etapa 8, critério de aceite: "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."
*
* De propósito, não reexercita casos de erro/borda (já cobertos nos specs
* dedicados de cada etapa) — só o caminho feliz de ponta a ponta, contra
* Postgres/Redis reais, pra provar que os módulos das 8 etapas realmente
* se encaixam quando usados em sequência por um único tenant.
*/
describe('Fluxo completo — do zero ao dashboard financeiro (e2e)', () => {
let app: NestExpressApplication;
let prisma: PrismaService;
let httpServer: Parameters<typeof request>[0];
const senha = 'SenhaForteDemo#26';
const suffix = randomUUID().slice(0, 8);
const masterEmail = `master-fluxo-${suffix}@teste.com`;
let masterId: string;
let masterToken: string;
const tenantIds: string[] = [];
const planoIds: string[] = [];
const tenantUserIds: string[] = [];
function asMaster() {
return {
post: (url: string) =>
request(httpServer).post(url).set('Authorization', `Bearer ${masterToken}`),
};
}
function asTenant(token: string) {
return {
get: (url: string) => request(httpServer).get(url).set('Authorization', `Bearer ${token}`),
post: (url: string) => request(httpServer).post(url).set('Authorization', `Bearer ${token}`),
patch: (url: string) =>
request(httpServer).patch(url).set('Authorization', `Bearer ${token}`),
};
}
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication<NestExpressApplication>();
applySecurityConfig(app, app.get(ConfigService));
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.init();
httpServer = app.getHttpServer();
prisma = app.get(PrismaService);
const senha_hash = await hashPassword(senha);
const master = await prisma.master_admins.create({
data: { nome: 'Master Fluxo Completo', email: masterEmail, senha_hash },
});
masterId = master.id;
});
afterAll(async () => {
await prisma.sales.deleteMany({ where: { tenant_id: { in: tenantIds } } });
await prisma.tenants.deleteMany({ where: { id: { in: tenantIds } } });
await prisma.planos.deleteMany({ where: { id: { in: planoIds } } });
await prisma.auth_refresh_tokens.deleteMany({
where: { usuario_id: { in: [masterId, ...tenantUserIds] } },
});
await prisma.login_attempts.deleteMany({
where: { usuario_tipo: 'master', email: masterEmail },
});
await prisma.audit_logs.deleteMany({ where: { usuario_id: masterId } });
await prisma.master_admins.deleteMany({ where: { id: masterId } });
await app.close();
});
it('do master criando o tenant até o dashboard financeiro refletir o pagamento', async () => {
// 1) master autentica
const masterLogin = await request(httpServer)
.post('/auth/master/login')
.send({ email: masterEmail, senha })
.expect(200);
masterToken = (masterLogin.body as TokenPair).access_token;
// 2) master cria um plano e, com ele, um tenant novo (já com o primeiro tenant_admin)
const plano = await asMaster()
.post('/master/planos')
.send({ nome: `Plano Fluxo ${suffix}`, valor_mensal: 149.9 })
.expect(201);
planoIds.push((plano.body as { id: string }).id);
const emailAdmin = `admin-fluxo-${suffix}@teste.com`;
const criarTenant = await asMaster()
.post('/master/tenants')
.send({
razao_social: `Tenant Fluxo Completo ${suffix}`,
email_contato: `contato-fluxo-${suffix}@teste.com`,
plano_id: (plano.body as { id: string }).id,
admin: { nome: 'Admin Fluxo Completo', email: emailAdmin },
})
.expect(201);
const { tenant, admin } = criarTenant.body as {
tenant: { id: string };
admin: { id: string; senha_provisoria: string };
};
tenantIds.push(tenant.id);
tenantUserIds.push(admin.id);
// tenant criado nasce em `trial`; ativa pra poder logar sem bloqueio
await prisma.tenants.update({ where: { id: tenant.id }, data: { status: 'ativo' } });
// 3) tenant_admin faz login com a senha provisória
const tenantLogin = await request(httpServer)
.post('/auth/tenant/login')
.send({ email: emailAdmin, senha: admin.senha_provisoria })
.expect(200);
const tenantToken = (tenantLogin.body as TokenPair).access_token;
// 4) cadastra um customer
const customer = await asTenant(tenantToken)
.post('/tenant/customers')
.send({ razao_social: 'Cliente Fluxo Completo Ltda', email: 'cliente@fluxo-completo.teste' })
.expect(201);
const customerId = (customer.body as { id: string }).id;
// 5) cadastra um produto
const produto = await asTenant(tenantToken)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: 'Produto Fluxo Completo', valor_venda: 300 })
.expect(201);
const produtoId = (produto.body as { id: string }).id;
// 6) faz uma venda com 2 parcelas
const venda = await asTenant(tenantToken)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 300 }],
numero_parcelas: 2,
})
.expect(201);
const vendaBody = venda.body as {
id: string;
valor_total: string;
parcelas: Array<{ id: string; valor: string }>;
};
expect(Number(vendaBody.valor_total)).toBe(300);
expect(vendaBody.parcelas).toHaveLength(2);
// 7) confere o financeiro ANTES de pagar (delta-based: tenant novo, começa em zero)
const financeiroAntes = await asTenant(tenantToken)
.get('/tenant/dashboard/financeiro')
.expect(200);
expect((financeiroAntes.body as { total_recebido_mes: string }).total_recebido_mes).toBe(
'0.00',
);
expect(Number((financeiroAntes.body as { total_a_receber: string }).total_a_receber)).toBe(300);
// 8) marca a primeira parcela como paga
const primeiraParcela = vendaBody.parcelas[0];
await asTenant(tenantToken)
.patch(`/tenant/recebimentos/${primeiraParcela.id}/marcar-pago`)
.send({ forma_pagamento: 'pix' })
.expect(200);
// 9) dashboard financeiro reflete o pagamento
const financeiroDepois = await asTenant(tenantToken)
.get('/tenant/dashboard/financeiro')
.expect(200);
const depois = financeiroDepois.body as {
total_recebido_mes: string;
total_a_receber: string;
proximas_parcelas: Array<{ id: string }>;
};
expect(Number(depois.total_recebido_mes)).toBeCloseTo(Number(primeiraParcela.valor), 2);
expect(Number(depois.total_a_receber)).toBeCloseTo(300 - Number(primeiraParcela.valor), 2);
expect(depois.proximas_parcelas.some((p) => p.id === primeiraParcela.id)).toBe(false);
// 10) a venda em si também reflete a parcela paga
const vendaDetalhe = await asTenant(tenantToken)
.get(`/tenant/vendas/${vendaBody.id}`)
.expect(200);
const parcelasDetalhe = (
vendaDetalhe.body as { parcelas: Array<{ id: string; status: string }> }
).parcelas;
expect(parcelasDetalhe.find((p) => p.id === primeiraParcela.id)?.status).toBe('pago');
});
});
+288
View File
@@ -0,0 +1,288 @@
import { randomUUID } from 'node:crypto';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestExpressApplication } from '@nestjs/platform-express';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/config/prisma.service';
import { hashPassword } from '../src/modules/auth/password.util';
import { applySecurityConfig } from '../src/security.util';
interface TokenPair {
access_token: string;
refresh_token: string;
}
// PNG 1x1 mínimo válido (base64), só pra exercitar o upload de verdade.
const PNG_1X1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
);
describe('Perfil do Tenant e Personalização Whitelabel (e2e)', () => {
let app: NestExpressApplication;
let prisma: PrismaService;
let httpServer: Parameters<typeof request>[0];
const senha = 'SenhaForteDemo#26';
const suffix = randomUUID().slice(0, 8);
const masterEmail = `master-etapa8-${suffix}@teste.com`;
let masterId: string;
let masterToken: string;
const tenantIds: string[] = [];
const planoIds: string[] = [];
const tenantUserIds: string[] = [];
async function authMaster(): Promise<string> {
const res = await request(httpServer)
.post('/auth/master/login')
.send({ email: masterEmail, senha })
.expect(200);
return (res.body as TokenPair).access_token;
}
function asMaster() {
return {
get: (url: string) =>
request(httpServer).get(url).set('Authorization', `Bearer ${masterToken}`),
post: (url: string) =>
request(httpServer).post(url).set('Authorization', `Bearer ${masterToken}`),
};
}
function asTenant(token: string) {
return {
get: (url: string) => request(httpServer).get(url).set('Authorization', `Bearer ${token}`),
put: (url: string) => request(httpServer).put(url).set('Authorization', `Bearer ${token}`),
delete: (url: string) =>
request(httpServer).delete(url).set('Authorization', `Bearer ${token}`),
};
}
async function criarPlano() {
const res = await asMaster()
.post('/master/planos')
.send({ nome: `Plano ${suffix} ${randomUUID().slice(0, 4)}`, valor_mensal: 199.9 })
.expect(201);
planoIds.push((res.body as { id: string }).id);
return res.body as { id: string };
}
async function criarTenantComAdmin() {
const plano = await criarPlano();
const emailAdmin = `admin-${randomUUID().slice(0, 8)}-${suffix}@teste.com`;
const res = await asMaster()
.post('/master/tenants')
.send({
razao_social: `Tenant Etapa8 ${suffix} ${randomUUID().slice(0, 6)}`,
email_contato: `contato-${randomUUID().slice(0, 6)}@teste.com`,
plano_id: plano.id,
admin: { nome: 'Admin de Teste', email: emailAdmin },
})
.expect(201);
const body = res.body as {
tenant: { id: string };
admin: { id: string; email: string; senha_provisoria: string };
};
tenantIds.push(body.tenant.id);
tenantUserIds.push(body.admin.id);
await prisma.tenants.update({ where: { id: body.tenant.id }, data: { status: 'ativo' } });
const login = await request(httpServer)
.post('/auth/tenant/login')
.send({ email: body.admin.email, senha: body.admin.senha_provisoria })
.expect(200);
return { tenantId: body.tenant.id, token: (login.body as TokenPair).access_token };
}
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication<NestExpressApplication>();
applySecurityConfig(app, app.get(ConfigService));
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.init();
httpServer = app.getHttpServer();
prisma = app.get(PrismaService);
const senha_hash = await hashPassword(senha);
const master = await prisma.master_admins.create({
data: { nome: 'Master Etapa 8', email: masterEmail, senha_hash },
});
masterId = master.id;
masterToken = await authMaster();
});
afterAll(async () => {
await prisma.tenants.deleteMany({ where: { id: { in: tenantIds } } });
await prisma.planos.deleteMany({ where: { id: { in: planoIds } } });
await prisma.auth_refresh_tokens.deleteMany({
where: { usuario_id: { in: [masterId, ...tenantUserIds] } },
});
await prisma.login_attempts.deleteMany({
where: { usuario_tipo: 'master', email: masterEmail },
});
await prisma.audit_logs.deleteMany({ where: { usuario_id: masterId } });
await prisma.master_admins.deleteMany({ where: { id: masterId } });
await app.close();
});
describe('GET /tenant/perfil', () => {
it('retorna o perfil sem logo/cor definidos inicialmente', async () => {
const tenant = await criarTenantComAdmin();
const res = await asTenant(tenant.token).get('/tenant/perfil').expect(200);
expect(res.body).toMatchObject({ logo_url: null, cor_primaria: null });
});
});
describe('POST/DELETE /tenant/perfil/logo', () => {
it('faz upload de um PNG válido e reflete em GET /tenant/perfil', async () => {
const tenant = await criarTenantComAdmin();
const upload = await request(httpServer)
.post('/tenant/perfil/logo')
.set('Authorization', `Bearer ${tenant.token}`)
.attach('logo', PNG_1X1, { filename: 'logo.png', contentType: 'image/png' })
.expect(201);
const logoUrl = (upload.body as { logo_url: string }).logo_url;
expect(logoUrl).toMatch(/^\/uploads\/.+\.png$/);
const perfil = await asTenant(tenant.token).get('/tenant/perfil').expect(200);
expect((perfil.body as { logo_url: string }).logo_url).toBe(logoUrl);
// servido como arquivo estático público, sem autenticação
await request(httpServer).get(logoUrl).expect(200);
});
it('rejeita arquivo maior que 2MB', async () => {
const tenant = await criarTenantComAdmin();
const arquivoGrande = Buffer.alloc(3 * 1024 * 1024, 1);
await request(httpServer)
.post('/tenant/perfil/logo')
.set('Authorization', `Bearer ${tenant.token}`)
.attach('logo', arquivoGrande, { filename: 'logo.png', contentType: 'image/png' })
.expect(400);
});
it('rejeita tipo de arquivo não suportado', async () => {
const tenant = await criarTenantComAdmin();
await request(httpServer)
.post('/tenant/perfil/logo')
.set('Authorization', `Bearer ${tenant.token}`)
.attach('logo', Buffer.from('não é uma imagem'), {
filename: 'arquivo.txt',
contentType: 'text/plain',
})
.expect(400);
});
it('DELETE remove a logo e volta ao padrão (logo_url null)', async () => {
const tenant = await criarTenantComAdmin();
await request(httpServer)
.post('/tenant/perfil/logo')
.set('Authorization', `Bearer ${tenant.token}`)
.attach('logo', PNG_1X1, { filename: 'logo.png', contentType: 'image/png' })
.expect(201);
await asTenant(tenant.token).delete('/tenant/perfil/logo').expect(200);
const perfil = await asTenant(tenant.token).get('/tenant/perfil').expect(200);
expect((perfil.body as { logo_url: string | null }).logo_url).toBeNull();
});
});
describe('PUT /tenant/perfil/cor', () => {
it('atualiza a cor primária em formato hex válido', async () => {
const tenant = await criarTenantComAdmin();
await asTenant(tenant.token)
.put('/tenant/perfil/cor')
.send({ cor_primaria: '#1A73E8' })
.expect(200);
const perfil = await asTenant(tenant.token).get('/tenant/perfil').expect(200);
expect((perfil.body as { cor_primaria: string }).cor_primaria).toBe('#1A73E8');
});
it('rejeita cor fora do formato hex #RRGGBB', async () => {
const tenant = await criarTenantComAdmin();
await asTenant(tenant.token)
.put('/tenant/perfil/cor')
.send({ cor_primaria: 'azul' })
.expect(400);
await asTenant(tenant.token)
.put('/tenant/perfil/cor')
.send({ cor_primaria: '#FFF' })
.expect(400);
});
});
describe('GET /public/tenants/:id/tema — sem autenticação', () => {
it('retorna nome/logo/cor sem token', async () => {
const tenant = await criarTenantComAdmin();
await asTenant(tenant.token)
.put('/tenant/perfil/cor')
.send({ cor_primaria: '#00FF00' })
.expect(200);
const res = await request(httpServer)
.get(`/public/tenants/${tenant.tenantId}/tema`)
.expect(200);
expect(res.body).toMatchObject({ cor_primaria: '#00FF00' });
expect((res.body as { nome: string }).nome).toEqual(expect.any(String));
});
it('tenant inexistente retorna 404', async () => {
await request(httpServer).get(`/public/tenants/${randomUUID()}/tema`).expect(404);
});
it('id mal formado retorna 400', async () => {
await request(httpServer).get('/public/tenants/not-a-uuid/tema').expect(400);
});
});
describe('controle de acesso', () => {
it('master "puro" recebe 403 em /tenant/perfil', async () => {
await asMaster().get('/tenant/perfil').expect(403);
});
});
describe('cabeçalhos de segurança (Helmet) e CORS', () => {
it('Helmet adiciona cabeçalhos de segurança padrão', async () => {
const res = await request(httpServer).get('/health').expect(200);
expect(res.headers['x-content-type-options']).toBe('nosniff');
});
it('CORS libera só origem configurada em CORS_ORIGINS', async () => {
const permitida = await request(httpServer)
.get('/health')
.set('Origin', 'http://localhost:5173')
.expect(200);
expect(permitida.headers['access-control-allow-origin']).toBe('http://localhost:5173');
const naoPermitida = await request(httpServer)
.get('/health')
.set('Origin', 'http://origem-nao-autorizada.com')
.expect(200);
expect(naoPermitida.headers['access-control-allow-origin']).toBeUndefined();
});
});
});