teste
This commit is contained in:
commit
ebd666b361
12
.cursor/rules/arquitetura.mdc
Normal file
12
.cursor/rules/arquitetura.mdc
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
description: Stack e pastas da Sinka — NestJS + Next.js, sem PHP
|
||||||
|
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.
|
||||||
14
.cursor/rules/nestjs.mdc
Normal file
14
.cursor/rules/nestjs.mdc
Normal file
@ -0,0 +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
|
||||||
13
.cursor/rules/nextjs.mdc
Normal file
13
.cursor/rules/nextjs.mdc
Normal file
@ -0,0 +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
|
||||||
13
.cursor/rules/seguranca.mdc
Normal file
13
.cursor/rules/seguranca.mdc
Normal file
@ -0,0 +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
|
||||||
21
.env.example
Normal file
21
.env.example
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# WEB
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
||||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.next/
|
||||||
|
.turbo/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
api/.env
|
||||||
|
web/.env.local
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
prisma/*.db
|
||||||
|
api/src/generated/
|
||||||
49
README.md
Normal file
49
README.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# Sinka
|
||||||
|
|
||||||
|
Plataforma SaaS multi-tenant de inteligência logística (desafio técnico).
|
||||||
|
|
||||||
|
**Stack:** NestJS · Next.js · TypeScript · MySQL · Redis · Docker
|
||||||
|
|
||||||
|
Não há PHP neste repositório.
|
||||||
|
|
||||||
|
## Estrutura
|
||||||
|
|
||||||
|
```
|
||||||
|
api/ NestJS (porta 3001)
|
||||||
|
web/ Next.js (porta 3000)
|
||||||
|
docs/ Arquitetura e decisões
|
||||||
|
```
|
||||||
|
|
||||||
|
Leia `RULES.md` e `docs/DECISIONS.md` antes de alterar código.
|
||||||
|
|
||||||
|
Isolamento: **um database MySQL por empresa** (`sinka_platform` + `sinka_t_<slug>`).
|
||||||
|
|
||||||
|
## Subir local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
cp .env.example .env
|
||||||
|
cd api && npm install && npm run prisma:setup && npm run start:dev
|
||||||
|
cd web && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- API: http://localhost:3001/api/health
|
||||||
|
- Web: http://localhost:3000
|
||||||
|
- Login empresa demo: http://localhost:3000/demo/login
|
||||||
|
- Sistema demo: http://localhost:3000/demo
|
||||||
|
- Admin do produto: http://localhost:3000/admin/login
|
||||||
|
|
||||||
|
### Contas de demonstração
|
||||||
|
|
||||||
|
| Área | Caminho | E-mail | Senha |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Produto | `/admin` | tina.r@example.net | SinkaPlatform!1 |
|
||||||
|
| Demo | `/demo` | xena.w@example.org | SinkaAdmin!1 |
|
||||||
|
| Demo | `/demo` | james.b@example.com | SinkaManager!1 |
|
||||||
|
| Demo | `/demo` | ursula.b@example.com | SinkaOperador!1 |
|
||||||
|
|
||||||
|
Papéis na empresa: Administrador, Manager, Operador.
|
||||||
|
|
||||||
|
## Etapa atual
|
||||||
|
|
||||||
|
Auth (JWT + refresh), área restrita, papéis, multi-tenant (banco por empresa), clientes, transportadoras e simulação de frete (ViaCEP + Nominatim).
|
||||||
34
RULES.md
Normal file
34
RULES.md
Normal file
@ -0,0 +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
|
||||||
4
api/.prettierrc
Normal file
4
api/.prettierrc
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
98
api/README.md
Normal file
98
api/README.md
Normal file
@ -0,0 +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>
|
||||||
|
<!--[](https://opencollective.com/nest#backer)
|
||||||
|
[](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).
|
||||||
35
api/eslint.config.mjs
Normal file
35
api/eslint.config.mjs
Normal file
@ -0,0 +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" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
14
api/nest-cli.json
Normal file
14
api/nest-cli.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true,
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"include": "generated/**/*",
|
||||||
|
"watchAssets": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
11665
api/package-lock.json
generated
Normal file
11665
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
94
api/package.json
Normal file
94
api/package.json
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"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/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/terminus": "^12.0.0",
|
||||||
|
"@prisma/client": "^6.19.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"@nestjs/jwt": "^11.0.0",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"ioredis": "^5.6.1",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.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/passport-jwt": "^4.0.1",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@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",
|
||||||
|
"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": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
62
api/prisma/platform/schema.prisma
Normal file
62
api/prisma/platform/schema.prisma
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
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
|
||||||
|
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])
|
||||||
|
}
|
||||||
119
api/prisma/seed.ts
Normal file
119
api/prisma/seed.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
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 main() {
|
||||||
|
const platform = new PlatformPrisma();
|
||||||
|
const slug = 'demo';
|
||||||
|
const database = databaseForSlug(slug);
|
||||||
|
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let tenant = await platform.tenant.findUnique({ where: { slug } });
|
||||||
|
if (!tenant) {
|
||||||
|
await createMysqlDatabase(database);
|
||||||
|
await pushTenantSchema(database);
|
||||||
|
tenant = await platform.tenant.create({
|
||||||
|
data: { name: 'Sinka Demo', slug, database },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await pushTenantSchema(tenant.database);
|
||||||
|
}
|
||||||
|
|
||||||
|
const others = await platform.tenant.findMany({ where: { slug: { not: slug } } });
|
||||||
|
for (const item of others) {
|
||||||
|
await pushTenantSchema(item.database);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new TenantPrisma({
|
||||||
|
datasources: { db: { url: urlForDatabase(tenant.database) } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await upsertUser(
|
||||||
|
db,
|
||||||
|
process.env.DEMO_ADMIN_EMAIL ?? 'xena.w@example.org',
|
||||||
|
'Ana Admin',
|
||||||
|
process.env.DEMO_ADMIN_PASSWORD ?? 'SinkaAdmin!1',
|
||||||
|
'ADMIN',
|
||||||
|
);
|
||||||
|
await upsertUser(
|
||||||
|
db,
|
||||||
|
process.env.DEMO_MANAGER_EMAIL ?? 'james.b@example.com',
|
||||||
|
'Marcos Manager',
|
||||||
|
process.env.DEMO_MANAGER_PASSWORD ?? 'SinkaManager!1',
|
||||||
|
'MANAGER',
|
||||||
|
);
|
||||||
|
await upsertUser(
|
||||||
|
db,
|
||||||
|
process.env.DEMO_OPERATOR_EMAIL ?? 'ursula.b@example.com',
|
||||||
|
'Olga Operador',
|
||||||
|
process.env.DEMO_OPERATOR_PASSWORD ?? 'SinkaOperador!1',
|
||||||
|
'OPERATOR',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const customer of [
|
||||||
|
{ 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' },
|
||||||
|
]) {
|
||||||
|
const existing = await db.customer.findFirst({ where: { name: customer.name } });
|
||||||
|
if (!existing) {
|
||||||
|
await db.customer.create({ data: customer });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const carriers = [
|
||||||
|
{ 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 },
|
||||||
|
];
|
||||||
|
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();
|
||||||
|
await platform.$disconnect();
|
||||||
|
console.log('Seed ok: platform + tenant demo (admin, manager, operador).');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(async (error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
136
api/prisma/tenant/schema.prisma
Normal file
136
api/prisma/tenant/schema.prisma
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
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])
|
||||||
|
}
|
||||||
32
api/src/app.module.ts
Normal file
32
api/src/app.module.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
envFilePath: ['.env', '../.env'],
|
||||||
|
}),
|
||||||
|
RedisModule,
|
||||||
|
TenancyModule,
|
||||||
|
AuthModule,
|
||||||
|
PlatformModule,
|
||||||
|
UsersModule,
|
||||||
|
CustomersModule,
|
||||||
|
CarriersModule,
|
||||||
|
FreightModule,
|
||||||
|
DashboardModule,
|
||||||
|
HealthModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
52
api/src/auth/auth.controller.ts
Normal file
52
api/src/auth/auth.controller.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { Body, Controller, Get, HttpCode, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
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';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly auth: AuthService) {}
|
||||||
|
|
||||||
|
@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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
api/src/auth/auth.module.ts
Normal file
24
api/src/auth/auth.module.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@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],
|
||||||
|
exports: [AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
222
api/src/auth/auth.service.ts
Normal file
222
api/src/auth/auth.service.ts
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Injectable,
|
||||||
|
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 { permissionsFor, ROLE_LABEL } from './roles';
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
profile(user: AuthUser) {
|
||||||
|
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,
|
||||||
|
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 UnauthorizedException('Credenciais inválidas.');
|
||||||
|
}
|
||||||
|
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 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: 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: 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);
|
||||||
|
}
|
||||||
14
api/src/auth/auth.types.ts
Normal file
14
api/src/auth/auth.types.ts
Normal file
@ -0,0 +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';
|
||||||
|
};
|
||||||
27
api/src/auth/cookies.ts
Normal file
27
api/src/auth/cookies.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
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.NODE_ENV === 'production',
|
||||||
|
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];
|
||||||
|
}
|
||||||
6
api/src/auth/decorators/current-user.decorator.ts
Normal file
6
api/src/auth/decorators/current-user.decorator.ts
Normal file
@ -0,0 +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;
|
||||||
|
});
|
||||||
5
api/src/auth/decorators/roles.decorator.ts
Normal file
5
api/src/auth/decorators/roles.decorator.ts
Normal file
@ -0,0 +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);
|
||||||
14
api/src/auth/dto/login.dto.ts
Normal file
14
api/src/auth/dto/login.dto.ts
Normal file
@ -0,0 +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;
|
||||||
|
}
|
||||||
5
api/src/auth/guards/jwt-auth.guard.ts
Normal file
5
api/src/auth/guards/jwt-auth.guard.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||||
22
api/src/auth/guards/roles.guard.ts
Normal file
22
api/src/auth/guards/roles.guard.ts
Normal file
@ -0,0 +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));
|
||||||
|
}
|
||||||
|
}
|
||||||
16
api/src/auth/roles.spec.ts
Normal file
16
api/src/auth/roles.spec.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
34
api/src/auth/roles.ts
Normal file
34
api/src/auth/roles.ts
Normal file
@ -0,0 +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';
|
||||||
|
}
|
||||||
54
api/src/auth/strategies/jwt.strategy.ts
Normal file
54
api/src/auth/strategies/jwt.strategy.ts
Normal file
@ -0,0 +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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
44
api/src/carriers/carriers.controller.ts
Normal file
44
api/src/carriers/carriers.controller.ts
Normal file
@ -0,0 +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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
api/src/carriers/carriers.module.ts
Normal file
9
api/src/carriers/carriers.module.ts
Normal file
@ -0,0 +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 {}
|
||||||
101
api/src/carriers/carriers.service.ts
Normal file
101
api/src/carriers/carriers.service.ts
Normal file
@ -0,0 +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,
|
||||||
|
};
|
||||||
|
}
|
||||||
50
api/src/carriers/dto/upsert-carrier.dto.ts
Normal file
50
api/src/carriers/dto/upsert-carrier.dto.ts
Normal file
@ -0,0 +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;
|
||||||
|
}
|
||||||
44
api/src/customers/customers.controller.ts
Normal file
44
api/src/customers/customers.controller.ts
Normal file
@ -0,0 +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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
api/src/customers/customers.module.ts
Normal file
9
api/src/customers/customers.module.ts
Normal file
@ -0,0 +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 {}
|
||||||
52
api/src/customers/customers.service.ts
Normal file
52
api/src/customers/customers.service.ts
Normal file
@ -0,0 +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;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
api/src/customers/dto/upsert-customer.dto.ts
Normal file
34
api/src/customers/dto/upsert-customer.dto.ts
Normal file
@ -0,0 +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;
|
||||||
|
}
|
||||||
47
api/src/dashboard/dashboard-metrics.spec.ts
Normal file
47
api/src/dashboard/dashboard-metrics.spec.ts
Normal file
@ -0,0 +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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
181
api/src/dashboard/dashboard-metrics.ts
Normal file
181
api/src/dashboard/dashboard-metrics.ts
Normal file
@ -0,0 +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,
|
||||||
|
};
|
||||||
|
}
|
||||||
19
api/src/dashboard/dashboard.controller.ts
Normal file
19
api/src/dashboard/dashboard.controller.ts
Normal file
@ -0,0 +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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
api/src/dashboard/dashboard.module.ts
Normal file
9
api/src/dashboard/dashboard.module.ts
Normal file
@ -0,0 +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 {}
|
||||||
73
api/src/dashboard/dashboard.service.ts
Normal file
73
api/src/dashboard/dashboard.service.ts
Normal file
@ -0,0 +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,
|
||||||
|
};
|
||||||
|
}
|
||||||
47
api/src/freight/dto/simulate-freight.dto.ts
Normal file
47
api/src/freight/dto/simulate-freight.dto.ts
Normal file
@ -0,0 +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;
|
||||||
|
}
|
||||||
38
api/src/freight/freight-calculator.spec.ts
Normal file
38
api/src/freight/freight-calculator.spec.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
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('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);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
api/src/freight/freight-calculator.ts
Normal file
68
api/src/freight/freight-calculator.ts
Normal file
@ -0,0 +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;
|
||||||
|
}
|
||||||
26
api/src/freight/freight.controller.ts
Normal file
26
api/src/freight/freight.controller.ts
Normal file
@ -0,0 +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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
api/src/freight/freight.module.ts
Normal file
11
api/src/freight/freight.module.ts
Normal file
@ -0,0 +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 {}
|
||||||
144
api/src/freight/freight.service.ts
Normal file
144
api/src/freight/freight.service.ts
Normal file
@ -0,0 +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,
|
||||||
|
};
|
||||||
|
}
|
||||||
8
api/src/geo/geo.module.ts
Normal file
8
api/src/geo/geo.module.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { GeoService } from './geo.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [GeoService],
|
||||||
|
exports: [GeoService],
|
||||||
|
})
|
||||||
|
export class GeoModule {}
|
||||||
84
api/src/geo/geo.service.ts
Normal file
84
api/src/geo/geo.service.ts
Normal file
@ -0,0 +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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
22
api/src/geo/geo.types.ts
Normal file
22
api/src/geo/geo.types.ts
Normal file
@ -0,0 +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;
|
||||||
|
}
|
||||||
22
api/src/health/health.controller.spec.ts
Normal file
22
api/src/health/health.controller.spec.ts
Normal file
@ -0,0 +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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
13
api/src/health/health.controller.ts
Normal file
13
api/src/health/health.controller.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
check() {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
service: 'sinka-api',
|
||||||
|
stack: 'nestjs',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
api/src/health/health.module.ts
Normal file
7
api/src/health/health.module.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
26
api/src/main.ts
Normal file
26
api/src/main.ts
Normal file
@ -0,0 +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();
|
||||||
12
api/src/platform/dto/create-tenant.dto.ts
Normal file
12
api/src/platform/dto/create-tenant.dto.ts
Normal file
@ -0,0 +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;
|
||||||
|
}
|
||||||
41
api/src/platform/platform.controller.ts
Normal file
41
api/src/platform/platform.controller.ts
Normal file
@ -0,0 +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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
api/src/platform/platform.module.ts
Normal file
9
api/src/platform/platform.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformController } from './platform.controller';
|
||||||
|
import { PlatformService } from './platform.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PlatformController],
|
||||||
|
providers: [PlatformService],
|
||||||
|
})
|
||||||
|
export class PlatformModule {}
|
||||||
135
api/src/platform/platform.service.ts
Normal file
135
api/src/platform/platform.service.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
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 { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
||||||
|
import { normalizeSlug } from '../tenancy/tenant-url';
|
||||||
|
import type { CreateTenantDto } from './dto/create-tenant.dto';
|
||||||
|
|
||||||
|
@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 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
9
api/src/prisma/platform-prisma.service.ts
Normal file
9
api/src/prisma/platform-prisma.service.ts
Normal file
@ -0,0 +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();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
api/src/redis/redis.module.ts
Normal file
9
api/src/redis/redis.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { RedisService } from './redis.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [RedisService],
|
||||||
|
exports: [RedisService],
|
||||||
|
})
|
||||||
|
export class RedisModule {}
|
||||||
71
api/src/redis/redis.service.ts
Normal file
71
api/src/redis/redis.service.ts
Normal file
@ -0,0 +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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
api/src/tenancy/actor.ts
Normal file
14
api/src/tenancy/actor.ts
Normal file
@ -0,0 +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));
|
||||||
|
}
|
||||||
35
api/src/tenancy/provision-database.ts
Normal file
35
api/src/tenancy/provision-database.ts
Normal file
@ -0,0 +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);
|
||||||
|
}
|
||||||
10
api/src/tenancy/tenancy.module.ts
Normal file
10
api/src/tenancy/tenancy.module.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PlatformPrismaService } from '../prisma/platform-prisma.service';
|
||||||
|
import { TenantConnectionService } from './tenant-connection.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PlatformPrismaService, TenantConnectionService],
|
||||||
|
exports: [PlatformPrismaService, TenantConnectionService],
|
||||||
|
})
|
||||||
|
export class TenancyModule {}
|
||||||
39
api/src/tenancy/tenant-connection.service.ts
Normal file
39
api/src/tenancy/tenant-connection.service.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { Injectable, NotFoundException, OnModuleDestroy, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { PrismaClient as TenantPrismaClient } from '../generated/tenant';
|
||||||
|
import { PlatformPrismaService } from '../prisma/platform-prisma.service';
|
||||||
|
import { urlForDatabase } from './tenant-url';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TenantConnectionService implements OnModuleDestroy {
|
||||||
|
private readonly clients = new Map<string, TenantPrismaClient>();
|
||||||
|
|
||||||
|
constructor(private readonly platform: PlatformPrismaService) {}
|
||||||
|
|
||||||
|
async getByTenantId(tenantId: string): Promise<TenantPrismaClient> {
|
||||||
|
const tenant = await this.platform.tenant.findUnique({ where: { id: tenantId } });
|
||||||
|
if (!tenant) {
|
||||||
|
throw new NotFoundException('Empresa não encontrada.');
|
||||||
|
}
|
||||||
|
if (tenant.status !== 'ACTIVE') {
|
||||||
|
throw new ForbiddenException('Empresa suspensa.');
|
||||||
|
}
|
||||||
|
return this.getByDatabase(tenant.database);
|
||||||
|
}
|
||||||
|
|
||||||
|
getByDatabase(database: string): TenantPrismaClient {
|
||||||
|
const cached = this.clients.get(database);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const client = new TenantPrismaClient({
|
||||||
|
datasources: { db: { url: urlForDatabase(database) } },
|
||||||
|
});
|
||||||
|
this.clients.set(database, client);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
await Promise.all([...this.clients.values()].map((client) => client.$disconnect()));
|
||||||
|
this.clients.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
api/src/tenancy/tenant-url.spec.ts
Normal file
16
api/src/tenancy/tenant-url.spec.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { normalizeSlug, tenantDatabaseName } from './tenant-url';
|
||||||
|
|
||||||
|
describe('tenantDatabaseName', () => {
|
||||||
|
it('maps slug to a dedicated mysql database', () => {
|
||||||
|
expect(tenantDatabaseName('demo')).toBe('sinka_t_demo');
|
||||||
|
expect(tenantDatabaseName('filial-sul')).toBe('sinka_t_filial_sul');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeSlug', () => {
|
||||||
|
it('rejects reserved product paths', () => {
|
||||||
|
expect(() => normalizeSlug('admin')).toThrow(BadRequestException);
|
||||||
|
expect(() => normalizeSlug('login')).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
api/src/tenancy/tenant-url.ts
Normal file
34
api/src/tenancy/tenant-url.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
const RESERVED = new Set(["platform", "admin", "login", "app", "api", "brand", "film", "topo"]);
|
||||||
|
|
||||||
|
export function normalizeSlug(slug: string): string {
|
||||||
|
const value = slug.trim().toLowerCase();
|
||||||
|
if (!/^[a-z][a-z0-9-]{1,30}$/.test(value) || RESERVED.has(value)) {
|
||||||
|
throw new BadRequestException("Informe um código de empresa válido.");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tenantDatabaseName(slug: string): string {
|
||||||
|
return `sinka_t_${slug.replace(/-/g, '_')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function urlForDatabase(database: string): string {
|
||||||
|
const base = process.env.DATABASE_URL;
|
||||||
|
if (!base) {
|
||||||
|
throw new Error('DATABASE_URL ausente');
|
||||||
|
}
|
||||||
|
return base.replace(/\/[^/?]+(\?.*)?$/, `/${database}$1`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminDatabaseUrl(): string {
|
||||||
|
const raw = process.env.MYSQL_ADMIN_URL ?? process.env.DATABASE_URL;
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error('MYSQL_ADMIN_URL ausente');
|
||||||
|
}
|
||||||
|
if (/\/[A-Za-z0-9_]+(\?|$)/.test(raw)) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return `${raw.replace(/\/$/, '')}/mysql`;
|
||||||
|
}
|
||||||
19
api/src/users/dto/create-user.dto.ts
Normal file
19
api/src/users/dto/create-user.dto.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { IsEmail, IsIn, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
import { TENANT_ROLES, type TenantRole } from '../../auth/roles';
|
||||||
|
|
||||||
|
export class CreateUserDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(80)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@IsIn(TENANT_ROLES)
|
||||||
|
role!: TenantRole;
|
||||||
|
}
|
||||||
7
api/src/users/dto/update-role.dto.ts
Normal file
7
api/src/users/dto/update-role.dto.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { IsIn } from 'class-validator';
|
||||||
|
import { TENANT_ROLES, type TenantRole } from '../../auth/roles';
|
||||||
|
|
||||||
|
export class UpdateRoleDto {
|
||||||
|
@IsIn(TENANT_ROLES)
|
||||||
|
role!: TenantRole;
|
||||||
|
}
|
||||||
39
api/src/users/users.controller.ts
Normal file
39
api/src/users/users.controller.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { Body, Controller, 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 { CreateUserDto } from './dto/create-user.dto';
|
||||||
|
import { UpdateRoleDto } from './dto/update-role.dto';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
@Controller('users')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly users: UsersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles('ADMIN', 'MANAGER')
|
||||||
|
list(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.users.list(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Roles('ADMIN')
|
||||||
|
create(@CurrentUser() user: AuthUser, @Body() dto: CreateUserDto, @Req() request: Request) {
|
||||||
|
return this.users.create(user, dto, request.ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/role')
|
||||||
|
@Roles('ADMIN')
|
||||||
|
updateRole(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateRoleDto,
|
||||||
|
@Req() request: Request,
|
||||||
|
) {
|
||||||
|
return this.users.updateRole(user, id, dto, request.ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
api/src/users/users.module.ts
Normal file
9
api/src/users/users.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [UsersController],
|
||||||
|
providers: [UsersService],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
78
api/src/users/users.service.ts
Normal file
78
api/src/users/users.service.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import type { AuthUser } from '../auth/auth.types';
|
||||||
|
import { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
||||||
|
import type { CreateUserDto } from './dto/create-user.dto';
|
||||||
|
import type { UpdateRoleDto } from './dto/update-role.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(private readonly tenants: TenantConnectionService) {}
|
||||||
|
|
||||||
|
async list(actor: AuthUser) {
|
||||||
|
const db = await this.tenantDb(actor);
|
||||||
|
return db.user.findMany({
|
||||||
|
select: { id: true, name: true, email: true, role: true, lastLoginAt: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(actor: AuthUser, dto: CreateUserDto, ip?: string) {
|
||||||
|
const db = await this.tenantDb(actor);
|
||||||
|
const user = await db.user.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
email: dto.email.toLowerCase(),
|
||||||
|
passwordHash: await bcrypt.hash(dto.password, 10),
|
||||||
|
issuedPassword: dto.password,
|
||||||
|
role: dto.role,
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, email: true, role: true, createdAt: true },
|
||||||
|
});
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: actor.sub,
|
||||||
|
action: 'user.create',
|
||||||
|
entity: 'User',
|
||||||
|
entityId: user.id,
|
||||||
|
ip,
|
||||||
|
metadata: { role: dto.role, email: user.email },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRole(actor: AuthUser, id: string, dto: UpdateRoleDto, ip?: string) {
|
||||||
|
if (actor.sub === id) {
|
||||||
|
throw new ForbiddenException('Não é possível alterar o próprio papel.');
|
||||||
|
}
|
||||||
|
const db = await this.tenantDb(actor);
|
||||||
|
const existing = await db.user.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Usuário não encontrado.');
|
||||||
|
}
|
||||||
|
const user = await db.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { role: dto.role },
|
||||||
|
select: { id: true, name: true, email: true, role: true },
|
||||||
|
});
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: actor.sub,
|
||||||
|
action: 'user.role',
|
||||||
|
entity: 'User',
|
||||||
|
entityId: id,
|
||||||
|
ip,
|
||||||
|
metadata: { from: existing.role, to: dto.role },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tenantDb(actor: AuthUser) {
|
||||||
|
if (actor.kind !== 'tenant' || !actor.tenantId) {
|
||||||
|
throw new ForbiddenException('Acesso restrito à empresa.');
|
||||||
|
}
|
||||||
|
return this.tenants.getByTenantId(actor.tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
api/test/app.e2e-spec.ts
Normal file
31
api/test/app.e2e-spec.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { HealthModule } from './../src/health/health.module';
|
||||||
|
|
||||||
|
describe('API (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [HealthModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/api/health (GET)', () => {
|
||||||
|
return request(app.getHttpServer()).get('/api/health').expect(200).expect({
|
||||||
|
status: 'ok',
|
||||||
|
service: 'sinka-api',
|
||||||
|
stack: 'nestjs',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
9
api/test/jest-e2e.json
Normal file
9
api/test/jest-e2e.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
api/tsconfig.build.json
Normal file
7
api/tsconfig.build.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma"]
|
||||||
|
}
|
||||||
1
api/tsconfig.build.tsbuildinfo
Normal file
1
api/tsconfig.build.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
25
api/tsconfig.json
Normal file
25
api/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"resolvePackageJsonExports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
desafio_tecnico_logistica (1).pdf
Normal file
BIN
desafio_tecnico_logistica (1).pdf
Normal file
Binary file not shown.
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
container_name: sinka-mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
MYSQL_DATABASE: sinka_platform
|
||||||
|
MYSQL_USER: sinka
|
||||||
|
MYSQL_PASSWORD: sinka
|
||||||
|
volumes:
|
||||||
|
- sinka_mysql_data:/var/lib/mysql
|
||||||
|
- ./docker/mysql-init:/docker-entrypoint-initdb.d
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: sinka-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sinka_mysql_data:
|
||||||
3
docker/mysql-init/01-grants.sql
Normal file
3
docker/mysql-init/01-grants.sql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
GRANT CREATE, DROP ON *.* TO 'sinka'@'%';
|
||||||
|
GRANT ALL PRIVILEGES ON `sinka_t_%`.* TO 'sinka'@'%';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
17
docs/ARCHITECTURE.md
Normal file
17
docs/ARCHITECTURE.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Arquitetura — Sinka
|
||||||
|
|
||||||
|
```
|
||||||
|
[Next.js :3000] → [NestJS :3001/api] → [MySQL sinka_platform]
|
||||||
|
↓ ↓
|
||||||
|
[Redis] [MySQL sinka_t_<slug> …]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `web/`: landing pública, login e área autenticada (`/app`, `/platform`).
|
||||||
|
- `api/`: REST NestJS, autenticação, provisionamento de tenant, regras de negócio.
|
||||||
|
- `sinka_platform`: empresas e administrador da plataforma.
|
||||||
|
- `sinka_t_<slug>`: dados isolados de cada empresa.
|
||||||
|
- Redis: rate limit, cache, filas (BullMQ) e pub/sub.
|
||||||
|
|
||||||
|
Document root de produto: o frontend Next.js. A API não serve HTML.
|
||||||
|
|
||||||
|
Login de empresa exige o slug **na URL** (`/demo`, `/filial-sul`). O JWT carrega `tenantId` + papel; as queries usam o PrismaClient daquele database.
|
||||||
42
docs/DECISIONS.md
Normal file
42
docs/DECISIONS.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# Decisões técnicas
|
||||||
|
|
||||||
|
## Multi-tenant: um banco MySQL por empresa
|
||||||
|
|
||||||
|
Cada empresa recebe o próprio database (`sinka_t_<slug>`). O catálogo de empresas e o administrador da plataforma ficam em `sinka_platform`.
|
||||||
|
|
||||||
|
A API resolve o banco no login (slug da empresa) e reutiliza um `PrismaClient` por database. Dados de operação — usuários, clientes, simulações — nunca compartilham tabela com outra empresa.
|
||||||
|
|
||||||
|
**Por quê:** o desafio pede isolamento entre empresas. Database-per-tenant é o isolamento mais forte no MySQL: backup, restore e exclusão por empresa; um vazamento de `WHERE` não cruza tenant. O custo extra (provisionar database + `db push` no onboarding) cabe no desafio e deixa a justificativa explícita.
|
||||||
|
|
||||||
|
O `PLATFORM_ADMIN` não mora no banco da empresa. Ele só cria/suspende tenants e não lê operação.
|
||||||
|
|
||||||
|
Papéis **dentro** da empresa: `ADMIN` (Administrador), `MANAGER`, `OPERATOR`.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
JWT de acesso (cookie httpOnly, 15 min) + refresh (cookie httpOnly, 7 dias, hash no banco da empresa). Login com slug + e-mail + senha. Rate limit no Redis. Google, GitHub e TOTP ficam para a etapa seguinte; os campos já existem no usuário.
|
||||||
|
|
||||||
|
Auditoria: login, provisionamento de empresa, mudança de papel e CRUD de usuários.
|
||||||
|
|
||||||
|
## Prisma + NestJS
|
||||||
|
|
||||||
|
Dois schemas Prisma: `platform` (catálogo) e `tenant` (cópia idêntica em cada database de empresa). Módulos Nest por domínio.
|
||||||
|
|
||||||
|
## Redis
|
||||||
|
|
||||||
|
Fila de importação (próxima etapa), rate limit de login, cache de CEP/geocoding.
|
||||||
|
|
||||||
|
## Integrações externas
|
||||||
|
|
||||||
|
1. **ViaCEP** — cidade/UF a partir do CEP de origem e destino
|
||||||
|
2. **OpenStreetMap Nominatim** — coordenadas; distância por haversine
|
||||||
|
|
||||||
|
Se o Nominatim falhar, a simulação usa uma distância de fallback (mesmo município / mesmo estado / interestadual).
|
||||||
|
|
||||||
|
## Cálculo de frete
|
||||||
|
|
||||||
|
Peso cobrado = máximo entre peso real e peso cubado `(L × A × C) / 6000`.
|
||||||
|
|
||||||
|
Preço = taxa base + (R$/kg × peso cobrado) + (R$/km × distância) + (ad valorem × valor da carga).
|
||||||
|
|
||||||
|
Cada transportadora ativa da empresa gera uma cotação; a simulação grava o ranking no histórico.
|
||||||
11
package.json
Normal file
11
package.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "sinka",
|
||||||
|
"private": true,
|
||||||
|
"description": "Plataforma SaaS de inteligência logística — NestJS + Next.js",
|
||||||
|
"scripts": {
|
||||||
|
"docker:up": "docker compose up -d",
|
||||||
|
"docker:down": "docker compose down",
|
||||||
|
"dev:api": "npm run start:dev --prefix api",
|
||||||
|
"dev:web": "npm run dev --prefix web"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
web
Submodule
1
web
Submodule
@ -0,0 +1 @@
|
|||||||
|
Subproject commit a54be2fbb8f508e864dbf001e25a2c951cf22e58
|
||||||
Loading…
x
Reference in New Issue
Block a user