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