From f9738e54da225577b6632fed8b42ba2373a8d4de Mon Sep 17 00:00:00 2001 From: Tiago dos Santos Date: Fri, 14 Aug 2026 23:38:37 -0300 Subject: [PATCH] =?UTF-8?q?Etapa=208=20=E2=80=94=20Personaliza=C3=A7=C3=A3?= =?UTF-8?q?o=20whitelabel=20e=20ajustes=20finais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 7 + Etapas/09-controle-de-progresso.md | 209 ++++++++++++- README.md | 81 ++++- docker-compose.yml | 6 + nest-cli.json | 3 +- package-lock.json | 100 +++++- package.json | 3 + src/app.module.ts | 2 + src/common/storage/local-storage.service.ts | 26 ++ src/common/storage/storage.service.ts | 14 + src/config/env.validation.ts | 4 + src/main.ts | 21 +- .../dto/list-alert-templates.query.dto.ts | 17 +- .../alerts/master-alert-logs.controller.ts | 3 + .../master-alert-templates.controller.ts | 3 + .../alerts/master-alert-templates.service.ts | 32 +- .../alerts/tenant-alert-logs.controller.ts | 3 + .../tenant-alert-templates.controller.ts | 3 + .../alerts/tenant-alert-templates.service.ts | 32 +- src/modules/auth/auth.controller.ts | 2 + src/modules/auth/auth.module.ts | 2 + src/modules/auth/auth.service.ts | 36 ++- .../customers/customer-contacts.controller.ts | 3 + src/modules/customers/customers.controller.ts | 3 + src/modules/email-config/email.service.ts | 27 +- .../master-email-config.controller.ts | 3 + .../tenant-email-config.controller.ts | 3 + src/modules/evolution/evolution.controller.ts | 3 + src/modules/health/health.controller.ts | 2 + src/modules/planos/planos.controller.ts | 3 + src/modules/products/products.controller.ts | 3 + src/modules/sales/dashboard.controller.ts | 3 + src/modules/sales/sale-payments.controller.ts | 3 + src/modules/sales/sales.controller.ts | 3 + .../tenant-profile/dto/update-cor.dto.ts | 6 + src/modules/tenant-profile/logo-mime.util.ts | 19 ++ .../public-tenant.controller.ts | 40 +++ .../tenant-profile.controller.ts | 84 +++++ .../tenant-profile/tenant-profile.module.ts | 11 + .../tenant-profile/tenant-profile.service.ts | 116 +++++++ src/modules/tenants/dashboard.controller.ts | 3 + src/modules/tenants/faturas.controller.ts | 3 + src/modules/tenants/tenants.controller.ts | 3 + src/modules/whatsapp/whatsapp.controller.ts | 3 + src/security.util.ts | 34 +++ test/auth.e2e-spec.ts | 48 ++- test/fluxo-completo.e2e-spec.ts | 208 +++++++++++++ test/perfil-whitelabel.e2e-spec.ts | 288 ++++++++++++++++++ 48 files changed, 1470 insertions(+), 64 deletions(-) create mode 100644 src/common/storage/local-storage.service.ts create mode 100644 src/common/storage/storage.service.ts create mode 100644 src/modules/tenant-profile/dto/update-cor.dto.ts create mode 100644 src/modules/tenant-profile/logo-mime.util.ts create mode 100644 src/modules/tenant-profile/public-tenant.controller.ts create mode 100644 src/modules/tenant-profile/tenant-profile.controller.ts create mode 100644 src/modules/tenant-profile/tenant-profile.module.ts create mode 100644 src/modules/tenant-profile/tenant-profile.service.ts create mode 100644 src/security.util.ts create mode 100644 test/fluxo-completo.e2e-spec.ts create mode 100644 test/perfil-whitelabel.e2e-spec.ts diff --git a/.env.example b/.env.example index fd4e01f..02ca636 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Etapas/09-controle-de-progresso.md b/Etapas/09-controle-de-progresso.md index 56a7766..35d0b2b 100644 --- a/Etapas/09-controle-de-progresso.md +++ b/Etapas/09-controle-de-progresso.md @@ -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. diff --git a/README.md b/README.md index a12893e..41374c4 100644 --- a/README.md +++ b/README.md @@ -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:///health` (200, + `{"status":"ok","database":"up"}`), `https:///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 diff --git a/docker-compose.yml b/docker-compose.yml index 93cd152..d66520b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/nest-cli.json b/nest-cli.json index f9aa683..e8552c2 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -3,6 +3,7 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": true, + "plugins": ["@nestjs/swagger"] } } diff --git a/package-lock.json b/package-lock.json index f67a73d..fdba7d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c231c00..02f23fe 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app.module.ts b/src/app.module.ts index 11c248c..367fbef 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 }], }) diff --git a/src/common/storage/local-storage.service.ts b/src/common/storage/local-storage.service.ts new file mode 100644 index 0000000..fc863dd --- /dev/null +++ b/src/common/storage/local-storage.service.ts @@ -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 { + await fs.writeFile(join(this.diretorio, nomeArquivo), conteudo); + return `/uploads/${nomeArquivo}`; + } + + async remover(url: string): Promise { + // `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 }); + } +} diff --git a/src/common/storage/storage.service.ts b/src/common/storage/storage.service.ts new file mode 100644 index 0000000..49c7ddc --- /dev/null +++ b/src/common/storage/storage.service.ts @@ -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; + + /** 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; +} diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index f7fdec8..0f9d7f1 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -84,6 +84,10 @@ class EnvironmentVariables { @IsInt() @IsOptional() ALERT_JOB_BACKOFF_MS?: number; + + @IsString() + @IsOptional() + CORS_ORIGINS?: string; } export function validate(config: Record) { diff --git a/src/main.ts b/src/main.ts index da4fb08..6674f14 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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(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('PORT', 3000); await app.listen(port); } diff --git a/src/modules/alerts/dto/list-alert-templates.query.dto.ts b/src/modules/alerts/dto/list-alert-templates.query.dto.ts index 3bde808..37e23a3 100644 --- a/src/modules/alerts/dto/list-alert-templates.query.dto.ts +++ b/src/modules/alerts/dto/list-alert-templates.query.dto.ts @@ -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; } diff --git a/src/modules/alerts/master-alert-logs.controller.ts b/src/modules/alerts/master-alert-logs.controller.ts index 65d5194..52c19f5 100644 --- a/src/modules/alerts/master-alert-logs.controller.ts +++ b/src/modules/alerts/master-alert-logs.controller.ts @@ -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) {} diff --git a/src/modules/alerts/master-alert-templates.controller.ts b/src/modules/alerts/master-alert-templates.controller.ts index 3e67631..ef3adb2 100644 --- a/src/modules/alerts/master-alert-templates.controller.ts +++ b/src/modules/alerts/master-alert-templates.controller.ts @@ -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) {} diff --git a/src/modules/alerts/master-alert-templates.service.ts b/src/modules/alerts/master-alert-templates.service.ts index bd8a88e..c1b5f53 100644 --- a/src/modules/alerts/master-alert-templates.service.ts +++ b/src/modules/alerts/master-alert-templates.service.ts @@ -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) { diff --git a/src/modules/alerts/tenant-alert-logs.controller.ts b/src/modules/alerts/tenant-alert-logs.controller.ts index 7b8e6ea..304cd1b 100644 --- a/src/modules/alerts/tenant-alert-logs.controller.ts +++ b/src/modules/alerts/tenant-alert-logs.controller.ts @@ -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) {} diff --git a/src/modules/alerts/tenant-alert-templates.controller.ts b/src/modules/alerts/tenant-alert-templates.controller.ts index a8220fd..6c1c033 100644 --- a/src/modules/alerts/tenant-alert-templates.controller.ts +++ b/src/modules/alerts/tenant-alert-templates.controller.ts @@ -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) {} diff --git a/src/modules/alerts/tenant-alert-templates.service.ts b/src/modules/alerts/tenant-alert-templates.service.ts index cb239df..af07404 100644 --- a/src/modules/alerts/tenant-alert-templates.service.ts +++ b/src/modules/alerts/tenant-alert-templates.service.ts @@ -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) { diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 68a6ce0..858a46f 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -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( diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index b1a5a7e..07b2c11 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -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('JWT_SECRET'), }), }), + EmailModule, ], controllers: [AuthController], providers: [ diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 45047b1..03a32a3 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -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('JWT_ACCESS_EXPIRES_IN', '15m'); this.refreshTokenExpiresInDays = this.configService.get( @@ -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:

${token}

` + + `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; } diff --git a/src/modules/customers/customer-contacts.controller.ts b/src/modules/customers/customer-contacts.controller.ts index 87d5f8e..edc093f 100644 --- a/src/modules/customers/customer-contacts.controller.ts +++ b/src/modules/customers/customer-contacts.controller.ts @@ -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) {} diff --git a/src/modules/customers/customers.controller.ts b/src/modules/customers/customers.controller.ts index 0807ff8..2294125 100644 --- a/src/modules/customers/customers.controller.ts +++ b/src/modules/customers/customers.controller.ts @@ -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) {} diff --git a/src/modules/email-config/email.service.ts b/src/modules/email-config/email.service.ts index 238b414..268e2ef 100644 --- a/src/modules/email-config/email.service.ts +++ b/src/modules/email-config/email.service.ts @@ -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 { 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 { - 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 { + 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 { + const config = await this.resolverConfigPadrao(); + await this.enviarComConfig(config, destinatario, assunto, corpo); + } } diff --git a/src/modules/email-config/master-email-config.controller.ts b/src/modules/email-config/master-email-config.controller.ts index 2b2250b..5d9645e 100644 --- a/src/modules/email-config/master-email-config.controller.ts +++ b/src/modules/email-config/master-email-config.controller.ts @@ -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) {} diff --git a/src/modules/email-config/tenant-email-config.controller.ts b/src/modules/email-config/tenant-email-config.controller.ts index a111222..fb8d75d 100644 --- a/src/modules/email-config/tenant-email-config.controller.ts +++ b/src/modules/email-config/tenant-email-config.controller.ts @@ -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) {} diff --git a/src/modules/evolution/evolution.controller.ts b/src/modules/evolution/evolution.controller.ts index 6448907..2ff8c33 100644 --- a/src/modules/evolution/evolution.controller.ts +++ b/src/modules/evolution/evolution.controller.ts @@ -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) {} diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts index a26b6ab..56d5cbf 100644 --- a/src/modules/health/health.controller.ts +++ b/src/modules/health/health.controller.ts @@ -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) {} diff --git a/src/modules/planos/planos.controller.ts b/src/modules/planos/planos.controller.ts index 53e145b..edb1256 100644 --- a/src/modules/planos/planos.controller.ts +++ b/src/modules/planos/planos.controller.ts @@ -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) {} diff --git a/src/modules/products/products.controller.ts b/src/modules/products/products.controller.ts index bfe7486..ae24b13 100644 --- a/src/modules/products/products.controller.ts +++ b/src/modules/products/products.controller.ts @@ -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) {} diff --git a/src/modules/sales/dashboard.controller.ts b/src/modules/sales/dashboard.controller.ts index bbbb135..7b49b52 100644 --- a/src/modules/sales/dashboard.controller.ts +++ b/src/modules/sales/dashboard.controller.ts @@ -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) {} diff --git a/src/modules/sales/sale-payments.controller.ts b/src/modules/sales/sale-payments.controller.ts index 60d8bae..8503d2b 100644 --- a/src/modules/sales/sale-payments.controller.ts +++ b/src/modules/sales/sale-payments.controller.ts @@ -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) {} diff --git a/src/modules/sales/sales.controller.ts b/src/modules/sales/sales.controller.ts index 924e8ac..36cde7b 100644 --- a/src/modules/sales/sales.controller.ts +++ b/src/modules/sales/sales.controller.ts @@ -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) {} diff --git a/src/modules/tenant-profile/dto/update-cor.dto.ts b/src/modules/tenant-profile/dto/update-cor.dto.ts new file mode 100644 index 0000000..e28ca9c --- /dev/null +++ b/src/modules/tenant-profile/dto/update-cor.dto.ts @@ -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; +} diff --git a/src/modules/tenant-profile/logo-mime.util.ts b/src/modules/tenant-profile/logo-mime.util.ts new file mode 100644 index 0000000..459a448 --- /dev/null +++ b/src/modules/tenant-profile/logo-mime.util.ts @@ -0,0 +1,19 @@ +import { UnsupportedMediaTypeException } from '@nestjs/common'; + +const EXTENSAO_POR_MIME: Record = { + '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; +} diff --git a/src/modules/tenant-profile/public-tenant.controller.ts b/src/modules/tenant-profile/public-tenant.controller.ts new file mode 100644 index 0000000..982f422 --- /dev/null +++ b/src/modules/tenant-profile/public-tenant.controller.ts @@ -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, + }; + } +} diff --git a/src/modules/tenant-profile/tenant-profile.controller.ts b/src/modules/tenant-profile/tenant-profile.controller.ts new file mode 100644 index 0000000..a4e1683 --- /dev/null +++ b/src/modules/tenant-profile/tenant-profile.controller.ts @@ -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); + } +} diff --git a/src/modules/tenant-profile/tenant-profile.module.ts b/src/modules/tenant-profile/tenant-profile.module.ts new file mode 100644 index 0000000..7bf1ff7 --- /dev/null +++ b/src/modules/tenant-profile/tenant-profile.module.ts @@ -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 {} diff --git a/src/modules/tenant-profile/tenant-profile.service.ts b/src/modules/tenant-profile/tenant-profile.service.ts new file mode 100644 index 0000000..3187ba9 --- /dev/null +++ b/src/modules/tenant-profile/tenant-profile.service.ts @@ -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 }; + }); + } +} diff --git a/src/modules/tenants/dashboard.controller.ts b/src/modules/tenants/dashboard.controller.ts index b824928..1113226 100644 --- a/src/modules/tenants/dashboard.controller.ts +++ b/src/modules/tenants/dashboard.controller.ts @@ -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) {} diff --git a/src/modules/tenants/faturas.controller.ts b/src/modules/tenants/faturas.controller.ts index 3846c0c..111c2aa 100644 --- a/src/modules/tenants/faturas.controller.ts +++ b/src/modules/tenants/faturas.controller.ts @@ -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) {} diff --git a/src/modules/tenants/tenants.controller.ts b/src/modules/tenants/tenants.controller.ts index 4b538dd..5c9e5fe 100644 --- a/src/modules/tenants/tenants.controller.ts +++ b/src/modules/tenants/tenants.controller.ts @@ -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) {} diff --git a/src/modules/whatsapp/whatsapp.controller.ts b/src/modules/whatsapp/whatsapp.controller.ts index c6f46ec..554cd87 100644 --- a/src/modules/whatsapp/whatsapp.controller.ts +++ b/src/modules/whatsapp/whatsapp.controller.ts @@ -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) {} diff --git a/src/security.util.ts b/src/security.util.ts new file mode 100644 index 0000000..74a21df --- /dev/null +++ b/src/security.util.ts @@ -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('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/' }); +} diff --git a/test/auth.e2e-spec.ts b/test/auth.e2e-spec.ts index 8326305..4c7e178 100644 --- a/test/auth.e2e-spec.ts +++ b/test/auth.e2e-spec.ts @@ -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 = /(? { + let app: NestExpressApplication; + let prisma: PrismaService; + let httpServer: Parameters[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(); + 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'); + }); +}); diff --git a/test/perfil-whitelabel.e2e-spec.ts b/test/perfil-whitelabel.e2e-spec.ts new file mode 100644 index 0000000..19e1475 --- /dev/null +++ b/test/perfil-whitelabel.e2e-spec.ts @@ -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[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 { + 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(); + 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(); + }); + }); +});