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
alwaysApply: true
---
# Sinka
Backend NestJS em `api/`. Frontend Next.js em `web/`. MySQL + Redis via Docker Compose.
**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.
---
description: Stack e pastas da Sinka: NestJS + Next.js
alwaysApply: true
---
# Sinka
Backend NestJS em `api/`. Frontend Next.js em `web/`. MySQL + Redis via Docker Compose.
**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.

View File

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

View File

@ -1,13 +1,13 @@
---
description: Convenções Next.js App Router
globs: web/**/*.{ts,tsx}
alwaysApply: false
---
# Next.js
- App Router em `web/src/app`
- Landing pública em `/`; área logada virá em `/app` nas próximas etapas
- TypeScript estrito; componentes em `web/src/components`
- 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
---
description: Convenções Next.js App Router
globs: web/**/*.{ts,tsx}
alwaysApply: false
---
# Next.js
- App Router em `web/src/app`
- Landing pública em `/`; área logada virá em `/app` nas próximas etapas
- TypeScript estrito; componentes em `web/src/components`
- 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

View File

@ -1,13 +1,13 @@
---
description: Multi-tenant e segurança da plataforma
alwaysApply: true
---
# Tenant e segurança
- 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.
- 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
- Rate limit de login com Redis
- Auditoria: login, permissão, provisionamento, CRUD de usuários
---
description: Multi-tenant e segurança da plataforma
alwaysApply: true
---
# Tenant e segurança
- 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.
- 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
- Rate limit de login com Redis
- Auditoria: login, permissão, provisionamento, CRUD de usuários

View File

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

188
README.md
View File

@ -1,94 +1,94 @@
# 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.
## O que faz
- 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
- Clientes, equipe e papéis (Administrador, Manager, Operador)
- Dashboard de custo
- Chat interno (admin da plataforma ↔ 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`).
## Isolamento
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.
## Stack
| Camada | Tecnologia |
| --- | --- |
| API | NestJS, Prisma, JWT em cookie httpOnly |
| Web | Next.js (App Router) |
| Dados | MySQL 8, Redis |
| Infra | Docker Compose |
```
Next.js → NestJS /api → sinka_platform
↓ ↓
Redis sinka_t_<slug>
```
## Estrutura
```
api/ REST, auth, regras, Prisma
web/ landing, login, painéis
docs/ arquitetura, decisões, testes
```
## Como rodar
```bash
cp .env.example .env
docker compose up -d
cd api && npm install && npm run prisma:setup && npm run start:dev
cd web && npm install && npm run dev
```
MySQL e Redis sobem no Docker; API e web no Node da máquina.
Tudo em containers:
```bash
cp .env.example .env
docker compose --profile full up -d --build
```
- App: http://localhost:3000
- API: http://localhost:3001/api/health
## Contas
| Área | URL | E-mail | Senha |
| --- | --- | --- | --- |
| Plataforma | `/admin/login` | tina.r@example.net | SinkaPlatform!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 (operador) | `/cliente-1/login` | ursula.b@example.com | SinkaOperador!1 |
| Cliente 2 (admin) | `/cliente-2/login` | ivan.p@example.net | Cliente2Admin!1 |
## Testes
```bash
npm test
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).
## Documentação
- [Arquitetura](docs/ARCHITECTURE.md)
- [Decisões](docs/DECISIONS.md)
- [Testes](docs/TESTING.md)
- [Entrega e CI](docs/CI.md)
## 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).
# 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.
## O que faz
- 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
- Clientes, equipe e papéis (Administrador, Manager, Operador)
- Dashboard de custo
- Chat interno (admin da plataforma ↔ 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`).
## Isolamento
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.
## Stack
| Camada | Tecnologia |
| --- | --- |
| API | NestJS, Prisma, JWT em cookie httpOnly |
| Web | Next.js (App Router) |
| Dados | MySQL 8, Redis |
| Infra | Docker Compose |
```
Next.js → NestJS /api → sinka_platform
↓ ↓
Redis sinka_t_<slug>
```
## Estrutura
```
api/ REST, auth, regras, Prisma
web/ landing, login, painéis
docs/ arquitetura, decisões, testes
```
## Como rodar
```bash
cp .env.example .env
docker compose up -d
cd api && npm install && npm run prisma:setup && npm run start:dev
cd web && npm install && npm run dev
```
MySQL e Redis sobem no Docker; API e web no Node da máquina.
Tudo em containers:
```bash
cp .env.example .env
docker compose --profile full up -d --build
```
- App: http://localhost:3000
- API: http://localhost:3001/api/health
## Contas
| Área | URL | E-mail | Senha |
| --- | --- | --- | --- |
| Plataforma | `/admin/login` | tina.r@example.net | SinkaPlatform!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 (operador) | `/cliente-1/login` | ursula.b@example.com | SinkaOperador!1 |
| Cliente 2 (admin) | `/cliente-2/login` | ivan.p@example.net | Cliente2Admin!1 |
## Testes
```bash
npm test
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).
## Documentação
- [Arquitetura](docs/ARCHITECTURE.md)
- [Decisões](docs/DECISIONS.md)
- [Testes](docs/TESTING.md)
- [Entrega e CI](docs/CI.md)
## 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).

View File

@ -1,34 +1,34 @@
# 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
- Backend: Node.js + **NestJS** + TypeScript (`api/`)
- Frontend: **Next.js** + TypeScript (`web/`)
- Banco: **MySQL**, `sinka_platform` + um database por empresa
- Infra: **Docker**, **Docker Compose**, **Redis**
## Pastas
| Pasta | Responsabilidade |
| --- | --- |
| `api/` | NestJS: módulos, casos de uso, Prisma, filas |
| `web/` | Next.js App Router: landing + área autenticada |
| `docs/` | Arquitetura e decisões |
| `.cursor/rules/` | Regras do agente |
## Como evoluir uma feature
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
3. Abrir o PrismaClient do tenant do JWT, nunca consultar outro database
4. Tela em `web/src/app` (App Router)
5. Teste no módulo da API quando a regra for importante
## Não fazer
- PHP, Composer, views `.php`, PDO
- Framework PHP, mesmo “só para prototipar”
- 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
# 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
- Backend: Node.js + **NestJS** + TypeScript (`api/`)
- Frontend: **Next.js** + TypeScript (`web/`)
- Banco: **MySQL**, `sinka_platform` + um database por empresa
- Infra: **Docker**, **Docker Compose**, **Redis**
## Pastas
| Pasta | Responsabilidade |
| --- | --- |
| `api/` | NestJS: módulos, casos de uso, Prisma, filas |
| `web/` | Next.js App Router: landing + área autenticada |
| `docs/` | Arquitetura e decisões |
| `.cursor/rules/` | Regras do agente |
## Como evoluir uma feature
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
3. Abrir o PrismaClient do tenant do JWT, nunca consultar outro database
4. Tela em `web/src/app` (App Router)
5. Teste no módulo da API quando a regra for importante
## Não fazer
- PHP, Composer, views `.php`, PDO
- Framework PHP, mesmo “só para prototipar”
- 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

View File

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

View File

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

View File

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

View File

@ -1,98 +1,98 @@
<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>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[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 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/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://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#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://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>
</p>
<!--[![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)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## 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.
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
$ npm install -g @nestjs/mau
$ 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.
## Resources
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.
- 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/).
- 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).
- 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).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.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
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
<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>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[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 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/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://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#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://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>
</p>
<!--[![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)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## 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.
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
$ npm install -g @nestjs/mau
$ 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.
## Resources
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.
- 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/).
- 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).
- 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).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.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
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## 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');
function run(command, args) {
const result = spawnSync(command, args, {
stdio: 'inherit',
env: process.env,
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
run('npx', ['prisma', 'db', 'push', '--schema', 'prisma/platform/schema.prisma', '--skip-generate']);
run('npx', ['tsx', 'prisma/seed.ts']);
const child = spawn('node', ['dist/main.js'], {
stdio: 'inherit',
env: process.env,
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
const { spawn, spawnSync } = require('node:child_process');
function run(command, args) {
const result = spawnSync(command, args, {
stdio: 'inherit',
env: process.env,
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
run('npx', ['prisma', 'db', 'push', '--schema', 'prisma/platform/schema.prisma', '--skip-generate']);
run('npx', ['tsx', 'prisma/seed.ts']);
const child = spawn('node', ['dist/main.js'], {
stdio: 'inherit',
env: process.env,
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import type { AuthRole } from '../roles';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: AuthRole[]) => SetMetadata(ROLES_KEY, roles);
import { SetMetadata } from '@nestjs/common';
import type { AuthRole } from '../roles';
export const 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';
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
@IsOptional()
@IsString()
tenantSlug?: string;
}
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
@IsOptional()
@IsString()
tenantSlug?: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,54 +1,54 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import type { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import type { JwtPayload } from '../auth.types';
import type { AuthUser } from '../auth.types';
import { PlatformPrismaService } from '../../prisma/platform-prisma.service';
import { TenantConnectionService } from '../../tenancy/tenant-connection.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
config: ConfigService,
private readonly platform: PlatformPrismaService,
private readonly tenants: TenantConnectionService,
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => request?.cookies?.sinka_access ?? null,
ExtractJwt.fromAuthHeaderAsBearerToken(),
]),
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('JWT_SECRET'),
});
}
async validate(payload: JwtPayload): Promise<AuthUser> {
if (payload.role === 'PLATFORM_ADMIN') {
const user = await this.platform.platformUser.findUnique({ where: { id: payload.sub } });
if (!user) {
throw new UnauthorizedException();
}
return { ...payload, kind: 'platform' };
}
if (!payload.tenantId) {
throw new UnauthorizedException();
}
const tenant = await this.platform.tenant.findUnique({ where: { id: payload.tenantId } });
if (!tenant || tenant.status !== 'ACTIVE') {
throw new UnauthorizedException();
}
const db = await this.tenants.getByTenantId(tenant.id);
const user = await db.user.findUnique({ where: { id: payload.sub } });
if (!user) {
throw new UnauthorizedException();
}
return { ...payload, tenantSlug: tenant.slug, kind: 'tenant' };
}
}
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import type { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import type { JwtPayload } from '../auth.types';
import type { AuthUser } from '../auth.types';
import { PlatformPrismaService } from '../../prisma/platform-prisma.service';
import { TenantConnectionService } from '../../tenancy/tenant-connection.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
config: ConfigService,
private readonly platform: PlatformPrismaService,
private readonly tenants: TenantConnectionService,
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => request?.cookies?.sinka_access ?? null,
ExtractJwt.fromAuthHeaderAsBearerToken(),
]),
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('JWT_SECRET'),
});
}
async validate(payload: JwtPayload): Promise<AuthUser> {
if (payload.role === 'PLATFORM_ADMIN') {
const user = await this.platform.platformUser.findUnique({ where: { id: payload.sub } });
if (!user) {
throw new UnauthorizedException();
}
return { ...payload, kind: 'platform' };
}
if (!payload.tenantId) {
throw new UnauthorizedException();
}
const tenant = await this.platform.tenant.findUnique({ where: { id: payload.tenantId } });
if (!tenant || tenant.status !== 'ACTIVE') {
throw new UnauthorizedException();
}
const db = await this.tenants.getByTenantId(tenant.id);
const user = await db.user.findUnique({ where: { id: payload.sub } });
if (!user) {
throw new UnauthorizedException();
}
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 type { Request } from 'express';
import type { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { CarriersService } from './carriers.service';
import { UpsertCarrierDto } from './dto/upsert-carrier.dto';
@Controller('carriers')
@UseGuards(JwtAuthGuard, RolesGuard)
export class CarriersController {
constructor(private readonly carriers: CarriersService) {}
@Get()
@Roles('ADMIN', 'MANAGER', 'OPERATOR')
list(@CurrentUser() user: AuthUser) {
return this.carriers.list(user);
}
@Post()
@Roles('ADMIN', 'MANAGER')
create(@CurrentUser() user: AuthUser, @Body() dto: UpsertCarrierDto, @Req() request: Request) {
return this.carriers.create(user, dto, request.ip);
}
@Patch(':id')
@Roles('ADMIN', 'MANAGER')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpsertCarrierDto,
@Req() request: Request,
) {
return this.carriers.update(user, id, dto, request.ip);
}
@Delete(':id')
@Roles('ADMIN', 'MANAGER')
remove(@CurrentUser() user: AuthUser, @Param('id') id: string, @Req() request: Request) {
return this.carriers.remove(user, id, request.ip);
}
}
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import type { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { CarriersService } from './carriers.service';
import { UpsertCarrierDto } from './dto/upsert-carrier.dto';
@Controller('carriers')
@UseGuards(JwtAuthGuard, RolesGuard)
export class CarriersController {
constructor(private readonly carriers: CarriersService) {}
@Get()
@Roles('ADMIN', 'MANAGER', 'OPERATOR')
list(@CurrentUser() user: AuthUser) {
return this.carriers.list(user);
}
@Post()
@Roles('ADMIN', 'MANAGER')
create(@CurrentUser() user: AuthUser, @Body() dto: UpsertCarrierDto, @Req() request: Request) {
return this.carriers.create(user, dto, request.ip);
}
@Patch(':id')
@Roles('ADMIN', 'MANAGER')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpsertCarrierDto,
@Req() request: Request,
) {
return this.carriers.update(user, id, dto, request.ip);
}
@Delete(':id')
@Roles('ADMIN', 'MANAGER')
remove(@CurrentUser() user: AuthUser, @Param('id') id: string, @Req() request: Request) {
return this.carriers.remove(user, id, request.ip);
}
}

View File

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

View File

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

View File

@ -1,50 +1,50 @@
import { Transform } from 'class-transformer';
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));
export class UpsertCarrierDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(20)
document?: string;
@IsOptional()
@IsString()
@MaxLength(20)
phone?: string;
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
baseFee?: number;
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
pricePerKg?: number;
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
pricePerKm?: number;
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
@Max(1)
adValoremRate?: number;
@IsOptional()
@IsBoolean()
active?: boolean;
}
import { Transform } from 'class-transformer';
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));
export class UpsertCarrierDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(20)
document?: string;
@IsOptional()
@IsString()
@MaxLength(20)
phone?: string;
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
baseFee?: number;
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
pricePerKg?: number;
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
pricePerKm?: number;
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
@Max(1)
adValoremRate?: number;
@IsOptional()
@IsBoolean()
active?: boolean;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,18 +1,18 @@
import { fileExtension, isAllowedUpload, safeOriginalName } from './file-allow';
describe('file upload allow list', () => {
it('accepts csv and xlsx', () => {
expect(isAllowedUpload('tabela.csv')).toBe(true);
expect(isAllowedUpload('planilha.XLSX')).toBe(true);
});
it('rejects other extensions', () => {
expect(isAllowedUpload('foto.exe')).toBe(false);
expect(isAllowedUpload('sem-extensao')).toBe(false);
});
it('strips path from the original name', () => {
expect(fileExtension('C:\\tmp\\a.csv')).toBe('csv');
expect(safeOriginalName('C:\\tmp\\carga.csv')).toBe('carga.csv');
});
});
import { fileExtension, isAllowedUpload, safeOriginalName } from './file-allow';
describe('file upload allow list', () => {
it('accepts csv and xlsx', () => {
expect(isAllowedUpload('tabela.csv')).toBe(true);
expect(isAllowedUpload('planilha.XLSX')).toBe(true);
});
it('rejects other extensions', () => {
expect(isAllowedUpload('foto.exe')).toBe(false);
expect(isAllowedUpload('sem-extensao')).toBe(false);
});
it('strips path from the original name', () => {
expect(fileExtension('C:\\tmp\\a.csv')).toBe('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']);
export function fileExtension(name: string): string {
const base = name.replace(/\\/g, '/').split('/').pop() ?? '';
const dot = base.lastIndexOf('.');
return dot >= 0 ? base.slice(dot + 1).toLowerCase() : '';
}
export function isAllowedUpload(name: string): boolean {
return ALLOWED.has(fileExtension(name));
}
export function safeOriginalName(name: string): string {
const base = name.replace(/\\/g, '/').split('/').pop()?.trim() || 'arquivo';
return base.replace(/[^\w.\- ()[\]]+/g, '_').slice(0, 180);
}
const ALLOWED = new Set(['csv', 'xlsx', 'xls', 'pdf', 'txt']);
export function fileExtension(name: string): string {
const base = name.replace(/\\/g, '/').split('/').pop() ?? '';
const dot = base.lastIndexOf('.');
return dot >= 0 ? base.slice(dot + 1).toLowerCase() : '';
}
export function isAllowedUpload(name: string): boolean {
return ALLOWED.has(fileExtension(name));
}
export function safeOriginalName(name: string): string {
const base = name.replace(/\\/g, '/').split('/').pop()?.trim() || 'arquivo';
return base.replace(/[^\w.\- ()[\]]+/g, '_').slice(0, 180);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,12 @@
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
export class CreateTenantDto {
@IsString()
@MinLength(2)
@MaxLength(80)
name!: string;
@IsString()
@Matches(/^[a-z][a-z0-9-]{1,30}$/)
slug!: string;
}
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
export class CreateTenantDto {
@IsString()
@MinLength(2)
@MaxLength(80)
name!: string;
@IsString()
@Matches(/^[a-z][a-z0-9-]{1,30}$/)
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 { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { PlatformService } from './platform.service';
@Controller('platform')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('PLATFORM_ADMIN')
export class PlatformOverviewController {
constructor(private readonly platform: PlatformService) {}
@Get('overview')
overview() {
return this.platform.overview();
}
}
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 { PlatformService } from './platform.service';
@Controller('platform')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('PLATFORM_ADMIN')
export class PlatformOverviewController {
constructor(private readonly platform: PlatformService) {}
@Get('overview')
overview() {
return this.platform.overview();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,35 +1,35 @@
import { execFile } from 'node:child_process';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { PrismaClient as PlatformPrismaClient } from '../generated/platform';
import { adminDatabaseUrl, tenantDatabaseName, urlForDatabase } from './tenant-url';
const execFileAsync = promisify(execFile);
export async function createMysqlDatabase(database: string): Promise<void> {
const admin = new PlatformPrismaClient({
datasources: { db: { url: adminDatabaseUrl() } },
});
try {
await admin.$executeRawUnsafe(
`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('FLUSH PRIVILEGES');
} finally {
await admin.$disconnect();
}
}
export async function pushTenantSchema(database: string): Promise<void> {
const prismaCli = join(process.cwd(), 'node_modules', 'prisma', 'build', 'index.js');
const schema = join(process.cwd(), 'prisma', 'tenant', 'schema.prisma');
await execFileAsync(process.execPath, [prismaCli, 'db', 'push', '--schema', schema, '--skip-generate'], {
env: { ...process.env, DATABASE_URL: urlForDatabase(database) },
timeout: 90_000,
});
}
export function databaseForSlug(slug: string): string {
return tenantDatabaseName(slug);
}
import { execFile } from 'node:child_process';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { PrismaClient as PlatformPrismaClient } from '../generated/platform';
import { adminDatabaseUrl, tenantDatabaseName, urlForDatabase } from './tenant-url';
const execFileAsync = promisify(execFile);
export async function createMysqlDatabase(database: string): Promise<void> {
const admin = new PlatformPrismaClient({
datasources: { db: { url: adminDatabaseUrl() } },
});
try {
await admin.$executeRawUnsafe(
`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('FLUSH PRIVILEGES');
} finally {
await admin.$disconnect();
}
}
export async function pushTenantSchema(database: string): Promise<void> {
const prismaCli = join(process.cwd(), 'node_modules', 'prisma', 'build', 'index.js');
const schema = join(process.cwd(), 'prisma', 'tenant', 'schema.prisma');
await execFileAsync(process.execPath, [prismaCli, 'db', 'push', '--schema', schema, '--skip-generate'], {
env: { ...process.env, DATABASE_URL: urlForDatabase(database) },
timeout: 90_000,
});
}
export function databaseForSlug(slug: string): string {
return tenantDatabaseName(slug);
}

View File

@ -1,19 +1,19 @@
import { NotFoundException } from '@nestjs/common';
import { publicTenantView } from './public-tenant';
describe('publicTenantView', () => {
it('hides internal fields and returns the public company', () => {
expect(
publicTenantView({
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', () => {
expect(() => publicTenantView(null)).toThrow(NotFoundException);
expect(() => publicTenantView(null)).toThrow('Este cliente não existe.');
});
});
import { NotFoundException } from '@nestjs/common';
import { publicTenantView } from './public-tenant';
describe('publicTenantView', () => {
it('hides internal fields and returns the public company', () => {
expect(
publicTenantView({
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', () => {
expect(() => publicTenantView(null)).toThrow(NotFoundException);
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