This commit is contained in:
armando 2026-09-15 21:10:42 -03:00
commit ebd666b361
87 changed files with 14880 additions and 0 deletions

View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View File

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

98
api/README.md Normal file
View 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>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

35
api/eslint.config.mjs Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

94
api/package.json Normal file
View 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"
}
}

View 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
View 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);
});

View 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
View 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 {}

View 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);
}
}

View 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 {}

View 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);
}

View 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
View 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];
}

View 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;
});

View 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);

View 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;
}

View File

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

View 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));
}
}

View 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
View 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';
}

View 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' };
}
}

View 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);
}
}

View 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 {}

View 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,
};
}

View 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;
}

View 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);
}
}

View 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 {}

View 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;
}
}

View 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;
}

View 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/);
});
});

View 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,
};
}

View 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);
}
}

View 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 {}

View 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,
};
}

View 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;
}

View 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);
});
});

View 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;
}

View 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);
}
}

View 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 {}

View 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,
};
}

View File

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

View 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
View 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;
}

View 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',
});
});
});

View 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',
};
}
}

View 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
View 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();

View 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;
}

View 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);
}
}

View 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 {}

View 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 };
}
}

View 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();
}
}

View 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 {}

View 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
View 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));
}

View 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);
}

View 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 {}

View 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();
}
}

View 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);
});
});

View 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`;
}

View 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;
}

View 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;
}

View 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);
}
}

View 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 {}

View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
},
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma"]
}

File diff suppressed because one or more lines are too long

25
api/tsconfig.json Normal file
View 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
}
}

Binary file not shown.

35
docker-compose.yml Normal file
View 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:

View 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
View 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
View 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
View 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

@ -0,0 +1 @@
Subproject commit a54be2fbb8f508e864dbf001e25a2c951cf22e58