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)) # do backoff exponencial (tentativa N espera ALERT_JOB_BACKOFF_MS * 2^(N-1))
ALERT_JOB_MAX_ATTEMPTS=3 ALERT_JOB_MAX_ATTEMPTS=3
ALERT_JOB_BACKOFF_MS=5000 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 | | 5 | Painel Tenant (clientes/produtos) | Concluída | 2026-08-14 |
| 6 | Vendas, Parcelas e Dashboards | 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 | | 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`, Valores possíveis para "Status": `Não iniciada`, `Em andamento`,
`Concluída`, `Concluída com pendências` (usar este último se algo ficou `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 ## Etapa 8 — Personalização Whitelabel e Ajustes Finais
- [ ] Upload/remoção de logo e alteração de cor funcionam - [x] Upload/remoção de logo e alteração de cor funcionam
- [ ] Endpoint público de tema retorna logo/cor sem autenticação - [x] Endpoint público de tema retorna logo/cor sem autenticação
- [ ] CORS, Helmet e rate limiting revisados e ativos - [x] CORS, Helmet e rate limiting revisados e ativos
- [ ] Nenhum segredo aparece em log ou resposta de API - [x] Nenhum segredo aparece em log ou resposta de API
- [ ] Swagger disponível cobrindo todos os módulos, com guards indicados - [x] Swagger disponível cobrindo todos os módulos, com guards indicados
- [ ] Build de produção do Docker sem dependências de desenvolvimento - [x] Build de produção do Docker sem dependências de desenvolvimento
- [ ] Teste E2E do fluxo completo passa do início ao fim - [x] Teste E2E do fluxo completo passa do início ao fim
- [ ] README final consolidado (dev, deploy, migrations, RLS) - [x] README final consolidado (dev, deploy, migrations, RLS)
**Observações:** **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 ## 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 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 futura considerar um WhatsApp de notificações dedicado da plataforma, se
o produto precisar. 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.
+59
View File
@@ -63,6 +63,57 @@ npm run prisma:migrate
npm run dev 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 ## Variáveis de ambiente
Veja [.env.example](.env.example) — todas as chaves usadas pela aplicação e Veja [.env.example](.env.example) — todas as chaves usadas pela aplicação e
@@ -194,12 +245,16 @@ src/
├── main.ts # processo web (HTTP) ├── main.ts # processo web (HTTP)
├── worker.ts # processo worker (crons + filas BullMQ, sem HTTP) ├── worker.ts # processo worker (crons + filas BullMQ, sem HTTP)
├── app.module.ts # módulos do processo web ├── 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/ ├── config/ # ConfigModule, validação de env, PrismaService/PrismaMasterService/
│ # PrismaTenantService, TenantContextService (mecanismo de RLS por request) │ # PrismaTenantService, TenantContextService (mecanismo de RLS por request)
├── worker/ # WorkerModule, AlertSchedulerService (crons), processors do BullMQ ├── worker/ # WorkerModule, AlertSchedulerService (crons), processors do BullMQ
└── modules/ └── modules/
├── health/ # GET /health ├── health/ # GET /health
├── auth/ # login/refresh/logout/reset, guards, decorators, JwtStrategy ├── 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 └── alerts/ # CRUD de alert_templates, GET .../alertas/logs, resolver de fallback
# e placeholders (usado pelo módulo web e pelo worker) # e placeholders (usado pelo módulo web e pelo worker)
prisma/ prisma/
@@ -208,6 +263,10 @@ prisma/
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 ## Progresso do build
Este projeto é construído em etapas. O estado oficial de cada etapa (o que 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' - '${PORT:-3000}:3000'
env_file: env_file:
- .env - .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: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -72,3 +77,4 @@ services:
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
uploads_data:
+2 -1
View File
@@ -3,6 +3,7 @@
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src", "sourceRoot": "src",
"compilerOptions": { "compilerOptions": {
"deleteOutDir": true "deleteOutDir": true,
"plugins": ["@nestjs/swagger"]
} }
} }
+99 -1
View File
@@ -17,6 +17,7 @@
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.4.15", "@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^4.1.2", "@nestjs/schedule": "^4.1.2",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.5.0", "@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"argon2": "^0.45.1", "argon2": "^0.45.1",
@@ -24,6 +25,7 @@
"bullmq": "^5.34.5", "bullmq": "^5.34.5",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"helmet": "^7.1.0",
"nodemailer": "^9.0.5", "nodemailer": "^9.0.5",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
@@ -37,6 +39,7 @@
"@nestjs/testing": "^10.4.15", "@nestjs/testing": "^10.4.15",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/multer": "^1.4.12",
"@types/node": "^20.17.10", "@types/node": "^20.17.10",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
@@ -1755,6 +1758,12 @@
"node": ">=8" "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": { "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", "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" "@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": { "node_modules/@nestjs/passport": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz",
@@ -2164,6 +2193,51 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@nestjs/testing": {
"version": "10.4.22", "version": "10.4.22",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.22.tgz", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.22.tgz",
@@ -2665,6 +2739,16 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "20.19.43", "version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -3605,7 +3689,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/array-flatten": { "node_modules/array-flatten": {
@@ -6043,6 +6126,15 @@
"node": ">= 0.4" "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": { "node_modules/html-escaper": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -9533,6 +9625,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/symbol-observable": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", "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/passport": "^11.0.5",
"@nestjs/platform-express": "^10.4.15", "@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^4.1.2", "@nestjs/schedule": "^4.1.2",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.5.0", "@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"argon2": "^0.45.1", "argon2": "^0.45.1",
@@ -40,6 +41,7 @@
"bullmq": "^5.34.5", "bullmq": "^5.34.5",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"helmet": "^7.1.0",
"nodemailer": "^9.0.5", "nodemailer": "^9.0.5",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
@@ -53,6 +55,7 @@
"@nestjs/testing": "^10.4.15", "@nestjs/testing": "^10.4.15",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/multer": "^1.4.12",
"@types/node": "^20.17.10", "@types/node": "^20.17.10",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/passport-jwt": "^4.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 { PlanosModule } from './modules/planos/planos.module';
import { ProductsModule } from './modules/products/products.module'; import { ProductsModule } from './modules/products/products.module';
import { SalesModule } from './modules/sales/sales.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 { TenantsModule } from './modules/tenants/tenants.module';
import { WhatsappModule } from './modules/whatsapp/whatsapp.module'; import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
@@ -39,6 +40,7 @@ import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
ProductsModule, ProductsModule,
SalesModule, SalesModule,
AlertsModule, AlertsModule,
TenantProfileModule,
], ],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }], 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() @IsInt()
@IsOptional() @IsOptional()
ALERT_JOB_BACKOFF_MS?: number; ALERT_JOB_BACKOFF_MS?: number;
@IsString()
@IsOptional()
CORS_ORIGINS?: string;
} }
export function validate(config: Record<string, unknown>) { export function validate(config: Record<string, unknown>) {
+20 -1
View File
@@ -1,12 +1,17 @@
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { applySecurityConfig } from './security.util';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
const configService = app.get(ConfigService); const configService = app.get(ConfigService);
applySecurityConfig(app, configService);
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
whitelist: true, 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); const port = configService.get<number>('PORT', 3000);
await app.listen(port); await app.listen(port);
} }
@@ -1,5 +1,5 @@
import { Transform } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsIn, IsOptional } from 'class-validator'; import { IsBoolean, IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
import { import {
CANAIS_TEMPLATE, CANAIS_TEMPLATE,
CanalTemplate, CanalTemplate,
@@ -20,4 +20,17 @@ export class ListAlertTemplatesQueryDto {
@Transform(({ value }) => (value === undefined ? undefined : value === 'true' || value === true)) @Transform(({ value }) => (value === undefined ? undefined : value === 'true' || value === true))
@IsBoolean() @IsBoolean()
ativo?: boolean; 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 { MasterGuard } from '../auth/guards/master.guard';
import { ListAlertLogsMasterQueryDto } from './dto/list-alert-logs.query.dto'; import { ListAlertLogsMasterQueryDto } from './dto/list-alert-logs.query.dto';
import { MasterAlertLogsService } from './master-alert-logs.service'; import { MasterAlertLogsService } from './master-alert-logs.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, MasterGuard) @UseGuards(JwtAuthGuard, MasterGuard)
@ApiTags('master/alertas')
@ApiBearerAuth('jwt')
@Controller('master/alertas/logs') @Controller('master/alertas/logs')
export class MasterAlertLogsController { export class MasterAlertLogsController {
constructor(private readonly masterAlertLogsService: MasterAlertLogsService) {} 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 { ListAlertTemplatesQueryDto } from './dto/list-alert-templates.query.dto';
import { UpdateMasterAlertTemplateDto } from './dto/update-master-alert-template.dto'; import { UpdateMasterAlertTemplateDto } from './dto/update-master-alert-template.dto';
import { MasterAlertTemplatesService } from './master-alert-templates.service'; import { MasterAlertTemplatesService } from './master-alert-templates.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, MasterGuard) @UseGuards(JwtAuthGuard, MasterGuard)
@ApiTags('master/alertas')
@ApiBearerAuth('jwt')
@Controller('master/alertas/templates') @Controller('master/alertas/templates')
export class MasterAlertTemplatesController { export class MasterAlertTemplatesController {
constructor(private readonly masterAlertTemplatesService: MasterAlertTemplatesService) {} constructor(private readonly masterAlertTemplatesService: MasterAlertTemplatesService) {}
@@ -29,16 +29,28 @@ export class MasterAlertTemplatesService {
}); });
} }
findAll(query: ListAlertTemplatesQueryDto) { async findAll(query: ListAlertTemplatesQueryDto) {
return this.prismaMaster.alert_templates.findMany({ const page = query.page ?? 1;
where: { const limit = query.limit ?? 20;
const where: Prisma.alert_templatesWhereInput = {
tenant_id: null, tenant_id: null,
...(query.tipo ? { tipo: query.tipo } : {}), ...(query.tipo ? { tipo: query.tipo } : {}),
...(query.canal ? { canal: query.canal } : {}), ...(query.canal ? { canal: query.canal } : {}),
...(query.ativo === undefined ? {} : { ativo: query.ativo }), ...(query.ativo === undefined ? {} : { ativo: query.ativo }),
}, };
const [data, total] = await this.prismaMaster.$transaction([
this.prismaMaster.alert_templates.findMany({
where,
orderBy: { criado_em: 'desc' }, 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) { async findOne(id: string) {
@@ -5,8 +5,11 @@ import { TenantGuard } from '../auth/guards/tenant.guard';
import { JwtPayload } from '../auth/types/jwt-payload.type'; import { JwtPayload } from '../auth/types/jwt-payload.type';
import { ListAlertLogsTenantQueryDto } from './dto/list-alert-logs.query.dto'; import { ListAlertLogsTenantQueryDto } from './dto/list-alert-logs.query.dto';
import { TenantAlertLogsService } from './tenant-alert-logs.service'; import { TenantAlertLogsService } from './tenant-alert-logs.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard) @UseGuards(JwtAuthGuard, TenantGuard)
@ApiTags('tenant/alertas')
@ApiBearerAuth('jwt')
@Controller('tenant/alertas/logs') @Controller('tenant/alertas/logs')
export class TenantAlertLogsController { export class TenantAlertLogsController {
constructor(private readonly tenantAlertLogsService: TenantAlertLogsService) {} 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 { ListAlertTemplatesQueryDto } from './dto/list-alert-templates.query.dto';
import { UpdateTenantAlertTemplateDto } from './dto/update-tenant-alert-template.dto'; import { UpdateTenantAlertTemplateDto } from './dto/update-tenant-alert-template.dto';
import { TenantAlertTemplatesService } from './tenant-alert-templates.service'; import { TenantAlertTemplatesService } from './tenant-alert-templates.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard, RolesGuard) @UseGuards(JwtAuthGuard, TenantGuard, RolesGuard)
@Roles('tenant_admin') @Roles('tenant_admin')
@ApiTags('tenant/alertas')
@ApiBearerAuth('jwt')
@Controller('tenant/alertas/templates') @Controller('tenant/alertas/templates')
export class TenantAlertTemplatesController { export class TenantAlertTemplatesController {
constructor(private readonly tenantAlertTemplatesService: TenantAlertTemplatesService) {} constructor(private readonly tenantAlertTemplatesService: TenantAlertTemplatesService) {}
@@ -43,16 +43,28 @@ export class TenantAlertTemplatesService {
}); });
} }
findAll(query: ListAlertTemplatesQueryDto, actor: JwtPayload) { async findAll(query: ListAlertTemplatesQueryDto, actor: JwtPayload) {
return this.prismaMaster.alert_templates.findMany({ const page = query.page ?? 1;
where: { const limit = query.limit ?? 20;
const where: Prisma.alert_templatesWhereInput = {
tenant_id: actor.tenant_id, tenant_id: actor.tenant_id,
...(query.tipo ? { tipo: query.tipo } : {}), ...(query.tipo ? { tipo: query.tipo } : {}),
...(query.canal ? { canal: query.canal } : {}), ...(query.canal ? { canal: query.canal } : {}),
...(query.ativo === undefined ? {} : { ativo: query.ativo }), ...(query.ativo === undefined ? {} : { ativo: query.ativo }),
}, };
const [data, total] = await this.prismaMaster.$transaction([
this.prismaMaster.alert_templates.findMany({
where,
orderBy: { criado_em: 'desc' }, 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) { 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 { RolesGuard } from './guards/roles.guard';
import { TenantGuard } from './guards/tenant.guard'; import { TenantGuard } from './guards/tenant.guard';
import { JwtPayload } from './types/jwt-payload.type'; 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 // Rate limit por IP nas rotas de login, como segunda camada além do
// bloqueio persistente por e-mail (`login_attempts`, ver // 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') }; return { ip: req.ip, userAgent: req.get('user-agent') };
} }
@ApiTags('auth')
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor( constructor(
+2
View File
@@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_INTERCEPTOR } from '@nestjs/core'; import { APP_INTERCEPTOR } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { EmailModule } from '../email-config/email.module';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { JwtAuthGuard } from './guards/jwt-auth.guard';
@@ -22,6 +23,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
secret: configService.get<string>('JWT_SECRET'), secret: configService.get<string>('JWT_SECRET'),
}), }),
}), }),
EmailModule,
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [ providers: [
+28 -6
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 { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { user_role } from '@prisma/client'; import { user_role } from '@prisma/client';
import { PrismaService } from '../../config/prisma.service'; import { PrismaService } from '../../config/prisma.service';
import { EmailService } from '../email-config/email.service';
import { ForgotPasswordDto } from './dto/forgot-password.dto'; import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { ResetPasswordDto } from './dto/reset-password.dto'; import { ResetPasswordDto } from './dto/reset-password.dto';
@@ -28,6 +35,7 @@ export const TENANT_BLOCKED_STATUSES = new Set(['suspenso', 'cancelado']);
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly accessTokenExpiresIn: string; private readonly accessTokenExpiresIn: string;
private readonly refreshTokenExpiresInDays: number; private readonly refreshTokenExpiresInDays: number;
private readonly passwordResetExpiresInHours: number; private readonly passwordResetExpiresInHours: number;
@@ -38,6 +46,7 @@ export class AuthService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly emailService: EmailService,
) { ) {
this.accessTokenExpiresIn = this.configService.get<string>('JWT_ACCESS_EXPIRES_IN', '15m'); this.accessTokenExpiresIn = this.configService.get<string>('JWT_ACCESS_EXPIRES_IN', '15m');
this.refreshTokenExpiresInDays = this.configService.get<number>( 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); const assunto = 'Redefinição de senha — SeConectai Vendas';
// por ora, só loga o token/link no console, como pedido no prompt desta etapa. 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( try {
`[password-reset] usuario_tipo=${dto.usuario_tipo} usuario_id=${usuario.id} ` + if (dto.usuario_tipo === 'master') {
`token=${token} expira_em=${expira_em.toISOString()}`, 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; return generic;
} }
@@ -18,8 +18,11 @@ import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CustomerContactsService } from './customer-contacts.service'; import { CustomerContactsService } from './customer-contacts.service';
import { CreateContactDto } from './dto/create-contact.dto'; import { CreateContactDto } from './dto/create-contact.dto';
import { UpdateContactDto } from './dto/update-contact.dto'; import { UpdateContactDto } from './dto/update-contact.dto';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard) @UseGuards(JwtAuthGuard, TenantGuard)
@ApiTags('tenant/customers')
@ApiBearerAuth('jwt')
@Controller('tenant/customers/:customerId/contatos') @Controller('tenant/customers/:customerId/contatos')
export class CustomerContactsController { export class CustomerContactsController {
constructor(private readonly customerContactsService: CustomerContactsService) {} constructor(private readonly customerContactsService: CustomerContactsService) {}
@@ -20,8 +20,11 @@ import { CustomersService } from './customers.service';
import { CreateCustomerDto } from './dto/create-customer.dto'; import { CreateCustomerDto } from './dto/create-customer.dto';
import { ListCustomersQueryDto } from './dto/list-customers.query.dto'; import { ListCustomersQueryDto } from './dto/list-customers.query.dto';
import { UpdateCustomerDto } from './dto/update-customer.dto'; import { UpdateCustomerDto } from './dto/update-customer.dto';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard) @UseGuards(JwtAuthGuard, TenantGuard)
@ApiTags('tenant/customers')
@ApiBearerAuth('jwt')
@Controller('tenant/customers') @Controller('tenant/customers')
export class CustomersController { export class CustomersController {
constructor(private readonly customersService: CustomersService) {} 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({ const padrao = await this.prismaMaster.master_email_config.findFirst({
where: { ativo: true }, where: { ativo: true },
orderBy: { criado_em: 'desc' }, orderBy: { criado_em: 'desc' },
@@ -71,14 +76,12 @@ export class EmailService {
}; };
} }
async enviar( private async enviarComConfig(
tenantId: string, config: ResolvedEmailConfig,
destinatario: string, destinatario: string,
assunto: string, assunto: string,
corpo: string, corpo: string,
): Promise<void> { ): Promise<void> {
const config = await this.resolverConfig(tenantId);
const transporter = createTransport({ const transporter = createTransport({
host: config.smtp_host, host: config.smtp_host,
port: config.smtp_porta, port: config.smtp_porta,
@@ -97,4 +100,20 @@ export class EmailService {
throw new BadRequestException(`Falha ao enviar e-mail: ${(error as Error).message}`); 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 { CreateMasterEmailConfigDto } from './dto/create-master-email-config.dto';
import { UpdateMasterEmailConfigDto } from './dto/update-master-email-config.dto'; import { UpdateMasterEmailConfigDto } from './dto/update-master-email-config.dto';
import { MasterEmailConfigService } from './master-email-config.service'; import { MasterEmailConfigService } from './master-email-config.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, MasterGuard) @UseGuards(JwtAuthGuard, MasterGuard)
@ApiTags('master/email-config')
@ApiBearerAuth('jwt')
@Controller('master/email-config') @Controller('master/email-config')
export class MasterEmailConfigController { export class MasterEmailConfigController {
constructor(private readonly masterEmailConfigService: MasterEmailConfigService) {} 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 { TestarEnvioEmailDto } from './dto/testar-envio-email.dto';
import { UpdateTenantEmailConfigDto } from './dto/update-tenant-email-config.dto'; import { UpdateTenantEmailConfigDto } from './dto/update-tenant-email-config.dto';
import { TenantEmailConfigService } from './tenant-email-config.service'; import { TenantEmailConfigService } from './tenant-email-config.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard, RolesGuard) @UseGuards(JwtAuthGuard, TenantGuard, RolesGuard)
@Roles('tenant_admin') @Roles('tenant_admin')
@ApiTags('tenant/email-config')
@ApiBearerAuth('jwt')
@Controller('tenant/email-config') @Controller('tenant/email-config')
export class TenantEmailConfigController { export class TenantEmailConfigController {
constructor(private readonly tenantEmailConfigService: TenantEmailConfigService) {} 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 { CreateEvolutionConfigDto } from './dto/create-evolution-config.dto';
import { UpdateEvolutionConfigDto } from './dto/update-evolution-config.dto'; import { UpdateEvolutionConfigDto } from './dto/update-evolution-config.dto';
import { EvolutionService } from './evolution.service'; import { EvolutionService } from './evolution.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, MasterGuard) @UseGuards(JwtAuthGuard, MasterGuard)
@ApiTags('master/evolution-config')
@ApiBearerAuth('jwt')
@Controller('master/evolution-config') @Controller('master/evolution-config')
export class EvolutionController { export class EvolutionController {
constructor(private readonly evolutionService: EvolutionService) {} constructor(private readonly evolutionService: EvolutionService) {}
+2
View File
@@ -1,6 +1,8 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { HealthService, HealthStatus } from './health.service'; import { HealthService, HealthStatus } from './health.service';
import { ApiTags } from '@nestjs/swagger';
@ApiTags('health')
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
constructor(private readonly healthService: HealthService) {} 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 { ListPlanosQueryDto } from './dto/list-planos.query.dto';
import { UpdatePlanoDto } from './dto/update-plano.dto'; import { UpdatePlanoDto } from './dto/update-plano.dto';
import { PlanosService } from './planos.service'; import { PlanosService } from './planos.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, MasterGuard) @UseGuards(JwtAuthGuard, MasterGuard)
@ApiTags('master/planos')
@ApiBearerAuth('jwt')
@Controller('master/planos') @Controller('master/planos')
export class PlanosController { export class PlanosController {
constructor(private readonly planosService: PlanosService) {} 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 { ListProductsQueryDto } from './dto/list-products.query.dto';
import { UpdateProductDto } from './dto/update-product.dto'; import { UpdateProductDto } from './dto/update-product.dto';
import { ProductsService } from './products.service'; import { ProductsService } from './products.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard) @UseGuards(JwtAuthGuard, TenantGuard)
@ApiTags('tenant/produtos')
@ApiBearerAuth('jwt')
@Controller('tenant/produtos') @Controller('tenant/produtos')
export class ProductsController { export class ProductsController {
constructor(private readonly productsService: ProductsService) {} constructor(private readonly productsService: ProductsService) {}
@@ -1,4 +1,5 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { TenantGuard } from '../auth/guards/tenant.guard'; import { TenantGuard } from '../auth/guards/tenant.guard';
import { TenantDashboardService } from './dashboard.service'; import { TenantDashboardService } from './dashboard.service';
@@ -8,6 +9,8 @@ import {
} from './dto/dashboard-periodo.query.dto'; } from './dto/dashboard-periodo.query.dto';
@UseGuards(JwtAuthGuard, TenantGuard) @UseGuards(JwtAuthGuard, TenantGuard)
@ApiTags('tenant/dashboard')
@ApiBearerAuth('jwt')
@Controller('tenant/dashboard') @Controller('tenant/dashboard')
export class TenantDashboardController { export class TenantDashboardController {
constructor(private readonly dashboardService: TenantDashboardService) {} 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 { ListRecebimentosQueryDto } from './dto/list-recebimentos.query.dto';
import { MarcarPagoDto } from './dto/marcar-pago.dto'; import { MarcarPagoDto } from './dto/marcar-pago.dto';
import { SalePaymentsService } from './sale-payments.service'; import { SalePaymentsService } from './sale-payments.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard) @UseGuards(JwtAuthGuard, TenantGuard)
@ApiTags('tenant/recebimentos')
@ApiBearerAuth('jwt')
@Controller('tenant/recebimentos') @Controller('tenant/recebimentos')
export class SalePaymentsController { export class SalePaymentsController {
constructor(private readonly salePaymentsService: SalePaymentsService) {} 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 { ListSalesQueryDto } from './dto/list-sales.query.dto';
import { UpdateSaleDto } from './dto/update-sale.dto'; import { UpdateSaleDto } from './dto/update-sale.dto';
import { SalesService } from './sales.service'; import { SalesService } from './sales.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard) @UseGuards(JwtAuthGuard, TenantGuard)
@ApiTags('tenant/vendas')
@ApiBearerAuth('jwt')
@Controller('tenant/vendas') @Controller('tenant/vendas')
export class SalesController { export class SalesController {
constructor(private readonly salesService: SalesService) {} 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 { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { MasterGuard } from '../auth/guards/master.guard'; import { MasterGuard } from '../auth/guards/master.guard';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, MasterGuard) @UseGuards(JwtAuthGuard, MasterGuard)
@ApiTags('master/dashboard')
@ApiBearerAuth('jwt')
@Controller('master/dashboard') @Controller('master/dashboard')
export class DashboardController { export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {} 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 { GerarFaturasMesDto } from './dto/gerar-faturas-mes.dto';
import { ListFaturasQueryDto } from './dto/list-faturas.query.dto'; import { ListFaturasQueryDto } from './dto/list-faturas.query.dto';
import { FaturasService } from './faturas.service'; import { FaturasService } from './faturas.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, MasterGuard) @UseGuards(JwtAuthGuard, MasterGuard)
@ApiTags('master/faturas')
@ApiBearerAuth('jwt')
@Controller('master/faturas') @Controller('master/faturas')
export class FaturasController { export class FaturasController {
constructor(private readonly faturasService: FaturasService) {} 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 { UpdateTenantDto } from './dto/update-tenant.dto';
import { UpdateTenantStatusDto } from './dto/update-tenant-status.dto'; import { UpdateTenantStatusDto } from './dto/update-tenant-status.dto';
import { TenantsService } from './tenants.service'; import { TenantsService } from './tenants.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, MasterGuard) @UseGuards(JwtAuthGuard, MasterGuard)
@ApiTags('master/tenants')
@ApiBearerAuth('jwt')
@Controller('master/tenants') @Controller('master/tenants')
export class TenantsController { export class TenantsController {
constructor(private readonly tenantsService: TenantsService) {} 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 { JwtPayload } from '../auth/types/jwt-payload.type';
import { TestarEnvioWhatsappDto } from './dto/testar-envio-whatsapp.dto'; import { TestarEnvioWhatsappDto } from './dto/testar-envio-whatsapp.dto';
import { WhatsappService } from './whatsapp.service'; import { WhatsappService } from './whatsapp.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@UseGuards(JwtAuthGuard, TenantGuard, RolesGuard) @UseGuards(JwtAuthGuard, TenantGuard, RolesGuard)
@Roles('tenant_admin') @Roles('tenant_admin')
@ApiTags('tenant/whatsapp')
@ApiBearerAuth('jwt')
@Controller('tenant/whatsapp/instancias') @Controller('tenant/whatsapp/instancias')
export class WhatsappController { export class WhatsappController {
constructor(private readonly whatsappService: WhatsappService) {} 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 { PrismaService } from '../src/config/prisma.service';
import { hashPassword } from '../src/modules/auth/password.util'; 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 { interface TokenPair {
access_token: string; access_token: string;
refresh_token: string; refresh_token: string;
@@ -32,6 +40,7 @@ describe('Auth (e2e)', () => {
let tenantAAdminId: string; let tenantAAdminId: string;
let tenantAVendedorId: string; let tenantAVendedorId: string;
let tenantBAdminId: string; let tenantBAdminId: string;
const masterEmailConfigIds: string[] = [];
beforeAll(async () => { beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({ const moduleFixture: TestingModule = await Test.createTestingModule({
@@ -115,6 +124,7 @@ describe('Auth (e2e)', () => {
await prisma.login_attempts.deleteMany({ where: { email: { in: emails } } }); await prisma.login_attempts.deleteMany({ where: { email: { in: emails } } });
await prisma.tenant_users.deleteMany({ where: { tenant_id: { in: [tenantAId, tenantBId] } } }); await prisma.tenant_users.deleteMany({ where: { tenant_id: { in: [tenantAId, tenantBId] } } });
await prisma.tenants.deleteMany({ where: { 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 prisma.master_admins.deleteMany({ where: { id: masterId } });
await app.close(); await app.close();
@@ -249,21 +259,43 @@ describe('Auth (e2e)', () => {
}); });
describe('esqueci minha senha / reset', () => { 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 () => { 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) await request(httpServer)
.post('/auth/password/forgot') .post('/auth/password/forgot')
.send({ email: tenantAVendedorEmail, usuario_tipo: 'tenant_user' }) .send({ email: tenantAVendedorEmail, usuario_tipo: 'tenant_user' })
.expect(200); .expect(200);
const logLine = consoleSpy.mock.calls expect(mockSendMail).toHaveBeenCalledTimes(1);
.map((args) => String(args[0])) const [emailEnviado] = mockSendMail.mock.calls[0] as [{ html: string }];
.find((line) => line.includes('token=')); const corpoEmail = String(emailEnviado.html);
consoleSpy.mockRestore(); const token = /(?<![\w-])[\w-]{20,}(?![\w-])/.exec(corpoEmail)?.[0];
expect(logLine).toBeDefined();
const token = /token=(\S+)/.exec(logLine ?? '')?.[1];
expect(token).toBeTruthy(); expect(token).toBeTruthy();
const novaSenha = 'NovaSenhaForte#26'; 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();
});
});
});