Compare commits

...

No commits in common. "cf16bf1fb634400686e5f5212e08a8f709767d1f" and "0b19e8ac5c1b79f7dc80beb8a0d22e6b3a5c82d0" have entirely different histories.

162 changed files with 20939 additions and 21160 deletions

View File

@ -1,12 +1,12 @@
--- ---
description: Stack e pastas da Sinka: NestJS + Next.js description: Stack e pastas da Sinka: NestJS + Next.js
alwaysApply: true alwaysApply: true
--- ---
# Sinka # Sinka
Backend NestJS em `api/`. Frontend Next.js em `web/`. MySQL + Redis via Docker Compose. Backend NestJS em `api/`. Frontend Next.js em `web/`. MySQL + Redis via Docker Compose.
**Proibido:** PHP, Composer, Blade, `index.php`, PDO, Laravel. **Proibido:** PHP, Composer, Blade, `index.php`, PDO, Laravel.
Feature nova: Prisma (platform ou tenant) → módulo NestJS (DTO + service) → tela App Router. Dado de empresa só no database daquela empresa. Feature nova: Prisma (platform ou tenant) → módulo NestJS (DTO + service) → tela App Router. Dado de empresa só no database daquela empresa.

View File

@ -1,14 +1,14 @@
--- ---
description: Convenções NestJS na API description: Convenções NestJS na API
globs: api/**/*.ts globs: api/**/*.ts
alwaysApply: false alwaysApply: false
--- ---
# NestJS # NestJS
- Módulo por domínio (`users`, `customers`, `freight`) - Módulo por domínio (`users`, `customers`, `freight`)
- Controller fino; regra no service/use case - Controller fino; regra no service/use case
- DTO com `class-validator`; `ValidationPipe` global (`whitelist`) - DTO com `class-validator`; `ValidationPipe` global (`whitelist`)
- Prefix da API: `/api` - Prefix da API: `/api`
- Não colocar SQL cru se o Prisma cobre o caso - Não colocar SQL cru se o Prisma cobre o caso
- Teste unitário no service quando a regra importar - Teste unitário no service quando a regra importar

View File

@ -1,13 +1,13 @@
--- ---
description: Convenções Next.js App Router description: Convenções Next.js App Router
globs: web/**/*.{ts,tsx} globs: web/**/*.{ts,tsx}
alwaysApply: false alwaysApply: false
--- ---
# Next.js # Next.js
- App Router em `web/src/app` - App Router em `web/src/app`
- Landing pública em `/`; área logada virá em `/app` nas próximas etapas - Landing pública em `/`; área logada virá em `/app` nas próximas etapas
- TypeScript estrito; componentes em `web/src/components` - TypeScript estrito; componentes em `web/src/components`
- Chamar a API em `NEXT_PUBLIC_API_URL` (default `http://localhost:3001/api`) - Chamar a API em `NEXT_PUBLIC_API_URL` (default `http://localhost:3001/api`)
- Sem páginas PHP, sem CSS global de template Create Next App genérico na landing - Sem páginas PHP, sem CSS global de template Create Next App genérico na landing

View File

@ -1,13 +1,13 @@
--- ---
description: Multi-tenant e segurança da plataforma description: Multi-tenant e segurança da plataforma
alwaysApply: true alwaysApply: true
--- ---
# Tenant e segurança # Tenant e segurança
- Isolamento: **um database MySQL por empresa** (`sinka_t_<slug>`). Catálogo em `sinka_platform`. - Isolamento: **um database MySQL por empresa** (`sinka_t_<slug>`). Catálogo em `sinka_platform`.
- Queries de operação usam o PrismaClient do tenant autenticado, nunca o banco de outra empresa. - Queries de operação usam o PrismaClient do tenant autenticado, nunca o banco de outra empresa.
- Auth desta etapa: JWT + refresh em cookie httpOnly, papéis ADMIN / MANAGER / OPERATOR. Google, GitHub e TOTP na seguinte. - Auth desta etapa: JWT + refresh em cookie httpOnly, papéis ADMIN / MANAGER / OPERATOR. Google, GitHub e TOTP na seguinte.
- Segredos só em `.env`; nunca commitar - Segredos só em `.env`; nunca commitar
- Rate limit de login com Redis - Rate limit de login com Redis
- Auditoria: login, permissão, provisionamento, CRUD de usuários - Auditoria: login, permissão, provisionamento, CRUD de usuários

View File

@ -1,47 +1,43 @@
# API # API
PORT=3001 PORT=3001
WEB_ORIGIN=http://localhost:3000 WEB_ORIGIN=http://localhost:3000
DATABASE_URL="mysql://sinka:sinka@localhost:3306/sinka_platform" DATABASE_URL="mysql://sinka:sinka@localhost:3306/sinka_platform"
MYSQL_ADMIN_URL="mysql://root:root@localhost:3306" MYSQL_ADMIN_URL="mysql://root:root@localhost:3306"
REDIS_URL="redis://localhost:6379" REDIS_URL="redis://localhost:6379"
JWT_SECRET=troque-este-segredo JWT_SECRET=troque-este-segredo
JWT_REFRESH_SECRET=troque-este-refresh JWT_REFRESH_SECRET=troque-este-refresh
# Seed (local) # Seed (local)
PLATFORM_ADMIN_EMAIL=tina.r@example.net PLATFORM_ADMIN_EMAIL=tina.r@example.net
PLATFORM_ADMIN_PASSWORD=SinkaPlatform!1 PLATFORM_ADMIN_PASSWORD=SinkaPlatform!1
DEMO_ADMIN_EMAIL=xena.w@example.org DEMO_ADMIN_EMAIL=xena.w@example.org
DEMO_ADMIN_PASSWORD=SinkaAdmin!1 DEMO_ADMIN_PASSWORD=SinkaAdmin!1
DEMO_MANAGER_EMAIL=james.b@example.com DEMO_MANAGER_EMAIL=james.b@example.com
DEMO_MANAGER_PASSWORD=SinkaManager!1 DEMO_MANAGER_PASSWORD=SinkaManager!1
DEMO_OPERATOR_EMAIL=ursula.b@example.com DEMO_OPERATOR_EMAIL=ursula.b@example.com
DEMO_OPERATOR_PASSWORD=SinkaOperador!1 DEMO_OPERATOR_PASSWORD=SinkaOperador!1
# OAuth Google (console: origens e callback do ambiente) # OAuth Google (console: origem http://localhost:3000, callback abaixo)
# Local: origem http://localhost:3000 GOOGLE_CLIENT_ID=
# callback http://localhost:3000/api/auth/google/callback GOOGLE_CLIENT_SECRET=
# Demo: origem https://armandosoares.tech GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback
# callback https://armandosoares.tech/api/auth/google/callback
GOOGLE_CLIENT_ID= # Firebase Realtime (chat admin ↔ cliente, modo teste sem auth)
GOOGLE_CLIENT_SECRET= FIREBASE_PROJECT_ID=
GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback FIREBASE_DATABASE_URL=
NEXT_PUBLIC_FIREBASE_DATABASE_URL=
# Firebase Realtime (chat admin ↔ cliente, modo teste sem auth)
FIREBASE_PROJECT_ID= # Arquivos da operação (CSV/XLSX), pasta local por empresa
FIREBASE_DATABASE_URL= UPLOAD_DIR=
NEXT_PUBLIC_FIREBASE_DATABASE_URL=
# WEB
# Arquivos da operação (CSV/XLSX), pasta local por empresa NEXT_PUBLIC_API_URL=http://localhost:3001/api
UPLOAD_DIR=
# Docker (tudo no container): npm run docker:full
# WEB # Cookie Secure só com HTTPS na VPS (COOKIE_SECURE=true)
NEXT_PUBLIC_API_URL=http://localhost:3001/api COOKIE_SECURE=false
# MYSQL_ROOT_PASSWORD=root
# Docker (tudo no container): npm run docker:full # MYSQL_PASSWORD=sinka
# Cookie Secure só com HTTPS na VPS (COOKIE_SECURE=true) # Se 3306/3000 já estiverem ocupados na VPS:
COOKIE_SECURE=false # SINKA_MYSQL_PORT=3307
# MYSQL_ROOT_PASSWORD=root # SINKA_WEB_PORT=3000
# MYSQL_PASSWORD=sinka
# Se 3306/3000 já estiverem ocupados na VPS:
# SINKA_MYSQL_PORT=3307
# SINKA_WEB_PORT=3000

45
.gitignore vendored
View File

@ -1,23 +1,22 @@
node_modules/ node_modules/
dist/ dist/
coverage/ coverage/
.next/ .next/
.turbo/ .turbo/
*.log *.log
.env .env
.env.local .env.local
api/.env api/.env
web/.env.local web/.env.local
.DS_Store .DS_Store
Thumbs.db Thumbs.db
prisma/*.db prisma/*.db
api/src/generated/ api/src/generated/
uploads/ uploads/
api/uploads/ api/uploads/
*.pdf *.pdf
msg.txt msg.txt
*.tsbuildinfo *.tsbuildinfo
docker-compose.vps.yml docker-compose.vps.yml
deploy/ deploy/
msg.txt

188
README.md
View File

@ -1,94 +1,94 @@
# Sinka # Sinka
SaaS de inteligência logística. Cada operação simula frete, compara transportadoras e acompanha custo, com os dados de uma empresa isolados dos das outras. SaaS de inteligência logística. Cada operação simula frete, compara transportadoras e acompanha custo, com os dados de uma empresa isolados dos das outras.
## O que faz ## O que faz
- Cotação de frete (peso cubado, distância, ad valorem) a partir de CEP (ViaCEP) e coordenadas (Nominatim) - Cotação de frete (peso cubado, distância, ad valorem) a partir de CEP (ViaCEP) e coordenadas (Nominatim)
- Ranking de transportadoras da própria empresa - Ranking de transportadoras da própria empresa
- Clientes, equipe e papéis (Administrador, Manager, Operador) - Clientes, equipe e papéis (Administrador, Manager, Operador)
- Dashboard de custo - Dashboard de custo
- Chat interno (admin da plataforma ↔ empresa) - Chat interno (admin da plataforma ↔ empresa)
- Arquivos de operação (CSV/XLSX) por empresa - Arquivos de operação (CSV/XLSX) por empresa
Dois produtos no mesmo sistema: o **admin da Sinka** (`/admin`) cria e suspende empresas; cada empresa entra pelo slug na URL (`/cliente-1`, `/cliente-2`). Dois produtos no mesmo sistema: o **admin da Sinka** (`/admin`) cria e suspende empresas; cada empresa entra pelo slug na URL (`/cliente-1`, `/cliente-2`).
## Isolamento ## Isolamento
Um database MySQL por empresa (`sinka_t_<slug>`). O catálogo fica em `sinka_platform`. Um database MySQL por empresa (`sinka_t_<slug>`). O catálogo fica em `sinka_platform`.
O JWT carrega o tenant. As queries de operação usam o PrismaClient daquele banco: um `WHERE` errado não cruza empresa. Backup, restore e exclusão também são por tenant. O JWT carrega o tenant. As queries de operação usam o PrismaClient daquele banco: um `WHERE` errado não cruza empresa. Backup, restore e exclusão também são por tenant.
## Stack ## Stack
| Camada | Tecnologia | | Camada | Tecnologia |
| --- | --- | | --- | --- |
| API | NestJS, Prisma, JWT em cookie httpOnly | | API | NestJS, Prisma, JWT em cookie httpOnly |
| Web | Next.js (App Router) | | Web | Next.js (App Router) |
| Dados | MySQL 8, Redis | | Dados | MySQL 8, Redis |
| Infra | Docker Compose | | Infra | Docker Compose |
``` ```
Next.js → NestJS /api → sinka_platform Next.js → NestJS /api → sinka_platform
↓ ↓ ↓ ↓
Redis sinka_t_<slug> Redis sinka_t_<slug>
``` ```
## Estrutura ## Estrutura
``` ```
api/ REST, auth, regras, Prisma api/ REST, auth, regras, Prisma
web/ landing, login, painéis web/ landing, login, painéis
docs/ arquitetura, decisões, testes docs/ arquitetura, decisões, testes
``` ```
## Como rodar ## Como rodar
```bash ```bash
cp .env.example .env cp .env.example .env
docker compose up -d docker compose up -d
cd api && npm install && npm run prisma:setup && npm run start:dev cd api && npm install && npm run prisma:setup && npm run start:dev
cd web && npm install && npm run dev cd web && npm install && npm run dev
``` ```
MySQL e Redis sobem no Docker; API e web no Node da máquina. MySQL e Redis sobem no Docker; API e web no Node da máquina.
Tudo em containers: Tudo em containers:
```bash ```bash
cp .env.example .env cp .env.example .env
docker compose --profile full up -d --build docker compose --profile full up -d --build
``` ```
- App: http://localhost:3000 - App: http://localhost:3000
- API: http://localhost:3001/api/health - API: http://localhost:3001/api/health
## Contas ## Contas
| Área | URL | E-mail | Senha | | Área | URL | E-mail | Senha |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Plataforma | `/admin/login` | tina.r@example.net | SinkaPlatform!1 | | Plataforma | `/admin/login` | tina.r@example.net | SinkaPlatform!1 |
| Cliente 1 (admin) | `/cliente-1/login` | xena.w@example.org | SinkaAdmin!1 | | Cliente 1 (admin) | `/cliente-1/login` | xena.w@example.org | SinkaAdmin!1 |
| Cliente 1 (manager) | `/cliente-1/login` | james.b@example.com | SinkaManager!1 | | Cliente 1 (manager) | `/cliente-1/login` | james.b@example.com | SinkaManager!1 |
| Cliente 1 (operador) | `/cliente-1/login` | ursula.b@example.com | SinkaOperador!1 | | Cliente 1 (operador) | `/cliente-1/login` | ursula.b@example.com | SinkaOperador!1 |
| Cliente 2 (admin) | `/cliente-2/login` | ivan.p@example.net | Cliente2Admin!1 | | Cliente 2 (admin) | `/cliente-2/login` | ivan.p@example.net | Cliente2Admin!1 |
## Testes ## Testes
```bash ```bash
npm test npm test
npm run test:cov npm run test:cov
``` ```
Jest na API: frete, papéis, tenancy, chat, dashboard. A cobertura mede regra de negócio. Detalhes em [docs/TESTING.md](docs/TESTING.md). Jest na API: frete, papéis, tenancy, chat, dashboard. A cobertura mede regra de negócio. Detalhes em [docs/TESTING.md](docs/TESTING.md).
## Documentação ## Documentação
- [Arquitetura](docs/ARCHITECTURE.md) - [Arquitetura](docs/ARCHITECTURE.md)
- [Decisões](docs/DECISIONS.md) - [Decisões](docs/DECISIONS.md)
- [Testes](docs/TESTING.md) - [Testes](docs/TESTING.md)
- [Entrega e CI](docs/CI.md) - [Entrega e CI](docs/CI.md)
## Entrega ## Entrega
Push em `main` sobe a demo na hora. Neste desafio isso basta; em produção o caminho é **merge request → pipeline (testes e build) → merge → deploy**. Detalhes em [docs/CI.md](docs/CI.md). Push em `main` sobe a demo na hora. Neste desafio isso basta; em produção o caminho é **merge request → pipeline (testes e build) → merge → deploy**. Detalhes em [docs/CI.md](docs/CI.md).

View File

@ -1,34 +1,34 @@
# RULES: Sinka # RULES: Sinka
Stack obrigatório do desafio. **Não usar PHP.** Não reintroduzir Laravel, Blade, `public/index.php` nem pasta `app/` no estilo PHP. Stack obrigatório do desafio. **Não usar PHP.** Não reintroduzir Laravel, Blade, `public/index.php` nem pasta `app/` no estilo PHP.
## Stack ## Stack
- Backend: Node.js + **NestJS** + TypeScript (`api/`) - Backend: Node.js + **NestJS** + TypeScript (`api/`)
- Frontend: **Next.js** + TypeScript (`web/`) - Frontend: **Next.js** + TypeScript (`web/`)
- Banco: **MySQL**, `sinka_platform` + um database por empresa - Banco: **MySQL**, `sinka_platform` + um database por empresa
- Infra: **Docker**, **Docker Compose**, **Redis** - Infra: **Docker**, **Docker Compose**, **Redis**
## Pastas ## Pastas
| Pasta | Responsabilidade | | Pasta | Responsabilidade |
| --- | --- | | --- | --- |
| `api/` | NestJS: módulos, casos de uso, Prisma, filas | | `api/` | NestJS: módulos, casos de uso, Prisma, filas |
| `web/` | Next.js App Router: landing + área autenticada | | `web/` | Next.js App Router: landing + área autenticada |
| `docs/` | Arquitetura e decisões | | `docs/` | Arquitetura e decisões |
| `.cursor/rules/` | Regras do agente | | `.cursor/rules/` | Regras do agente |
## Como evoluir uma feature ## Como evoluir uma feature
1. Modelar no Prisma tenant (`api/prisma/tenant`) se for dado da empresa; no platform se for catálogo 1. Modelar no Prisma tenant (`api/prisma/tenant`) se for dado da empresa; no platform se for catálogo
2. Módulo NestJS: controller → use case/service → DTO + validação 2. Módulo NestJS: controller → use case/service → DTO + validação
3. Abrir o PrismaClient do tenant do JWT, nunca consultar outro database 3. Abrir o PrismaClient do tenant do JWT, nunca consultar outro database
4. Tela em `web/src/app` (App Router) 4. Tela em `web/src/app` (App Router)
5. Teste no módulo da API quando a regra for importante 5. Teste no módulo da API quando a regra for importante
## Não fazer ## Não fazer
- PHP, Composer, views `.php`, PDO - PHP, Composer, views `.php`, PDO
- Framework PHP, mesmo “só para prototipar” - Framework PHP, mesmo “só para prototipar”
- Lógica de negócio em controller inchado ou em componente React - Lógica de negócio em controller inchado ou em componente React
- Ler/escrever dado de empresa no banco da plataforma ou no database de outro tenant - Ler/escrever dado de empresa no banco da plataforma ou no database de outro tenant

View File

@ -1,7 +1,7 @@
node_modules node_modules
dist dist
coverage coverage
uploads uploads
.env .env
*.log *.log
*.tsbuildinfo *.tsbuildinfo

View File

@ -1,4 +1,4 @@
{ {
"singleQuote": true, "singleQuote": true,
"trailingComma": "all" "trailingComma": "all"
} }

View File

@ -1,21 +1,21 @@
FROM node:22-bookworm-slim FROM node:22-bookworm-slim
WORKDIR /app WORKDIR /app
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \ && apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
COPY prisma ./prisma COPY prisma ./prisma
RUN npm ci RUN npm ci
COPY . . COPY . .
RUN npm run prisma:generate && npm run build RUN npm run prisma:generate && npm run build
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3001 ENV PORT=3001
EXPOSE 3001 EXPOSE 3001
CMD ["node", "docker-entrypoint.js"] CMD ["node", "docker-entrypoint.js"]

View File

@ -1,98 +1,98 @@
<p align="center"> <p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a> <a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p> </p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest [circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p> <p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center"> <p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a> <a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a> <a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a> <a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a> <a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a> <a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a> <a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a> <a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a> <a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a> <a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a> <a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p> </p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer) <!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)--> [![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description ## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup ## Project setup
```bash ```bash
$ npm install $ npm install
``` ```
## Compile and run the project ## Compile and run the project
```bash ```bash
# development # development
$ npm run start $ npm run start
# watch mode # watch mode
$ npm run start:dev $ npm run start:dev
# production mode # production mode
$ npm run start:prod $ npm run start:prod
``` ```
## Run tests ## Run tests
```bash ```bash
# unit tests # unit tests
$ npm run test $ npm run test
# e2e tests # e2e tests
$ npm run test:e2e $ npm run test:e2e
# test coverage # test coverage
$ npm run test:cov $ npm run test:cov
``` ```
## Deployment ## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps: If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash ```bash
$ npm install -g @nestjs/mau $ npm install -g @nestjs/mau
$ mau deploy $ mau deploy
``` ```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources ## Resources
Check out a few resources that may come in handy when working with NestJS: Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. - Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). - For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). - To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. - Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). - Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). - Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). - To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). - Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support ## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch ## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) - Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/) - Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework) - Twitter - [@nestframework](https://twitter.com/nestframework)
## License ## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

View File

@ -1,26 +1,26 @@
const { spawn, spawnSync } = require('node:child_process'); const { spawn, spawnSync } = require('node:child_process');
function run(command, args) { function run(command, args) {
const result = spawnSync(command, args, { const result = spawnSync(command, args, {
stdio: 'inherit', stdio: 'inherit',
env: process.env, env: process.env,
}); });
if (result.status !== 0) { if (result.status !== 0) {
process.exit(result.status ?? 1); process.exit(result.status ?? 1);
} }
} }
run('npx', ['prisma', 'db', 'push', '--schema', 'prisma/platform/schema.prisma', '--skip-generate']); run('npx', ['prisma', 'db', 'push', '--schema', 'prisma/platform/schema.prisma', '--skip-generate']);
run('npx', ['tsx', 'prisma/seed.ts']); run('npx', ['tsx', 'prisma/seed.ts']);
const child = spawn('node', ['dist/main.js'], { const child = spawn('node', ['dist/main.js'], {
stdio: 'inherit', stdio: 'inherit',
env: process.env, env: process.env,
}); });
child.on('exit', (code, signal) => { child.on('exit', (code, signal) => {
if (signal) { if (signal) {
process.kill(process.pid, signal); process.kill(process.pid, signal);
return; return;
} }
process.exit(code ?? 1); process.exit(code ?? 1);
}); });

View File

@ -1,35 +1,35 @@
// @ts-check // @ts-check
import eslint from '@eslint/js'; import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals'; import globals from 'globals';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
export default tseslint.config( export default tseslint.config(
{ {
ignores: ['eslint.config.mjs', 'src/generated/**'], ignores: ['eslint.config.mjs', 'src/generated/**'],
}, },
eslint.configs.recommended, eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked, ...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended, eslintPluginPrettierRecommended,
{ {
languageOptions: { languageOptions: {
globals: { globals: {
...globals.node, ...globals.node,
...globals.jest, ...globals.jest,
}, },
sourceType: 'commonjs', sourceType: 'commonjs',
parserOptions: { parserOptions: {
projectService: true, projectService: true,
tsconfigRootDir: import.meta.dirname, tsconfigRootDir: import.meta.dirname,
}, },
}, },
}, },
{ {
rules: { rules: {
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }], "prettier/prettier": ["error", { endOfLine: "auto" }],
}, },
}, },
); );

View File

@ -1,14 +1,14 @@
{ {
"$schema": "https://json.schemastore.org/nest-cli", "$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src", "sourceRoot": "src",
"compilerOptions": { "compilerOptions": {
"deleteOutDir": true, "deleteOutDir": true,
"assets": [ "assets": [
{ {
"include": "generated/**/*", "include": "generated/**/*",
"watchAssets": true "watchAssets": true
} }
] ]
} }
} }

26632
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,130 +1,130 @@
{ {
"name": "api", "name": "api",
"version": "0.0.1", "version": "0.0.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start", "start": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate --schema prisma/platform/schema.prisma && prisma generate --schema prisma/tenant/schema.prisma", "prisma:generate": "prisma generate --schema prisma/platform/schema.prisma && prisma generate --schema prisma/tenant/schema.prisma",
"prisma:push:platform": "prisma db push --schema prisma/platform/schema.prisma", "prisma:push:platform": "prisma db push --schema prisma/platform/schema.prisma",
"prisma:seed": "tsx prisma/seed.ts", "prisma:seed": "tsx prisma/seed.ts",
"prisma:setup": "npm run prisma:generate && npm run prisma:push:platform && npm run prisma:seed", "prisma:setup": "npm run prisma:generate && npm run prisma:push:platform && npm run prisma:seed",
"postinstall": "npm run prisma:generate" "postinstall": "npm run prisma:generate"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^12.0.0", "@nestjs/config": "^12.0.0",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/terminus": "^12.0.0", "@nestjs/terminus": "^12.0.0",
"@prisma/client": "^6.19.0", "@prisma/client": "^6.19.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"firebase-admin": "^14.4.0", "firebase-admin": "^14.4.0",
"ioredis": "^5.6.1", "ioredis": "^5.6.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.8", "@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/multer": "^2.2.0", "@types/multer": "^2.2.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/passport-google-oauth20": "^2.0.17", "@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0", "globals": "^17.0.0",
"jest": "^30.0.0", "jest": "^30.0.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prisma": "^6.19.0", "prisma": "^6.19.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"ts-loader": "^9.5.2", "ts-loader": "^9.5.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"tsx": "^4.20.5", "tsx": "^4.20.5",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.20.0" "typescript-eslint": "^8.20.0"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [
"js", "js",
"json", "json",
"ts" "ts"
], ],
"rootDir": "src", "rootDir": "src",
"testRegex": ".*\\.spec\\.ts$", "testRegex": ".*\\.spec\\.ts$",
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },
"collectCoverageFrom": [ "collectCoverageFrom": [
"**/*.ts", "**/*.ts",
"!**/*.spec.ts", "!**/*.spec.ts",
"!**/*.module.ts", "!**/*.module.ts",
"!**/*.dto.ts", "!**/*.dto.ts",
"!main.ts", "!main.ts",
"!generated/**" "!generated/**"
], ],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"coveragePathIgnorePatterns": [ "coveragePathIgnorePatterns": [
"/generated/", "/generated/",
"main.ts", "main.ts",
".module.ts", ".module.ts",
".controller.ts", ".controller.ts",
".dto.ts", ".dto.ts",
".strategy.ts", ".strategy.ts",
"platform-prisma.service.ts", "platform-prisma.service.ts",
"tenant-connection.service.ts", "tenant-connection.service.ts",
"provision-database.ts", "provision-database.ts",
"redis.service.ts", "redis.service.ts",
"auth.service.ts", "auth.service.ts",
"jwt-auth.guard.ts", "jwt-auth.guard.ts",
"current-user.decorator.ts", "current-user.decorator.ts",
"chat.service.ts", "chat.service.ts",
"dashboard.service.ts", "dashboard.service.ts",
"freight.service.ts", "freight.service.ts",
"geo.service.ts", "geo.service.ts",
"carriers.service.ts", "carriers.service.ts",
"customers.service.ts", "customers.service.ts",
"users.service.ts", "users.service.ts",
"platform.service.ts" "platform.service.ts"
], ],
"coverageReporters": [ "coverageReporters": [
"text", "text",
"lcov" "lcov"
], ],
"testEnvironment": "node" "testEnvironment": "node"
} }
} }

View File

@ -1,66 +1,66 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
output = "../../src/generated/platform" output = "../../src/generated/platform"
} }
datasource db { datasource db {
provider = "mysql" provider = "mysql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
enum TenantStatus { enum TenantStatus {
ACTIVE ACTIVE
SUSPENDED SUSPENDED
} }
model Tenant { model Tenant {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
slug String @unique slug String @unique
database String @unique database String @unique
status TenantStatus @default(ACTIVE) status TenantStatus @default(ACTIVE)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model PlatformUser { model PlatformUser {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
email String @unique email String @unique
passwordHash String passwordHash String
googleId String? @unique googleId String? @unique
githubId String? @unique githubId String? @unique
totpSecret String? totpSecret String?
totpEnabled Boolean @default(false) totpEnabled Boolean @default(false)
lastLoginAt DateTime? lastLoginAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
refreshTokens PlatformRefreshToken[] refreshTokens PlatformRefreshToken[]
auditLogs PlatformAuditLog[] auditLogs PlatformAuditLog[]
} }
model PlatformRefreshToken { model PlatformRefreshToken {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
user PlatformUser @relation(fields: [userId], references: [id], onDelete: Cascade) user PlatformUser @relation(fields: [userId], references: [id], onDelete: Cascade)
hashed String hashed String
expiresAt DateTime expiresAt DateTime
revokedAt DateTime? revokedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([userId]) @@index([userId])
} }
model PlatformAuditLog { model PlatformAuditLog {
id String @id @default(uuid()) id String @id @default(uuid())
userId String? userId String?
user PlatformUser? @relation(fields: [userId], references: [id], onDelete: SetNull) user PlatformUser? @relation(fields: [userId], references: [id], onDelete: SetNull)
action String action String
entity String? entity String?
entityId String? entityId String?
ip String? ip String?
metadata Json? metadata Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([createdAt]) @@index([createdAt])
} }

View File

@ -1,172 +1,172 @@
import { config } from 'dotenv'; import { config } from 'dotenv';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { PrismaClient as PlatformPrisma } from '../src/generated/platform'; import { PrismaClient as PlatformPrisma } from '../src/generated/platform';
import { PrismaClient as TenantPrisma } from '../src/generated/tenant'; import { PrismaClient as TenantPrisma } from '../src/generated/tenant';
import { createMysqlDatabase, databaseForSlug, pushTenantSchema } from '../src/tenancy/provision-database'; import { createMysqlDatabase, databaseForSlug, pushTenantSchema } from '../src/tenancy/provision-database';
import { urlForDatabase } from '../src/tenancy/tenant-url'; import { urlForDatabase } from '../src/tenancy/tenant-url';
config({ path: resolve(__dirname, '../../.env') }); config({ path: resolve(__dirname, '../../.env') });
config({ path: resolve(__dirname, '../.env') }); config({ path: resolve(__dirname, '../.env') });
async function upsertUser( async function upsertUser(
db: TenantPrisma, db: TenantPrisma,
email: string, email: string,
name: string, name: string,
password: string, password: string,
role: 'ADMIN' | 'MANAGER' | 'OPERATOR', role: 'ADMIN' | 'MANAGER' | 'OPERATOR',
) { ) {
const passwordHash = await bcrypt.hash(password, 10); const passwordHash = await bcrypt.hash(password, 10);
await db.user.upsert({ await db.user.upsert({
where: { email }, where: { email },
update: { name, passwordHash, role, issuedPassword: password }, update: { name, passwordHash, role, issuedPassword: password },
create: { email, name, passwordHash, role, issuedPassword: password }, create: { email, name, passwordHash, role, issuedPassword: password },
}); });
} }
async function ensureTenant( async function ensureTenant(
platform: PlatformPrisma, platform: PlatformPrisma,
name: string, name: string,
slug: string, slug: string,
aliases: string[] = [], aliases: string[] = [],
) { ) {
let tenant = await platform.tenant.findUnique({ where: { slug } }); let tenant = await platform.tenant.findUnique({ where: { slug } });
if (!tenant) { if (!tenant) {
for (const alias of aliases) { for (const alias of aliases) {
const old = await platform.tenant.findUnique({ where: { slug: alias } }); const old = await platform.tenant.findUnique({ where: { slug: alias } });
if (old) { if (old) {
tenant = await platform.tenant.update({ tenant = await platform.tenant.update({
where: { id: old.id }, where: { id: old.id },
data: { name, slug }, data: { name, slug },
}); });
break; break;
} }
} }
} }
if (!tenant) { if (!tenant) {
const database = databaseForSlug(slug); const database = databaseForSlug(slug);
await createMysqlDatabase(database); await createMysqlDatabase(database);
await pushTenantSchema(database); await pushTenantSchema(database);
tenant = await platform.tenant.create({ data: { name, slug, database } }); tenant = await platform.tenant.create({ data: { name, slug, database } });
} else { } else {
await pushTenantSchema(tenant.database); await pushTenantSchema(tenant.database);
if (tenant.name !== name) { if (tenant.name !== name) {
tenant = await platform.tenant.update({ where: { id: tenant.id }, data: { name } }); tenant = await platform.tenant.update({ where: { id: tenant.id }, data: { name } });
} }
} }
return tenant; return tenant;
} }
async function seedCompany( async function seedCompany(
tenant: { database: string }, tenant: { database: string },
users: { email: string; name: string; password: string; role: 'ADMIN' | 'MANAGER' | 'OPERATOR' }[], users: { email: string; name: string; password: string; role: 'ADMIN' | 'MANAGER' | 'OPERATOR' }[],
customers: { name: string; document: string; city: string; state: string }[], customers: { name: string; document: string; city: string; state: string }[],
carriers: { name: string; baseFee: number; pricePerKg: number; pricePerKm: number; adValoremRate: number }[], carriers: { name: string; baseFee: number; pricePerKg: number; pricePerKm: number; adValoremRate: number }[],
) { ) {
const db = new TenantPrisma({ const db = new TenantPrisma({
datasources: { db: { url: urlForDatabase(tenant.database) } }, datasources: { db: { url: urlForDatabase(tenant.database) } },
}); });
for (const user of users) { for (const user of users) {
await upsertUser(db, user.email, user.name, user.password, user.role); await upsertUser(db, user.email, user.name, user.password, user.role);
} }
for (const customer of customers) { for (const customer of customers) {
const existing = await db.customer.findFirst({ where: { name: customer.name } }); const existing = await db.customer.findFirst({ where: { name: customer.name } });
if (!existing) { if (!existing) {
await db.customer.create({ data: customer }); await db.customer.create({ data: customer });
} }
} }
for (const carrier of carriers) { for (const carrier of carriers) {
const existing = await db.carrier.findFirst({ where: { name: carrier.name } }); const existing = await db.carrier.findFirst({ where: { name: carrier.name } });
if (existing) { if (existing) {
await db.carrier.update({ where: { id: existing.id }, data: carrier }); await db.carrier.update({ where: { id: existing.id }, data: carrier });
} else { } else {
await db.carrier.create({ data: carrier }); await db.carrier.create({ data: carrier });
} }
} }
await db.$disconnect(); await db.$disconnect();
} }
async function main() { async function main() {
const platform = new PlatformPrisma(); const platform = new PlatformPrisma();
const platformEmail = process.env.PLATFORM_ADMIN_EMAIL ?? 'tina.r@example.net'; const platformEmail = process.env.PLATFORM_ADMIN_EMAIL ?? 'tina.r@example.net';
const platformPassword = process.env.PLATFORM_ADMIN_PASSWORD ?? 'SinkaPlatform!1'; const platformPassword = process.env.PLATFORM_ADMIN_PASSWORD ?? 'SinkaPlatform!1';
await platform.platformUser.upsert({ await platform.platformUser.upsert({
where: { email: platformEmail }, where: { email: platformEmail },
update: { passwordHash: await bcrypt.hash(platformPassword, 10), name: 'Sinka Platform' }, update: { passwordHash: await bcrypt.hash(platformPassword, 10), name: 'Sinka Platform' },
create: { create: {
email: platformEmail, email: platformEmail,
name: 'Sinka Platform', name: 'Sinka Platform',
passwordHash: await bcrypt.hash(platformPassword, 10), passwordHash: await bcrypt.hash(platformPassword, 10),
}, },
}); });
const cliente1 = await ensureTenant(platform, 'Cliente 1', 'cliente-1', ['demo']); const cliente1 = await ensureTenant(platform, 'Cliente 1', 'cliente-1', ['demo']);
const cliente2 = await ensureTenant(platform, 'Cliente 2', 'cliente-2'); const cliente2 = await ensureTenant(platform, 'Cliente 2', 'cliente-2');
const others = await platform.tenant.findMany({ const others = await platform.tenant.findMany({
where: { slug: { notIn: ['cliente-1', 'cliente-2'] } }, where: { slug: { notIn: ['cliente-1', 'cliente-2'] } },
}); });
for (const item of others) { for (const item of others) {
await pushTenantSchema(item.database); await pushTenantSchema(item.database);
} }
await seedCompany( await seedCompany(
cliente1, cliente1,
[ [
{ {
email: process.env.DEMO_ADMIN_EMAIL ?? 'xena.w@example.org', email: process.env.DEMO_ADMIN_EMAIL ?? 'xena.w@example.org',
name: 'Ana Admin', name: 'Ana Admin',
password: process.env.DEMO_ADMIN_PASSWORD ?? 'SinkaAdmin!1', password: process.env.DEMO_ADMIN_PASSWORD ?? 'SinkaAdmin!1',
role: 'ADMIN', role: 'ADMIN',
}, },
{ {
email: process.env.DEMO_MANAGER_EMAIL ?? 'james.b@example.com', email: process.env.DEMO_MANAGER_EMAIL ?? 'james.b@example.com',
name: 'Marcos Manager', name: 'Marcos Manager',
password: process.env.DEMO_MANAGER_PASSWORD ?? 'SinkaManager!1', password: process.env.DEMO_MANAGER_PASSWORD ?? 'SinkaManager!1',
role: 'MANAGER', role: 'MANAGER',
}, },
{ {
email: process.env.DEMO_OPERATOR_EMAIL ?? 'ursula.b@example.com', email: process.env.DEMO_OPERATOR_EMAIL ?? 'ursula.b@example.com',
name: 'Olga Operador', name: 'Olga Operador',
password: process.env.DEMO_OPERATOR_PASSWORD ?? 'SinkaOperador!1', password: process.env.DEMO_OPERATOR_PASSWORD ?? 'SinkaOperador!1',
role: 'OPERATOR', role: 'OPERATOR',
}, },
], ],
[ [
{ name: 'Mercado Aurora', document: '12.345.678/0001-90', city: 'São Paulo', state: 'SP' }, { name: 'Mercado Aurora', document: '12.345.678/0001-90', city: 'São Paulo', state: 'SP' },
{ name: 'Café do Litoral', document: '98.765.432/0001-10', city: 'Santos', state: 'SP' }, { name: 'Café do Litoral', document: '98.765.432/0001-10', city: 'Santos', state: 'SP' },
], ],
[ [
{ name: 'Litoral Mix', baseFee: 45, pricePerKg: 1.8, pricePerKm: 0.85, adValoremRate: 0.003 }, { name: 'Litoral Mix', baseFee: 45, pricePerKg: 1.8, pricePerKm: 0.85, adValoremRate: 0.003 },
{ name: 'Rota Sul', baseFee: 32, pricePerKg: 1.4, pricePerKm: 0.72, adValoremRate: 0.004 }, { name: 'Rota Sul', baseFee: 32, pricePerKg: 1.4, pricePerKm: 0.72, adValoremRate: 0.004 },
{ name: 'Express RJ', baseFee: 60, pricePerKg: 2.2, pricePerKm: 1.1, adValoremRate: 0.002 }, { name: 'Express RJ', baseFee: 60, pricePerKg: 2.2, pricePerKm: 1.1, adValoremRate: 0.002 },
], ],
); );
await seedCompany( await seedCompany(
cliente2, cliente2,
[ [
{ {
email: 'ivan.p@example.net', email: 'ivan.p@example.net',
name: 'Bruno Admin', name: 'Bruno Admin',
password: 'Cliente2Admin!1', password: 'Cliente2Admin!1',
role: 'ADMIN', role: 'ADMIN',
}, },
], ],
[ [
{ name: 'Padaria Centro', document: '11.222.333/0001-44', city: 'Curitiba', state: 'PR' }, { name: 'Padaria Centro', document: '11.222.333/0001-44', city: 'Curitiba', state: 'PR' },
{ name: 'Loja Norte', document: '55.666.777/0001-88', city: 'Joinville', state: 'SC' }, { name: 'Loja Norte', document: '55.666.777/0001-88', city: 'Joinville', state: 'SC' },
], ],
[{ name: 'Carga Paraná', baseFee: 38, pricePerKg: 1.5, pricePerKm: 0.7, adValoremRate: 0.0035 }], [{ name: 'Carga Paraná', baseFee: 38, pricePerKg: 1.5, pricePerKm: 0.7, adValoremRate: 0.0035 }],
); );
await platform.$disconnect(); await platform.$disconnect();
console.log('Seed ok: platform + Cliente 1 + Cliente 2.'); console.log('Seed ok: platform + Cliente 1 + Cliente 2.');
} }
main().catch(async (error) => { main().catch(async (error) => {
console.error(error); console.error(error);
process.exit(1); process.exit(1);
}); });

View File

@ -1,147 +1,147 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
output = "../../src/generated/tenant" output = "../../src/generated/tenant"
} }
datasource db { datasource db {
provider = "mysql" provider = "mysql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
enum Role { enum Role {
ADMIN ADMIN
MANAGER MANAGER
OPERATOR OPERATOR
} }
enum SimulationStatus { enum SimulationStatus {
DRAFT DRAFT
CALCULATED CALCULATED
ARCHIVED ARCHIVED
} }
enum ImportJobStatus { enum ImportJobStatus {
PENDING PENDING
PROCESSING PROCESSING
DONE DONE
FAILED FAILED
} }
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
email String @unique email String @unique
passwordHash String passwordHash String
issuedPassword String? issuedPassword String?
role Role @default(OPERATOR) role Role @default(OPERATOR)
googleId String? @unique googleId String? @unique
githubId String? @unique githubId String? @unique
totpSecret String? totpSecret String?
totpEnabled Boolean @default(false) totpEnabled Boolean @default(false)
lastLoginAt DateTime? lastLoginAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
auditLogs AuditLog[] auditLogs AuditLog[]
} }
model RefreshToken { model RefreshToken {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
hashed String hashed String
expiresAt DateTime expiresAt DateTime
revokedAt DateTime? revokedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([userId]) @@index([userId])
} }
model AuditLog { model AuditLog {
id String @id @default(uuid()) id String @id @default(uuid())
userId String? userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull) user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
action String action String
entity String? entity String?
entityId String? entityId String?
ip String? ip String?
metadata Json? metadata Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([createdAt]) @@index([createdAt])
} }
model Customer { model Customer {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
document String? document String?
email String? email String?
phone String? phone String?
city String? city String?
state String? state String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
simulations FreightSimulation[] simulations FreightSimulation[]
} }
model Carrier { model Carrier {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
document String? document String?
email String? email String?
phone String? phone String?
baseFee Decimal @default(0) @db.Decimal(10, 2) baseFee Decimal @default(0) @db.Decimal(10, 2)
pricePerKg Decimal @default(0) @db.Decimal(10, 2) pricePerKg Decimal @default(0) @db.Decimal(10, 2)
pricePerKm Decimal @default(0) @db.Decimal(10, 2) pricePerKm Decimal @default(0) @db.Decimal(10, 2)
adValoremRate Decimal @default(0) @db.Decimal(6, 4) adValoremRate Decimal @default(0) @db.Decimal(6, 4)
active Boolean @default(true) active Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model FreightSimulation { model FreightSimulation {
id String @id @default(uuid()) id String @id @default(uuid())
originZip String originZip String
destinationZip String destinationZip String
originLabel String? originLabel String?
destinationLabel String? destinationLabel String?
weightKg Decimal @db.Decimal(10, 3) weightKg Decimal @db.Decimal(10, 3)
widthCm Decimal @db.Decimal(8, 2) widthCm Decimal @db.Decimal(8, 2)
heightCm Decimal @db.Decimal(8, 2) heightCm Decimal @db.Decimal(8, 2)
lengthCm Decimal @db.Decimal(8, 2) lengthCm Decimal @db.Decimal(8, 2)
cargoValue Decimal @db.Decimal(12, 2) cargoValue Decimal @db.Decimal(12, 2)
quotedPrice Decimal? @db.Decimal(12, 2) quotedPrice Decimal? @db.Decimal(12, 2)
distanceKm Decimal? @db.Decimal(10, 2) distanceKm Decimal? @db.Decimal(10, 2)
chargeableKg Decimal? @db.Decimal(10, 3) chargeableKg Decimal? @db.Decimal(10, 3)
bestCarrierName String? bestCarrierName String?
quotes Json? quotes Json?
status SimulationStatus @default(DRAFT) status SimulationStatus @default(DRAFT)
customerId String? customerId String?
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull) customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([createdAt]) @@index([createdAt])
} }
model ImportJob { model ImportJob {
id String @id @default(uuid()) id String @id @default(uuid())
filename String filename String
status ImportJobStatus @default(PENDING) status ImportJobStatus @default(PENDING)
error String? error String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
finishedAt DateTime? finishedAt DateTime?
@@index([createdAt]) @@index([createdAt])
} }
model OperationFile { model OperationFile {
id String @id @default(uuid()) id String @id @default(uuid())
originalName String originalName String
storedName String storedName String
mimeType String mimeType String
sizeBytes Int sizeBytes Int
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([createdAt]) @@index([createdAt])
} }

View File

@ -1,36 +1,36 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { PlatformModule } from './platform/platform.module'; import { PlatformModule } from './platform/platform.module';
import { RedisModule } from './redis/redis.module'; import { RedisModule } from './redis/redis.module';
import { TenancyModule } from './tenancy/tenancy.module'; import { TenancyModule } from './tenancy/tenancy.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
import { CarriersModule } from './carriers/carriers.module'; import { CarriersModule } from './carriers/carriers.module';
import { CustomersModule } from './customers/customers.module'; import { CustomersModule } from './customers/customers.module';
import { FreightModule } from './freight/freight.module'; import { FreightModule } from './freight/freight.module';
import { DashboardModule } from './dashboard/dashboard.module'; import { DashboardModule } from './dashboard/dashboard.module';
import { ChatModule } from './chat/chat.module'; import { ChatModule } from './chat/chat.module';
import { FilesModule } from './files/files.module'; import { FilesModule } from './files/files.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
envFilePath: ['.env', '../.env'], envFilePath: ['.env', '../.env'],
}), }),
RedisModule, RedisModule,
TenancyModule, TenancyModule,
AuthModule, AuthModule,
PlatformModule, PlatformModule,
UsersModule, UsersModule,
CustomersModule, CustomersModule,
CarriersModule, CarriersModule,
FreightModule, FreightModule,
DashboardModule, DashboardModule,
ChatModule, ChatModule,
FilesModule, FilesModule,
HealthModule, HealthModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@ -1,132 +1,132 @@
import { Body, Controller, Get, HttpCode, Post, Query, Req, Res, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, HttpCode, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import passport from 'passport'; import passport from 'passport';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator'; import { CurrentUser } from './decorators/current-user.decorator';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { clearAuthCookies, readRefreshCookie, setAuthCookies } from './cookies'; import { clearAuthCookies, readRefreshCookie, setAuthCookies } from './cookies';
import type { AuthUser } from './auth.types'; import type { AuthUser } from './auth.types';
import { readOAuthState, signOAuthState } from './oauth-state'; import { readOAuthState, signOAuthState } from './oauth-state';
import type { GoogleIdentity } from './strategies/google.strategy'; import type { GoogleIdentity } from './strategies/google.strategy';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor( constructor(
private readonly auth: AuthService, private readonly auth: AuthService,
private readonly config: ConfigService, private readonly config: ConfigService,
) {} ) {}
@Post('login') @Post('login')
@HttpCode(200) @HttpCode(200)
async login( async login(
@Body() dto: LoginDto, @Body() dto: LoginDto,
@Req() request: Request, @Req() request: Request,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
const result = await this.auth.login(dto, request.ip); const result = await this.auth.login(dto, request.ip);
setAuthCookies(response, result.accessToken, result.refreshToken); setAuthCookies(response, result.accessToken, result.refreshToken);
return { user: result.user }; return { user: result.user };
} }
@Get('google') @Get('google')
googleStart( googleStart(
@Query('tenant') tenant: string | undefined, @Query('tenant') tenant: string | undefined,
@Query('next') next: string | undefined, @Query('next') next: string | undefined,
@Res() response: Response, @Res() response: Response,
) { ) {
if (!this.auth.googleConfigured()) { if (!this.auth.googleConfigured()) {
return response.redirect(this.failUrl(tenant, 'google_off')); return response.redirect(this.failUrl(tenant, 'google_off'));
} }
const kind = tenant ? 'tenant' : 'platform'; const kind = tenant ? 'tenant' : 'platform';
const state = signOAuthState( const state = signOAuthState(
{ kind, slug: tenant, next }, { kind, slug: tenant, next },
this.config.getOrThrow<string>('JWT_SECRET'), this.config.getOrThrow<string>('JWT_SECRET'),
); );
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth'); const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
url.searchParams.set('client_id', this.config.getOrThrow<string>('GOOGLE_CLIENT_ID')); url.searchParams.set('client_id', this.config.getOrThrow<string>('GOOGLE_CLIENT_ID'));
url.searchParams.set('redirect_uri', this.config.getOrThrow<string>('GOOGLE_CALLBACK_URL')); url.searchParams.set('redirect_uri', this.config.getOrThrow<string>('GOOGLE_CALLBACK_URL'));
url.searchParams.set('response_type', 'code'); url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'openid email profile'); url.searchParams.set('scope', 'openid email profile');
url.searchParams.set('state', state); url.searchParams.set('state', state);
url.searchParams.set('prompt', 'select_account'); url.searchParams.set('prompt', 'select_account');
return response.redirect(url.toString()); return response.redirect(url.toString());
} }
@Get('google/callback') @Get('google/callback')
googleCallback(@Req() request: Request, @Res() response: Response) { googleCallback(@Req() request: Request, @Res() response: Response) {
const rawState = String(request.query.state ?? ''); const rawState = String(request.query.state ?? '');
const tenant = this.slugFromState(rawState); const tenant = this.slugFromState(rawState);
if (request.query.error) { if (request.query.error) {
return response.redirect(this.failUrl(tenant, 'google_denied')); return response.redirect(this.failUrl(tenant, 'google_denied'));
} }
passport.authenticate('google', { session: false }, (error: unknown, identity: GoogleIdentity | false) => { passport.authenticate('google', { session: false }, (error: unknown, identity: GoogleIdentity | false) => {
void this.finishGoogle(request, response, rawState, tenant, error, identity); void this.finishGoogle(request, response, rawState, tenant, error, identity);
})(request, response); })(request, response);
} }
@Post('refresh') @Post('refresh')
@HttpCode(200) @HttpCode(200)
async refresh(@Req() request: Request, @Res({ passthrough: true }) response: Response) { async refresh(@Req() request: Request, @Res({ passthrough: true }) response: Response) {
const result = await this.auth.refresh(readRefreshCookie(request.cookies)); const result = await this.auth.refresh(readRefreshCookie(request.cookies));
setAuthCookies(response, result.accessToken, result.refreshToken); setAuthCookies(response, result.accessToken, result.refreshToken);
return { user: result.user }; return { user: result.user };
} }
@Post('logout') @Post('logout')
@HttpCode(200) @HttpCode(200)
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
async logout( async logout(
@Req() request: Request, @Req() request: Request,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
@CurrentUser() user: AuthUser, @CurrentUser() user: AuthUser,
) { ) {
await this.auth.logout(readRefreshCookie(request.cookies), user); await this.auth.logout(readRefreshCookie(request.cookies), user);
clearAuthCookies(response); clearAuthCookies(response);
return { ok: true }; return { ok: true };
} }
@Get('me') @Get('me')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
me(@CurrentUser() user: AuthUser) { me(@CurrentUser() user: AuthUser) {
return this.auth.profile(user); return this.auth.profile(user);
} }
private async finishGoogle( private async finishGoogle(
request: Request, request: Request,
response: Response, response: Response,
rawState: string, rawState: string,
tenant: string | undefined, tenant: string | undefined,
error: unknown, error: unknown,
identity: GoogleIdentity | false, identity: GoogleIdentity | false,
) { ) {
try { try {
if (error || !identity) { if (error || !identity) {
return response.redirect(this.failUrl(tenant, 'google_denied')); return response.redirect(this.failUrl(tenant, 'google_denied'));
} }
const state = readOAuthState(rawState, this.config.getOrThrow<string>('JWT_SECRET')); const state = readOAuthState(rawState, this.config.getOrThrow<string>('JWT_SECRET'));
const { session, redirect } = await this.auth.loginWithGoogle(identity, state, request.ip); const { session, redirect } = await this.auth.loginWithGoogle(identity, state, request.ip);
setAuthCookies(response, session.accessToken, session.refreshToken); setAuthCookies(response, session.accessToken, session.refreshToken);
return response.redirect(redirect); return response.redirect(redirect);
} catch (caught) { } catch (caught) {
const message = caught instanceof Error ? caught.message : ''; const message = caught instanceof Error ? caught.message : '';
const code = message.includes('não tem acesso') ? 'google_unlinked' : 'google_denied'; const code = message.includes('não tem acesso') ? 'google_unlinked' : 'google_denied';
return response.redirect(this.failUrl(tenant, code)); return response.redirect(this.failUrl(tenant, code));
} }
} }
private slugFromState(raw: string) { private slugFromState(raw: string) {
try { try {
return readOAuthState(raw, this.config.getOrThrow<string>('JWT_SECRET')).slug; return readOAuthState(raw, this.config.getOrThrow<string>('JWT_SECRET')).slug;
} catch { } catch {
return undefined; return undefined;
} }
} }
private failUrl(tenant: string | undefined, code: string) { private failUrl(tenant: string | undefined, code: string) {
const path = tenant ? `/${tenant}/login` : '/admin/login'; const path = tenant ? `/${tenant}/login` : '/admin/login';
return `${this.auth.webOrigin()}${path}?error=${code}`; return `${this.auth.webOrigin()}${path}?error=${code}`;
} }
} }

View File

@ -1,25 +1,25 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy'; import { JwtStrategy } from './strategies/jwt.strategy';
import { GoogleStrategy } from './strategies/google.strategy'; import { GoogleStrategy } from './strategies/google.strategy';
@Module({ @Module({
imports: [ imports: [
PassportModule.register({ defaultStrategy: 'jwt' }), PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({ JwtModule.registerAsync({
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_SECRET'), secret: config.getOrThrow<string>('JWT_SECRET'),
signOptions: { expiresIn: '15m' }, signOptions: { expiresIn: '15m' },
}), }),
}), }),
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, JwtStrategy, GoogleStrategy], providers: [AuthService, JwtStrategy, GoogleStrategy],
exports: [AuthService], exports: [AuthService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -1,321 +1,321 @@
import { import {
ForbiddenException, ForbiddenException,
HttpException, HttpException,
HttpStatus, HttpStatus,
Injectable, Injectable,
NotFoundException, NotFoundException,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { createHash, randomBytes } from 'node:crypto'; import { createHash, randomBytes } from 'node:crypto';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { PlatformPrismaService } from '../prisma/platform-prisma.service'; import { PlatformPrismaService } from '../prisma/platform-prisma.service';
import { RedisService } from '../redis/redis.service'; import { RedisService } from '../redis/redis.service';
import { TenantConnectionService } from '../tenancy/tenant-connection.service'; import { TenantConnectionService } from '../tenancy/tenant-connection.service';
import { normalizeSlug } from '../tenancy/tenant-url'; import { normalizeSlug } from '../tenancy/tenant-url';
import type { AuthUser, JwtPayload } from './auth.types'; import type { AuthUser, JwtPayload } from './auth.types';
import type { LoginDto } from './dto/login.dto'; import type { LoginDto } from './dto/login.dto';
import type { GoogleIdentity } from './strategies/google.strategy'; import type { GoogleIdentity } from './strategies/google.strategy';
import { permissionsFor, ROLE_LABEL } from './roles'; import { permissionsFor, ROLE_LABEL } from './roles';
import { type OAuthState, safeNext } from './oauth-state'; import { type OAuthState, safeNext } from './oauth-state';
const LOGIN_WINDOW_SECONDS = 15 * 60; const LOGIN_WINDOW_SECONDS = 15 * 60;
const LOGIN_MAX_ATTEMPTS = 10; const LOGIN_MAX_ATTEMPTS = 10;
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
private readonly platform: PlatformPrismaService, private readonly platform: PlatformPrismaService,
private readonly tenants: TenantConnectionService, private readonly tenants: TenantConnectionService,
private readonly jwt: JwtService, private readonly jwt: JwtService,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly redis: RedisService, private readonly redis: RedisService,
) {} ) {}
async login(dto: LoginDto, ip?: string) { async login(dto: LoginDto, ip?: string) {
await this.assertRateLimit(dto.email, ip); await this.assertRateLimit(dto.email, ip);
const slug = dto.tenantSlug?.trim(); const slug = dto.tenantSlug?.trim();
if (!slug || slug.toLowerCase() === 'platform') { if (!slug || slug.toLowerCase() === 'platform') {
return this.loginPlatform(dto.email, dto.password, ip); return this.loginPlatform(dto.email, dto.password, ip);
} }
return this.loginTenant(normalizeSlug(slug), dto.email, dto.password, ip); return this.loginTenant(normalizeSlug(slug), dto.email, dto.password, ip);
} }
googleConfigured() { googleConfigured() {
return Boolean(this.config.get<string>('GOOGLE_CLIENT_ID') && this.config.get<string>('GOOGLE_CLIENT_SECRET')); return Boolean(this.config.get<string>('GOOGLE_CLIENT_ID') && this.config.get<string>('GOOGLE_CLIENT_SECRET'));
} }
webOrigin() { webOrigin() {
return this.config.get<string>('WEB_ORIGIN') ?? 'http://localhost:3000'; return this.config.get<string>('WEB_ORIGIN') ?? 'http://localhost:3000';
} }
async loginWithGoogle(identity: GoogleIdentity, state: OAuthState, ip?: string) { async loginWithGoogle(identity: GoogleIdentity, state: OAuthState, ip?: string) {
await this.assertRateLimit(identity.email, ip); await this.assertRateLimit(identity.email, ip);
if (state.kind === 'platform') { if (state.kind === 'platform') {
const session = await this.loginPlatformGoogle(identity, ip); const session = await this.loginPlatformGoogle(identity, ip);
return { session, redirect: `${this.webOrigin()}${safeNext(state.next, '/admin')}` }; return { session, redirect: `${this.webOrigin()}${safeNext(state.next, '/admin')}` };
} }
const slug = normalizeSlug(state.slug ?? ''); const slug = normalizeSlug(state.slug ?? '');
const session = await this.loginTenantGoogle(slug, identity, ip); const session = await this.loginTenantGoogle(slug, identity, ip);
return { session, redirect: `${this.webOrigin()}${safeNext(state.next, `/${slug}`)}` }; return { session, redirect: `${this.webOrigin()}${safeNext(state.next, `/${slug}`)}` };
} }
async refresh(rawToken: string | undefined) { async refresh(rawToken: string | undefined) {
if (!rawToken) { if (!rawToken) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
const hashed = hashToken(rawToken); const hashed = hashToken(rawToken);
const platformToken = await this.platform.platformRefreshToken.findFirst({ const platformToken = await this.platform.platformRefreshToken.findFirst({
where: { hashed, revokedAt: null, expiresAt: { gt: new Date() } }, where: { hashed, revokedAt: null, expiresAt: { gt: new Date() } },
include: { user: true }, include: { user: true },
}); });
if (platformToken) { if (platformToken) {
await this.platform.platformRefreshToken.update({ await this.platform.platformRefreshToken.update({
where: { id: platformToken.id }, where: { id: platformToken.id },
data: { revokedAt: new Date() }, data: { revokedAt: new Date() },
}); });
return this.issuePlatformSession(platformToken.user.id, platformToken.user.email, platformToken.user.name); return this.issuePlatformSession(platformToken.user.id, platformToken.user.email, platformToken.user.name);
} }
const tenants = await this.platform.tenant.findMany({ where: { status: 'ACTIVE' } }); const tenants = await this.platform.tenant.findMany({ where: { status: 'ACTIVE' } });
for (const tenant of tenants) { for (const tenant of tenants) {
const db = this.tenants.getByDatabase(tenant.database); const db = this.tenants.getByDatabase(tenant.database);
const token = await db.refreshToken.findFirst({ const token = await db.refreshToken.findFirst({
where: { hashed, revokedAt: null, expiresAt: { gt: new Date() } }, where: { hashed, revokedAt: null, expiresAt: { gt: new Date() } },
include: { user: true }, include: { user: true },
}); });
if (!token) { if (!token) {
continue; continue;
} }
await db.refreshToken.update({ await db.refreshToken.update({
where: { id: token.id }, where: { id: token.id },
data: { revokedAt: new Date() }, data: { revokedAt: new Date() },
}); });
return this.issueTenantSession(tenant.id, tenant.slug, token.user.id, token.user.email, token.user.name, token.user.role); return this.issueTenantSession(tenant.id, tenant.slug, token.user.id, token.user.email, token.user.name, token.user.role);
} }
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
async logout(rawToken: string | undefined, user: AuthUser) { async logout(rawToken: string | undefined, user: AuthUser) {
if (!rawToken) { if (!rawToken) {
return; return;
} }
const hashed = hashToken(rawToken); const hashed = hashToken(rawToken);
if (user.kind === 'platform') { if (user.kind === 'platform') {
await this.platform.platformRefreshToken.updateMany({ await this.platform.platformRefreshToken.updateMany({
where: { hashed, revokedAt: null }, where: { hashed, revokedAt: null },
data: { revokedAt: new Date() }, data: { revokedAt: new Date() },
}); });
return; return;
} }
if (!user.tenantId) { if (!user.tenantId) {
return; return;
} }
const db = await this.tenants.getByTenantId(user.tenantId); const db = await this.tenants.getByTenantId(user.tenantId);
await db.refreshToken.updateMany({ await db.refreshToken.updateMany({
where: { hashed, revokedAt: null }, where: { hashed, revokedAt: null },
data: { revokedAt: new Date() }, data: { revokedAt: new Date() },
}); });
} }
async profile(user: AuthUser) { async profile(user: AuthUser) {
let tenantName: string | null = null; let tenantName: string | null = null;
if (user.kind === 'tenant' && user.tenantId) { if (user.kind === 'tenant' && user.tenantId) {
const tenant = await this.platform.tenant.findUnique({ where: { id: user.tenantId } }); const tenant = await this.platform.tenant.findUnique({ where: { id: user.tenantId } });
tenantName = tenant?.name ?? null; tenantName = tenant?.name ?? null;
} }
return { return {
id: user.sub, id: user.sub,
name: user.name, name: user.name,
email: user.email, email: user.email,
role: user.role, role: user.role,
roleLabel: ROLE_LABEL[user.role], roleLabel: ROLE_LABEL[user.role],
kind: user.kind, kind: user.kind,
tenantId: user.tenantId ?? null, tenantId: user.tenantId ?? null,
tenantSlug: user.tenantSlug ?? null, tenantSlug: user.tenantSlug ?? null,
tenantName, tenantName,
permissions: permissionsFor(user.role), permissions: permissionsFor(user.role),
}; };
} }
private async loginPlatform(email: string, password: string, ip?: string) { private async loginPlatform(email: string, password: string, ip?: string) {
const user = await this.platform.platformUser.findUnique({ where: { email } }); const user = await this.platform.platformUser.findUnique({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) { if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
throw new UnauthorizedException('Credenciais inválidas.'); throw new UnauthorizedException('Credenciais inválidas.');
} }
await this.platform.platformUser.update({ await this.platform.platformUser.update({
where: { id: user.id }, where: { id: user.id },
data: { lastLoginAt: new Date() }, data: { lastLoginAt: new Date() },
}); });
await this.platform.platformAuditLog.create({ await this.platform.platformAuditLog.create({
data: { userId: user.id, action: 'login', ip, entity: 'PlatformUser', entityId: user.id }, data: { userId: user.id, action: 'login', ip, entity: 'PlatformUser', entityId: user.id },
}); });
return this.issuePlatformSession(user.id, user.email, user.name); return this.issuePlatformSession(user.id, user.email, user.name);
} }
private async loginTenant(slug: string, email: string, password: string, ip?: string) { private async loginTenant(slug: string, email: string, password: string, ip?: string) {
const tenant = await this.platform.tenant.findUnique({ where: { slug } }); const tenant = await this.platform.tenant.findUnique({ where: { slug } });
if (!tenant) { if (!tenant) {
throw new NotFoundException('Este cliente não existe.'); throw new NotFoundException('Este cliente não existe.');
} }
if (tenant.status !== 'ACTIVE') { if (tenant.status !== 'ACTIVE') {
throw new ForbiddenException('Empresa suspensa.'); throw new ForbiddenException('Empresa suspensa.');
} }
const db = this.tenants.getByDatabase(tenant.database); const db = this.tenants.getByDatabase(tenant.database);
const user = await db.user.findUnique({ where: { email } }); const user = await db.user.findUnique({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) { if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
throw new UnauthorizedException('Credenciais inválidas.'); throw new UnauthorizedException('Credenciais inválidas.');
} }
await db.user.update({ await db.user.update({
where: { id: user.id }, where: { id: user.id },
data: { lastLoginAt: new Date() }, data: { lastLoginAt: new Date() },
}); });
await db.auditLog.create({ await db.auditLog.create({
data: { userId: user.id, action: 'login', ip, entity: 'User', entityId: user.id }, data: { userId: user.id, action: 'login', ip, entity: 'User', entityId: user.id },
}); });
return this.issueTenantSession(tenant.id, tenant.slug, user.id, user.email, user.name, user.role); return this.issueTenantSession(tenant.id, tenant.slug, user.id, user.email, user.name, user.role);
} }
private async loginPlatformGoogle(identity: GoogleIdentity, ip?: string) { private async loginPlatformGoogle(identity: GoogleIdentity, ip?: string) {
const user = const user =
(await this.platform.platformUser.findUnique({ where: { googleId: identity.googleId } })) ?? (await this.platform.platformUser.findUnique({ where: { googleId: identity.googleId } })) ??
(await this.platform.platformUser.findUnique({ where: { email: identity.email } })); (await this.platform.platformUser.findUnique({ where: { email: identity.email } }));
if (!user) { if (!user) {
throw new UnauthorizedException('Este e-mail Google não tem acesso à plataforma.'); throw new UnauthorizedException('Este e-mail Google não tem acesso à plataforma.');
} }
if (!user.googleId) { if (!user.googleId) {
await this.platform.platformUser.update({ await this.platform.platformUser.update({
where: { id: user.id }, where: { id: user.id },
data: { googleId: identity.googleId, lastLoginAt: new Date() }, data: { googleId: identity.googleId, lastLoginAt: new Date() },
}); });
} else { } else {
await this.platform.platformUser.update({ await this.platform.platformUser.update({
where: { id: user.id }, where: { id: user.id },
data: { lastLoginAt: new Date() }, data: { lastLoginAt: new Date() },
}); });
} }
await this.platform.platformAuditLog.create({ await this.platform.platformAuditLog.create({
data: { userId: user.id, action: 'login.google', ip, entity: 'PlatformUser', entityId: user.id }, data: { userId: user.id, action: 'login.google', ip, entity: 'PlatformUser', entityId: user.id },
}); });
return this.issuePlatformSession(user.id, user.email, user.name); return this.issuePlatformSession(user.id, user.email, user.name);
} }
private async loginTenantGoogle(slug: string, identity: GoogleIdentity, ip?: string) { private async loginTenantGoogle(slug: string, identity: GoogleIdentity, ip?: string) {
const tenant = await this.platform.tenant.findUnique({ where: { slug } }); const tenant = await this.platform.tenant.findUnique({ where: { slug } });
if (!tenant) { if (!tenant) {
throw new NotFoundException('Este cliente não existe.'); throw new NotFoundException('Este cliente não existe.');
} }
if (tenant.status !== 'ACTIVE') { if (tenant.status !== 'ACTIVE') {
throw new ForbiddenException('Empresa suspensa.'); throw new ForbiddenException('Empresa suspensa.');
} }
const db = this.tenants.getByDatabase(tenant.database); const db = this.tenants.getByDatabase(tenant.database);
let user = let user =
(await db.user.findUnique({ where: { googleId: identity.googleId } })) ?? (await db.user.findUnique({ where: { googleId: identity.googleId } })) ??
(await db.user.findUnique({ where: { email: identity.email } })); (await db.user.findUnique({ where: { email: identity.email } }));
if (!user) { if (!user) {
const issuedPassword = `Sinka-${randomBytes(3).toString('hex')}`; const issuedPassword = `Sinka-${randomBytes(3).toString('hex')}`;
user = await db.user.create({ user = await db.user.create({
data: { data: {
name: identity.name, name: identity.name,
email: identity.email, email: identity.email,
googleId: identity.googleId, googleId: identity.googleId,
passwordHash: await bcrypt.hash(issuedPassword, 10), passwordHash: await bcrypt.hash(issuedPassword, 10),
issuedPassword, issuedPassword,
role: 'OPERATOR', role: 'OPERATOR',
lastLoginAt: new Date(), lastLoginAt: new Date(),
}, },
}); });
await db.auditLog.create({ await db.auditLog.create({
data: { data: {
userId: user.id, userId: user.id,
action: 'user.create', action: 'user.create',
ip, ip,
entity: 'User', entity: 'User',
entityId: user.id, entityId: user.id,
metadata: { via: 'google' }, metadata: { via: 'google' },
}, },
}); });
} else { } else {
await db.user.update({ await db.user.update({
where: { id: user.id }, where: { id: user.id },
data: { googleId: user.googleId ?? identity.googleId, lastLoginAt: new Date() }, data: { googleId: user.googleId ?? identity.googleId, lastLoginAt: new Date() },
}); });
} }
await db.auditLog.create({ await db.auditLog.create({
data: { userId: user.id, action: 'login.google', ip, entity: 'User', entityId: user.id }, data: { userId: user.id, action: 'login.google', ip, entity: 'User', entityId: user.id },
}); });
return this.issueTenantSession(tenant.id, tenant.slug, user.id, user.email, user.name, user.role); return this.issueTenantSession(tenant.id, tenant.slug, user.id, user.email, user.name, user.role);
} }
private async issuePlatformSession(id: string, email: string, name: string) { private async issuePlatformSession(id: string, email: string, name: string) {
const payload: JwtPayload = { const payload: JwtPayload = {
sub: id, sub: id,
email, email,
name, name,
role: 'PLATFORM_ADMIN', role: 'PLATFORM_ADMIN',
}; };
const accessToken = await this.jwt.signAsync(payload); const accessToken = await this.jwt.signAsync(payload);
const refreshToken = rawRefreshToken(); const refreshToken = rawRefreshToken();
await this.platform.platformRefreshToken.create({ await this.platform.platformRefreshToken.create({
data: { data: {
userId: id, userId: id,
hashed: hashToken(refreshToken), hashed: hashToken(refreshToken),
expiresAt: refreshExpiry(), expiresAt: refreshExpiry(),
}, },
}); });
return { accessToken, refreshToken, user: await this.profile({ ...payload, kind: 'platform' }) }; return { accessToken, refreshToken, user: await this.profile({ ...payload, kind: 'platform' }) };
} }
private async issueTenantSession( private async issueTenantSession(
tenantId: string, tenantId: string,
tenantSlug: string, tenantSlug: string,
id: string, id: string,
email: string, email: string,
name: string, name: string,
role: JwtPayload['role'], role: JwtPayload['role'],
) { ) {
const payload: JwtPayload = { const payload: JwtPayload = {
sub: id, sub: id,
email, email,
name, name,
role, role,
tenantId, tenantId,
tenantSlug, tenantSlug,
}; };
const accessToken = await this.jwt.signAsync(payload); const accessToken = await this.jwt.signAsync(payload);
const refreshToken = rawRefreshToken(); const refreshToken = rawRefreshToken();
const db = await this.tenants.getByTenantId(tenantId); const db = await this.tenants.getByTenantId(tenantId);
await db.refreshToken.create({ await db.refreshToken.create({
data: { data: {
userId: id, userId: id,
hashed: hashToken(refreshToken), hashed: hashToken(refreshToken),
expiresAt: refreshExpiry(), expiresAt: refreshExpiry(),
}, },
}); });
return { accessToken, refreshToken, user: await this.profile({ ...payload, kind: 'tenant' }) }; return { accessToken, refreshToken, user: await this.profile({ ...payload, kind: 'tenant' }) };
} }
private async assertRateLimit(email: string, ip?: string) { private async assertRateLimit(email: string, ip?: string) {
const key = `login:${ip ?? 'unknown'}:${email.toLowerCase()}`; const key = `login:${ip ?? 'unknown'}:${email.toLowerCase()}`;
const count = await this.redis.increment(key, LOGIN_WINDOW_SECONDS); const count = await this.redis.increment(key, LOGIN_WINDOW_SECONDS);
if (count !== null && count > LOGIN_MAX_ATTEMPTS) { if (count !== null && count > LOGIN_MAX_ATTEMPTS) {
throw new HttpException('Muitas tentativas. Aguarde alguns minutos.', HttpStatus.TOO_MANY_REQUESTS); throw new HttpException('Muitas tentativas. Aguarde alguns minutos.', HttpStatus.TOO_MANY_REQUESTS);
} }
} }
} }
function rawRefreshToken(): string { function rawRefreshToken(): string {
return randomBytes(48).toString('hex'); return randomBytes(48).toString('hex');
} }
function hashToken(token: string): string { function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex'); return createHash('sha256').update(token).digest('hex');
} }
function refreshExpiry(): Date { function refreshExpiry(): Date {
return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
} }

View File

@ -1,14 +1,14 @@
import type { AuthRole } from './roles'; import type { AuthRole } from './roles';
export type JwtPayload = { export type JwtPayload = {
sub: string; sub: string;
email: string; email: string;
name: string; name: string;
role: AuthRole; role: AuthRole;
tenantId?: string; tenantId?: string;
tenantSlug?: string; tenantSlug?: string;
}; };
export type AuthUser = JwtPayload & { export type AuthUser = JwtPayload & {
kind: 'platform' | 'tenant'; kind: 'platform' | 'tenant';
}; };

View File

@ -1,30 +1,30 @@
import { clearAuthCookies, readRefreshCookie, setAuthCookies } from './cookies'; import { clearAuthCookies, readRefreshCookie, setAuthCookies } from './cookies';
describe('auth cookies', () => { describe('auth cookies', () => {
it('sets httpOnly access and refresh cookies', () => { it('sets httpOnly access and refresh cookies', () => {
const cookie = jest.fn(); const cookie = jest.fn();
setAuthCookies({ cookie } as never, 'access', 'refresh'); setAuthCookies({ cookie } as never, 'access', 'refresh');
expect(cookie).toHaveBeenCalledWith( expect(cookie).toHaveBeenCalledWith(
'sinka_access', 'sinka_access',
'access', 'access',
expect.objectContaining({ httpOnly: true, sameSite: 'lax', path: '/' }), expect.objectContaining({ httpOnly: true, sameSite: 'lax', path: '/' }),
); );
expect(cookie).toHaveBeenCalledWith( expect(cookie).toHaveBeenCalledWith(
'sinka_refresh', 'sinka_refresh',
'refresh', 'refresh',
expect.objectContaining({ httpOnly: true }), expect.objectContaining({ httpOnly: true }),
); );
}); });
it('reads only the refresh cookie', () => { it('reads only the refresh cookie', () => {
expect(readRefreshCookie({ sinka_refresh: 'abc', other: 'x' })).toBe('abc'); expect(readRefreshCookie({ sinka_refresh: 'abc', other: 'x' })).toBe('abc');
expect(readRefreshCookie(undefined)).toBeUndefined(); expect(readRefreshCookie(undefined)).toBeUndefined();
}); });
it('clears both cookies on logout', () => { it('clears both cookies on logout', () => {
const clearCookie = jest.fn(); const clearCookie = jest.fn();
clearAuthCookies({ clearCookie } as never); clearAuthCookies({ clearCookie } as never);
expect(clearCookie).toHaveBeenCalledWith('sinka_access', expect.objectContaining({ httpOnly: true })); expect(clearCookie).toHaveBeenCalledWith('sinka_access', expect.objectContaining({ httpOnly: true }));
expect(clearCookie).toHaveBeenCalledWith('sinka_refresh', expect.objectContaining({ httpOnly: true })); expect(clearCookie).toHaveBeenCalledWith('sinka_refresh', expect.objectContaining({ httpOnly: true }));
}); });
}); });

View File

@ -1,29 +1,29 @@
import type { Response } from 'express'; import type { Response } from 'express';
const accessCookie = 'sinka_access'; const accessCookie = 'sinka_access';
const refreshCookie = 'sinka_refresh'; const refreshCookie = 'sinka_refresh';
function cookieBase() { function cookieBase() {
return { return {
httpOnly: true, httpOnly: true,
sameSite: 'lax' as const, sameSite: 'lax' as const,
secure: secure:
process.env.COOKIE_SECURE === 'true' || process.env.COOKIE_SECURE === 'true' ||
(process.env.NODE_ENV === 'production' && process.env.COOKIE_SECURE !== 'false'), (process.env.NODE_ENV === 'production' && process.env.COOKIE_SECURE !== 'false'),
path: '/', path: '/',
}; };
} }
export function setAuthCookies(res: Response, accessToken: string, refreshToken: string): void { export function setAuthCookies(res: Response, accessToken: string, refreshToken: string): void {
res.cookie(accessCookie, accessToken, { ...cookieBase(), maxAge: 15 * 60 * 1000 }); res.cookie(accessCookie, accessToken, { ...cookieBase(), maxAge: 15 * 60 * 1000 });
res.cookie(refreshCookie, refreshToken, { ...cookieBase(), maxAge: 7 * 24 * 60 * 60 * 1000 }); res.cookie(refreshCookie, refreshToken, { ...cookieBase(), maxAge: 7 * 24 * 60 * 60 * 1000 });
} }
export function clearAuthCookies(res: Response): void { export function clearAuthCookies(res: Response): void {
res.clearCookie(accessCookie, cookieBase()); res.clearCookie(accessCookie, cookieBase());
res.clearCookie(refreshCookie, cookieBase()); res.clearCookie(refreshCookie, cookieBase());
} }
export function readRefreshCookie(cookies: Record<string, string> | undefined): string | undefined { export function readRefreshCookie(cookies: Record<string, string> | undefined): string | undefined {
return cookies?.[refreshCookie]; return cookies?.[refreshCookie];
} }

View File

@ -1,6 +1,6 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { AuthUser } from '../auth.types'; import type { AuthUser } from '../auth.types';
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext): AuthUser => { export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext): AuthUser => {
return ctx.switchToHttp().getRequest<{ user: AuthUser }>().user; return ctx.switchToHttp().getRequest<{ user: AuthUser }>().user;
}); });

View File

@ -1,5 +1,5 @@
import { SetMetadata } from '@nestjs/common'; import { SetMetadata } from '@nestjs/common';
import type { AuthRole } from '../roles'; import type { AuthRole } from '../roles';
export const ROLES_KEY = 'roles'; export const ROLES_KEY = 'roles';
export const Roles = (...roles: AuthRole[]) => SetMetadata(ROLES_KEY, roles); export const Roles = (...roles: AuthRole[]) => SetMetadata(ROLES_KEY, roles);

View File

@ -1,14 +1,14 @@
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
export class LoginDto { export class LoginDto {
@IsEmail() @IsEmail()
email!: string; email!: string;
@IsString() @IsString()
@MinLength(8) @MinLength(8)
password!: string; password!: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
tenantSlug?: string; tenantSlug?: string;
} }

View File

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {} export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@ -1,37 +1,37 @@
import { ExecutionContext } from '@nestjs/common'; import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { RolesGuard } from './roles.guard'; import { RolesGuard } from './roles.guard';
function contextWith(user: unknown): ExecutionContext { function contextWith(user: unknown): ExecutionContext {
return { return {
getHandler: () => ({}), getHandler: () => ({}),
getClass: () => ({}), getClass: () => ({}),
switchToHttp: () => ({ switchToHttp: () => ({
getRequest: () => ({ user }), getRequest: () => ({ user }),
}), }),
} as ExecutionContext; } as ExecutionContext;
} }
describe('RolesGuard', () => { describe('RolesGuard', () => {
const reflector = { getAllAndOverride: jest.fn() }; const reflector = { getAllAndOverride: jest.fn() };
const guard = new RolesGuard(reflector as unknown as Reflector); const guard = new RolesGuard(reflector as unknown as Reflector);
beforeEach(() => { beforeEach(() => {
reflector.getAllAndOverride.mockReset(); reflector.getAllAndOverride.mockReset();
}); });
it('allows when the route has no role list', () => { it('allows when the route has no role list', () => {
reflector.getAllAndOverride.mockReturnValue(undefined); reflector.getAllAndOverride.mockReturnValue(undefined);
expect(guard.canActivate(contextWith(undefined))).toBe(true); expect(guard.canActivate(contextWith(undefined))).toBe(true);
}); });
it('allows a matching role', () => { it('allows a matching role', () => {
reflector.getAllAndOverride.mockReturnValue(['ADMIN', 'MANAGER']); reflector.getAllAndOverride.mockReturnValue(['ADMIN', 'MANAGER']);
expect(guard.canActivate(contextWith({ role: 'MANAGER' }))).toBe(true); expect(guard.canActivate(contextWith({ role: 'MANAGER' }))).toBe(true);
}); });
it('blocks an operator on an admin route', () => { it('blocks an operator on an admin route', () => {
reflector.getAllAndOverride.mockReturnValue(['ADMIN']); reflector.getAllAndOverride.mockReturnValue(['ADMIN']);
expect(guard.canActivate(contextWith({ role: 'OPERATOR' }))).toBe(false); expect(guard.canActivate(contextWith({ role: 'OPERATOR' }))).toBe(false);
}); });
}); });

View File

@ -1,22 +1,22 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator'; import { ROLES_KEY } from '../decorators/roles.decorator';
import type { AuthRole } from '../roles'; import type { AuthRole } from '../roles';
import type { AuthUser } from '../auth.types'; import type { AuthUser } from '../auth.types';
@Injectable() @Injectable()
export class RolesGuard implements CanActivate { export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {} constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.getAllAndOverride<AuthRole[]>(ROLES_KEY, [ const roles = this.reflector.getAllAndOverride<AuthRole[]>(ROLES_KEY, [
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),
]); ]);
if (!roles?.length) { if (!roles?.length) {
return true; return true;
} }
const user = context.switchToHttp().getRequest<{ user?: AuthUser }>().user; const user = context.switchToHttp().getRequest<{ user?: AuthUser }>().user;
return Boolean(user && roles.includes(user.role)); return Boolean(user && roles.includes(user.role));
} }
} }

View File

@ -1,21 +1,21 @@
import { readOAuthState, safeNext, signOAuthState } from './oauth-state'; import { readOAuthState, safeNext, signOAuthState } from './oauth-state';
describe('oauth state', () => { describe('oauth state', () => {
const secret = 'test-secret'; const secret = 'test-secret';
it('round-trips a signed tenant state', () => { it('round-trips a signed tenant state', () => {
const token = signOAuthState({ kind: 'tenant', slug: 'demo', next: '/demo' }, secret); const token = signOAuthState({ kind: 'tenant', slug: 'demo', next: '/demo' }, secret);
expect(readOAuthState(token, secret)).toEqual({ kind: 'tenant', slug: 'demo', next: '/demo' }); expect(readOAuthState(token, secret)).toEqual({ kind: 'tenant', slug: 'demo', next: '/demo' });
}); });
it('rejects a tampered state', () => { it('rejects a tampered state', () => {
const token = signOAuthState({ kind: 'platform' }, secret); const token = signOAuthState({ kind: 'platform' }, secret);
expect(() => readOAuthState(`${token}x`, secret)).toThrow(); expect(() => readOAuthState(`${token}x`, secret)).toThrow();
}); });
it('blocks open redirects', () => { it('blocks open redirects', () => {
expect(safeNext('https://evil.test', '/admin')).toBe('/admin'); expect(safeNext('https://evil.test', '/admin')).toBe('/admin');
expect(safeNext('//evil.test', '/admin')).toBe('/admin'); expect(safeNext('//evil.test', '/admin')).toBe('/admin');
expect(safeNext('/admin/clientes', '/admin')).toBe('/admin/clientes'); expect(safeNext('/admin/clientes', '/admin')).toBe('/admin/clientes');
}); });
}); });

View File

@ -1,41 +1,41 @@
import { createHmac, timingSafeEqual } from 'node:crypto'; import { createHmac, timingSafeEqual } from 'node:crypto';
export type OAuthState = { export type OAuthState = {
kind: 'platform' | 'tenant'; kind: 'platform' | 'tenant';
slug?: string; slug?: string;
next?: string; next?: string;
}; };
export function signOAuthState(payload: OAuthState, secret: string): string { export function signOAuthState(payload: OAuthState, secret: string): string {
const body = Buffer.from(JSON.stringify({ ...payload, exp: Date.now() + 10 * 60 * 1000 })).toString('base64url'); const body = Buffer.from(JSON.stringify({ ...payload, exp: Date.now() + 10 * 60 * 1000 })).toString('base64url');
const sig = createHmac('sha256', secret).update(body).digest('base64url'); const sig = createHmac('sha256', secret).update(body).digest('base64url');
return `${body}.${sig}`; return `${body}.${sig}`;
} }
export function readOAuthState(raw: string | undefined, secret: string): OAuthState { export function readOAuthState(raw: string | undefined, secret: string): OAuthState {
if (!raw || !raw.includes('.')) { if (!raw || !raw.includes('.')) {
throw new Error('state'); throw new Error('state');
} }
const [body, sig] = raw.split('.'); const [body, sig] = raw.split('.');
const expected = createHmac('sha256', secret).update(body).digest('base64url'); const expected = createHmac('sha256', secret).update(body).digest('base64url');
const a = Buffer.from(sig); const a = Buffer.from(sig);
const b = Buffer.from(expected); const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) { if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new Error('state'); throw new Error('state');
} }
const parsed = JSON.parse(Buffer.from(body, 'base64url').toString()) as OAuthState & { exp?: number }; const parsed = JSON.parse(Buffer.from(body, 'base64url').toString()) as OAuthState & { exp?: number };
if (!parsed.exp || parsed.exp < Date.now()) { if (!parsed.exp || parsed.exp < Date.now()) {
throw new Error('state'); throw new Error('state');
} }
if (parsed.kind !== 'platform' && parsed.kind !== 'tenant') { if (parsed.kind !== 'platform' && parsed.kind !== 'tenant') {
throw new Error('state'); throw new Error('state');
} }
return { kind: parsed.kind, slug: parsed.slug, next: parsed.next }; return { kind: parsed.kind, slug: parsed.slug, next: parsed.next };
} }
export function safeNext(raw: string | undefined, fallback: string): string { export function safeNext(raw: string | undefined, fallback: string): string {
if (!raw || !raw.startsWith('/') || raw.startsWith('//') || raw.includes('://')) { if (!raw || !raw.startsWith('/') || raw.startsWith('//') || raw.includes('://')) {
return fallback; return fallback;
} }
return raw; return raw;
} }

View File

@ -1,23 +1,23 @@
import { canManageUsers, permissionsFor, ROLE_LABEL } from './roles'; import { canManageUsers, permissionsFor, ROLE_LABEL } from './roles';
describe('roles', () => { describe('roles', () => {
it('isolates platform admin from company operations', () => { it('isolates platform admin from company operations', () => {
expect(permissionsFor('PLATFORM_ADMIN')).toEqual(['tenants:manage', 'audit:read']); expect(permissionsFor('PLATFORM_ADMIN')).toEqual(['tenants:manage', 'audit:read']);
expect(permissionsFor('ADMIN')).toContain('users:manage'); expect(permissionsFor('ADMIN')).toContain('users:manage');
expect(permissionsFor('MANAGER')).not.toContain('users:manage'); expect(permissionsFor('MANAGER')).not.toContain('users:manage');
expect(permissionsFor('OPERATOR')).toEqual(['customers:read', 'simulations:write']); expect(permissionsFor('OPERATOR')).toEqual(['customers:read', 'simulations:write']);
}); });
it('labels the three company access levels', () => { it('labels the three company access levels', () => {
expect(ROLE_LABEL.ADMIN).toBe('Administrador'); expect(ROLE_LABEL.ADMIN).toBe('Administrador');
expect(ROLE_LABEL.MANAGER).toBe('Manager'); expect(ROLE_LABEL.MANAGER).toBe('Manager');
expect(ROLE_LABEL.OPERATOR).toBe('Operador'); expect(ROLE_LABEL.OPERATOR).toBe('Operador');
}); });
it('lets only the company admin manage users', () => { it('lets only the company admin manage users', () => {
expect(canManageUsers('ADMIN')).toBe(true); expect(canManageUsers('ADMIN')).toBe(true);
expect(canManageUsers('MANAGER')).toBe(false); expect(canManageUsers('MANAGER')).toBe(false);
expect(canManageUsers('OPERATOR')).toBe(false); expect(canManageUsers('OPERATOR')).toBe(false);
expect(canManageUsers('PLATFORM_ADMIN')).toBe(false); expect(canManageUsers('PLATFORM_ADMIN')).toBe(false);
}); });
}); });

View File

@ -1,34 +1,34 @@
export const TENANT_ROLES = ['ADMIN', 'MANAGER', 'OPERATOR'] as const; export const TENANT_ROLES = ['ADMIN', 'MANAGER', 'OPERATOR'] as const;
export type TenantRole = (typeof TENANT_ROLES)[number]; export type TenantRole = (typeof TENANT_ROLES)[number];
export type AuthRole = TenantRole | 'PLATFORM_ADMIN'; export type AuthRole = TenantRole | 'PLATFORM_ADMIN';
export const ROLE_LABEL: Record<AuthRole, string> = { export const ROLE_LABEL: Record<AuthRole, string> = {
PLATFORM_ADMIN: 'Administrador da plataforma', PLATFORM_ADMIN: 'Administrador da plataforma',
ADMIN: 'Administrador', ADMIN: 'Administrador',
MANAGER: 'Manager', MANAGER: 'Manager',
OPERATOR: 'Operador', OPERATOR: 'Operador',
}; };
export function permissionsFor(role: AuthRole): string[] { export function permissionsFor(role: AuthRole): string[] {
if (role === 'PLATFORM_ADMIN') { if (role === 'PLATFORM_ADMIN') {
return ['tenants:manage', 'audit:read']; return ['tenants:manage', 'audit:read'];
} }
if (role === 'ADMIN') { if (role === 'ADMIN') {
return [ return [
'users:manage', 'users:manage',
'customers:write', 'customers:write',
'carriers:write', 'carriers:write',
'simulations:write', 'simulations:write',
'imports:write', 'imports:write',
'audit:read', 'audit:read',
]; ];
} }
if (role === 'MANAGER') { if (role === 'MANAGER') {
return ['customers:write', 'carriers:write', 'simulations:write', 'imports:write', 'audit:read']; return ['customers:write', 'carriers:write', 'simulations:write', 'imports:write', 'audit:read'];
} }
return ['customers:read', 'simulations:write']; return ['customers:read', 'simulations:write'];
} }
export function canManageUsers(role: AuthRole): boolean { export function canManageUsers(role: AuthRole): boolean {
return role === 'ADMIN'; return role === 'ADMIN';
} }

View File

@ -1,34 +1,34 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Strategy, type Profile } from 'passport-google-oauth20'; import { Strategy, type Profile } from 'passport-google-oauth20';
export type GoogleIdentity = { export type GoogleIdentity = {
googleId: string; googleId: string;
email: string; email: string;
name: string; name: string;
}; };
@Injectable() @Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(config: ConfigService) { constructor(config: ConfigService) {
super({ super({
clientID: config.get<string>('GOOGLE_CLIENT_ID') || 'not-configured', clientID: config.get<string>('GOOGLE_CLIENT_ID') || 'not-configured',
clientSecret: config.get<string>('GOOGLE_CLIENT_SECRET') || 'not-configured', clientSecret: config.get<string>('GOOGLE_CLIENT_SECRET') || 'not-configured',
callbackURL: config.get<string>('GOOGLE_CALLBACK_URL') || 'http://localhost:3000/api/auth/google/callback', callbackURL: config.get<string>('GOOGLE_CALLBACK_URL') || 'http://localhost:3000/api/auth/google/callback',
scope: ['email', 'profile'], scope: ['email', 'profile'],
}); });
} }
validate(_accessToken: string, _refreshToken: string, profile: Profile): GoogleIdentity { validate(_accessToken: string, _refreshToken: string, profile: Profile): GoogleIdentity {
const email = profile.emails?.[0]?.value?.toLowerCase(); const email = profile.emails?.[0]?.value?.toLowerCase();
if (!email || !profile.id) { if (!email || !profile.id) {
throw new UnauthorizedException('Conta Google sem e-mail.'); throw new UnauthorizedException('Conta Google sem e-mail.');
} }
return { return {
googleId: profile.id, googleId: profile.id,
email, email,
name: profile.displayName?.trim() || email, name: profile.displayName?.trim() || email,
}; };
} }
} }

View File

@ -1,54 +1,54 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import type { Request } from 'express'; import type { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import type { JwtPayload } from '../auth.types'; import type { JwtPayload } from '../auth.types';
import type { AuthUser } from '../auth.types'; import type { AuthUser } from '../auth.types';
import { PlatformPrismaService } from '../../prisma/platform-prisma.service'; import { PlatformPrismaService } from '../../prisma/platform-prisma.service';
import { TenantConnectionService } from '../../tenancy/tenant-connection.service'; import { TenantConnectionService } from '../../tenancy/tenant-connection.service';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
constructor( constructor(
config: ConfigService, config: ConfigService,
private readonly platform: PlatformPrismaService, private readonly platform: PlatformPrismaService,
private readonly tenants: TenantConnectionService, private readonly tenants: TenantConnectionService,
) { ) {
super({ super({
jwtFromRequest: ExtractJwt.fromExtractors([ jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => request?.cookies?.sinka_access ?? null, (request: Request) => request?.cookies?.sinka_access ?? null,
ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromAuthHeaderAsBearerToken(),
]), ]),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('JWT_SECRET'), secretOrKey: config.getOrThrow<string>('JWT_SECRET'),
}); });
} }
async validate(payload: JwtPayload): Promise<AuthUser> { async validate(payload: JwtPayload): Promise<AuthUser> {
if (payload.role === 'PLATFORM_ADMIN') { if (payload.role === 'PLATFORM_ADMIN') {
const user = await this.platform.platformUser.findUnique({ where: { id: payload.sub } }); const user = await this.platform.platformUser.findUnique({ where: { id: payload.sub } });
if (!user) { if (!user) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
return { ...payload, kind: 'platform' }; return { ...payload, kind: 'platform' };
} }
if (!payload.tenantId) { if (!payload.tenantId) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
const tenant = await this.platform.tenant.findUnique({ where: { id: payload.tenantId } }); const tenant = await this.platform.tenant.findUnique({ where: { id: payload.tenantId } });
if (!tenant || tenant.status !== 'ACTIVE') { if (!tenant || tenant.status !== 'ACTIVE') {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
const db = await this.tenants.getByTenantId(tenant.id); const db = await this.tenants.getByTenantId(tenant.id);
const user = await db.user.findUnique({ where: { id: payload.sub } }); const user = await db.user.findUnique({ where: { id: payload.sub } });
if (!user) { if (!user) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
return { ...payload, tenantSlug: tenant.slug, kind: 'tenant' }; return { ...payload, tenantSlug: tenant.slug, kind: 'tenant' };
} }
} }

View File

@ -1,44 +1,44 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express'; import type { Request } from 'express';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { CarriersService } from './carriers.service'; import { CarriersService } from './carriers.service';
import { UpsertCarrierDto } from './dto/upsert-carrier.dto'; import { UpsertCarrierDto } from './dto/upsert-carrier.dto';
@Controller('carriers') @Controller('carriers')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
export class CarriersController { export class CarriersController {
constructor(private readonly carriers: CarriersService) {} constructor(private readonly carriers: CarriersService) {}
@Get() @Get()
@Roles('ADMIN', 'MANAGER', 'OPERATOR') @Roles('ADMIN', 'MANAGER', 'OPERATOR')
list(@CurrentUser() user: AuthUser) { list(@CurrentUser() user: AuthUser) {
return this.carriers.list(user); return this.carriers.list(user);
} }
@Post() @Post()
@Roles('ADMIN', 'MANAGER') @Roles('ADMIN', 'MANAGER')
create(@CurrentUser() user: AuthUser, @Body() dto: UpsertCarrierDto, @Req() request: Request) { create(@CurrentUser() user: AuthUser, @Body() dto: UpsertCarrierDto, @Req() request: Request) {
return this.carriers.create(user, dto, request.ip); return this.carriers.create(user, dto, request.ip);
} }
@Patch(':id') @Patch(':id')
@Roles('ADMIN', 'MANAGER') @Roles('ADMIN', 'MANAGER')
update( update(
@CurrentUser() user: AuthUser, @CurrentUser() user: AuthUser,
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpsertCarrierDto, @Body() dto: UpsertCarrierDto,
@Req() request: Request, @Req() request: Request,
) { ) {
return this.carriers.update(user, id, dto, request.ip); return this.carriers.update(user, id, dto, request.ip);
} }
@Delete(':id') @Delete(':id')
@Roles('ADMIN', 'MANAGER') @Roles('ADMIN', 'MANAGER')
remove(@CurrentUser() user: AuthUser, @Param('id') id: string, @Req() request: Request) { remove(@CurrentUser() user: AuthUser, @Param('id') id: string, @Req() request: Request) {
return this.carriers.remove(user, id, request.ip); return this.carriers.remove(user, id, request.ip);
} }
} }

View File

@ -1,9 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CarriersController } from './carriers.controller'; import { CarriersController } from './carriers.controller';
import { CarriersService } from './carriers.service'; import { CarriersService } from './carriers.service';
@Module({ @Module({
controllers: [CarriersController], controllers: [CarriersController],
providers: [CarriersService], providers: [CarriersService],
}) })
export class CarriersModule {} export class CarriersModule {}

View File

@ -1,101 +1,101 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
import { tenantClient } from '../tenancy/actor'; import { tenantClient } from '../tenancy/actor';
import { TenantConnectionService } from '../tenancy/tenant-connection.service'; import { TenantConnectionService } from '../tenancy/tenant-connection.service';
import type { UpsertCarrierDto } from './dto/upsert-carrier.dto'; import type { UpsertCarrierDto } from './dto/upsert-carrier.dto';
@Injectable() @Injectable()
export class CarriersService { export class CarriersService {
constructor(private readonly tenants: TenantConnectionService) {} constructor(private readonly tenants: TenantConnectionService) {}
async list(actor: AuthUser) { async list(actor: AuthUser) {
const db = await tenantClient(this.tenants, actor); const db = await tenantClient(this.tenants, actor);
const rows = await db.carrier.findMany({ orderBy: { name: 'asc' } }); const rows = await db.carrier.findMany({ orderBy: { name: 'asc' } });
return rows.map(serializeCarrier); return rows.map(serializeCarrier);
} }
async create(actor: AuthUser, dto: UpsertCarrierDto, ip?: string) { async create(actor: AuthUser, dto: UpsertCarrierDto, ip?: string) {
const db = await tenantClient(this.tenants, actor); const db = await tenantClient(this.tenants, actor);
const carrier = await db.carrier.create({ const carrier = await db.carrier.create({
data: { data: {
name: dto.name, name: dto.name,
document: dto.document, document: dto.document,
phone: dto.phone, phone: dto.phone,
baseFee: dto.baseFee ?? 0, baseFee: dto.baseFee ?? 0,
pricePerKg: dto.pricePerKg ?? 0, pricePerKg: dto.pricePerKg ?? 0,
pricePerKm: dto.pricePerKm ?? 0, pricePerKm: dto.pricePerKm ?? 0,
adValoremRate: dto.adValoremRate ?? 0, adValoremRate: dto.adValoremRate ?? 0,
active: dto.active ?? true, active: dto.active ?? true,
}, },
}); });
await db.auditLog.create({ await db.auditLog.create({
data: { userId: actor.sub, action: 'carrier.create', entity: 'Carrier', entityId: carrier.id, ip }, data: { userId: actor.sub, action: 'carrier.create', entity: 'Carrier', entityId: carrier.id, ip },
}); });
return serializeCarrier(carrier); return serializeCarrier(carrier);
} }
async update(actor: AuthUser, id: string, dto: UpsertCarrierDto, ip?: string) { async update(actor: AuthUser, id: string, dto: UpsertCarrierDto, ip?: string) {
const db = await tenantClient(this.tenants, actor); const db = await tenantClient(this.tenants, actor);
await this.ensure(db, id); await this.ensure(db, id);
const carrier = await db.carrier.update({ const carrier = await db.carrier.update({
where: { id }, where: { id },
data: { data: {
name: dto.name, name: dto.name,
document: dto.document, document: dto.document,
phone: dto.phone, phone: dto.phone,
baseFee: dto.baseFee, baseFee: dto.baseFee,
pricePerKg: dto.pricePerKg, pricePerKg: dto.pricePerKg,
pricePerKm: dto.pricePerKm, pricePerKm: dto.pricePerKm,
adValoremRate: dto.adValoremRate, adValoremRate: dto.adValoremRate,
active: dto.active, active: dto.active,
}, },
}); });
await db.auditLog.create({ await db.auditLog.create({
data: { userId: actor.sub, action: 'carrier.update', entity: 'Carrier', entityId: id, ip }, data: { userId: actor.sub, action: 'carrier.update', entity: 'Carrier', entityId: id, ip },
}); });
return serializeCarrier(carrier); return serializeCarrier(carrier);
} }
async remove(actor: AuthUser, id: string, ip?: string) { async remove(actor: AuthUser, id: string, ip?: string) {
const db = await tenantClient(this.tenants, actor); const db = await tenantClient(this.tenants, actor);
await this.ensure(db, id); await this.ensure(db, id);
await db.carrier.delete({ where: { id } }); await db.carrier.delete({ where: { id } });
await db.auditLog.create({ await db.auditLog.create({
data: { userId: actor.sub, action: 'carrier.delete', entity: 'Carrier', entityId: id, ip }, data: { userId: actor.sub, action: 'carrier.delete', entity: 'Carrier', entityId: id, ip },
}); });
return { ok: true }; return { ok: true };
} }
private async ensure(db: Awaited<ReturnType<TenantConnectionService['getByTenantId']>>, id: string) { private async ensure(db: Awaited<ReturnType<TenantConnectionService['getByTenantId']>>, id: string) {
const row = await db.carrier.findUnique({ where: { id } }); const row = await db.carrier.findUnique({ where: { id } });
if (!row) { if (!row) {
throw new NotFoundException('Transportadora não encontrada.'); throw new NotFoundException('Transportadora não encontrada.');
} }
} }
} }
function serializeCarrier(row: { function serializeCarrier(row: {
id: string; id: string;
name: string; name: string;
document: string | null; document: string | null;
email: string | null; email: string | null;
phone: string | null; phone: string | null;
baseFee: { toString(): string }; baseFee: { toString(): string };
pricePerKg: { toString(): string }; pricePerKg: { toString(): string };
pricePerKm: { toString(): string }; pricePerKm: { toString(): string };
adValoremRate: { toString(): string }; adValoremRate: { toString(): string };
active: boolean; active: boolean;
}) { }) {
return { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
document: row.document, document: row.document,
email: row.email, email: row.email,
phone: row.phone, phone: row.phone,
baseFee: Number(row.baseFee), baseFee: Number(row.baseFee),
pricePerKg: Number(row.pricePerKg), pricePerKg: Number(row.pricePerKg),
pricePerKm: Number(row.pricePerKm), pricePerKm: Number(row.pricePerKm),
adValoremRate: Number(row.adValoremRate), adValoremRate: Number(row.adValoremRate),
active: row.active, active: row.active,
}; };
} }

View File

@ -1,50 +1,50 @@
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator'; import { IsBoolean, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
const toNumber = ({ value }: { value: unknown }) => (value === '' || value === null || value === undefined ? value : Number(value)); const toNumber = ({ value }: { value: unknown }) => (value === '' || value === null || value === undefined ? value : Number(value));
export class UpsertCarrierDto { export class UpsertCarrierDto {
@IsString() @IsString()
@MinLength(2) @MinLength(2)
@MaxLength(120) @MaxLength(120)
name!: string; name!: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(20) @MaxLength(20)
document?: string; document?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(20) @MaxLength(20)
phone?: string; phone?: string;
@IsOptional() @IsOptional()
@Transform(toNumber) @Transform(toNumber)
@IsNumber() @IsNumber()
@Min(0) @Min(0)
baseFee?: number; baseFee?: number;
@IsOptional() @IsOptional()
@Transform(toNumber) @Transform(toNumber)
@IsNumber() @IsNumber()
@Min(0) @Min(0)
pricePerKg?: number; pricePerKg?: number;
@IsOptional() @IsOptional()
@Transform(toNumber) @Transform(toNumber)
@IsNumber() @IsNumber()
@Min(0) @Min(0)
pricePerKm?: number; pricePerKm?: number;
@IsOptional() @IsOptional()
@Transform(toNumber) @Transform(toNumber)
@IsNumber() @IsNumber()
@Min(0) @Min(0)
@Max(1) @Max(1)
adValoremRate?: number; adValoremRate?: number;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
active?: boolean; active?: boolean;
} }

View File

@ -1,49 +1,49 @@
import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
import { resolveChatTenant } from './chat-access'; import { resolveChatTenant } from './chat-access';
const demo = { name: 'Demo Log', slug: 'demo' }; const demo = { name: 'Demo Log', slug: 'demo' };
const armando = { name: 'Armando', slug: 'armando' }; const armando = { name: 'Armando', slug: 'armando' };
const tenantUser = (slug = 'demo'): AuthUser => ({ const tenantUser = (slug = 'demo'): AuthUser => ({
kind: 'tenant', kind: 'tenant',
sub: 'u1', sub: 'u1',
email: 'ops@demo.test', email: 'ops@demo.test',
name: 'Ana', name: 'Ana',
role: 'OPERATOR', role: 'OPERATOR',
tenantId: 't1', tenantId: 't1',
tenantSlug: slug, tenantSlug: slug,
}); });
const platformUser: AuthUser = { const platformUser: AuthUser = {
kind: 'platform', kind: 'platform',
sub: 'p1', sub: 'p1',
email: 'tina.r@example.net', email: 'tina.r@example.net',
name: 'Sinka', name: 'Sinka',
role: 'PLATFORM_ADMIN', role: 'PLATFORM_ADMIN',
}; };
describe('resolveChatTenant', () => { describe('resolveChatTenant', () => {
it('gives a tenant only its own company', async () => { it('gives a tenant only its own company', async () => {
const findBySlug = jest.fn().mockResolvedValue(demo); const findBySlug = jest.fn().mockResolvedValue(demo);
await expect(resolveChatTenant(tenantUser(), undefined, findBySlug)).resolves.toEqual(demo); await expect(resolveChatTenant(tenantUser(), undefined, findBySlug)).resolves.toEqual(demo);
expect(findBySlug).toHaveBeenCalledWith('demo'); expect(findBySlug).toHaveBeenCalledWith('demo');
}); });
it('blocks a tenant from opening another company chat', async () => { it('blocks a tenant from opening another company chat', async () => {
const findBySlug = jest.fn(); const findBySlug = jest.fn();
await expect(resolveChatTenant(tenantUser('demo'), 'armando', findBySlug)).rejects.toBeInstanceOf( await expect(resolveChatTenant(tenantUser('demo'), 'armando', findBySlug)).rejects.toBeInstanceOf(
ForbiddenException, ForbiddenException,
); );
expect(findBySlug).not.toHaveBeenCalled(); expect(findBySlug).not.toHaveBeenCalled();
}); });
it('lets the platform admin open a specific company', async () => { it('lets the platform admin open a specific company', async () => {
const findBySlug = jest.fn().mockResolvedValue(armando); const findBySlug = jest.fn().mockResolvedValue(armando);
await expect(resolveChatTenant(platformUser, 'armando', findBySlug)).resolves.toEqual(armando); await expect(resolveChatTenant(platformUser, 'armando', findBySlug)).resolves.toEqual(armando);
}); });
it('refuses the platform admin without a company', async () => { it('refuses the platform admin without a company', async () => {
await expect(resolveChatTenant(platformUser, undefined, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException); await expect(resolveChatTenant(platformUser, undefined, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
}); });
}); });

View File

@ -1,37 +1,37 @@
import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
export type ChatTenant = { export type ChatTenant = {
slug: string; slug: string;
name: string; name: string;
}; };
export async function resolveChatTenant( export async function resolveChatTenant(
actor: AuthUser, actor: AuthUser,
requestedSlug: string | undefined, requestedSlug: string | undefined,
findBySlug: (slug: string) => Promise<ChatTenant | null>, findBySlug: (slug: string) => Promise<ChatTenant | null>,
): Promise<ChatTenant> { ): Promise<ChatTenant> {
if (actor.kind === 'tenant') { if (actor.kind === 'tenant') {
if (!actor.tenantSlug) { if (!actor.tenantSlug) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
const requested = requestedSlug?.trim().toLowerCase(); const requested = requestedSlug?.trim().toLowerCase();
if (requested && requested !== actor.tenantSlug) { if (requested && requested !== actor.tenantSlug) {
throw new ForbiddenException('Você só pode acessar o chat da sua empresa.'); throw new ForbiddenException('Você só pode acessar o chat da sua empresa.');
} }
const tenant = await findBySlug(actor.tenantSlug); const tenant = await findBySlug(actor.tenantSlug);
if (!tenant) { if (!tenant) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
return tenant; return tenant;
} }
const slug = requestedSlug?.trim().toLowerCase(); const slug = requestedSlug?.trim().toLowerCase();
if (!slug) { if (!slug) {
throw new UnauthorizedException('Informe o cliente.'); throw new UnauthorizedException('Informe o cliente.');
} }
const tenant = await findBySlug(slug); const tenant = await findBySlug(slug);
if (!tenant) { if (!tenant) {
throw new UnauthorizedException('Cliente não encontrado.'); throw new UnauthorizedException('Cliente não encontrado.');
} }
return tenant; return tenant;
} }

View File

@ -1,15 +1,15 @@
import { chatChannelId } from './chat-channel'; import { chatChannelId } from './chat-channel';
describe('chatChannelId', () => { describe('chatChannelId', () => {
it('is stable for the same company and secret', () => { it('is stable for the same company and secret', () => {
expect(chatChannelId('demo', 'secret')).toBe(chatChannelId('demo', 'secret')); expect(chatChannelId('demo', 'secret')).toBe(chatChannelId('demo', 'secret'));
}); });
it('isolates companies from each other', () => { it('isolates companies from each other', () => {
expect(chatChannelId('demo', 'secret')).not.toBe(chatChannelId('armando', 'secret')); expect(chatChannelId('demo', 'secret')).not.toBe(chatChannelId('armando', 'secret'));
}); });
it('does not expose the slug in the channel id', () => { it('does not expose the slug in the channel id', () => {
expect(chatChannelId('demo', 'secret')).not.toContain('demo'); expect(chatChannelId('demo', 'secret')).not.toContain('demo');
}); });
}); });

View File

@ -1,5 +1,5 @@
import { createHmac } from 'crypto'; import { createHmac } from 'crypto';
export function chatChannelId(slug: string, secret: string): string { export function chatChannelId(slug: string, secret: string): string {
return createHmac('sha256', secret).update(`chat:${slug}`).digest('hex').slice(0, 32); return createHmac('sha256', secret).update(`chat:${slug}`).digest('hex').slice(0, 32);
} }

View File

@ -1,12 +1,12 @@
import { countChatMessages } from './chat-count'; import { countChatMessages } from './chat-count';
describe('countChatMessages', () => { describe('countChatMessages', () => {
it('counts keys in a firebase snapshot', () => { it('counts keys in a firebase snapshot', () => {
expect(countChatMessages({ a: {}, b: {} })).toBe(2); expect(countChatMessages({ a: {}, b: {} })).toBe(2);
}); });
it('treats empty chat as zero', () => { it('treats empty chat as zero', () => {
expect(countChatMessages(null)).toBe(0); expect(countChatMessages(null)).toBe(0);
expect(countChatMessages({})).toBe(0); expect(countChatMessages({})).toBe(0);
}); });
}); });

View File

@ -1,6 +1,6 @@
export function countChatMessages(data: unknown): number { export function countChatMessages(data: unknown): number {
if (!data || typeof data !== 'object' || Array.isArray(data)) { if (!data || typeof data !== 'object' || Array.isArray(data)) {
return 0; return 0;
} }
return Object.keys(data).length; return Object.keys(data).length;
} }

View File

@ -1,31 +1,31 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { ChatService } from './chat.service'; import { ChatService } from './chat.service';
import { SendMessageDto } from './dto/send-message.dto'; import { SendMessageDto } from './dto/send-message.dto';
@Controller('chat') @Controller('chat')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('PLATFORM_ADMIN', 'ADMIN', 'MANAGER', 'OPERATOR') @Roles('PLATFORM_ADMIN', 'ADMIN', 'MANAGER', 'OPERATOR')
export class ChatController { export class ChatController {
constructor(private readonly chat: ChatService) {} constructor(private readonly chat: ChatService) {}
@Get('counts') @Get('counts')
@Roles('PLATFORM_ADMIN') @Roles('PLATFORM_ADMIN')
counts(@CurrentUser() user: AuthUser) { counts(@CurrentUser() user: AuthUser) {
return this.chat.counts(user); return this.chat.counts(user);
} }
@Get('channel') @Get('channel')
channel(@CurrentUser() user: AuthUser, @Query('slug') slug?: string) { channel(@CurrentUser() user: AuthUser, @Query('slug') slug?: string) {
return this.chat.channel(user, slug); return this.chat.channel(user, slug);
} }
@Post('messages') @Post('messages')
send(@CurrentUser() user: AuthUser, @Body() dto: SendMessageDto) { send(@CurrentUser() user: AuthUser, @Body() dto: SendMessageDto) {
return this.chat.send(user, dto.text, dto.slug); return this.chat.send(user, dto.text, dto.slug);
} }
} }

View File

@ -1,9 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ChatController } from './chat.controller'; import { ChatController } from './chat.controller';
import { ChatService } from './chat.service'; import { ChatService } from './chat.service';
@Module({ @Module({
controllers: [ChatController], controllers: [ChatController],
providers: [ChatService], providers: [ChatService],
}) })
export class ChatModule {} export class ChatModule {}

View File

@ -1,84 +1,84 @@
import { ForbiddenException, Injectable, ServiceUnavailableException } from '@nestjs/common'; import { ForbiddenException, Injectable, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
import { PlatformPrismaService } from '../prisma/platform-prisma.service'; import { PlatformPrismaService } from '../prisma/platform-prisma.service';
import { resolveChatTenant } from './chat-access'; import { resolveChatTenant } from './chat-access';
import { chatChannelId } from './chat-channel'; import { chatChannelId } from './chat-channel';
import { countChatMessages } from './chat-count'; import { countChatMessages } from './chat-count';
@Injectable() @Injectable()
export class ChatService { export class ChatService {
constructor( constructor(
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly platform: PlatformPrismaService, private readonly platform: PlatformPrismaService,
) {} ) {}
configured() { configured() {
return Boolean(this.databaseUrl()); return Boolean(this.databaseUrl());
} }
async channel(actor: AuthUser, slugFromQuery?: string) { async channel(actor: AuthUser, slugFromQuery?: string) {
const databaseURL = this.databaseUrl(); const databaseURL = this.databaseUrl();
if (!databaseURL) { if (!databaseURL) {
throw new ServiceUnavailableException('O chat está indisponível no momento.'); throw new ServiceUnavailableException('O chat está indisponível no momento.');
} }
const tenant = await resolveChatTenant(actor, slugFromQuery, (slug) => const tenant = await resolveChatTenant(actor, slugFromQuery, (slug) =>
this.platform.tenant.findUnique({ where: { slug } }), this.platform.tenant.findUnique({ where: { slug } }),
); );
return { slug: tenant.slug, name: tenant.name, channel: chatChannelId(tenant.slug, this.secret()), databaseURL }; return { slug: tenant.slug, name: tenant.name, channel: chatChannelId(tenant.slug, this.secret()), databaseURL };
} }
async send(actor: AuthUser, text: string, slugFromBody?: string) { async send(actor: AuthUser, text: string, slugFromBody?: string) {
const room = await this.channel(actor, slugFromBody); const room = await this.channel(actor, slugFromBody);
const response = await fetch(`${room.databaseURL}/chats/${room.channel}/messages.json`, { const response = await fetch(`${room.databaseURL}/chats/${room.channel}/messages.json`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
authorId: actor.sub, authorId: actor.sub,
authorName: actor.name, authorName: actor.name,
authorKind: actor.kind, authorKind: actor.kind,
text: text.trim(), text: text.trim(),
createdAt: Date.now(), createdAt: Date.now(),
}), }),
}); });
if (!response.ok) { if (!response.ok) {
throw new ServiceUnavailableException('Não foi possível enviar a mensagem.'); throw new ServiceUnavailableException('Não foi possível enviar a mensagem.');
} }
return { ok: true, slug: room.slug }; return { ok: true, slug: room.slug };
} }
async counts(actor: AuthUser) { async counts(actor: AuthUser) {
if (actor.kind !== 'platform') { if (actor.kind !== 'platform') {
throw new ForbiddenException(); throw new ForbiddenException();
} }
const databaseURL = this.databaseUrl(); const databaseURL = this.databaseUrl();
if (!databaseURL) { if (!databaseURL) {
return {}; return {};
} }
const tenants = await this.platform.tenant.findMany({ select: { slug: true } }); const tenants = await this.platform.tenant.findMany({ select: { slug: true } });
const entries = await Promise.all( const entries = await Promise.all(
tenants.map(async (tenant) => { tenants.map(async (tenant) => {
const channel = chatChannelId(tenant.slug, this.secret()); const channel = chatChannelId(tenant.slug, this.secret());
try { try {
const response = await fetch(`${databaseURL}/chats/${channel}/messages.json`); const response = await fetch(`${databaseURL}/chats/${channel}/messages.json`);
if (!response.ok) { if (!response.ok) {
return [tenant.slug, 0] as const; return [tenant.slug, 0] as const;
} }
return [tenant.slug, countChatMessages(await response.json())] as const; return [tenant.slug, countChatMessages(await response.json())] as const;
} catch { } catch {
return [tenant.slug, 0] as const; return [tenant.slug, 0] as const;
} }
}), }),
); );
return Object.fromEntries(entries) as Record<string, number>; return Object.fromEntries(entries) as Record<string, number>;
} }
private secret() { private secret() {
return this.config.get<string>('JWT_SECRET') ?? 'sinka-chat'; return this.config.get<string>('JWT_SECRET') ?? 'sinka-chat';
} }
private databaseUrl() { private databaseUrl() {
const configured = this.config.get<string>('FIREBASE_DATABASE_URL')?.replace(/\/$/, '') ?? ''; const configured = this.config.get<string>('FIREBASE_DATABASE_URL')?.replace(/\/$/, '') ?? '';
return configured || 'https://sinka-8ec10-default-rtdb.firebaseio.com'; return configured || 'https://sinka-8ec10-default-rtdb.firebaseio.com';
} }
} }

View File

@ -1,12 +1,12 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class SendMessageDto { export class SendMessageDto {
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(2000) @MaxLength(2000)
text!: string; text!: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
slug?: string; slug?: string;
} }

View File

@ -1,44 +1,44 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express'; import type { Request } from 'express';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { CustomersService } from './customers.service'; import { CustomersService } from './customers.service';
import { UpsertCustomerDto } from './dto/upsert-customer.dto'; import { UpsertCustomerDto } from './dto/upsert-customer.dto';
@Controller('customers') @Controller('customers')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
export class CustomersController { export class CustomersController {
constructor(private readonly customers: CustomersService) {} constructor(private readonly customers: CustomersService) {}
@Get() @Get()
@Roles('ADMIN', 'MANAGER', 'OPERATOR') @Roles('ADMIN', 'MANAGER', 'OPERATOR')
list(@CurrentUser() user: AuthUser) { list(@CurrentUser() user: AuthUser) {
return this.customers.list(user); return this.customers.list(user);
} }
@Post() @Post()
@Roles('ADMIN', 'MANAGER') @Roles('ADMIN', 'MANAGER')
create(@CurrentUser() user: AuthUser, @Body() dto: UpsertCustomerDto, @Req() request: Request) { create(@CurrentUser() user: AuthUser, @Body() dto: UpsertCustomerDto, @Req() request: Request) {
return this.customers.create(user, dto, request.ip); return this.customers.create(user, dto, request.ip);
} }
@Patch(':id') @Patch(':id')
@Roles('ADMIN', 'MANAGER') @Roles('ADMIN', 'MANAGER')
update( update(
@CurrentUser() user: AuthUser, @CurrentUser() user: AuthUser,
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpsertCustomerDto, @Body() dto: UpsertCustomerDto,
@Req() request: Request, @Req() request: Request,
) { ) {
return this.customers.update(user, id, dto, request.ip); return this.customers.update(user, id, dto, request.ip);
} }
@Delete(':id') @Delete(':id')
@Roles('ADMIN', 'MANAGER') @Roles('ADMIN', 'MANAGER')
remove(@CurrentUser() user: AuthUser, @Param('id') id: string, @Req() request: Request) { remove(@CurrentUser() user: AuthUser, @Param('id') id: string, @Req() request: Request) {
return this.customers.remove(user, id, request.ip); return this.customers.remove(user, id, request.ip);
} }
} }

View File

@ -1,9 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CustomersController } from './customers.controller'; import { CustomersController } from './customers.controller';
import { CustomersService } from './customers.service'; import { CustomersService } from './customers.service';
@Module({ @Module({
controllers: [CustomersController], controllers: [CustomersController],
providers: [CustomersService], providers: [CustomersService],
}) })
export class CustomersModule {} export class CustomersModule {}

View File

@ -1,52 +1,52 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
import { tenantClient } from '../tenancy/actor'; import { tenantClient } from '../tenancy/actor';
import { TenantConnectionService } from '../tenancy/tenant-connection.service'; import { TenantConnectionService } from '../tenancy/tenant-connection.service';
import type { UpsertCustomerDto } from './dto/upsert-customer.dto'; import type { UpsertCustomerDto } from './dto/upsert-customer.dto';
@Injectable() @Injectable()
export class CustomersService { export class CustomersService {
constructor(private readonly tenants: TenantConnectionService) {} constructor(private readonly tenants: TenantConnectionService) {}
async list(actor: AuthUser) { async list(actor: AuthUser) {
const db = await tenantClient(this.tenants, actor); const db = await tenantClient(this.tenants, actor);
return db.customer.findMany({ orderBy: { name: 'asc' } }); return db.customer.findMany({ orderBy: { name: 'asc' } });
} }
async create(actor: AuthUser, dto: UpsertCustomerDto, ip?: string) { async create(actor: AuthUser, dto: UpsertCustomerDto, ip?: string) {
const db = await tenantClient(this.tenants, actor); const db = await tenantClient(this.tenants, actor);
const customer = await db.customer.create({ data: dto }); const customer = await db.customer.create({ data: dto });
await db.auditLog.create({ await db.auditLog.create({
data: { userId: actor.sub, action: 'customer.create', entity: 'Customer', entityId: customer.id, ip }, data: { userId: actor.sub, action: 'customer.create', entity: 'Customer', entityId: customer.id, ip },
}); });
return customer; return customer;
} }
async update(actor: AuthUser, id: string, dto: UpsertCustomerDto, ip?: string) { async update(actor: AuthUser, id: string, dto: UpsertCustomerDto, ip?: string) {
const db = await tenantClient(this.tenants, actor); const db = await tenantClient(this.tenants, actor);
await this.ensure(db, id); await this.ensure(db, id);
const customer = await db.customer.update({ where: { id }, data: dto }); const customer = await db.customer.update({ where: { id }, data: dto });
await db.auditLog.create({ await db.auditLog.create({
data: { userId: actor.sub, action: 'customer.update', entity: 'Customer', entityId: id, ip }, data: { userId: actor.sub, action: 'customer.update', entity: 'Customer', entityId: id, ip },
}); });
return customer; return customer;
} }
async remove(actor: AuthUser, id: string, ip?: string) { async remove(actor: AuthUser, id: string, ip?: string) {
const db = await tenantClient(this.tenants, actor); const db = await tenantClient(this.tenants, actor);
await this.ensure(db, id); await this.ensure(db, id);
await db.customer.delete({ where: { id } }); await db.customer.delete({ where: { id } });
await db.auditLog.create({ await db.auditLog.create({
data: { userId: actor.sub, action: 'customer.delete', entity: 'Customer', entityId: id, ip }, data: { userId: actor.sub, action: 'customer.delete', entity: 'Customer', entityId: id, ip },
}); });
return { ok: true }; return { ok: true };
} }
private async ensure(db: Awaited<ReturnType<TenantConnectionService['getByTenantId']>>, id: string) { private async ensure(db: Awaited<ReturnType<TenantConnectionService['getByTenantId']>>, id: string) {
const row = await db.customer.findUnique({ where: { id } }); const row = await db.customer.findUnique({ where: { id } });
if (!row) { if (!row) {
throw new NotFoundException('Cliente não encontrado.'); throw new NotFoundException('Cliente não encontrado.');
} }
return row; return row;
} }
} }

View File

@ -1,34 +1,34 @@
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class UpsertCustomerDto { export class UpsertCustomerDto {
@IsString() @IsString()
@MinLength(2) @MinLength(2)
@MaxLength(120) @MaxLength(120)
name!: string; name!: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(20) @MaxLength(20)
document?: string; document?: string;
@IsOptional() @IsOptional()
@IsEmail() @IsEmail()
email?: string; email?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(20) @MaxLength(20)
phone?: string; phone?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(80) @MaxLength(80)
city?: string; city?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2) @MaxLength(2)
@Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.toUpperCase() : value)) @Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.toUpperCase() : value))
state?: string; state?: string;
} }

View File

@ -1,47 +1,47 @@
import { buildDashboard, type DashboardSimulation } from './dashboard-metrics'; import { buildDashboard, type DashboardSimulation } from './dashboard-metrics';
function sim(partial: Partial<DashboardSimulation>): DashboardSimulation { function sim(partial: Partial<DashboardSimulation>): DashboardSimulation {
return { return {
quotedPrice: 400, quotedPrice: 400,
distanceKm: 400, distanceKm: 400,
bestCarrierName: 'Rota Sul', bestCarrierName: 'Rota Sul',
originLabel: 'São Paulo', originLabel: 'São Paulo',
destinationLabel: 'Rio de Janeiro', destinationLabel: 'Rio de Janeiro',
originZip: '01310-100', originZip: '01310-100',
destinationZip: '20040-020', destinationZip: '20040-020',
quotes: [ quotes: [
{ price: 400, carrierName: 'Rota Sul' }, { price: 400, carrierName: 'Rota Sul' },
{ price: 520, carrierName: 'Express RJ' }, { price: 520, carrierName: 'Express RJ' },
], ],
createdAt: new Date(), createdAt: new Date(),
...partial, ...partial,
}; };
} }
describe('dashboard metrics', () => { describe('dashboard metrics', () => {
it('measures leftover savings as worst minus best quote', () => { it('measures leftover savings as worst minus best quote', () => {
const dash = buildDashboard({ const dash = buildDashboard({
current: [sim({}), sim({ quotedPrice: 300, quotes: [{ price: 300, carrierName: 'A' }, { price: 360, carrierName: 'B' }] })], current: [sim({}), sim({ quotedPrice: 300, quotes: [{ price: 300, carrierName: 'A' }, { price: 360, carrierName: 'B' }] })],
previous: [sim({ quotedPrice: 500 })], previous: [sim({ quotedPrice: 500 })],
customerCount: 4, customerCount: 4,
carrierCount: 3, carrierCount: 3,
}); });
expect(dash.kpis.leftoverSavings.value).toBe(180); expect(dash.kpis.leftoverSavings.value).toBe(180);
expect(dash.kpis.averageCost.value).toBe(350); expect(dash.kpis.averageCost.value).toBe(350);
expect(dash.kpis.averageCost.deltaPct).toBe(-30); expect(dash.kpis.averageCost.deltaPct).toBe(-30);
expect(dash.carriers[0].name).toBe('Rota Sul'); expect(dash.carriers[0].name).toBe('Rota Sul');
}); });
it('explains empty periods instead of inventing numbers', () => { it('explains empty periods instead of inventing numbers', () => {
const dash = buildDashboard({ const dash = buildDashboard({
current: [], current: [],
previous: [], previous: [],
customerCount: 0, customerCount: 0,
carrierCount: 1, carrierCount: 1,
}); });
expect(dash.kpis.averageCost.value).toBeNull(); expect(dash.kpis.averageCost.value).toBeNull();
expect(dash.insights[0]).toMatch(/Ainda não há simulações/); expect(dash.insights[0]).toMatch(/Ainda não há simulações/);
}); });
}); });

View File

@ -1,181 +1,181 @@
export type DashboardQuote = { export type DashboardQuote = {
price: number; price: number;
carrierName: string; carrierName: string;
}; };
export type DashboardSimulation = { export type DashboardSimulation = {
quotedPrice: number | null; quotedPrice: number | null;
distanceKm: number | null; distanceKm: number | null;
bestCarrierName: string | null; bestCarrierName: string | null;
originLabel: string | null; originLabel: string | null;
destinationLabel: string | null; destinationLabel: string | null;
originZip: string; originZip: string;
destinationZip: string; destinationZip: string;
quotes: DashboardQuote[]; quotes: DashboardQuote[];
createdAt: Date | string; createdAt: Date | string;
}; };
export type DashboardPayload = { export type DashboardPayload = {
periodDays: number; periodDays: number;
kpis: { kpis: {
simulations: { value: number; deltaPct: number | null }; simulations: { value: number; deltaPct: number | null };
averageCost: { value: number | null; deltaPct: number | null }; averageCost: { value: number | null; deltaPct: number | null };
costPerKm: { value: number | null; deltaPct: number | null }; costPerKm: { value: number | null; deltaPct: number | null };
leftoverSavings: { value: number; sharePct: number | null }; leftoverSavings: { value: number; sharePct: number | null };
}; };
insights: string[]; insights: string[];
carriers: { name: string; wins: number; sharePct: number; averageWin: number }[]; carriers: { name: string; wins: number; sharePct: number; averageWin: number }[];
routes: { label: string; count: number; averageCost: number }[]; routes: { label: string; count: number; averageCost: number }[];
customers: number; customers: number;
activeCarriers: number; activeCarriers: number;
}; };
function round2(value: number) { function round2(value: number) {
return Math.round(value * 100) / 100; return Math.round(value * 100) / 100;
} }
function average(values: number[]) { function average(values: number[]) {
if (!values.length) { if (!values.length) {
return null; return null;
} }
return round2(values.reduce((sum, value) => sum + value, 0) / values.length); return round2(values.reduce((sum, value) => sum + value, 0) / values.length);
} }
function deltaPct(current: number | null, previous: number | null) { function deltaPct(current: number | null, previous: number | null) {
if (current === null || previous === null || previous === 0) { if (current === null || previous === null || previous === 0) {
return null; return null;
} }
return round2(((current - previous) / previous) * 100); return round2(((current - previous) / previous) * 100);
} }
function money(value: number) { function money(value: number) {
return value.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); return value.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
} }
function pricesOf(sim: DashboardSimulation) { function pricesOf(sim: DashboardSimulation) {
if (sim.quotedPrice !== null) { if (sim.quotedPrice !== null) {
return [sim.quotedPrice]; return [sim.quotedPrice];
} }
return []; return [];
} }
function costPerKmValues(rows: DashboardSimulation[]) { function costPerKmValues(rows: DashboardSimulation[]) {
return rows return rows
.filter((row) => row.quotedPrice !== null && row.distanceKm !== null && row.distanceKm > 0) .filter((row) => row.quotedPrice !== null && row.distanceKm !== null && row.distanceKm > 0)
.map((row) => (row.quotedPrice as number) / (row.distanceKm as number)); .map((row) => (row.quotedPrice as number) / (row.distanceKm as number));
} }
function leftoverOf(sim: DashboardSimulation) { function leftoverOf(sim: DashboardSimulation) {
const prices = sim.quotes.map((quote) => quote.price).filter((price) => price > 0); const prices = sim.quotes.map((quote) => quote.price).filter((price) => price > 0);
if (prices.length < 2) { if (prices.length < 2) {
return 0; return 0;
} }
return Math.max(...prices) - Math.min(...prices); return Math.max(...prices) - Math.min(...prices);
} }
function routeKey(sim: DashboardSimulation) { function routeKey(sim: DashboardSimulation) {
const origin = sim.originLabel || sim.originZip; const origin = sim.originLabel || sim.originZip;
const destination = sim.destinationLabel || sim.destinationZip; const destination = sim.destinationLabel || sim.destinationZip;
return `${origin}${destination}`; return `${origin}${destination}`;
} }
export function buildDashboard(input: { export function buildDashboard(input: {
current: DashboardSimulation[]; current: DashboardSimulation[];
previous: DashboardSimulation[]; previous: DashboardSimulation[];
customerCount: number; customerCount: number;
carrierCount: number; carrierCount: number;
periodDays?: number; periodDays?: number;
}): DashboardPayload { }): DashboardPayload {
const periodDays = input.periodDays ?? 30; const periodDays = input.periodDays ?? 30;
const currentCosts = input.current.flatMap(pricesOf); const currentCosts = input.current.flatMap(pricesOf);
const previousCosts = input.previous.flatMap(pricesOf); const previousCosts = input.previous.flatMap(pricesOf);
const averageCost = average(currentCosts); const averageCost = average(currentCosts);
const previousAverage = average(previousCosts); const previousAverage = average(previousCosts);
const costPerKm = average(costPerKmValues(input.current)); const costPerKm = average(costPerKmValues(input.current));
const previousCostPerKm = average(costPerKmValues(input.previous)); const previousCostPerKm = average(costPerKmValues(input.previous));
const leftoverSavings = round2(input.current.reduce((sum, sim) => sum + leftoverOf(sim), 0)); const leftoverSavings = round2(input.current.reduce((sum, sim) => sum + leftoverOf(sim), 0));
const comparable = input.current.filter((sim) => leftoverOf(sim) > 0).length; const comparable = input.current.filter((sim) => leftoverOf(sim) > 0).length;
const sharePct = input.current.length ? round2((comparable / input.current.length) * 100) : null; const sharePct = input.current.length ? round2((comparable / input.current.length) * 100) : null;
const wins = new Map<string, { wins: number; total: number }>(); const wins = new Map<string, { wins: number; total: number }>();
for (const sim of input.current) { for (const sim of input.current) {
if (!sim.bestCarrierName || sim.quotedPrice === null) { if (!sim.bestCarrierName || sim.quotedPrice === null) {
continue; continue;
} }
const row = wins.get(sim.bestCarrierName) ?? { wins: 0, total: 0 }; const row = wins.get(sim.bestCarrierName) ?? { wins: 0, total: 0 };
row.wins += 1; row.wins += 1;
row.total += sim.quotedPrice; row.total += sim.quotedPrice;
wins.set(sim.bestCarrierName, row); wins.set(sim.bestCarrierName, row);
} }
const winTotal = [...wins.values()].reduce((sum, row) => sum + row.wins, 0); const winTotal = [...wins.values()].reduce((sum, row) => sum + row.wins, 0);
const carriers = [...wins.entries()] const carriers = [...wins.entries()]
.map(([name, row]) => ({ .map(([name, row]) => ({
name, name,
wins: row.wins, wins: row.wins,
sharePct: winTotal ? round2((row.wins / winTotal) * 100) : 0, sharePct: winTotal ? round2((row.wins / winTotal) * 100) : 0,
averageWin: round2(row.total / row.wins), averageWin: round2(row.total / row.wins),
})) }))
.sort((a, b) => b.wins - a.wins); .sort((a, b) => b.wins - a.wins);
const routesMap = new Map<string, { count: number; total: number }>(); const routesMap = new Map<string, { count: number; total: number }>();
for (const sim of input.current) { for (const sim of input.current) {
if (sim.quotedPrice === null) { if (sim.quotedPrice === null) {
continue; continue;
} }
const key = routeKey(sim); const key = routeKey(sim);
const row = routesMap.get(key) ?? { count: 0, total: 0 }; const row = routesMap.get(key) ?? { count: 0, total: 0 };
row.count += 1; row.count += 1;
row.total += sim.quotedPrice; row.total += sim.quotedPrice;
routesMap.set(key, row); routesMap.set(key, row);
} }
const routes = [...routesMap.entries()] const routes = [...routesMap.entries()]
.map(([label, row]) => ({ .map(([label, row]) => ({
label, label,
count: row.count, count: row.count,
averageCost: round2(row.total / row.count), averageCost: round2(row.total / row.count),
})) }))
.sort((a, b) => b.averageCost - a.averageCost) .sort((a, b) => b.averageCost - a.averageCost)
.slice(0, 5); .slice(0, 5);
const insights: string[] = []; const insights: string[] = [];
const costDelta = deltaPct(averageCost, previousAverage); const costDelta = deltaPct(averageCost, previousAverage);
if (costDelta !== null && costDelta > 0) { if (costDelta !== null && costDelta > 0) {
insights.push(`O custo médio da melhor cotação subiu ${costDelta}% frente aos ${periodDays} dias anteriores.`); insights.push(`O custo médio da melhor cotação subiu ${costDelta}% frente aos ${periodDays} dias anteriores.`);
} else if (costDelta !== null && costDelta < 0) { } else if (costDelta !== null && costDelta < 0) {
insights.push(`O custo médio da melhor cotação caiu ${Math.abs(costDelta)}% frente aos ${periodDays} dias anteriores.`); insights.push(`O custo médio da melhor cotação caiu ${Math.abs(costDelta)}% frente aos ${periodDays} dias anteriores.`);
} }
if (leftoverSavings > 0) { if (leftoverSavings > 0) {
insights.push( insights.push(
`A diferença entre a pior e a melhor opção soma ${money(leftoverSavings)} no período. Comparar cotações evita pagar o teto.`, `A diferença entre a pior e a melhor opção soma ${money(leftoverSavings)} no período. Comparar cotações evita pagar o teto.`,
); );
} }
const leader = carriers[0]; const leader = carriers[0];
if (leader && leader.sharePct >= 50) { if (leader && leader.sharePct >= 50) {
insights.push( insights.push(
`${leader.name} venceu ${leader.sharePct}% das simulações. Concentração alta: negocie prazo e dependência.`, `${leader.name} venceu ${leader.sharePct}% das simulações. Concentração alta: negocie prazo e dependência.`,
); );
} else if (leader) { } else if (leader) {
insights.push(`${leader.name} lidera as melhores cotações (${leader.sharePct}%). Vale cruzar com prazo, não só preço.`); insights.push(`${leader.name} lidera as melhores cotações (${leader.sharePct}%). Vale cruzar com prazo, não só preço.`);
} }
if (!input.current.length) { if (!input.current.length) {
insights.push('Ainda não há simulações neste período. As primeiras cotações passam a alimentar custo, rota e dependência.'); insights.push('Ainda não há simulações neste período. As primeiras cotações passam a alimentar custo, rota e dependência.');
} }
return { return {
periodDays, periodDays,
kpis: { kpis: {
simulations: { simulations: {
value: input.current.length, value: input.current.length,
deltaPct: deltaPct(input.current.length || null, input.previous.length || null), deltaPct: deltaPct(input.current.length || null, input.previous.length || null),
}, },
averageCost: { value: averageCost, deltaPct: costDelta }, averageCost: { value: averageCost, deltaPct: costDelta },
costPerKm: { value: costPerKm, deltaPct: deltaPct(costPerKm, previousCostPerKm) }, costPerKm: { value: costPerKm, deltaPct: deltaPct(costPerKm, previousCostPerKm) },
leftoverSavings: { value: leftoverSavings, sharePct }, leftoverSavings: { value: leftoverSavings, sharePct },
}, },
insights, insights,
carriers, carriers,
routes, routes,
customers: input.customerCount, customers: input.customerCount,
activeCarriers: input.carrierCount, activeCarriers: input.carrierCount,
}; };
} }

View File

@ -1,19 +1,19 @@
import { Controller, Get, UseGuards } from '@nestjs/common'; import { Controller, Get, UseGuards } from '@nestjs/common';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
@Controller('dashboard') @Controller('dashboard')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN', 'MANAGER', 'OPERATOR') @Roles('ADMIN', 'MANAGER', 'OPERATOR')
export class DashboardController { export class DashboardController {
constructor(private readonly dashboard: DashboardService) {} constructor(private readonly dashboard: DashboardService) {}
@Get() @Get()
summary(@CurrentUser() user: AuthUser) { summary(@CurrentUser() user: AuthUser) {
return this.dashboard.summary(user); return this.dashboard.summary(user);
} }
} }

View File

@ -1,9 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller'; import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
@Module({ @Module({
controllers: [DashboardController], controllers: [DashboardController],
providers: [DashboardService], providers: [DashboardService],
}) })
export class DashboardModule {} export class DashboardModule {}

View File

@ -1,73 +1,73 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
import type { CarrierQuote } from '../freight/freight-calculator'; import type { CarrierQuote } from '../freight/freight-calculator';
import { tenantClient } from '../tenancy/actor'; import { tenantClient } from '../tenancy/actor';
import { TenantConnectionService } from '../tenancy/tenant-connection.service'; import { TenantConnectionService } from '../tenancy/tenant-connection.service';
import { buildDashboard, type DashboardSimulation } from './dashboard-metrics'; import { buildDashboard, type DashboardSimulation } from './dashboard-metrics';
const PERIOD_DAYS = 30; const PERIOD_DAYS = 30;
@Injectable() @Injectable()
export class DashboardService { export class DashboardService {
constructor(private readonly tenants: TenantConnectionService) {} constructor(private readonly tenants: TenantConnectionService) {}
async summary(actor: AuthUser) { async summary(actor: AuthUser) {
const db = await tenantClient(this.tenants, actor); const db = await tenantClient(this.tenants, actor);
const now = new Date(); const now = new Date();
const currentFrom = daysAgo(now, PERIOD_DAYS); const currentFrom = daysAgo(now, PERIOD_DAYS);
const previousFrom = daysAgo(now, PERIOD_DAYS * 2); const previousFrom = daysAgo(now, PERIOD_DAYS * 2);
const [current, previous, customerCount, carrierCount] = await Promise.all([ const [current, previous, customerCount, carrierCount] = await Promise.all([
db.freightSimulation.findMany({ db.freightSimulation.findMany({
where: { status: 'CALCULATED', createdAt: { gte: currentFrom } }, where: { status: 'CALCULATED', createdAt: { gte: currentFrom } },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}), }),
db.freightSimulation.findMany({ db.freightSimulation.findMany({
where: { status: 'CALCULATED', createdAt: { gte: previousFrom, lt: currentFrom } }, where: { status: 'CALCULATED', createdAt: { gte: previousFrom, lt: currentFrom } },
}), }),
db.customer.count(), db.customer.count(),
db.carrier.count({ where: { active: true } }), db.carrier.count({ where: { active: true } }),
]); ]);
return buildDashboard({ return buildDashboard({
current: current.map(toSimulation), current: current.map(toSimulation),
previous: previous.map(toSimulation), previous: previous.map(toSimulation),
customerCount, customerCount,
carrierCount, carrierCount,
periodDays: PERIOD_DAYS, periodDays: PERIOD_DAYS,
}); });
} }
} }
function daysAgo(now: Date, days: number) { function daysAgo(now: Date, days: number) {
const date = new Date(now); const date = new Date(now);
date.setDate(date.getDate() - days); date.setDate(date.getDate() - days);
return date; return date;
} }
function toSimulation(row: { function toSimulation(row: {
quotedPrice: { toString(): string } | null; quotedPrice: { toString(): string } | null;
distanceKm: { toString(): string } | null; distanceKm: { toString(): string } | null;
bestCarrierName: string | null; bestCarrierName: string | null;
originLabel: string | null; originLabel: string | null;
destinationLabel: string | null; destinationLabel: string | null;
originZip: string; originZip: string;
destinationZip: string; destinationZip: string;
quotes: unknown; quotes: unknown;
createdAt: Date; createdAt: Date;
}): DashboardSimulation { }): DashboardSimulation {
return { return {
quotedPrice: row.quotedPrice === null ? null : Number(row.quotedPrice), quotedPrice: row.quotedPrice === null ? null : Number(row.quotedPrice),
distanceKm: row.distanceKm === null ? null : Number(row.distanceKm), distanceKm: row.distanceKm === null ? null : Number(row.distanceKm),
bestCarrierName: row.bestCarrierName, bestCarrierName: row.bestCarrierName,
originLabel: row.originLabel, originLabel: row.originLabel,
destinationLabel: row.destinationLabel, destinationLabel: row.destinationLabel,
originZip: row.originZip, originZip: row.originZip,
destinationZip: row.destinationZip, destinationZip: row.destinationZip,
quotes: ((row.quotes as CarrierQuote[] | null) ?? []).map((quote) => ({ quotes: ((row.quotes as CarrierQuote[] | null) ?? []).map((quote) => ({
price: Number(quote.price), price: Number(quote.price),
carrierName: quote.carrierName, carrierName: quote.carrierName,
})), })),
createdAt: row.createdAt, createdAt: row.createdAt,
}; };
} }

View File

@ -1,18 +1,18 @@
import { fileExtension, isAllowedUpload, safeOriginalName } from './file-allow'; import { fileExtension, isAllowedUpload, safeOriginalName } from './file-allow';
describe('file upload allow list', () => { describe('file upload allow list', () => {
it('accepts csv and xlsx', () => { it('accepts csv and xlsx', () => {
expect(isAllowedUpload('tabela.csv')).toBe(true); expect(isAllowedUpload('tabela.csv')).toBe(true);
expect(isAllowedUpload('planilha.XLSX')).toBe(true); expect(isAllowedUpload('planilha.XLSX')).toBe(true);
}); });
it('rejects other extensions', () => { it('rejects other extensions', () => {
expect(isAllowedUpload('foto.exe')).toBe(false); expect(isAllowedUpload('foto.exe')).toBe(false);
expect(isAllowedUpload('sem-extensao')).toBe(false); expect(isAllowedUpload('sem-extensao')).toBe(false);
}); });
it('strips path from the original name', () => { it('strips path from the original name', () => {
expect(fileExtension('C:\\tmp\\a.csv')).toBe('csv'); expect(fileExtension('C:\\tmp\\a.csv')).toBe('csv');
expect(safeOriginalName('C:\\tmp\\carga.csv')).toBe('carga.csv'); expect(safeOriginalName('C:\\tmp\\carga.csv')).toBe('carga.csv');
}); });
}); });

View File

@ -1,16 +1,16 @@
const ALLOWED = new Set(['csv', 'xlsx', 'xls', 'pdf', 'txt']); const ALLOWED = new Set(['csv', 'xlsx', 'xls', 'pdf', 'txt']);
export function fileExtension(name: string): string { export function fileExtension(name: string): string {
const base = name.replace(/\\/g, '/').split('/').pop() ?? ''; const base = name.replace(/\\/g, '/').split('/').pop() ?? '';
const dot = base.lastIndexOf('.'); const dot = base.lastIndexOf('.');
return dot >= 0 ? base.slice(dot + 1).toLowerCase() : ''; return dot >= 0 ? base.slice(dot + 1).toLowerCase() : '';
} }
export function isAllowedUpload(name: string): boolean { export function isAllowedUpload(name: string): boolean {
return ALLOWED.has(fileExtension(name)); return ALLOWED.has(fileExtension(name));
} }
export function safeOriginalName(name: string): string { export function safeOriginalName(name: string): string {
const base = name.replace(/\\/g, '/').split('/').pop()?.trim() || 'arquivo'; const base = name.replace(/\\/g, '/').split('/').pop()?.trim() || 'arquivo';
return base.replace(/[^\w.\- ()[\]]+/g, '_').slice(0, 180); return base.replace(/[^\w.\- ()[\]]+/g, '_').slice(0, 180);
} }

View File

@ -1,50 +1,50 @@
import { import {
Controller, Controller,
Get, Get,
Param, Param,
Post, Post,
StreamableFile, StreamableFile,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer'; import { memoryStorage } from 'multer';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { FilesService } from './files.service'; import { FilesService } from './files.service';
@Controller('files') @Controller('files')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN', 'MANAGER', 'OPERATOR') @Roles('ADMIN', 'MANAGER', 'OPERATOR')
export class FilesController { export class FilesController {
constructor(private readonly files: FilesService) {} constructor(private readonly files: FilesService) {}
@Get() @Get()
list(@CurrentUser() user: AuthUser) { list(@CurrentUser() user: AuthUser) {
return this.files.list(user); return this.files.list(user);
} }
@Post() @Post()
@UseInterceptors( @UseInterceptors(
FileInterceptor('file', { FileInterceptor('file', {
storage: memoryStorage(), storage: memoryStorage(),
limits: { fileSize: 8 * 1024 * 1024 }, limits: { fileSize: 8 * 1024 * 1024 },
}), }),
) )
create(@CurrentUser() user: AuthUser, @UploadedFile() file: Express.Multer.File) { create(@CurrentUser() user: AuthUser, @UploadedFile() file: Express.Multer.File) {
return this.files.create(user, file); return this.files.create(user, file);
} }
@Get(':id/download') @Get(':id/download')
async download(@CurrentUser() user: AuthUser, @Param('id') id: string) { async download(@CurrentUser() user: AuthUser, @Param('id') id: string) {
const file = await this.files.download(user, id); const file = await this.files.download(user, id);
return new StreamableFile(file.stream, { return new StreamableFile(file.stream, {
type: file.mimeType, type: file.mimeType,
disposition: `attachment; filename="${file.name.replace(/"/g, '')}"`, disposition: `attachment; filename="${file.name.replace(/"/g, '')}"`,
}); });
} }
} }

View File

@ -1,9 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { FilesController } from './files.controller'; import { FilesController } from './files.controller';
import { FilesService } from './files.service'; import { FilesService } from './files.service';
@Module({ @Module({
controllers: [FilesController], controllers: [FilesController],
providers: [FilesService], providers: [FilesService],
}) })
export class FilesModule {} export class FilesModule {}

View File

@ -1,105 +1,105 @@
import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { createReadStream } from 'node:fs'; import { createReadStream } from 'node:fs';
import { mkdir, unlink, writeFile } from 'node:fs/promises'; import { mkdir, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
import { tenantClient } from '../tenancy/actor'; import { tenantClient } from '../tenancy/actor';
import { TenantConnectionService } from '../tenancy/tenant-connection.service'; import { TenantConnectionService } from '../tenancy/tenant-connection.service';
import { fileExtension, isAllowedUpload, safeOriginalName } from './file-allow'; import { fileExtension, isAllowedUpload, safeOriginalName } from './file-allow';
const MAX_BYTES = 8 * 1024 * 1024; const MAX_BYTES = 8 * 1024 * 1024;
type IncomingFile = { type IncomingFile = {
originalname: string; originalname: string;
mimetype: string; mimetype: string;
size: number; size: number;
buffer: Buffer; buffer: Buffer;
}; };
@Injectable() @Injectable()
export class FilesService { export class FilesService {
constructor( constructor(
private readonly tenants: TenantConnectionService, private readonly tenants: TenantConnectionService,
private readonly config: ConfigService, private readonly config: ConfigService,
) {} ) {}
async list(actor: AuthUser) { async list(actor: AuthUser) {
const db = await tenantClient(this.tenants, actor); const db = await tenantClient(this.tenants, actor);
const rows = await db.operationFile.findMany({ orderBy: { createdAt: 'desc' } }); const rows = await db.operationFile.findMany({ orderBy: { createdAt: 'desc' } });
return rows.map((row) => ({ return rows.map((row) => ({
id: row.id, id: row.id,
name: row.originalName, name: row.originalName,
type: fileExtension(row.originalName).toUpperCase() || '-', type: fileExtension(row.originalName).toUpperCase() || '-',
sizeBytes: row.sizeBytes, sizeBytes: row.sizeBytes,
createdAt: row.createdAt, createdAt: row.createdAt,
})); }));
} }
async create(actor: AuthUser, file: IncomingFile | undefined) { async create(actor: AuthUser, file: IncomingFile | undefined) {
if (!file?.buffer?.length) { if (!file?.buffer?.length) {
throw new BadRequestException('Selecione um arquivo.'); throw new BadRequestException('Selecione um arquivo.');
} }
if (file.size > MAX_BYTES) { if (file.size > MAX_BYTES) {
throw new BadRequestException('Arquivo acima de 8 MB.'); throw new BadRequestException('Arquivo acima de 8 MB.');
} }
if (!isAllowedUpload(file.originalname)) { if (!isAllowedUpload(file.originalname)) {
throw new BadRequestException('Use CSV, XLSX, XLS, PDF ou TXT.'); throw new BadRequestException('Use CSV, XLSX, XLS, PDF ou TXT.');
} }
const slug = actor.tenantSlug; const slug = actor.tenantSlug;
if (!slug) { if (!slug) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
const id = randomUUID(); const id = randomUUID();
const originalName = safeOriginalName(file.originalname); const originalName = safeOriginalName(file.originalname);
const storedName = `${id}.${fileExtension(originalName)}`; const storedName = `${id}.${fileExtension(originalName)}`;
const folder = this.folder(slug); const folder = this.folder(slug);
await mkdir(folder, { recursive: true }); await mkdir(folder, { recursive: true });
const diskPath = join(folder, storedName); const diskPath = join(folder, storedName);
await writeFile(diskPath, file.buffer); await writeFile(diskPath, file.buffer);
try { try {
const db = await tenantClient(this.tenants, actor); const db = await tenantClient(this.tenants, actor);
const row = await db.operationFile.create({ const row = await db.operationFile.create({
data: { data: {
id, id,
originalName, originalName,
storedName, storedName,
mimeType: file.mimetype || 'application/octet-stream', mimeType: file.mimetype || 'application/octet-stream',
sizeBytes: file.size, sizeBytes: file.size,
}, },
}); });
await db.auditLog.create({ await db.auditLog.create({
data: { userId: actor.sub, action: 'file.create', entity: 'OperationFile', entityId: row.id }, data: { userId: actor.sub, action: 'file.create', entity: 'OperationFile', entityId: row.id },
}); });
return { id: row.id, name: row.originalName, type: fileExtension(row.originalName).toUpperCase(), sizeBytes: row.sizeBytes, createdAt: row.createdAt }; return { id: row.id, name: row.originalName, type: fileExtension(row.originalName).toUpperCase(), sizeBytes: row.sizeBytes, createdAt: row.createdAt };
} catch (error) { } catch (error) {
await unlink(diskPath).catch(() => undefined); await unlink(diskPath).catch(() => undefined);
throw error; throw error;
} }
} }
async download(actor: AuthUser, id: string) { async download(actor: AuthUser, id: string) {
const slug = actor.tenantSlug; const slug = actor.tenantSlug;
if (!slug) { if (!slug) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
const db = await tenantClient(this.tenants, actor); const db = await tenantClient(this.tenants, actor);
const row = await db.operationFile.findUnique({ where: { id } }); const row = await db.operationFile.findUnique({ where: { id } });
if (!row) { if (!row) {
throw new NotFoundException('Arquivo não encontrado.'); throw new NotFoundException('Arquivo não encontrado.');
} }
return { return {
name: row.originalName, name: row.originalName,
mimeType: row.mimeType, mimeType: row.mimeType,
stream: createReadStream(join(this.folder(slug), row.storedName)), stream: createReadStream(join(this.folder(slug), row.storedName)),
}; };
} }
private folder(slug: string) { private folder(slug: string) {
const root = this.config.get<string>('UPLOAD_DIR') ?? join(process.cwd(), 'uploads'); const root = this.config.get<string>('UPLOAD_DIR') ?? join(process.cwd(), 'uploads');
return join(root, slug); return join(root, slug);
} }
} }

View File

@ -1,47 +1,47 @@
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsNumber, IsOptional, IsString, IsUUID, Matches, Min } from 'class-validator'; import { IsNumber, IsOptional, IsString, IsUUID, Matches, Min } from 'class-validator';
const toNumber = ({ value }: { value: unknown }) => Number(value); const toNumber = ({ value }: { value: unknown }) => Number(value);
const zip = ({ value }: { value: unknown }) => const zip = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.replace(/\D/g, '').slice(0, 8) : value; typeof value === 'string' ? value.replace(/\D/g, '').slice(0, 8) : value;
export class SimulateFreightDto { export class SimulateFreightDto {
@Transform(zip) @Transform(zip)
@IsString() @IsString()
@Matches(/^\d{8}$/) @Matches(/^\d{8}$/)
originZip!: string; originZip!: string;
@Transform(zip) @Transform(zip)
@IsString() @IsString()
@Matches(/^\d{8}$/) @Matches(/^\d{8}$/)
destinationZip!: string; destinationZip!: string;
@Transform(toNumber) @Transform(toNumber)
@IsNumber() @IsNumber()
@Min(0.001) @Min(0.001)
weightKg!: number; weightKg!: number;
@Transform(toNumber) @Transform(toNumber)
@IsNumber() @IsNumber()
@Min(1) @Min(1)
widthCm!: number; widthCm!: number;
@Transform(toNumber) @Transform(toNumber)
@IsNumber() @IsNumber()
@Min(1) @Min(1)
heightCm!: number; heightCm!: number;
@Transform(toNumber) @Transform(toNumber)
@IsNumber() @IsNumber()
@Min(1) @Min(1)
lengthCm!: number; lengthCm!: number;
@Transform(toNumber) @Transform(toNumber)
@IsNumber() @IsNumber()
@Min(0) @Min(0)
cargoValue!: number; cargoValue!: number;
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
customerId?: string; customerId?: string;
} }

View File

@ -1,50 +1,50 @@
import { chargeableWeightKg, haversineKm, quoteCarrier, rankQuotes } from './freight-calculator'; import { chargeableWeightKg, haversineKm, quoteCarrier, rankQuotes } from './freight-calculator';
describe('freight calculator', () => { describe('freight calculator', () => {
const cargo = { const cargo = {
weightKg: 10, weightKg: 10,
widthCm: 40, widthCm: 40,
heightCm: 30, heightCm: 30,
lengthCm: 50, lengthCm: 50,
cargoValue: 1000, cargoValue: 1000,
distanceKm: 200, distanceKm: 200,
}; };
it('uses volumetric weight when it exceeds real weight', () => { it('uses volumetric weight when it exceeds real weight', () => {
expect(chargeableWeightKg({ weightKg: 1, widthCm: 100, heightCm: 100, lengthCm: 100 })).toBe(166.667); expect(chargeableWeightKg({ weightKg: 1, widthCm: 100, heightCm: 100, lengthCm: 100 })).toBe(166.667);
}); });
it('quotes base + kg + km + ad valorem', () => { it('quotes base + kg + km + ad valorem', () => {
const quote = quoteCarrier( const quote = quoteCarrier(
{ id: '1', name: 'Rota Sul', baseFee: 30, pricePerKg: 2, pricePerKm: 0.5, adValoremRate: 0.01 }, { id: '1', name: 'Rota Sul', baseFee: 30, pricePerKg: 2, pricePerKm: 0.5, adValoremRate: 0.01 },
cargo, cargo,
); );
expect(quote.price).toBe(160); expect(quote.price).toBe(160);
}); });
it('ranks cheapest first', () => { it('ranks cheapest first', () => {
const ranked = rankQuotes([ const ranked = rankQuotes([
{ carrierId: 'a', carrierName: 'A', price: 90, chargeableKg: 10 }, { carrierId: 'a', carrierName: 'A', price: 90, chargeableKg: 10 },
{ carrierId: 'b', carrierName: 'B', price: 40, chargeableKg: 10 }, { carrierId: 'b', carrierName: 'B', price: 40, chargeableKg: 10 },
]); ]);
expect(ranked[0].carrierId).toBe('b'); expect(ranked[0].carrierId).toBe('b');
}); });
it('uses real weight when it exceeds volumetric weight', () => { it('uses real weight when it exceeds volumetric weight', () => {
expect(chargeableWeightKg({ weightKg: 80, widthCm: 10, heightCm: 10, lengthCm: 10 })).toBe(80); expect(chargeableWeightKg({ weightKg: 80, widthCm: 10, heightCm: 10, lengthCm: 10 })).toBe(80);
}); });
it('never quotes a negative price', () => { it('never quotes a negative price', () => {
const quote = quoteCarrier( const quote = quoteCarrier(
{ id: '1', name: 'X', baseFee: 0, pricePerKg: 0, pricePerKm: 0, adValoremRate: 0 }, { id: '1', name: 'X', baseFee: 0, pricePerKg: 0, pricePerKm: 0, adValoremRate: 0 },
{ ...cargo, cargoValue: 0, distanceKm: 0, weightKg: 0, widthCm: 0, heightCm: 0, lengthCm: 0 }, { ...cargo, cargoValue: 0, distanceKm: 0, weightKg: 0, widthCm: 0, heightCm: 0, lengthCm: 0 },
); );
expect(quote.price).toBe(0); expect(quote.price).toBe(0);
}); });
it('computes haversine distance in km', () => { it('computes haversine distance in km', () => {
const km = haversineKm({ lat: -23.55, lon: -46.63 }, { lat: -22.9, lon: -43.2 }); const km = haversineKm({ lat: -23.55, lon: -46.63 }, { lat: -22.9, lon: -43.2 });
expect(km).toBeGreaterThan(350); expect(km).toBeGreaterThan(350);
expect(km).toBeLessThan(450); expect(km).toBeLessThan(450);
}); });
}); });

View File

@ -1,68 +1,68 @@
export type CarrierRate = { export type CarrierRate = {
id: string; id: string;
name: string; name: string;
baseFee: number; baseFee: number;
pricePerKg: number; pricePerKg: number;
pricePerKm: number; pricePerKm: number;
adValoremRate: number; adValoremRate: number;
}; };
export type CargoInput = { export type CargoInput = {
weightKg: number; weightKg: number;
widthCm: number; widthCm: number;
heightCm: number; heightCm: number;
lengthCm: number; lengthCm: number;
cargoValue: number; cargoValue: number;
distanceKm: number; distanceKm: number;
}; };
export type CarrierQuote = { export type CarrierQuote = {
carrierId: string; carrierId: string;
carrierName: string; carrierName: string;
price: number; price: number;
chargeableKg: number; chargeableKg: number;
}; };
const VOLUME_DIVISOR = 6000; const VOLUME_DIVISOR = 6000;
export function chargeableWeightKg(input: Pick<CargoInput, 'weightKg' | 'widthCm' | 'heightCm' | 'lengthCm'>): number { export function chargeableWeightKg(input: Pick<CargoInput, 'weightKg' | 'widthCm' | 'heightCm' | 'lengthCm'>): number {
const volumetric = (input.widthCm * input.heightCm * input.lengthCm) / VOLUME_DIVISOR; const volumetric = (input.widthCm * input.heightCm * input.lengthCm) / VOLUME_DIVISOR;
return round3(Math.max(input.weightKg, volumetric)); return round3(Math.max(input.weightKg, volumetric));
} }
export function quoteCarrier(carrier: CarrierRate, cargo: CargoInput): CarrierQuote { export function quoteCarrier(carrier: CarrierRate, cargo: CargoInput): CarrierQuote {
const chargeableKg = chargeableWeightKg(cargo); const chargeableKg = chargeableWeightKg(cargo);
const raw = const raw =
carrier.baseFee + carrier.baseFee +
carrier.pricePerKg * chargeableKg + carrier.pricePerKg * chargeableKg +
carrier.pricePerKm * cargo.distanceKm + carrier.pricePerKm * cargo.distanceKm +
carrier.adValoremRate * cargo.cargoValue; carrier.adValoremRate * cargo.cargoValue;
return { return {
carrierId: carrier.id, carrierId: carrier.id,
carrierName: carrier.name, carrierName: carrier.name,
chargeableKg, chargeableKg,
price: round2(Math.max(raw, 0)), price: round2(Math.max(raw, 0)),
}; };
} }
export function rankQuotes(quotes: CarrierQuote[]): CarrierQuote[] { export function rankQuotes(quotes: CarrierQuote[]): CarrierQuote[] {
return [...quotes].sort((a, b) => a.price - b.price); return [...quotes].sort((a, b) => a.price - b.price);
} }
export function haversineKm(a: { lat: number; lon: number }, b: { lat: number; lon: number }): number { export function haversineKm(a: { lat: number; lon: number }, b: { lat: number; lon: number }): number {
const toRad = (value: number) => (value * Math.PI) / 180; const toRad = (value: number) => (value * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat); const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon); const dLon = toRad(b.lon - a.lon);
const sinLat = Math.sin(dLat / 2); const sinLat = Math.sin(dLat / 2);
const sinLon = Math.sin(dLon / 2); const sinLon = Math.sin(dLon / 2);
const h = sinLat * sinLat + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * sinLon * sinLon; const h = sinLat * sinLat + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * sinLon * sinLon;
return round2(6371 * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h))); return round2(6371 * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)));
} }
function round2(value: number): number { function round2(value: number): number {
return Math.round(value * 100) / 100; return Math.round(value * 100) / 100;
} }
function round3(value: number): number { function round3(value: number): number {
return Math.round(value * 1000) / 1000; return Math.round(value * 1000) / 1000;
} }

View File

@ -1,26 +1,26 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express'; import type { Request } from 'express';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { SimulateFreightDto } from './dto/simulate-freight.dto'; import { SimulateFreightDto } from './dto/simulate-freight.dto';
import { FreightService } from './freight.service'; import { FreightService } from './freight.service';
@Controller('freight') @Controller('freight')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN', 'MANAGER', 'OPERATOR') @Roles('ADMIN', 'MANAGER', 'OPERATOR')
export class FreightController { export class FreightController {
constructor(private readonly freight: FreightService) {} constructor(private readonly freight: FreightService) {}
@Get() @Get()
history(@CurrentUser() user: AuthUser) { history(@CurrentUser() user: AuthUser) {
return this.freight.history(user); return this.freight.history(user);
} }
@Post('simulate') @Post('simulate')
simulate(@CurrentUser() user: AuthUser, @Body() dto: SimulateFreightDto, @Req() request: Request) { simulate(@CurrentUser() user: AuthUser, @Body() dto: SimulateFreightDto, @Req() request: Request) {
return this.freight.simulate(user, dto, request.ip); return this.freight.simulate(user, dto, request.ip);
} }
} }

View File

@ -1,11 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { GeoModule } from '../geo/geo.module'; import { GeoModule } from '../geo/geo.module';
import { FreightController } from './freight.controller'; import { FreightController } from './freight.controller';
import { FreightService } from './freight.service'; import { FreightService } from './freight.service';
@Module({ @Module({
imports: [GeoModule], imports: [GeoModule],
controllers: [FreightController], controllers: [FreightController],
providers: [FreightService], providers: [FreightService],
}) })
export class FreightModule {} export class FreightModule {}

View File

@ -1,144 +1,144 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import type { Prisma } from '../generated/tenant'; import type { Prisma } from '../generated/tenant';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
import { GeoService } from '../geo/geo.service'; import { GeoService } from '../geo/geo.service';
import { tenantClient } from '../tenancy/actor'; import { tenantClient } from '../tenancy/actor';
import { TenantConnectionService } from '../tenancy/tenant-connection.service'; import { TenantConnectionService } from '../tenancy/tenant-connection.service';
import type { SimulateFreightDto } from './dto/simulate-freight.dto'; import type { SimulateFreightDto } from './dto/simulate-freight.dto';
import { quoteCarrier, rankQuotes, type CarrierQuote } from './freight-calculator'; import { quoteCarrier, rankQuotes, type CarrierQuote } from './freight-calculator';
@Injectable() @Injectable()
export class FreightService { export class FreightService {
constructor( constructor(
private readonly tenants: TenantConnectionService, private readonly tenants: TenantConnectionService,
private readonly geo: GeoService, private readonly geo: GeoService,
) {} ) {}
async history(actor: AuthUser) { async history(actor: AuthUser) {
const db = await tenantClient(this.tenants, actor); const db = await tenantClient(this.tenants, actor);
const rows = await db.freightSimulation.findMany({ const rows = await db.freightSimulation.findMany({
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 50, take: 50,
include: { customer: { select: { id: true, name: true } } }, include: { customer: { select: { id: true, name: true } } },
}); });
return rows.map(serializeSimulation); return rows.map(serializeSimulation);
} }
async simulate(actor: AuthUser, dto: SimulateFreightDto, ip?: string) { async simulate(actor: AuthUser, dto: SimulateFreightDto, ip?: string) {
const db = await tenantClient(this.tenants, actor); const db = await tenantClient(this.tenants, actor);
const carriers = await db.carrier.findMany({ where: { active: true } }); const carriers = await db.carrier.findMany({ where: { active: true } });
if (!carriers.length) { if (!carriers.length) {
throw new BadRequestException('Cadastre ao menos uma transportadora ativa.'); throw new BadRequestException('Cadastre ao menos uma transportadora ativa.');
} }
if (dto.customerId) { if (dto.customerId) {
const customer = await db.customer.findUnique({ where: { id: dto.customerId } }); const customer = await db.customer.findUnique({ where: { id: dto.customerId } });
if (!customer) { if (!customer) {
throw new BadRequestException('Cliente não encontrado.'); throw new BadRequestException('Cliente não encontrado.');
} }
} }
const route = await this.geo.distanceKm(dto.originZip, dto.destinationZip); const route = await this.geo.distanceKm(dto.originZip, dto.destinationZip);
const cargo = { const cargo = {
weightKg: dto.weightKg, weightKg: dto.weightKg,
widthCm: dto.widthCm, widthCm: dto.widthCm,
heightCm: dto.heightCm, heightCm: dto.heightCm,
lengthCm: dto.lengthCm, lengthCm: dto.lengthCm,
cargoValue: dto.cargoValue, cargoValue: dto.cargoValue,
distanceKm: route.km, distanceKm: route.km,
}; };
const quotes = rankQuotes( const quotes = rankQuotes(
carriers.map((carrier) => carriers.map((carrier) =>
quoteCarrier( quoteCarrier(
{ {
id: carrier.id, id: carrier.id,
name: carrier.name, name: carrier.name,
baseFee: Number(carrier.baseFee), baseFee: Number(carrier.baseFee),
pricePerKg: Number(carrier.pricePerKg), pricePerKg: Number(carrier.pricePerKg),
pricePerKm: Number(carrier.pricePerKm), pricePerKm: Number(carrier.pricePerKm),
adValoremRate: Number(carrier.adValoremRate), adValoremRate: Number(carrier.adValoremRate),
}, },
cargo, cargo,
), ),
), ),
); );
const best = quotes[0]; const best = quotes[0];
const saved = await db.freightSimulation.create({ const saved = await db.freightSimulation.create({
data: { data: {
originZip: route.origin.zip, originZip: route.origin.zip,
destinationZip: route.destination.zip, destinationZip: route.destination.zip,
originLabel: route.origin.label, originLabel: route.origin.label,
destinationLabel: route.destination.label, destinationLabel: route.destination.label,
weightKg: dto.weightKg, weightKg: dto.weightKg,
widthCm: dto.widthCm, widthCm: dto.widthCm,
heightCm: dto.heightCm, heightCm: dto.heightCm,
lengthCm: dto.lengthCm, lengthCm: dto.lengthCm,
cargoValue: dto.cargoValue, cargoValue: dto.cargoValue,
distanceKm: route.km, distanceKm: route.km,
chargeableKg: best.chargeableKg, chargeableKg: best.chargeableKg,
quotedPrice: best.price, quotedPrice: best.price,
bestCarrierName: best.carrierName, bestCarrierName: best.carrierName,
quotes: quotes as unknown as Prisma.InputJsonValue, quotes: quotes as unknown as Prisma.InputJsonValue,
status: 'CALCULATED', status: 'CALCULATED',
customerId: dto.customerId, customerId: dto.customerId,
}, },
include: { customer: { select: { id: true, name: true } } }, include: { customer: { select: { id: true, name: true } } },
}); });
await db.auditLog.create({ await db.auditLog.create({
data: { data: {
userId: actor.sub, userId: actor.sub,
action: 'freight.simulate', action: 'freight.simulate',
entity: 'FreightSimulation', entity: 'FreightSimulation',
entityId: saved.id, entityId: saved.id,
ip, ip,
metadata: { best: best.carrierName, price: best.price, km: route.km }, metadata: { best: best.carrierName, price: best.price, km: route.km },
}, },
}); });
return { ...serializeSimulation(saved), distanceSource: route.source }; return { ...serializeSimulation(saved), distanceSource: route.source };
} }
} }
function serializeSimulation(row: { function serializeSimulation(row: {
id: string; id: string;
originZip: string; originZip: string;
destinationZip: string; destinationZip: string;
originLabel: string | null; originLabel: string | null;
destinationLabel: string | null; destinationLabel: string | null;
weightKg: { toString(): string }; weightKg: { toString(): string };
widthCm: { toString(): string }; widthCm: { toString(): string };
heightCm: { toString(): string }; heightCm: { toString(): string };
lengthCm: { toString(): string }; lengthCm: { toString(): string };
cargoValue: { toString(): string }; cargoValue: { toString(): string };
quotedPrice: { toString(): string } | null; quotedPrice: { toString(): string } | null;
distanceKm: { toString(): string } | null; distanceKm: { toString(): string } | null;
chargeableKg: { toString(): string } | null; chargeableKg: { toString(): string } | null;
bestCarrierName: string | null; bestCarrierName: string | null;
quotes: unknown; quotes: unknown;
status: string; status: string;
createdAt: Date; createdAt: Date;
customer: { id: string; name: string } | null; customer: { id: string; name: string } | null;
}) { }) {
return { return {
id: row.id, id: row.id,
originZip: row.originZip, originZip: row.originZip,
destinationZip: row.destinationZip, destinationZip: row.destinationZip,
originLabel: row.originLabel, originLabel: row.originLabel,
destinationLabel: row.destinationLabel, destinationLabel: row.destinationLabel,
weightKg: Number(row.weightKg), weightKg: Number(row.weightKg),
widthCm: Number(row.widthCm), widthCm: Number(row.widthCm),
heightCm: Number(row.heightCm), heightCm: Number(row.heightCm),
lengthCm: Number(row.lengthCm), lengthCm: Number(row.lengthCm),
cargoValue: Number(row.cargoValue), cargoValue: Number(row.cargoValue),
quotedPrice: row.quotedPrice === null ? null : Number(row.quotedPrice), quotedPrice: row.quotedPrice === null ? null : Number(row.quotedPrice),
distanceKm: row.distanceKm === null ? null : Number(row.distanceKm), distanceKm: row.distanceKm === null ? null : Number(row.distanceKm),
chargeableKg: row.chargeableKg === null ? null : Number(row.chargeableKg), chargeableKg: row.chargeableKg === null ? null : Number(row.chargeableKg),
bestCarrierName: row.bestCarrierName, bestCarrierName: row.bestCarrierName,
quotes: (row.quotes as CarrierQuote[] | null) ?? [], quotes: (row.quotes as CarrierQuote[] | null) ?? [],
status: row.status, status: row.status,
createdAt: row.createdAt, createdAt: row.createdAt,
customer: row.customer, customer: row.customer,
}; };
} }

View File

@ -1,8 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { GeoService } from './geo.service'; import { GeoService } from './geo.service';
@Module({ @Module({
providers: [GeoService], providers: [GeoService],
exports: [GeoService], exports: [GeoService],
}) })
export class GeoModule {} export class GeoModule {}

View File

@ -1,84 +1,84 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { RedisService } from '../redis/redis.service'; import { RedisService } from '../redis/redis.service';
import { digitsZip, fallbackDistanceKm, type ZipPlace } from './geo.types'; import { digitsZip, fallbackDistanceKm, type ZipPlace } from './geo.types';
import { haversineKm } from '../freight/freight-calculator'; import { haversineKm } from '../freight/freight-calculator';
@Injectable() @Injectable()
export class GeoService { export class GeoService {
constructor(private readonly redis: RedisService) {} constructor(private readonly redis: RedisService) {}
async placeFromZip(zip: string): Promise<ZipPlace> { async placeFromZip(zip: string): Promise<ZipPlace> {
const cep = digitsZip(zip); const cep = digitsZip(zip);
if (!/^\d{8}$/.test(cep)) { if (!/^\d{8}$/.test(cep)) {
throw new BadRequestException('CEP inválido.'); throw new BadRequestException('CEP inválido.');
} }
const cached = await this.redis.getJson<ZipPlace>(`geo:cep:${cep}`); const cached = await this.redis.getJson<ZipPlace>(`geo:cep:${cep}`);
if (cached) { if (cached) {
return cached; return cached;
} }
const viaCep = await this.lookupViaCep(cep); const viaCep = await this.lookupViaCep(cep);
const coords = await this.lookupNominatim(viaCep.city, viaCep.state); const coords = await this.lookupNominatim(viaCep.city, viaCep.state);
const place: ZipPlace = { const place: ZipPlace = {
zip: cep, zip: cep,
city: viaCep.city, city: viaCep.city,
state: viaCep.state, state: viaCep.state,
label: `${viaCep.city}/${viaCep.state}`, label: `${viaCep.city}/${viaCep.state}`,
lat: coords?.lat ?? 0, lat: coords?.lat ?? 0,
lon: coords?.lon ?? 0, lon: coords?.lon ?? 0,
}; };
await this.redis.setJson(`geo:cep:${cep}`, place, 60 * 60 * 24 * 7); await this.redis.setJson(`geo:cep:${cep}`, place, 60 * 60 * 24 * 7);
return place; return place;
} }
async distanceKm(originZip: string, destinationZip: string): Promise<{ async distanceKm(originZip: string, destinationZip: string): Promise<{
origin: ZipPlace; origin: ZipPlace;
destination: ZipPlace; destination: ZipPlace;
km: number; km: number;
source: 'nominatim' | 'fallback'; source: 'nominatim' | 'fallback';
}> { }> {
const origin = await this.placeFromZip(originZip); const origin = await this.placeFromZip(originZip);
const destination = await this.placeFromZip(destinationZip); const destination = await this.placeFromZip(destinationZip);
if (origin.lat && origin.lon && destination.lat && destination.lon) { if (origin.lat && origin.lon && destination.lat && destination.lon) {
return { origin, destination, km: Math.max(haversineKm(origin, destination), 1), source: 'nominatim' }; return { origin, destination, km: Math.max(haversineKm(origin, destination), 1), source: 'nominatim' };
} }
return { origin, destination, km: fallbackDistanceKm(origin, destination), source: 'fallback' }; return { origin, destination, km: fallbackDistanceKm(origin, destination), source: 'fallback' };
} }
private async lookupViaCep(cep: string): Promise<{ city: string; state: string }> { private async lookupViaCep(cep: string): Promise<{ city: string; state: string }> {
const response = await fetch(`https://viacep.com.br/ws/${cep}/json/`, { const response = await fetch(`https://viacep.com.br/ws/${cep}/json/`, {
signal: AbortSignal.timeout(8000), signal: AbortSignal.timeout(8000),
}); });
if (!response.ok) { if (!response.ok) {
throw new BadRequestException('Não foi possível consultar o CEP.'); throw new BadRequestException('Não foi possível consultar o CEP.');
} }
const data = (await response.json()) as { erro?: boolean; localidade?: string; uf?: string }; const data = (await response.json()) as { erro?: boolean; localidade?: string; uf?: string };
if (data.erro || !data.localidade || !data.uf) { if (data.erro || !data.localidade || !data.uf) {
throw new BadRequestException('CEP não encontrado.'); throw new BadRequestException('CEP não encontrado.');
} }
return { city: data.localidade, state: data.uf }; return { city: data.localidade, state: data.uf };
} }
private async lookupNominatim(city: string, state: string): Promise<{ lat: number; lon: number } | null> { private async lookupNominatim(city: string, state: string): Promise<{ lat: number; lon: number } | null> {
const query = new URLSearchParams({ const query = new URLSearchParams({
format: 'json', format: 'json',
limit: '1', limit: '1',
countrycodes: 'br', countrycodes: 'br',
q: `${city}, ${state}, Brasil`, q: `${city}, ${state}, Brasil`,
}); });
const response = await fetch(`https://nominatim.openstreetmap.org/search?${query.toString()}`, { const response = await fetch(`https://nominatim.openstreetmap.org/search?${query.toString()}`, {
headers: { 'User-Agent': 'SinkaLogistica/1.0 (desafio; tina.r@example.net)' }, headers: { 'User-Agent': 'SinkaLogistica/1.0 (desafio; tina.r@example.net)' },
signal: AbortSignal.timeout(8000), signal: AbortSignal.timeout(8000),
}); });
if (!response.ok) { if (!response.ok) {
return null; return null;
} }
const rows = (await response.json()) as Array<{ lat: string; lon: string }>; const rows = (await response.json()) as Array<{ lat: string; lon: string }>;
const first = rows[0]; const first = rows[0];
if (!first) { if (!first) {
return null; return null;
} }
return { lat: Number(first.lat), lon: Number(first.lon) }; return { lat: Number(first.lat), lon: Number(first.lon) };
} }
} }

View File

@ -1,31 +1,31 @@
import { digitsZip, fallbackDistanceKm, type ZipPlace } from './geo.types'; import { digitsZip, fallbackDistanceKm, type ZipPlace } from './geo.types';
const place = (city: string, state: string): ZipPlace => ({ const place = (city: string, state: string): ZipPlace => ({
zip: '01001000', zip: '01001000',
label: `${city}/${state}`, label: `${city}/${state}`,
city, city,
state, state,
lat: 0, lat: 0,
lon: 0, lon: 0,
}); });
describe('digitsZip', () => { describe('digitsZip', () => {
it('keeps only the last 8 digits', () => { it('keeps only the last 8 digits', () => {
expect(digitsZip('01310-100')).toBe('01310100'); expect(digitsZip('01310-100')).toBe('01310100');
expect(digitsZip('1310100')).toBe('01310100'); expect(digitsZip('1310100')).toBe('01310100');
}); });
}); });
describe('fallbackDistanceKm', () => { describe('fallbackDistanceKm', () => {
it('uses a short hop in the same city', () => { it('uses a short hop in the same city', () => {
expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('São Paulo', 'SP'))).toBe(12); expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('São Paulo', 'SP'))).toBe(12);
}); });
it('uses a regional hop in the same state', () => { it('uses a regional hop in the same state', () => {
expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('Campinas', 'SP'))).toBe(180); expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('Campinas', 'SP'))).toBe(180);
}); });
it('uses a long hop between states', () => { it('uses a long hop between states', () => {
expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('Rio de Janeiro', 'RJ'))).toBe(850); expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('Rio de Janeiro', 'RJ'))).toBe(850);
}); });
}); });

View File

@ -1,22 +1,22 @@
export type ZipPlace = { export type ZipPlace = {
zip: string; zip: string;
label: string; label: string;
city: string; city: string;
state: string; state: string;
lat: number; lat: number;
lon: number; lon: number;
}; };
export function digitsZip(value: string): string { export function digitsZip(value: string): string {
return value.replace(/\D/g, '').padStart(8, '0').slice(-8); return value.replace(/\D/g, '').padStart(8, '0').slice(-8);
} }
export function fallbackDistanceKm(origin: ZipPlace, destination: ZipPlace): number { export function fallbackDistanceKm(origin: ZipPlace, destination: ZipPlace): number {
if (origin.city === destination.city && origin.state === destination.state) { if (origin.city === destination.city && origin.state === destination.state) {
return 12; return 12;
} }
if (origin.state === destination.state) { if (origin.state === destination.state) {
return 180; return 180;
} }
return 850; return 850;
} }

View File

@ -1,22 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
describe('HealthController', () => { describe('HealthController', () => {
let controller: HealthController; let controller: HealthController;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [HealthController], controllers: [HealthController],
}).compile(); }).compile();
controller = module.get(HealthController); controller = module.get(HealthController);
}); });
it('returns api health payload', () => { it('returns api health payload', () => {
expect(controller.check()).toEqual({ expect(controller.check()).toEqual({
status: 'ok', status: 'ok',
service: 'sinka-api', service: 'sinka-api',
stack: 'nestjs', stack: 'nestjs',
}); });
}); });
}); });

View File

@ -1,13 +1,13 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
@Get() @Get()
check() { check() {
return { return {
status: 'ok', status: 'ok',
service: 'sinka-api', service: 'sinka-api',
stack: 'nestjs', stack: 'nestjs',
}; };
} }
} }

View File

@ -1,7 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
@Module({ @Module({
controllers: [HealthController], controllers: [HealthController],
}) })
export class HealthModule {} export class HealthModule {}

View File

@ -1,26 +1,26 @@
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
app.use(cookieParser()); app.use(cookieParser());
app.enableCors({ app.enableCors({
origin: process.env.WEB_ORIGIN ?? 'http://localhost:3000', origin: process.env.WEB_ORIGIN ?? 'http://localhost:3000',
credentials: true, credentials: true,
}); });
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
whitelist: true, whitelist: true,
transform: true, transform: true,
forbidNonWhitelisted: true, forbidNonWhitelisted: true,
}), }),
); );
await app.listen(process.env.PORT ?? 3001); await app.listen(process.env.PORT ?? 3001);
} }
void bootstrap(); void bootstrap();

View File

@ -1,12 +1,12 @@
import { IsString, Matches, MaxLength, MinLength } from 'class-validator'; import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
export class CreateTenantDto { export class CreateTenantDto {
@IsString() @IsString()
@MinLength(2) @MinLength(2)
@MaxLength(80) @MaxLength(80)
name!: string; name!: string;
@IsString() @IsString()
@Matches(/^[a-z][a-z0-9-]{1,30}$/) @Matches(/^[a-z][a-z0-9-]{1,30}$/)
slug!: string; slug!: string;
} }

View File

@ -1,17 +0,0 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { IntegrationsService } from './integrations.service';
@Controller('platform')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('PLATFORM_ADMIN')
export class IntegrationsController {
constructor(private readonly integrations: IntegrationsService) {}
@Get('integrations')
list() {
return this.integrations.list();
}
}

View File

@ -1,80 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { buildIntegrations, type Integration } from './integrations';
@Injectable()
export class IntegrationsService {
constructor(private readonly config: ConfigService) {}
async list(): Promise<{ items: Integration[] }> {
const [viaCep, nominatim, firebase] = await Promise.all([
this.probeViaCep(),
this.probeNominatim(),
this.probeFirebase(),
]);
return {
items: buildIntegrations({
viaCep,
nominatim,
google: this.googleConfigured(),
firebase,
}),
};
}
private googleConfigured() {
return Boolean(this.config.get<string>('GOOGLE_CLIENT_ID') && this.config.get<string>('GOOGLE_CLIENT_SECRET'));
}
private async probeViaCep() {
return this.ok(async () => {
const response = await fetch('https://viacep.com.br/ws/01001000/json/', {
signal: AbortSignal.timeout(4000),
});
if (!response.ok) {
return false;
}
const data = (await response.json()) as { erro?: boolean; localidade?: string };
return !data.erro && Boolean(data.localidade);
});
}
private async probeNominatim() {
return this.ok(async () => {
const query = new URLSearchParams({
format: 'json',
limit: '1',
countrycodes: 'br',
q: 'Sao Paulo, SP, Brasil',
});
const response = await fetch(`https://nominatim.openstreetmap.org/search?${query.toString()}`, {
headers: { 'User-Agent': 'SinkaLogistica/1.0 (desafio; tina.r@example.net)' },
signal: AbortSignal.timeout(4000),
});
if (!response.ok) {
return false;
}
const rows = (await response.json()) as unknown[];
return rows.length > 0;
});
}
private async probeFirebase() {
const url = (this.config.get<string>('FIREBASE_DATABASE_URL')?.replace(/\/$/, '') ||
'https://sinka-8ec10-default-rtdb.firebaseio.com');
return this.ok(async () => {
const response = await fetch(`${url}/.json?shallow=true`, {
signal: AbortSignal.timeout(4000),
});
return response.ok;
});
}
private async ok(probe: () => Promise<boolean>) {
try {
return await probe();
} catch {
return false;
}
}
}

View File

@ -1,19 +0,0 @@
import { buildIntegrations } from './integrations';
describe('buildIntegrations', () => {
it('marks each integration from the probe result', () => {
expect(
buildIntegrations({
viaCep: true,
nominatim: false,
google: true,
firebase: false,
}),
).toEqual([
expect.objectContaining({ id: 'viacep', status: 'active' }),
expect.objectContaining({ id: 'nominatim', status: 'inactive' }),
expect.objectContaining({ id: 'google', status: 'active' }),
expect.objectContaining({ id: 'firebase', status: 'inactive' }),
]);
});
});

View File

@ -1,42 +0,0 @@
export type IntegrationStatus = 'active' | 'inactive';
export type Integration = {
id: string;
name: string;
description: string;
status: IntegrationStatus;
};
export function buildIntegrations(probes: {
viaCep: boolean;
nominatim: boolean;
google: boolean;
firebase: boolean;
}): Integration[] {
return [
{
id: 'viacep',
name: 'ViaCEP',
description: 'Cidade e UF a partir do CEP de origem e destino.',
status: probes.viaCep ? 'active' : 'inactive',
},
{
id: 'nominatim',
name: 'OpenStreetMap Nominatim',
description: 'Coordenadas da cidade para calcular a distância do frete.',
status: probes.nominatim ? 'active' : 'inactive',
},
{
id: 'google',
name: 'Google',
description: 'Login com conta Google no admin e nas empresas.',
status: probes.google ? 'active' : 'inactive',
},
{
id: 'firebase',
name: 'Firebase Realtime Database',
description: 'Chat em tempo real entre o admin e o cliente.',
status: probes.firebase ? 'active' : 'inactive',
},
];
}

View File

@ -1,17 +1,17 @@
import { Controller, Get, UseGuards } from '@nestjs/common'; import { Controller, Get, UseGuards } from '@nestjs/common';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { PlatformService } from './platform.service'; import { PlatformService } from './platform.service';
@Controller('platform') @Controller('platform')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('PLATFORM_ADMIN') @Roles('PLATFORM_ADMIN')
export class PlatformOverviewController { export class PlatformOverviewController {
constructor(private readonly platform: PlatformService) {} constructor(private readonly platform: PlatformService) {}
@Get('overview') @Get('overview')
overview() { overview() {
return this.platform.overview(); return this.platform.overview();
} }
} }

View File

@ -1,20 +1,20 @@
import { buildPlatformOverview } from './platform-overview'; import { buildPlatformOverview } from './platform-overview';
describe('buildPlatformOverview', () => { describe('buildPlatformOverview', () => {
it('counts clients, active companies and users', () => { it('counts clients, active companies and users', () => {
expect( expect(
buildPlatformOverview( buildPlatformOverview(
[ [
{ status: 'ACTIVE' }, { status: 'ACTIVE' },
{ status: 'ACTIVE' }, { status: 'ACTIVE' },
{ status: 'SUSPENDED' }, { status: 'SUSPENDED' },
], ],
8, 8,
), ),
).toEqual({ clients: 3, active: 2, users: 8 }); ).toEqual({ clients: 3, active: 2, users: 8 });
}); });
it('starts empty when the platform has no companies', () => { it('starts empty when the platform has no companies', () => {
expect(buildPlatformOverview([], 0)).toEqual({ clients: 0, active: 0, users: 0 }); expect(buildPlatformOverview([], 0)).toEqual({ clients: 0, active: 0, users: 0 });
}); });
}); });

View File

@ -1,16 +1,16 @@
export type PlatformOverview = { export type PlatformOverview = {
clients: number; clients: number;
active: number; active: number;
users: number; users: number;
}; };
export function buildPlatformOverview( export function buildPlatformOverview(
tenants: { status: string }[], tenants: { status: string }[],
users: number, users: number,
): PlatformOverview { ): PlatformOverview {
return { return {
clients: tenants.length, clients: tenants.length,
active: tenants.filter((tenant) => tenant.status === 'ACTIVE').length, active: tenants.filter((tenant) => tenant.status === 'ACTIVE').length,
users, users,
}; };
} }

View File

@ -1,41 +1,41 @@
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express'; import type { Request } from 'express';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { CreateTenantDto } from './dto/create-tenant.dto'; import { CreateTenantDto } from './dto/create-tenant.dto';
import { PlatformService } from './platform.service'; import { PlatformService } from './platform.service';
@Controller('platform/tenants') @Controller('platform/tenants')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('PLATFORM_ADMIN') @Roles('PLATFORM_ADMIN')
export class PlatformController { export class PlatformController {
constructor(private readonly platform: PlatformService) {} constructor(private readonly platform: PlatformService) {}
@Get() @Get()
list() { list() {
return this.platform.list(); return this.platform.list();
} }
@Get(':id/users') @Get(':id/users')
listUsers(@CurrentUser() user: AuthUser, @Param('id') id: string, @Req() request: Request) { listUsers(@CurrentUser() user: AuthUser, @Param('id') id: string, @Req() request: Request) {
return this.platform.listUsers(user.sub, id, request.ip); return this.platform.listUsers(user.sub, id, request.ip);
} }
@Post(':id/users/:userId/password') @Post(':id/users/:userId/password')
issuePassword( issuePassword(
@CurrentUser() user: AuthUser, @CurrentUser() user: AuthUser,
@Param('id') id: string, @Param('id') id: string,
@Param('userId') userId: string, @Param('userId') userId: string,
@Req() request: Request, @Req() request: Request,
) { ) {
return this.platform.issuePassword(user.sub, id, userId, request.ip); return this.platform.issuePassword(user.sub, id, userId, request.ip);
} }
@Post() @Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreateTenantDto, @Req() request: Request) { create(@CurrentUser() user: AuthUser, @Body() dto: CreateTenantDto, @Req() request: Request) {
return this.platform.create(user.sub, dto, request.ip); return this.platform.create(user.sub, dto, request.ip);
} }
} }

View File

@ -1,18 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { IntegrationsController } from './integrations.controller'; import { PlatformController } from './platform.controller';
import { IntegrationsService } from './integrations.service'; import { PlatformOverviewController } from './platform-overview.controller';
import { PlatformController } from './platform.controller'; import { PlatformService } from './platform.service';
import { PlatformOverviewController } from './platform-overview.controller'; import { PublicTenantsController } from './public-tenants.controller';
import { PlatformService } from './platform.service';
import { PublicTenantsController } from './public-tenants.controller'; @Module({
controllers: [PlatformController, PlatformOverviewController, PublicTenantsController],
@Module({ providers: [PlatformService],
controllers: [ })
PlatformController, export class PlatformModule {}
PlatformOverviewController,
IntegrationsController,
PublicTenantsController,
],
providers: [PlatformService, IntegrationsService],
})
export class PlatformModule {}

View File

@ -1,161 +1,161 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { PlatformPrismaService } from '../prisma/platform-prisma.service'; import { PlatformPrismaService } from '../prisma/platform-prisma.service';
import { createMysqlDatabase, databaseForSlug, pushTenantSchema } from '../tenancy/provision-database'; import { createMysqlDatabase, databaseForSlug, pushTenantSchema } from '../tenancy/provision-database';
import { publicTenantView } from '../tenancy/public-tenant'; import { publicTenantView } from '../tenancy/public-tenant';
import { TenantConnectionService } from '../tenancy/tenant-connection.service'; import { TenantConnectionService } from '../tenancy/tenant-connection.service';
import { normalizeSlug } from '../tenancy/tenant-url'; import { normalizeSlug } from '../tenancy/tenant-url';
import type { CreateTenantDto } from './dto/create-tenant.dto'; import type { CreateTenantDto } from './dto/create-tenant.dto';
import { buildPlatformOverview } from './platform-overview'; import { buildPlatformOverview } from './platform-overview';
@Injectable() @Injectable()
export class PlatformService { export class PlatformService {
constructor( constructor(
private readonly platform: PlatformPrismaService, private readonly platform: PlatformPrismaService,
private readonly tenants: TenantConnectionService, private readonly tenants: TenantConnectionService,
) {} ) {}
list() { list() {
return this.platform.tenant.findMany({ return this.platform.tenant.findMany({
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
select: { id: true, name: true, slug: true, database: true, status: true, createdAt: true }, select: { id: true, name: true, slug: true, database: true, status: true, createdAt: true },
}); });
} }
async overview() { async overview() {
const tenants = await this.platform.tenant.findMany({ const tenants = await this.platform.tenant.findMany({
select: { status: true, database: true }, select: { status: true, database: true },
}); });
const users = ( const users = (
await Promise.all(tenants.map((tenant) => this.tenants.getByDatabase(tenant.database).user.count())) await Promise.all(tenants.map((tenant) => this.tenants.getByDatabase(tenant.database).user.count()))
).reduce((sum, count) => sum + count, 0); ).reduce((sum, count) => sum + count, 0);
return buildPlatformOverview(tenants, users); return buildPlatformOverview(tenants, users);
} }
async publicBySlug(raw: string) { async publicBySlug(raw: string) {
let slug: string; let slug: string;
try { try {
slug = normalizeSlug(raw); slug = normalizeSlug(raw);
} catch { } catch {
throw new NotFoundException('Este cliente não existe.'); throw new NotFoundException('Este cliente não existe.');
} }
const tenant = await this.platform.tenant.findUnique({ const tenant = await this.platform.tenant.findUnique({
where: { slug }, where: { slug },
select: { name: true, slug: true, status: true }, select: { name: true, slug: true, status: true },
}); });
return publicTenantView(tenant); return publicTenantView(tenant);
} }
async create(actorId: string, dto: CreateTenantDto, ip?: string) { async create(actorId: string, dto: CreateTenantDto, ip?: string) {
const slug = normalizeSlug(dto.slug); const slug = normalizeSlug(dto.slug);
const exists = await this.platform.tenant.findUnique({ where: { slug } }); const exists = await this.platform.tenant.findUnique({ where: { slug } });
if (exists) { if (exists) {
throw new ConflictException('Já existe uma empresa com esse código.'); throw new ConflictException('Já existe uma empresa com esse código.');
} }
const database = databaseForSlug(slug); const database = databaseForSlug(slug);
await createMysqlDatabase(database); await createMysqlDatabase(database);
await pushTenantSchema(database); await pushTenantSchema(database);
const tenant = await this.platform.tenant.create({ const tenant = await this.platform.tenant.create({
data: { name: dto.name.trim(), slug, database }, data: { name: dto.name.trim(), slug, database },
}); });
const adminEmail = `admin@${slug}.sinka`; const adminEmail = `admin@${slug}.sinka`;
const adminPassword = `Sinka-${randomBytes(3).toString('hex')}`; const adminPassword = `Sinka-${randomBytes(3).toString('hex')}`;
const db = this.tenants.getByDatabase(database); const db = this.tenants.getByDatabase(database);
await db.user.create({ await db.user.create({
data: { data: {
name: 'Administrador', name: 'Administrador',
email: adminEmail, email: adminEmail,
passwordHash: await bcrypt.hash(adminPassword, 10), passwordHash: await bcrypt.hash(adminPassword, 10),
issuedPassword: adminPassword, issuedPassword: adminPassword,
role: 'ADMIN', role: 'ADMIN',
}, },
}); });
await this.platform.platformAuditLog.create({ await this.platform.platformAuditLog.create({
data: { data: {
userId: actorId, userId: actorId,
action: 'tenant.create', action: 'tenant.create',
entity: 'Tenant', entity: 'Tenant',
entityId: tenant.id, entityId: tenant.id,
ip, ip,
metadata: { slug, database }, metadata: { slug, database },
}, },
}); });
return { return {
...tenant, ...tenant,
access: { access: {
email: adminEmail, email: adminEmail,
password: adminPassword, password: adminPassword,
path: `/${slug}/login`, path: `/${slug}/login`,
}, },
}; };
} }
async listUsers(actorId: string, tenantId: string, ip?: string) { async listUsers(actorId: string, tenantId: string, ip?: string) {
const tenant = await this.platform.tenant.findUnique({ where: { id: tenantId } }); const tenant = await this.platform.tenant.findUnique({ where: { id: tenantId } });
if (!tenant) { if (!tenant) {
throw new NotFoundException('Cliente não encontrado.'); throw new NotFoundException('Cliente não encontrado.');
} }
const db = this.tenants.getByDatabase(tenant.database); const db = this.tenants.getByDatabase(tenant.database);
const users = await db.user.findMany({ const users = await db.user.findMany({
select: { id: true, name: true, email: true, role: true, issuedPassword: true }, select: { id: true, name: true, email: true, role: true, issuedPassword: true },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
}); });
await this.platform.platformAuditLog.create({ await this.platform.platformAuditLog.create({
data: { data: {
userId: actorId, userId: actorId,
action: 'tenant.users', action: 'tenant.users',
entity: 'Tenant', entity: 'Tenant',
entityId: tenant.id, entityId: tenant.id,
ip, ip,
metadata: { slug: tenant.slug, count: users.length }, metadata: { slug: tenant.slug, count: users.length },
}, },
}); });
return { return {
tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug }, tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug },
users: users.map((user) => ({ users: users.map((user) => ({
id: user.id, id: user.id,
name: user.name, name: user.name,
email: user.email, email: user.email,
role: user.role, role: user.role,
password: user.issuedPassword, password: user.issuedPassword,
})), })),
}; };
} }
async issuePassword(actorId: string, tenantId: string, userId: string, ip?: string) { async issuePassword(actorId: string, tenantId: string, userId: string, ip?: string) {
const tenant = await this.platform.tenant.findUnique({ where: { id: tenantId } }); const tenant = await this.platform.tenant.findUnique({ where: { id: tenantId } });
if (!tenant) { if (!tenant) {
throw new NotFoundException('Cliente não encontrado.'); throw new NotFoundException('Cliente não encontrado.');
} }
const db = this.tenants.getByDatabase(tenant.database); const db = this.tenants.getByDatabase(tenant.database);
const user = await db.user.findUnique({ where: { id: userId } }); const user = await db.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new NotFoundException('Usuário não encontrado.'); throw new NotFoundException('Usuário não encontrado.');
} }
const password = `Sinka-${randomBytes(3).toString('hex')}`; const password = `Sinka-${randomBytes(3).toString('hex')}`;
await db.user.update({ await db.user.update({
where: { id: userId }, where: { id: userId },
data: { data: {
passwordHash: await bcrypt.hash(password, 10), passwordHash: await bcrypt.hash(password, 10),
issuedPassword: password, issuedPassword: password,
}, },
}); });
await this.platform.platformAuditLog.create({ await this.platform.platformAuditLog.create({
data: { data: {
userId: actorId, userId: actorId,
action: 'tenant.user.password', action: 'tenant.user.password',
entity: 'User', entity: 'User',
entityId: userId, entityId: userId,
ip, ip,
metadata: { slug: tenant.slug, email: user.email }, metadata: { slug: tenant.slug, email: user.email },
}, },
}); });
return { id: user.id, email: user.email, password }; return { id: user.id, email: user.email, password };
} }
} }

View File

@ -1,12 +1,12 @@
import { Controller, Get, Param } from '@nestjs/common'; import { Controller, Get, Param } from '@nestjs/common';
import { PlatformService } from './platform.service'; import { PlatformService } from './platform.service';
@Controller('tenants') @Controller('tenants')
export class PublicTenantsController { export class PublicTenantsController {
constructor(private readonly platform: PlatformService) {} constructor(private readonly platform: PlatformService) {}
@Get(':slug') @Get(':slug')
bySlug(@Param('slug') slug: string) { bySlug(@Param('slug') slug: string) {
return this.platform.publicBySlug(slug); return this.platform.publicBySlug(slug);
} }
} }

View File

@ -1,9 +1,9 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common'; import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '../generated/platform'; import { PrismaClient } from '../generated/platform';
@Injectable() @Injectable()
export class PlatformPrismaService extends PrismaClient implements OnModuleDestroy { export class PlatformPrismaService extends PrismaClient implements OnModuleDestroy {
async onModuleDestroy(): Promise<void> { async onModuleDestroy(): Promise<void> {
await this.$disconnect(); await this.$disconnect();
} }
} }

View File

@ -1,9 +1,9 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service'; import { RedisService } from './redis.service';
@Global() @Global()
@Module({ @Module({
providers: [RedisService], providers: [RedisService],
exports: [RedisService], exports: [RedisService],
}) })
export class RedisModule {} export class RedisModule {}

View File

@ -1,71 +1,71 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common'; import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis'; import Redis from 'ioredis';
@Injectable() @Injectable()
export class RedisService implements OnModuleDestroy { export class RedisService implements OnModuleDestroy {
private readonly client: Redis | null; private readonly client: Redis | null;
constructor(config: ConfigService) { constructor(config: ConfigService) {
const url = config.get<string>('REDIS_URL'); const url = config.get<string>('REDIS_URL');
this.client = url ? new Redis(url, { maxRetriesPerRequest: 1, lazyConnect: true }) : null; this.client = url ? new Redis(url, { maxRetriesPerRequest: 1, lazyConnect: true }) : null;
} }
async increment(key: string, ttlSeconds: number): Promise<number | null> { async increment(key: string, ttlSeconds: number): Promise<number | null> {
if (!(await this.ready())) { if (!(await this.ready())) {
return null; return null;
} }
try { try {
const count = await this.client!.incr(key); const count = await this.client!.incr(key);
if (count === 1) { if (count === 1) {
await this.client!.expire(key, ttlSeconds); await this.client!.expire(key, ttlSeconds);
} }
return count; return count;
} catch { } catch {
return null; return null;
} }
} }
async getJson<T>(key: string): Promise<T | null> { async getJson<T>(key: string): Promise<T | null> {
if (!(await this.ready())) { if (!(await this.ready())) {
return null; return null;
} }
try { try {
const raw = await this.client!.get(key); const raw = await this.client!.get(key);
return raw ? (JSON.parse(raw) as T) : null; return raw ? (JSON.parse(raw) as T) : null;
} catch { } catch {
return null; return null;
} }
} }
async setJson(key: string, value: unknown, ttlSeconds: number): Promise<void> { async setJson(key: string, value: unknown, ttlSeconds: number): Promise<void> {
if (!(await this.ready())) { if (!(await this.ready())) {
return; return;
} }
try { try {
await this.client!.set(key, JSON.stringify(value), 'EX', ttlSeconds); await this.client!.set(key, JSON.stringify(value), 'EX', ttlSeconds);
} catch { } catch {
return; return;
} }
} }
private async ready(): Promise<boolean> { private async ready(): Promise<boolean> {
if (!this.client) { if (!this.client) {
return false; return false;
} }
try { try {
if (this.client.status === 'wait') { if (this.client.status === 'wait') {
await this.client.connect(); await this.client.connect();
} }
return true; return true;
} catch { } catch {
return false; return false;
} }
} }
async onModuleDestroy(): Promise<void> { async onModuleDestroy(): Promise<void> {
if (this.client) { if (this.client) {
this.client.disconnect(); this.client.disconnect();
} }
} }
} }

View File

@ -1,31 +1,31 @@
import { ForbiddenException } from '@nestjs/common'; import { ForbiddenException } from '@nestjs/common';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
import { tenantIdOf } from './actor'; import { tenantIdOf } from './actor';
const tenant: AuthUser = { const tenant: AuthUser = {
kind: 'tenant', kind: 'tenant',
sub: '1', sub: '1',
email: 'a@b.c', email: 'a@b.c',
name: 'Ana', name: 'Ana',
role: 'ADMIN', role: 'ADMIN',
tenantId: 't-demo', tenantId: 't-demo',
tenantSlug: 'demo', tenantSlug: 'demo',
}; };
describe('tenantIdOf', () => { describe('tenantIdOf', () => {
it('returns the company id for a tenant actor', () => { it('returns the company id for a tenant actor', () => {
expect(tenantIdOf(tenant)).toBe('t-demo'); expect(tenantIdOf(tenant)).toBe('t-demo');
}); });
it('blocks platform staff from company data', () => { it('blocks platform staff from company data', () => {
expect(() => expect(() =>
tenantIdOf({ tenantIdOf({
kind: 'platform', kind: 'platform',
sub: 'p', sub: 'p',
email: 'p@x', email: 'p@x',
name: 'Sinka', name: 'Sinka',
role: 'PLATFORM_ADMIN', role: 'PLATFORM_ADMIN',
}), }),
).toThrow(ForbiddenException); ).toThrow(ForbiddenException);
}); });
}); });

View File

@ -1,14 +1,14 @@
import { ForbiddenException } from '@nestjs/common'; import { ForbiddenException } from '@nestjs/common';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
import type { TenantConnectionService } from './tenant-connection.service'; import type { TenantConnectionService } from './tenant-connection.service';
export function tenantIdOf(actor: AuthUser): string { export function tenantIdOf(actor: AuthUser): string {
if (actor.kind !== 'tenant' || !actor.tenantId) { if (actor.kind !== 'tenant' || !actor.tenantId) {
throw new ForbiddenException('Acesso restrito à empresa.'); throw new ForbiddenException('Acesso restrito à empresa.');
} }
return actor.tenantId; return actor.tenantId;
} }
export function tenantClient(tenants: TenantConnectionService, actor: AuthUser) { export function tenantClient(tenants: TenantConnectionService, actor: AuthUser) {
return tenants.getByTenantId(tenantIdOf(actor)); return tenants.getByTenantId(tenantIdOf(actor));
} }

View File

@ -1,35 +1,35 @@
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { join } from 'node:path'; import { join } from 'node:path';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { PrismaClient as PlatformPrismaClient } from '../generated/platform'; import { PrismaClient as PlatformPrismaClient } from '../generated/platform';
import { adminDatabaseUrl, tenantDatabaseName, urlForDatabase } from './tenant-url'; import { adminDatabaseUrl, tenantDatabaseName, urlForDatabase } from './tenant-url';
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
export async function createMysqlDatabase(database: string): Promise<void> { export async function createMysqlDatabase(database: string): Promise<void> {
const admin = new PlatformPrismaClient({ const admin = new PlatformPrismaClient({
datasources: { db: { url: adminDatabaseUrl() } }, datasources: { db: { url: adminDatabaseUrl() } },
}); });
try { try {
await admin.$executeRawUnsafe( await admin.$executeRawUnsafe(
`CREATE DATABASE IF NOT EXISTS \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`, `CREATE DATABASE IF NOT EXISTS \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`,
); );
await admin.$executeRawUnsafe(`GRANT ALL PRIVILEGES ON \`${database}\`.* TO 'sinka'@'%'`); await admin.$executeRawUnsafe(`GRANT ALL PRIVILEGES ON \`${database}\`.* TO 'sinka'@'%'`);
await admin.$executeRawUnsafe('FLUSH PRIVILEGES'); await admin.$executeRawUnsafe('FLUSH PRIVILEGES');
} finally { } finally {
await admin.$disconnect(); await admin.$disconnect();
} }
} }
export async function pushTenantSchema(database: string): Promise<void> { export async function pushTenantSchema(database: string): Promise<void> {
const prismaCli = join(process.cwd(), 'node_modules', 'prisma', 'build', 'index.js'); const prismaCli = join(process.cwd(), 'node_modules', 'prisma', 'build', 'index.js');
const schema = join(process.cwd(), 'prisma', 'tenant', 'schema.prisma'); const schema = join(process.cwd(), 'prisma', 'tenant', 'schema.prisma');
await execFileAsync(process.execPath, [prismaCli, 'db', 'push', '--schema', schema, '--skip-generate'], { await execFileAsync(process.execPath, [prismaCli, 'db', 'push', '--schema', schema, '--skip-generate'], {
env: { ...process.env, DATABASE_URL: urlForDatabase(database) }, env: { ...process.env, DATABASE_URL: urlForDatabase(database) },
timeout: 90_000, timeout: 90_000,
}); });
} }
export function databaseForSlug(slug: string): string { export function databaseForSlug(slug: string): string {
return tenantDatabaseName(slug); return tenantDatabaseName(slug);
} }

View File

@ -1,19 +1,19 @@
import { NotFoundException } from '@nestjs/common'; import { NotFoundException } from '@nestjs/common';
import { publicTenantView } from './public-tenant'; import { publicTenantView } from './public-tenant';
describe('publicTenantView', () => { describe('publicTenantView', () => {
it('hides internal fields and returns the public company', () => { it('hides internal fields and returns the public company', () => {
expect( expect(
publicTenantView({ publicTenantView({
name: 'Cliente 1', name: 'Cliente 1',
slug: 'cliente-1', slug: 'cliente-1',
status: 'ACTIVE', status: 'ACTIVE',
}), }),
).toEqual({ name: 'Cliente 1', slug: 'cliente-1', status: 'ACTIVE' }); ).toEqual({ name: 'Cliente 1', slug: 'cliente-1', status: 'ACTIVE' });
}); });
it('tells the visitor when the company is missing', () => { it('tells the visitor when the company is missing', () => {
expect(() => publicTenantView(null)).toThrow(NotFoundException); expect(() => publicTenantView(null)).toThrow(NotFoundException);
expect(() => publicTenantView(null)).toThrow('Este cliente não existe.'); expect(() => publicTenantView(null)).toThrow('Este cliente não existe.');
}); });
}); });

Some files were not shown because too many files have changed in this diff Show More