Compare commits
No commits in common. "0b19e8ac5c1b79f7dc80beb8a0d22e6b3a5c82d0" and "cf16bf1fb634400686e5f5212e08a8f709767d1f" have entirely different histories.
0b19e8ac5c
...
cf16bf1fb6
@ -1,12 +1,12 @@
|
||||
---
|
||||
description: Stack e pastas da Sinka: NestJS + Next.js
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Sinka
|
||||
|
||||
Backend NestJS em `api/`. Frontend Next.js em `web/`. MySQL + Redis via Docker Compose.
|
||||
|
||||
**Proibido:** PHP, Composer, Blade, `index.php`, PDO, Laravel.
|
||||
|
||||
Feature nova: Prisma (platform ou tenant) → módulo NestJS (DTO + service) → tela App Router. Dado de empresa só no database daquela empresa.
|
||||
---
|
||||
description: Stack e pastas da Sinka: NestJS + Next.js
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Sinka
|
||||
|
||||
Backend NestJS em `api/`. Frontend Next.js em `web/`. MySQL + Redis via Docker Compose.
|
||||
|
||||
**Proibido:** PHP, Composer, Blade, `index.php`, PDO, Laravel.
|
||||
|
||||
Feature nova: Prisma (platform ou tenant) → módulo NestJS (DTO + service) → tela App Router. Dado de empresa só no database daquela empresa.
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
---
|
||||
description: Convenções NestJS na API
|
||||
globs: api/**/*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# NestJS
|
||||
|
||||
- Módulo por domínio (`users`, `customers`, `freight`)
|
||||
- Controller fino; regra no service/use case
|
||||
- DTO com `class-validator`; `ValidationPipe` global (`whitelist`)
|
||||
- Prefix da API: `/api`
|
||||
- Não colocar SQL cru se o Prisma cobre o caso
|
||||
- Teste unitário no service quando a regra importar
|
||||
---
|
||||
description: Convenções NestJS na API
|
||||
globs: api/**/*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# NestJS
|
||||
|
||||
- Módulo por domínio (`users`, `customers`, `freight`)
|
||||
- Controller fino; regra no service/use case
|
||||
- DTO com `class-validator`; `ValidationPipe` global (`whitelist`)
|
||||
- Prefix da API: `/api`
|
||||
- Não colocar SQL cru se o Prisma cobre o caso
|
||||
- Teste unitário no service quando a regra importar
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
---
|
||||
description: Convenções Next.js App Router
|
||||
globs: web/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Next.js
|
||||
|
||||
- App Router em `web/src/app`
|
||||
- Landing pública em `/`; área logada virá em `/app` nas próximas etapas
|
||||
- TypeScript estrito; componentes em `web/src/components`
|
||||
- Chamar a API em `NEXT_PUBLIC_API_URL` (default `http://localhost:3001/api`)
|
||||
- Sem páginas PHP, sem CSS global de template Create Next App genérico na landing
|
||||
---
|
||||
description: Convenções Next.js App Router
|
||||
globs: web/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Next.js
|
||||
|
||||
- App Router em `web/src/app`
|
||||
- Landing pública em `/`; área logada virá em `/app` nas próximas etapas
|
||||
- TypeScript estrito; componentes em `web/src/components`
|
||||
- Chamar a API em `NEXT_PUBLIC_API_URL` (default `http://localhost:3001/api`)
|
||||
- Sem páginas PHP, sem CSS global de template Create Next App genérico na landing
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
---
|
||||
description: Multi-tenant e segurança da plataforma
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Tenant e segurança
|
||||
|
||||
- Isolamento: **um database MySQL por empresa** (`sinka_t_<slug>`). Catálogo em `sinka_platform`.
|
||||
- Queries de operação usam o PrismaClient do tenant autenticado, nunca o banco de outra empresa.
|
||||
- Auth desta etapa: JWT + refresh em cookie httpOnly, papéis ADMIN / MANAGER / OPERATOR. Google, GitHub e TOTP na seguinte.
|
||||
- Segredos só em `.env`; nunca commitar
|
||||
- Rate limit de login com Redis
|
||||
- Auditoria: login, permissão, provisionamento, CRUD de usuários
|
||||
---
|
||||
description: Multi-tenant e segurança da plataforma
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Tenant e segurança
|
||||
|
||||
- Isolamento: **um database MySQL por empresa** (`sinka_t_<slug>`). Catálogo em `sinka_platform`.
|
||||
- Queries de operação usam o PrismaClient do tenant autenticado, nunca o banco de outra empresa.
|
||||
- Auth desta etapa: JWT + refresh em cookie httpOnly, papéis ADMIN / MANAGER / OPERATOR. Google, GitHub e TOTP na seguinte.
|
||||
- Segredos só em `.env`; nunca commitar
|
||||
- Rate limit de login com Redis
|
||||
- Auditoria: login, permissão, provisionamento, CRUD de usuários
|
||||
|
||||
90
.env.example
90
.env.example
@ -1,43 +1,47 @@
|
||||
# API
|
||||
PORT=3001
|
||||
WEB_ORIGIN=http://localhost:3000
|
||||
DATABASE_URL="mysql://sinka:sinka@localhost:3306/sinka_platform"
|
||||
MYSQL_ADMIN_URL="mysql://root:root@localhost:3306"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
JWT_SECRET=troque-este-segredo
|
||||
JWT_REFRESH_SECRET=troque-este-refresh
|
||||
|
||||
# Seed (local)
|
||||
PLATFORM_ADMIN_EMAIL=tina.r@example.net
|
||||
PLATFORM_ADMIN_PASSWORD=SinkaPlatform!1
|
||||
DEMO_ADMIN_EMAIL=xena.w@example.org
|
||||
DEMO_ADMIN_PASSWORD=SinkaAdmin!1
|
||||
DEMO_MANAGER_EMAIL=james.b@example.com
|
||||
DEMO_MANAGER_PASSWORD=SinkaManager!1
|
||||
DEMO_OPERATOR_EMAIL=ursula.b@example.com
|
||||
DEMO_OPERATOR_PASSWORD=SinkaOperador!1
|
||||
|
||||
# OAuth Google (console: origem http://localhost:3000, callback abaixo)
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback
|
||||
|
||||
# Firebase Realtime (chat admin ↔ cliente, modo teste sem auth)
|
||||
FIREBASE_PROJECT_ID=
|
||||
FIREBASE_DATABASE_URL=
|
||||
NEXT_PUBLIC_FIREBASE_DATABASE_URL=
|
||||
|
||||
# Arquivos da operação (CSV/XLSX), pasta local por empresa
|
||||
UPLOAD_DIR=
|
||||
|
||||
# WEB
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
||||
|
||||
# Docker (tudo no container): npm run docker:full
|
||||
# Cookie Secure só com HTTPS na VPS (COOKIE_SECURE=true)
|
||||
COOKIE_SECURE=false
|
||||
# MYSQL_ROOT_PASSWORD=root
|
||||
# MYSQL_PASSWORD=sinka
|
||||
# Se 3306/3000 já estiverem ocupados na VPS:
|
||||
# SINKA_MYSQL_PORT=3307
|
||||
# SINKA_WEB_PORT=3000
|
||||
# API
|
||||
PORT=3001
|
||||
WEB_ORIGIN=http://localhost:3000
|
||||
DATABASE_URL="mysql://sinka:sinka@localhost:3306/sinka_platform"
|
||||
MYSQL_ADMIN_URL="mysql://root:root@localhost:3306"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
JWT_SECRET=troque-este-segredo
|
||||
JWT_REFRESH_SECRET=troque-este-refresh
|
||||
|
||||
# Seed (local)
|
||||
PLATFORM_ADMIN_EMAIL=tina.r@example.net
|
||||
PLATFORM_ADMIN_PASSWORD=SinkaPlatform!1
|
||||
DEMO_ADMIN_EMAIL=xena.w@example.org
|
||||
DEMO_ADMIN_PASSWORD=SinkaAdmin!1
|
||||
DEMO_MANAGER_EMAIL=james.b@example.com
|
||||
DEMO_MANAGER_PASSWORD=SinkaManager!1
|
||||
DEMO_OPERATOR_EMAIL=ursula.b@example.com
|
||||
DEMO_OPERATOR_PASSWORD=SinkaOperador!1
|
||||
|
||||
# OAuth Google (console: origens e callback do ambiente)
|
||||
# Local: origem http://localhost:3000
|
||||
# callback http://localhost:3000/api/auth/google/callback
|
||||
# Demo: origem https://armandosoares.tech
|
||||
# callback https://armandosoares.tech/api/auth/google/callback
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback
|
||||
|
||||
# Firebase Realtime (chat admin ↔ cliente, modo teste sem auth)
|
||||
FIREBASE_PROJECT_ID=
|
||||
FIREBASE_DATABASE_URL=
|
||||
NEXT_PUBLIC_FIREBASE_DATABASE_URL=
|
||||
|
||||
# Arquivos da operação (CSV/XLSX), pasta local por empresa
|
||||
UPLOAD_DIR=
|
||||
|
||||
# WEB
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
||||
|
||||
# Docker (tudo no container): npm run docker:full
|
||||
# Cookie Secure só com HTTPS na VPS (COOKIE_SECURE=true)
|
||||
COOKIE_SECURE=false
|
||||
# MYSQL_ROOT_PASSWORD=root
|
||||
# MYSQL_PASSWORD=sinka
|
||||
# Se 3306/3000 já estiverem ocupados na VPS:
|
||||
# SINKA_MYSQL_PORT=3307
|
||||
# SINKA_WEB_PORT=3000
|
||||
|
||||
45
.gitignore
vendored
45
.gitignore
vendored
@ -1,22 +1,23 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.next/
|
||||
.turbo/
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
api/.env
|
||||
web/.env.local
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
prisma/*.db
|
||||
api/src/generated/
|
||||
uploads/
|
||||
api/uploads/
|
||||
|
||||
*.pdf
|
||||
msg.txt
|
||||
*.tsbuildinfo
|
||||
docker-compose.vps.yml
|
||||
deploy/
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.next/
|
||||
.turbo/
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
api/.env
|
||||
web/.env.local
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
prisma/*.db
|
||||
api/src/generated/
|
||||
uploads/
|
||||
api/uploads/
|
||||
|
||||
*.pdf
|
||||
msg.txt
|
||||
*.tsbuildinfo
|
||||
docker-compose.vps.yml
|
||||
deploy/
|
||||
msg.txt
|
||||
|
||||
188
README.md
188
README.md
@ -1,94 +1,94 @@
|
||||
# Sinka
|
||||
|
||||
SaaS de inteligência logística. Cada operação simula frete, compara transportadoras e acompanha custo, com os dados de uma empresa isolados dos das outras.
|
||||
|
||||
## O que faz
|
||||
|
||||
- Cotação de frete (peso cubado, distância, ad valorem) a partir de CEP (ViaCEP) e coordenadas (Nominatim)
|
||||
- Ranking de transportadoras da própria empresa
|
||||
- Clientes, equipe e papéis (Administrador, Manager, Operador)
|
||||
- Dashboard de custo
|
||||
- Chat interno (admin da plataforma ↔ empresa)
|
||||
- Arquivos de operação (CSV/XLSX) por empresa
|
||||
|
||||
Dois produtos no mesmo sistema: o **admin da Sinka** (`/admin`) cria e suspende empresas; cada empresa entra pelo slug na URL (`/cliente-1`, `/cliente-2`).
|
||||
|
||||
## Isolamento
|
||||
|
||||
Um database MySQL por empresa (`sinka_t_<slug>`). O catálogo fica em `sinka_platform`.
|
||||
|
||||
O JWT carrega o tenant. As queries de operação usam o PrismaClient daquele banco: um `WHERE` errado não cruza empresa. Backup, restore e exclusão também são por tenant.
|
||||
|
||||
## Stack
|
||||
|
||||
| Camada | Tecnologia |
|
||||
| --- | --- |
|
||||
| API | NestJS, Prisma, JWT em cookie httpOnly |
|
||||
| Web | Next.js (App Router) |
|
||||
| Dados | MySQL 8, Redis |
|
||||
| Infra | Docker Compose |
|
||||
|
||||
```
|
||||
Next.js → NestJS /api → sinka_platform
|
||||
↓ ↓
|
||||
Redis sinka_t_<slug> …
|
||||
```
|
||||
|
||||
## Estrutura
|
||||
|
||||
```
|
||||
api/ REST, auth, regras, Prisma
|
||||
web/ landing, login, painéis
|
||||
docs/ arquitetura, decisões, testes
|
||||
```
|
||||
|
||||
## Como rodar
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
cd api && npm install && npm run prisma:setup && npm run start:dev
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
MySQL e Redis sobem no Docker; API e web no Node da máquina.
|
||||
|
||||
Tudo em containers:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose --profile full up -d --build
|
||||
```
|
||||
|
||||
- App: http://localhost:3000
|
||||
- API: http://localhost:3001/api/health
|
||||
|
||||
## Contas
|
||||
|
||||
| Área | URL | E-mail | Senha |
|
||||
| --- | --- | --- | --- |
|
||||
| Plataforma | `/admin/login` | tina.r@example.net | SinkaPlatform!1 |
|
||||
| Cliente 1 (admin) | `/cliente-1/login` | xena.w@example.org | SinkaAdmin!1 |
|
||||
| Cliente 1 (manager) | `/cliente-1/login` | james.b@example.com | SinkaManager!1 |
|
||||
| Cliente 1 (operador) | `/cliente-1/login` | ursula.b@example.com | SinkaOperador!1 |
|
||||
| Cliente 2 (admin) | `/cliente-2/login` | ivan.p@example.net | Cliente2Admin!1 |
|
||||
|
||||
## Testes
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run test:cov
|
||||
```
|
||||
|
||||
Jest na API: frete, papéis, tenancy, chat, dashboard. A cobertura mede regra de negócio. Detalhes em [docs/TESTING.md](docs/TESTING.md).
|
||||
|
||||
## Documentação
|
||||
|
||||
- [Arquitetura](docs/ARCHITECTURE.md)
|
||||
- [Decisões](docs/DECISIONS.md)
|
||||
- [Testes](docs/TESTING.md)
|
||||
- [Entrega e CI](docs/CI.md)
|
||||
|
||||
## Entrega
|
||||
|
||||
Push em `main` sobe a demo na hora. Neste desafio isso basta; em produção o caminho é **merge request → pipeline (testes e build) → merge → deploy**. Detalhes em [docs/CI.md](docs/CI.md).
|
||||
# Sinka
|
||||
|
||||
SaaS de inteligência logística. Cada operação simula frete, compara transportadoras e acompanha custo, com os dados de uma empresa isolados dos das outras.
|
||||
|
||||
## O que faz
|
||||
|
||||
- Cotação de frete (peso cubado, distância, ad valorem) a partir de CEP (ViaCEP) e coordenadas (Nominatim)
|
||||
- Ranking de transportadoras da própria empresa
|
||||
- Clientes, equipe e papéis (Administrador, Manager, Operador)
|
||||
- Dashboard de custo
|
||||
- Chat interno (admin da plataforma ↔ empresa)
|
||||
- Arquivos de operação (CSV/XLSX) por empresa
|
||||
|
||||
Dois produtos no mesmo sistema: o **admin da Sinka** (`/admin`) cria e suspende empresas; cada empresa entra pelo slug na URL (`/cliente-1`, `/cliente-2`).
|
||||
|
||||
## Isolamento
|
||||
|
||||
Um database MySQL por empresa (`sinka_t_<slug>`). O catálogo fica em `sinka_platform`.
|
||||
|
||||
O JWT carrega o tenant. As queries de operação usam o PrismaClient daquele banco: um `WHERE` errado não cruza empresa. Backup, restore e exclusão também são por tenant.
|
||||
|
||||
## Stack
|
||||
|
||||
| Camada | Tecnologia |
|
||||
| --- | --- |
|
||||
| API | NestJS, Prisma, JWT em cookie httpOnly |
|
||||
| Web | Next.js (App Router) |
|
||||
| Dados | MySQL 8, Redis |
|
||||
| Infra | Docker Compose |
|
||||
|
||||
```
|
||||
Next.js → NestJS /api → sinka_platform
|
||||
↓ ↓
|
||||
Redis sinka_t_<slug> …
|
||||
```
|
||||
|
||||
## Estrutura
|
||||
|
||||
```
|
||||
api/ REST, auth, regras, Prisma
|
||||
web/ landing, login, painéis
|
||||
docs/ arquitetura, decisões, testes
|
||||
```
|
||||
|
||||
## Como rodar
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
cd api && npm install && npm run prisma:setup && npm run start:dev
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
MySQL e Redis sobem no Docker; API e web no Node da máquina.
|
||||
|
||||
Tudo em containers:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose --profile full up -d --build
|
||||
```
|
||||
|
||||
- App: http://localhost:3000
|
||||
- API: http://localhost:3001/api/health
|
||||
|
||||
## Contas
|
||||
|
||||
| Área | URL | E-mail | Senha |
|
||||
| --- | --- | --- | --- |
|
||||
| Plataforma | `/admin/login` | tina.r@example.net | SinkaPlatform!1 |
|
||||
| Cliente 1 (admin) | `/cliente-1/login` | xena.w@example.org | SinkaAdmin!1 |
|
||||
| Cliente 1 (manager) | `/cliente-1/login` | james.b@example.com | SinkaManager!1 |
|
||||
| Cliente 1 (operador) | `/cliente-1/login` | ursula.b@example.com | SinkaOperador!1 |
|
||||
| Cliente 2 (admin) | `/cliente-2/login` | ivan.p@example.net | Cliente2Admin!1 |
|
||||
|
||||
## Testes
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run test:cov
|
||||
```
|
||||
|
||||
Jest na API: frete, papéis, tenancy, chat, dashboard. A cobertura mede regra de negócio. Detalhes em [docs/TESTING.md](docs/TESTING.md).
|
||||
|
||||
## Documentação
|
||||
|
||||
- [Arquitetura](docs/ARCHITECTURE.md)
|
||||
- [Decisões](docs/DECISIONS.md)
|
||||
- [Testes](docs/TESTING.md)
|
||||
- [Entrega e CI](docs/CI.md)
|
||||
|
||||
## Entrega
|
||||
|
||||
Push em `main` sobe a demo na hora. Neste desafio isso basta; em produção o caminho é **merge request → pipeline (testes e build) → merge → deploy**. Detalhes em [docs/CI.md](docs/CI.md).
|
||||
|
||||
68
RULES.md
68
RULES.md
@ -1,34 +1,34 @@
|
||||
# RULES: Sinka
|
||||
|
||||
Stack obrigatório do desafio. **Não usar PHP.** Não reintroduzir Laravel, Blade, `public/index.php` nem pasta `app/` no estilo PHP.
|
||||
|
||||
## Stack
|
||||
|
||||
- Backend: Node.js + **NestJS** + TypeScript (`api/`)
|
||||
- Frontend: **Next.js** + TypeScript (`web/`)
|
||||
- Banco: **MySQL**, `sinka_platform` + um database por empresa
|
||||
- Infra: **Docker**, **Docker Compose**, **Redis**
|
||||
|
||||
## Pastas
|
||||
|
||||
| Pasta | Responsabilidade |
|
||||
| --- | --- |
|
||||
| `api/` | NestJS: módulos, casos de uso, Prisma, filas |
|
||||
| `web/` | Next.js App Router: landing + área autenticada |
|
||||
| `docs/` | Arquitetura e decisões |
|
||||
| `.cursor/rules/` | Regras do agente |
|
||||
|
||||
## Como evoluir uma feature
|
||||
|
||||
1. Modelar no Prisma tenant (`api/prisma/tenant`) se for dado da empresa; no platform se for catálogo
|
||||
2. Módulo NestJS: controller → use case/service → DTO + validação
|
||||
3. Abrir o PrismaClient do tenant do JWT, nunca consultar outro database
|
||||
4. Tela em `web/src/app` (App Router)
|
||||
5. Teste no módulo da API quando a regra for importante
|
||||
|
||||
## Não fazer
|
||||
|
||||
- PHP, Composer, views `.php`, PDO
|
||||
- Framework PHP, mesmo “só para prototipar”
|
||||
- Lógica de negócio em controller inchado ou em componente React
|
||||
- Ler/escrever dado de empresa no banco da plataforma ou no database de outro tenant
|
||||
# RULES: Sinka
|
||||
|
||||
Stack obrigatório do desafio. **Não usar PHP.** Não reintroduzir Laravel, Blade, `public/index.php` nem pasta `app/` no estilo PHP.
|
||||
|
||||
## Stack
|
||||
|
||||
- Backend: Node.js + **NestJS** + TypeScript (`api/`)
|
||||
- Frontend: **Next.js** + TypeScript (`web/`)
|
||||
- Banco: **MySQL**, `sinka_platform` + um database por empresa
|
||||
- Infra: **Docker**, **Docker Compose**, **Redis**
|
||||
|
||||
## Pastas
|
||||
|
||||
| Pasta | Responsabilidade |
|
||||
| --- | --- |
|
||||
| `api/` | NestJS: módulos, casos de uso, Prisma, filas |
|
||||
| `web/` | Next.js App Router: landing + área autenticada |
|
||||
| `docs/` | Arquitetura e decisões |
|
||||
| `.cursor/rules/` | Regras do agente |
|
||||
|
||||
## Como evoluir uma feature
|
||||
|
||||
1. Modelar no Prisma tenant (`api/prisma/tenant`) se for dado da empresa; no platform se for catálogo
|
||||
2. Módulo NestJS: controller → use case/service → DTO + validação
|
||||
3. Abrir o PrismaClient do tenant do JWT, nunca consultar outro database
|
||||
4. Tela em `web/src/app` (App Router)
|
||||
5. Teste no módulo da API quando a regra for importante
|
||||
|
||||
## Não fazer
|
||||
|
||||
- PHP, Composer, views `.php`, PDO
|
||||
- Framework PHP, mesmo “só para prototipar”
|
||||
- Lógica de negócio em controller inchado ou em componente React
|
||||
- Ler/escrever dado de empresa no banco da plataforma ou no database de outro tenant
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
uploads
|
||||
.env
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
uploads
|
||||
.env
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run prisma:generate && npm run build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3001
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "docker-entrypoint.js"]
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run prisma:generate && npm run build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3001
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "docker-entrypoint.js"]
|
||||
|
||||
196
api/README.md
196
api/README.md
@ -1,98 +1,98 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Project setup
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ npm run test
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ npm install -g @nestjs/mau
|
||||
$ mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
## Resources
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Project setup
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ npm run test
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ npm install -g @nestjs/mau
|
||||
$ mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
## Resources
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||
|
||||
@ -1,26 +1,26 @@
|
||||
const { spawn, spawnSync } = require('node:child_process');
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
run('npx', ['prisma', 'db', 'push', '--schema', 'prisma/platform/schema.prisma', '--skip-generate']);
|
||||
run('npx', ['tsx', 'prisma/seed.ts']);
|
||||
|
||||
const child = spawn('node', ['dist/main.js'], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
const { spawn, spawnSync } = require('node:child_process');
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
run('npx', ['prisma', 'db', 'push', '--schema', 'prisma/platform/schema.prisma', '--skip-generate']);
|
||||
run('npx', ['tsx', 'prisma/seed.ts']);
|
||||
|
||||
const child = spawn('node', ['dist/main.js'], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
|
||||
@ -1,35 +1,35 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs', 'src/generated/**'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs', 'src/generated/**'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"assets": [
|
||||
{
|
||||
"include": "generated/**/*",
|
||||
"watchAssets": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"assets": [
|
||||
{
|
||||
"include": "generated/**/*",
|
||||
"watchAssets": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
26632
api/package-lock.json
generated
26632
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
260
api/package.json
260
api/package.json
@ -1,130 +1,130 @@
|
||||
{
|
||||
"name": "api",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"prisma:generate": "prisma generate --schema prisma/platform/schema.prisma && prisma generate --schema prisma/tenant/schema.prisma",
|
||||
"prisma:push:platform": "prisma db push --schema prisma/platform/schema.prisma",
|
||||
"prisma:seed": "tsx prisma/seed.ts",
|
||||
"prisma:setup": "npm run prisma:generate && npm run prisma:push:platform && npm run prisma:seed",
|
||||
"postinstall": "npm run prisma:generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^12.0.0",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/terminus": "^12.0.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^16.6.1",
|
||||
"firebase-admin": "^14.4.0",
|
||||
"ioredis": "^5.6.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^17.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^6.19.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.ts",
|
||||
"!**/*.spec.ts",
|
||||
"!**/*.module.ts",
|
||||
"!**/*.dto.ts",
|
||||
"!main.ts",
|
||||
"!generated/**"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"coveragePathIgnorePatterns": [
|
||||
"/generated/",
|
||||
"main.ts",
|
||||
".module.ts",
|
||||
".controller.ts",
|
||||
".dto.ts",
|
||||
".strategy.ts",
|
||||
"platform-prisma.service.ts",
|
||||
"tenant-connection.service.ts",
|
||||
"provision-database.ts",
|
||||
"redis.service.ts",
|
||||
"auth.service.ts",
|
||||
"jwt-auth.guard.ts",
|
||||
"current-user.decorator.ts",
|
||||
"chat.service.ts",
|
||||
"dashboard.service.ts",
|
||||
"freight.service.ts",
|
||||
"geo.service.ts",
|
||||
"carriers.service.ts",
|
||||
"customers.service.ts",
|
||||
"users.service.ts",
|
||||
"platform.service.ts"
|
||||
],
|
||||
"coverageReporters": [
|
||||
"text",
|
||||
"lcov"
|
||||
],
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "api",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"prisma:generate": "prisma generate --schema prisma/platform/schema.prisma && prisma generate --schema prisma/tenant/schema.prisma",
|
||||
"prisma:push:platform": "prisma db push --schema prisma/platform/schema.prisma",
|
||||
"prisma:seed": "tsx prisma/seed.ts",
|
||||
"prisma:setup": "npm run prisma:generate && npm run prisma:push:platform && npm run prisma:seed",
|
||||
"postinstall": "npm run prisma:generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^12.0.0",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/terminus": "^12.0.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^16.6.1",
|
||||
"firebase-admin": "^14.4.0",
|
||||
"ioredis": "^5.6.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^17.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^6.19.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.ts",
|
||||
"!**/*.spec.ts",
|
||||
"!**/*.module.ts",
|
||||
"!**/*.dto.ts",
|
||||
"!main.ts",
|
||||
"!generated/**"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"coveragePathIgnorePatterns": [
|
||||
"/generated/",
|
||||
"main.ts",
|
||||
".module.ts",
|
||||
".controller.ts",
|
||||
".dto.ts",
|
||||
".strategy.ts",
|
||||
"platform-prisma.service.ts",
|
||||
"tenant-connection.service.ts",
|
||||
"provision-database.ts",
|
||||
"redis.service.ts",
|
||||
"auth.service.ts",
|
||||
"jwt-auth.guard.ts",
|
||||
"current-user.decorator.ts",
|
||||
"chat.service.ts",
|
||||
"dashboard.service.ts",
|
||||
"freight.service.ts",
|
||||
"geo.service.ts",
|
||||
"carriers.service.ts",
|
||||
"customers.service.ts",
|
||||
"users.service.ts",
|
||||
"platform.service.ts"
|
||||
],
|
||||
"coverageReporters": [
|
||||
"text",
|
||||
"lcov"
|
||||
],
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,66 +1,66 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../../src/generated/platform"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum TenantStatus {
|
||||
ACTIVE
|
||||
SUSPENDED
|
||||
}
|
||||
|
||||
model Tenant {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
database String @unique
|
||||
status TenantStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model PlatformUser {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
email String @unique
|
||||
passwordHash String
|
||||
googleId String? @unique
|
||||
githubId String? @unique
|
||||
totpSecret String?
|
||||
totpEnabled Boolean @default(false)
|
||||
lastLoginAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
refreshTokens PlatformRefreshToken[]
|
||||
auditLogs PlatformAuditLog[]
|
||||
}
|
||||
|
||||
model PlatformRefreshToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user PlatformUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
hashed String
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model PlatformAuditLog {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
user PlatformUser? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
action String
|
||||
entity String?
|
||||
entityId String?
|
||||
ip String?
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../../src/generated/platform"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum TenantStatus {
|
||||
ACTIVE
|
||||
SUSPENDED
|
||||
}
|
||||
|
||||
model Tenant {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
database String @unique
|
||||
status TenantStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model PlatformUser {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
email String @unique
|
||||
passwordHash String
|
||||
googleId String? @unique
|
||||
githubId String? @unique
|
||||
totpSecret String?
|
||||
totpEnabled Boolean @default(false)
|
||||
lastLoginAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
refreshTokens PlatformRefreshToken[]
|
||||
auditLogs PlatformAuditLog[]
|
||||
}
|
||||
|
||||
model PlatformRefreshToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user PlatformUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
hashed String
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model PlatformAuditLog {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
user PlatformUser? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
action String
|
||||
entity String?
|
||||
entityId String?
|
||||
ip String?
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
@ -1,172 +1,172 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'node:path';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaClient as PlatformPrisma } from '../src/generated/platform';
|
||||
import { PrismaClient as TenantPrisma } from '../src/generated/tenant';
|
||||
import { createMysqlDatabase, databaseForSlug, pushTenantSchema } from '../src/tenancy/provision-database';
|
||||
import { urlForDatabase } from '../src/tenancy/tenant-url';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
config({ path: resolve(__dirname, '../.env') });
|
||||
|
||||
async function upsertUser(
|
||||
db: TenantPrisma,
|
||||
email: string,
|
||||
name: string,
|
||||
password: string,
|
||||
role: 'ADMIN' | 'MANAGER' | 'OPERATOR',
|
||||
) {
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
await db.user.upsert({
|
||||
where: { email },
|
||||
update: { name, passwordHash, role, issuedPassword: password },
|
||||
create: { email, name, passwordHash, role, issuedPassword: password },
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureTenant(
|
||||
platform: PlatformPrisma,
|
||||
name: string,
|
||||
slug: string,
|
||||
aliases: string[] = [],
|
||||
) {
|
||||
let tenant = await platform.tenant.findUnique({ where: { slug } });
|
||||
if (!tenant) {
|
||||
for (const alias of aliases) {
|
||||
const old = await platform.tenant.findUnique({ where: { slug: alias } });
|
||||
if (old) {
|
||||
tenant = await platform.tenant.update({
|
||||
where: { id: old.id },
|
||||
data: { name, slug },
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!tenant) {
|
||||
const database = databaseForSlug(slug);
|
||||
await createMysqlDatabase(database);
|
||||
await pushTenantSchema(database);
|
||||
tenant = await platform.tenant.create({ data: { name, slug, database } });
|
||||
} else {
|
||||
await pushTenantSchema(tenant.database);
|
||||
if (tenant.name !== name) {
|
||||
tenant = await platform.tenant.update({ where: { id: tenant.id }, data: { name } });
|
||||
}
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
|
||||
async function seedCompany(
|
||||
tenant: { database: string },
|
||||
users: { email: string; name: string; password: string; role: 'ADMIN' | 'MANAGER' | 'OPERATOR' }[],
|
||||
customers: { name: string; document: string; city: string; state: string }[],
|
||||
carriers: { name: string; baseFee: number; pricePerKg: number; pricePerKm: number; adValoremRate: number }[],
|
||||
) {
|
||||
const db = new TenantPrisma({
|
||||
datasources: { db: { url: urlForDatabase(tenant.database) } },
|
||||
});
|
||||
for (const user of users) {
|
||||
await upsertUser(db, user.email, user.name, user.password, user.role);
|
||||
}
|
||||
for (const customer of customers) {
|
||||
const existing = await db.customer.findFirst({ where: { name: customer.name } });
|
||||
if (!existing) {
|
||||
await db.customer.create({ data: customer });
|
||||
}
|
||||
}
|
||||
for (const carrier of carriers) {
|
||||
const existing = await db.carrier.findFirst({ where: { name: carrier.name } });
|
||||
if (existing) {
|
||||
await db.carrier.update({ where: { id: existing.id }, data: carrier });
|
||||
} else {
|
||||
await db.carrier.create({ data: carrier });
|
||||
}
|
||||
}
|
||||
await db.$disconnect();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const platform = new PlatformPrisma();
|
||||
|
||||
const platformEmail = process.env.PLATFORM_ADMIN_EMAIL ?? 'tina.r@example.net';
|
||||
const platformPassword = process.env.PLATFORM_ADMIN_PASSWORD ?? 'SinkaPlatform!1';
|
||||
|
||||
await platform.platformUser.upsert({
|
||||
where: { email: platformEmail },
|
||||
update: { passwordHash: await bcrypt.hash(platformPassword, 10), name: 'Sinka Platform' },
|
||||
create: {
|
||||
email: platformEmail,
|
||||
name: 'Sinka Platform',
|
||||
passwordHash: await bcrypt.hash(platformPassword, 10),
|
||||
},
|
||||
});
|
||||
|
||||
const cliente1 = await ensureTenant(platform, 'Cliente 1', 'cliente-1', ['demo']);
|
||||
const cliente2 = await ensureTenant(platform, 'Cliente 2', 'cliente-2');
|
||||
|
||||
const others = await platform.tenant.findMany({
|
||||
where: { slug: { notIn: ['cliente-1', 'cliente-2'] } },
|
||||
});
|
||||
for (const item of others) {
|
||||
await pushTenantSchema(item.database);
|
||||
}
|
||||
|
||||
await seedCompany(
|
||||
cliente1,
|
||||
[
|
||||
{
|
||||
email: process.env.DEMO_ADMIN_EMAIL ?? 'xena.w@example.org',
|
||||
name: 'Ana Admin',
|
||||
password: process.env.DEMO_ADMIN_PASSWORD ?? 'SinkaAdmin!1',
|
||||
role: 'ADMIN',
|
||||
},
|
||||
{
|
||||
email: process.env.DEMO_MANAGER_EMAIL ?? 'james.b@example.com',
|
||||
name: 'Marcos Manager',
|
||||
password: process.env.DEMO_MANAGER_PASSWORD ?? 'SinkaManager!1',
|
||||
role: 'MANAGER',
|
||||
},
|
||||
{
|
||||
email: process.env.DEMO_OPERATOR_EMAIL ?? 'ursula.b@example.com',
|
||||
name: 'Olga Operador',
|
||||
password: process.env.DEMO_OPERATOR_PASSWORD ?? 'SinkaOperador!1',
|
||||
role: 'OPERATOR',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ name: 'Mercado Aurora', document: '12.345.678/0001-90', city: 'São Paulo', state: 'SP' },
|
||||
{ name: 'Café do Litoral', document: '98.765.432/0001-10', city: 'Santos', state: 'SP' },
|
||||
],
|
||||
[
|
||||
{ name: 'Litoral Mix', baseFee: 45, pricePerKg: 1.8, pricePerKm: 0.85, adValoremRate: 0.003 },
|
||||
{ name: 'Rota Sul', baseFee: 32, pricePerKg: 1.4, pricePerKm: 0.72, adValoremRate: 0.004 },
|
||||
{ name: 'Express RJ', baseFee: 60, pricePerKg: 2.2, pricePerKm: 1.1, adValoremRate: 0.002 },
|
||||
],
|
||||
);
|
||||
|
||||
await seedCompany(
|
||||
cliente2,
|
||||
[
|
||||
{
|
||||
email: 'ivan.p@example.net',
|
||||
name: 'Bruno Admin',
|
||||
password: 'Cliente2Admin!1',
|
||||
role: 'ADMIN',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ name: 'Padaria Centro', document: '11.222.333/0001-44', city: 'Curitiba', state: 'PR' },
|
||||
{ name: 'Loja Norte', document: '55.666.777/0001-88', city: 'Joinville', state: 'SC' },
|
||||
],
|
||||
[{ name: 'Carga Paraná', baseFee: 38, pricePerKg: 1.5, pricePerKm: 0.7, adValoremRate: 0.0035 }],
|
||||
);
|
||||
|
||||
await platform.$disconnect();
|
||||
console.log('Seed ok: platform + Cliente 1 + Cliente 2.');
|
||||
}
|
||||
|
||||
main().catch(async (error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'node:path';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaClient as PlatformPrisma } from '../src/generated/platform';
|
||||
import { PrismaClient as TenantPrisma } from '../src/generated/tenant';
|
||||
import { createMysqlDatabase, databaseForSlug, pushTenantSchema } from '../src/tenancy/provision-database';
|
||||
import { urlForDatabase } from '../src/tenancy/tenant-url';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
config({ path: resolve(__dirname, '../.env') });
|
||||
|
||||
async function upsertUser(
|
||||
db: TenantPrisma,
|
||||
email: string,
|
||||
name: string,
|
||||
password: string,
|
||||
role: 'ADMIN' | 'MANAGER' | 'OPERATOR',
|
||||
) {
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
await db.user.upsert({
|
||||
where: { email },
|
||||
update: { name, passwordHash, role, issuedPassword: password },
|
||||
create: { email, name, passwordHash, role, issuedPassword: password },
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureTenant(
|
||||
platform: PlatformPrisma,
|
||||
name: string,
|
||||
slug: string,
|
||||
aliases: string[] = [],
|
||||
) {
|
||||
let tenant = await platform.tenant.findUnique({ where: { slug } });
|
||||
if (!tenant) {
|
||||
for (const alias of aliases) {
|
||||
const old = await platform.tenant.findUnique({ where: { slug: alias } });
|
||||
if (old) {
|
||||
tenant = await platform.tenant.update({
|
||||
where: { id: old.id },
|
||||
data: { name, slug },
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!tenant) {
|
||||
const database = databaseForSlug(slug);
|
||||
await createMysqlDatabase(database);
|
||||
await pushTenantSchema(database);
|
||||
tenant = await platform.tenant.create({ data: { name, slug, database } });
|
||||
} else {
|
||||
await pushTenantSchema(tenant.database);
|
||||
if (tenant.name !== name) {
|
||||
tenant = await platform.tenant.update({ where: { id: tenant.id }, data: { name } });
|
||||
}
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
|
||||
async function seedCompany(
|
||||
tenant: { database: string },
|
||||
users: { email: string; name: string; password: string; role: 'ADMIN' | 'MANAGER' | 'OPERATOR' }[],
|
||||
customers: { name: string; document: string; city: string; state: string }[],
|
||||
carriers: { name: string; baseFee: number; pricePerKg: number; pricePerKm: number; adValoremRate: number }[],
|
||||
) {
|
||||
const db = new TenantPrisma({
|
||||
datasources: { db: { url: urlForDatabase(tenant.database) } },
|
||||
});
|
||||
for (const user of users) {
|
||||
await upsertUser(db, user.email, user.name, user.password, user.role);
|
||||
}
|
||||
for (const customer of customers) {
|
||||
const existing = await db.customer.findFirst({ where: { name: customer.name } });
|
||||
if (!existing) {
|
||||
await db.customer.create({ data: customer });
|
||||
}
|
||||
}
|
||||
for (const carrier of carriers) {
|
||||
const existing = await db.carrier.findFirst({ where: { name: carrier.name } });
|
||||
if (existing) {
|
||||
await db.carrier.update({ where: { id: existing.id }, data: carrier });
|
||||
} else {
|
||||
await db.carrier.create({ data: carrier });
|
||||
}
|
||||
}
|
||||
await db.$disconnect();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const platform = new PlatformPrisma();
|
||||
|
||||
const platformEmail = process.env.PLATFORM_ADMIN_EMAIL ?? 'tina.r@example.net';
|
||||
const platformPassword = process.env.PLATFORM_ADMIN_PASSWORD ?? 'SinkaPlatform!1';
|
||||
|
||||
await platform.platformUser.upsert({
|
||||
where: { email: platformEmail },
|
||||
update: { passwordHash: await bcrypt.hash(platformPassword, 10), name: 'Sinka Platform' },
|
||||
create: {
|
||||
email: platformEmail,
|
||||
name: 'Sinka Platform',
|
||||
passwordHash: await bcrypt.hash(platformPassword, 10),
|
||||
},
|
||||
});
|
||||
|
||||
const cliente1 = await ensureTenant(platform, 'Cliente 1', 'cliente-1', ['demo']);
|
||||
const cliente2 = await ensureTenant(platform, 'Cliente 2', 'cliente-2');
|
||||
|
||||
const others = await platform.tenant.findMany({
|
||||
where: { slug: { notIn: ['cliente-1', 'cliente-2'] } },
|
||||
});
|
||||
for (const item of others) {
|
||||
await pushTenantSchema(item.database);
|
||||
}
|
||||
|
||||
await seedCompany(
|
||||
cliente1,
|
||||
[
|
||||
{
|
||||
email: process.env.DEMO_ADMIN_EMAIL ?? 'xena.w@example.org',
|
||||
name: 'Ana Admin',
|
||||
password: process.env.DEMO_ADMIN_PASSWORD ?? 'SinkaAdmin!1',
|
||||
role: 'ADMIN',
|
||||
},
|
||||
{
|
||||
email: process.env.DEMO_MANAGER_EMAIL ?? 'james.b@example.com',
|
||||
name: 'Marcos Manager',
|
||||
password: process.env.DEMO_MANAGER_PASSWORD ?? 'SinkaManager!1',
|
||||
role: 'MANAGER',
|
||||
},
|
||||
{
|
||||
email: process.env.DEMO_OPERATOR_EMAIL ?? 'ursula.b@example.com',
|
||||
name: 'Olga Operador',
|
||||
password: process.env.DEMO_OPERATOR_PASSWORD ?? 'SinkaOperador!1',
|
||||
role: 'OPERATOR',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ name: 'Mercado Aurora', document: '12.345.678/0001-90', city: 'São Paulo', state: 'SP' },
|
||||
{ name: 'Café do Litoral', document: '98.765.432/0001-10', city: 'Santos', state: 'SP' },
|
||||
],
|
||||
[
|
||||
{ name: 'Litoral Mix', baseFee: 45, pricePerKg: 1.8, pricePerKm: 0.85, adValoremRate: 0.003 },
|
||||
{ name: 'Rota Sul', baseFee: 32, pricePerKg: 1.4, pricePerKm: 0.72, adValoremRate: 0.004 },
|
||||
{ name: 'Express RJ', baseFee: 60, pricePerKg: 2.2, pricePerKm: 1.1, adValoremRate: 0.002 },
|
||||
],
|
||||
);
|
||||
|
||||
await seedCompany(
|
||||
cliente2,
|
||||
[
|
||||
{
|
||||
email: 'ivan.p@example.net',
|
||||
name: 'Bruno Admin',
|
||||
password: 'Cliente2Admin!1',
|
||||
role: 'ADMIN',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ name: 'Padaria Centro', document: '11.222.333/0001-44', city: 'Curitiba', state: 'PR' },
|
||||
{ name: 'Loja Norte', document: '55.666.777/0001-88', city: 'Joinville', state: 'SC' },
|
||||
],
|
||||
[{ name: 'Carga Paraná', baseFee: 38, pricePerKg: 1.5, pricePerKm: 0.7, adValoremRate: 0.0035 }],
|
||||
);
|
||||
|
||||
await platform.$disconnect();
|
||||
console.log('Seed ok: platform + Cliente 1 + Cliente 2.');
|
||||
}
|
||||
|
||||
main().catch(async (error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@ -1,147 +1,147 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../../src/generated/tenant"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
MANAGER
|
||||
OPERATOR
|
||||
}
|
||||
|
||||
enum SimulationStatus {
|
||||
DRAFT
|
||||
CALCULATED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum ImportJobStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
DONE
|
||||
FAILED
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
email String @unique
|
||||
passwordHash String
|
||||
issuedPassword String?
|
||||
role Role @default(OPERATOR)
|
||||
googleId String? @unique
|
||||
githubId String? @unique
|
||||
totpSecret String?
|
||||
totpEnabled Boolean @default(false)
|
||||
lastLoginAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
refreshTokens RefreshToken[]
|
||||
auditLogs AuditLog[]
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
hashed String
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
action String
|
||||
entity String?
|
||||
entityId String?
|
||||
ip String?
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model Customer {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
document String?
|
||||
email String?
|
||||
phone String?
|
||||
city String?
|
||||
state String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
simulations FreightSimulation[]
|
||||
}
|
||||
|
||||
model Carrier {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
document String?
|
||||
email String?
|
||||
phone String?
|
||||
baseFee Decimal @default(0) @db.Decimal(10, 2)
|
||||
pricePerKg Decimal @default(0) @db.Decimal(10, 2)
|
||||
pricePerKm Decimal @default(0) @db.Decimal(10, 2)
|
||||
adValoremRate Decimal @default(0) @db.Decimal(6, 4)
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model FreightSimulation {
|
||||
id String @id @default(uuid())
|
||||
originZip String
|
||||
destinationZip String
|
||||
originLabel String?
|
||||
destinationLabel String?
|
||||
weightKg Decimal @db.Decimal(10, 3)
|
||||
widthCm Decimal @db.Decimal(8, 2)
|
||||
heightCm Decimal @db.Decimal(8, 2)
|
||||
lengthCm Decimal @db.Decimal(8, 2)
|
||||
cargoValue Decimal @db.Decimal(12, 2)
|
||||
quotedPrice Decimal? @db.Decimal(12, 2)
|
||||
distanceKm Decimal? @db.Decimal(10, 2)
|
||||
chargeableKg Decimal? @db.Decimal(10, 3)
|
||||
bestCarrierName String?
|
||||
quotes Json?
|
||||
status SimulationStatus @default(DRAFT)
|
||||
customerId String?
|
||||
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model ImportJob {
|
||||
id String @id @default(uuid())
|
||||
filename String
|
||||
status ImportJobStatus @default(PENDING)
|
||||
error String?
|
||||
createdAt DateTime @default(now())
|
||||
finishedAt DateTime?
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model OperationFile {
|
||||
id String @id @default(uuid())
|
||||
originalName String
|
||||
storedName String
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../../src/generated/tenant"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
MANAGER
|
||||
OPERATOR
|
||||
}
|
||||
|
||||
enum SimulationStatus {
|
||||
DRAFT
|
||||
CALCULATED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum ImportJobStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
DONE
|
||||
FAILED
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
email String @unique
|
||||
passwordHash String
|
||||
issuedPassword String?
|
||||
role Role @default(OPERATOR)
|
||||
googleId String? @unique
|
||||
githubId String? @unique
|
||||
totpSecret String?
|
||||
totpEnabled Boolean @default(false)
|
||||
lastLoginAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
refreshTokens RefreshToken[]
|
||||
auditLogs AuditLog[]
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
hashed String
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
action String
|
||||
entity String?
|
||||
entityId String?
|
||||
ip String?
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model Customer {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
document String?
|
||||
email String?
|
||||
phone String?
|
||||
city String?
|
||||
state String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
simulations FreightSimulation[]
|
||||
}
|
||||
|
||||
model Carrier {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
document String?
|
||||
email String?
|
||||
phone String?
|
||||
baseFee Decimal @default(0) @db.Decimal(10, 2)
|
||||
pricePerKg Decimal @default(0) @db.Decimal(10, 2)
|
||||
pricePerKm Decimal @default(0) @db.Decimal(10, 2)
|
||||
adValoremRate Decimal @default(0) @db.Decimal(6, 4)
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model FreightSimulation {
|
||||
id String @id @default(uuid())
|
||||
originZip String
|
||||
destinationZip String
|
||||
originLabel String?
|
||||
destinationLabel String?
|
||||
weightKg Decimal @db.Decimal(10, 3)
|
||||
widthCm Decimal @db.Decimal(8, 2)
|
||||
heightCm Decimal @db.Decimal(8, 2)
|
||||
lengthCm Decimal @db.Decimal(8, 2)
|
||||
cargoValue Decimal @db.Decimal(12, 2)
|
||||
quotedPrice Decimal? @db.Decimal(12, 2)
|
||||
distanceKm Decimal? @db.Decimal(10, 2)
|
||||
chargeableKg Decimal? @db.Decimal(10, 3)
|
||||
bestCarrierName String?
|
||||
quotes Json?
|
||||
status SimulationStatus @default(DRAFT)
|
||||
customerId String?
|
||||
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model ImportJob {
|
||||
id String @id @default(uuid())
|
||||
filename String
|
||||
status ImportJobStatus @default(PENDING)
|
||||
error String?
|
||||
createdAt DateTime @default(now())
|
||||
finishedAt DateTime?
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model OperationFile {
|
||||
id String @id @default(uuid())
|
||||
originalName String
|
||||
storedName String
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
@ -1,36 +1,36 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PlatformModule } from './platform/platform.module';
|
||||
import { RedisModule } from './redis/redis.module';
|
||||
import { TenancyModule } from './tenancy/tenancy.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { CarriersModule } from './carriers/carriers.module';
|
||||
import { CustomersModule } from './customers/customers.module';
|
||||
import { FreightModule } from './freight/freight.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { ChatModule } from './chat/chat.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env', '../.env'],
|
||||
}),
|
||||
RedisModule,
|
||||
TenancyModule,
|
||||
AuthModule,
|
||||
PlatformModule,
|
||||
UsersModule,
|
||||
CustomersModule,
|
||||
CarriersModule,
|
||||
FreightModule,
|
||||
DashboardModule,
|
||||
ChatModule,
|
||||
FilesModule,
|
||||
HealthModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PlatformModule } from './platform/platform.module';
|
||||
import { RedisModule } from './redis/redis.module';
|
||||
import { TenancyModule } from './tenancy/tenancy.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { CarriersModule } from './carriers/carriers.module';
|
||||
import { CustomersModule } from './customers/customers.module';
|
||||
import { FreightModule } from './freight/freight.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { ChatModule } from './chat/chat.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env', '../.env'],
|
||||
}),
|
||||
RedisModule,
|
||||
TenancyModule,
|
||||
AuthModule,
|
||||
PlatformModule,
|
||||
UsersModule,
|
||||
CustomersModule,
|
||||
CarriersModule,
|
||||
FreightModule,
|
||||
DashboardModule,
|
||||
ChatModule,
|
||||
FilesModule,
|
||||
HealthModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@ -1,132 +1,132 @@
|
||||
import { Body, Controller, Get, HttpCode, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import passport from 'passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { clearAuthCookies, readRefreshCookie, setAuthCookies } from './cookies';
|
||||
import type { AuthUser } from './auth.types';
|
||||
import { readOAuthState, signOAuthState } from './oauth-state';
|
||||
import type { GoogleIdentity } from './strategies/google.strategy';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const result = await this.auth.login(dto, request.ip);
|
||||
setAuthCookies(response, result.accessToken, result.refreshToken);
|
||||
return { user: result.user };
|
||||
}
|
||||
|
||||
@Get('google')
|
||||
googleStart(
|
||||
@Query('tenant') tenant: string | undefined,
|
||||
@Query('next') next: string | undefined,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
if (!this.auth.googleConfigured()) {
|
||||
return response.redirect(this.failUrl(tenant, 'google_off'));
|
||||
}
|
||||
const kind = tenant ? 'tenant' : 'platform';
|
||||
const state = signOAuthState(
|
||||
{ kind, slug: tenant, next },
|
||||
this.config.getOrThrow<string>('JWT_SECRET'),
|
||||
);
|
||||
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
||||
url.searchParams.set('client_id', this.config.getOrThrow<string>('GOOGLE_CLIENT_ID'));
|
||||
url.searchParams.set('redirect_uri', this.config.getOrThrow<string>('GOOGLE_CALLBACK_URL'));
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('scope', 'openid email profile');
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('prompt', 'select_account');
|
||||
return response.redirect(url.toString());
|
||||
}
|
||||
|
||||
@Get('google/callback')
|
||||
googleCallback(@Req() request: Request, @Res() response: Response) {
|
||||
const rawState = String(request.query.state ?? '');
|
||||
const tenant = this.slugFromState(rawState);
|
||||
if (request.query.error) {
|
||||
return response.redirect(this.failUrl(tenant, 'google_denied'));
|
||||
}
|
||||
|
||||
passport.authenticate('google', { session: false }, (error: unknown, identity: GoogleIdentity | false) => {
|
||||
void this.finishGoogle(request, response, rawState, tenant, error, identity);
|
||||
})(request, response);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(200)
|
||||
async refresh(@Req() request: Request, @Res({ passthrough: true }) response: Response) {
|
||||
const result = await this.auth.refresh(readRefreshCookie(request.cookies));
|
||||
setAuthCookies(response, result.accessToken, result.refreshToken);
|
||||
return { user: result.user };
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(200)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async logout(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
await this.auth.logout(readRefreshCookie(request.cookies), user);
|
||||
clearAuthCookies(response);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return this.auth.profile(user);
|
||||
}
|
||||
|
||||
private async finishGoogle(
|
||||
request: Request,
|
||||
response: Response,
|
||||
rawState: string,
|
||||
tenant: string | undefined,
|
||||
error: unknown,
|
||||
identity: GoogleIdentity | false,
|
||||
) {
|
||||
try {
|
||||
if (error || !identity) {
|
||||
return response.redirect(this.failUrl(tenant, 'google_denied'));
|
||||
}
|
||||
const state = readOAuthState(rawState, this.config.getOrThrow<string>('JWT_SECRET'));
|
||||
const { session, redirect } = await this.auth.loginWithGoogle(identity, state, request.ip);
|
||||
setAuthCookies(response, session.accessToken, session.refreshToken);
|
||||
return response.redirect(redirect);
|
||||
} catch (caught) {
|
||||
const message = caught instanceof Error ? caught.message : '';
|
||||
const code = message.includes('não tem acesso') ? 'google_unlinked' : 'google_denied';
|
||||
return response.redirect(this.failUrl(tenant, code));
|
||||
}
|
||||
}
|
||||
|
||||
private slugFromState(raw: string) {
|
||||
try {
|
||||
return readOAuthState(raw, this.config.getOrThrow<string>('JWT_SECRET')).slug;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private failUrl(tenant: string | undefined, code: string) {
|
||||
const path = tenant ? `/${tenant}/login` : '/admin/login';
|
||||
return `${this.auth.webOrigin()}${path}?error=${code}`;
|
||||
}
|
||||
}
|
||||
import { Body, Controller, Get, HttpCode, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import passport from 'passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { clearAuthCookies, readRefreshCookie, setAuthCookies } from './cookies';
|
||||
import type { AuthUser } from './auth.types';
|
||||
import { readOAuthState, signOAuthState } from './oauth-state';
|
||||
import type { GoogleIdentity } from './strategies/google.strategy';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const result = await this.auth.login(dto, request.ip);
|
||||
setAuthCookies(response, result.accessToken, result.refreshToken);
|
||||
return { user: result.user };
|
||||
}
|
||||
|
||||
@Get('google')
|
||||
googleStart(
|
||||
@Query('tenant') tenant: string | undefined,
|
||||
@Query('next') next: string | undefined,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
if (!this.auth.googleConfigured()) {
|
||||
return response.redirect(this.failUrl(tenant, 'google_off'));
|
||||
}
|
||||
const kind = tenant ? 'tenant' : 'platform';
|
||||
const state = signOAuthState(
|
||||
{ kind, slug: tenant, next },
|
||||
this.config.getOrThrow<string>('JWT_SECRET'),
|
||||
);
|
||||
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
||||
url.searchParams.set('client_id', this.config.getOrThrow<string>('GOOGLE_CLIENT_ID'));
|
||||
url.searchParams.set('redirect_uri', this.config.getOrThrow<string>('GOOGLE_CALLBACK_URL'));
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('scope', 'openid email profile');
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('prompt', 'select_account');
|
||||
return response.redirect(url.toString());
|
||||
}
|
||||
|
||||
@Get('google/callback')
|
||||
googleCallback(@Req() request: Request, @Res() response: Response) {
|
||||
const rawState = String(request.query.state ?? '');
|
||||
const tenant = this.slugFromState(rawState);
|
||||
if (request.query.error) {
|
||||
return response.redirect(this.failUrl(tenant, 'google_denied'));
|
||||
}
|
||||
|
||||
passport.authenticate('google', { session: false }, (error: unknown, identity: GoogleIdentity | false) => {
|
||||
void this.finishGoogle(request, response, rawState, tenant, error, identity);
|
||||
})(request, response);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(200)
|
||||
async refresh(@Req() request: Request, @Res({ passthrough: true }) response: Response) {
|
||||
const result = await this.auth.refresh(readRefreshCookie(request.cookies));
|
||||
setAuthCookies(response, result.accessToken, result.refreshToken);
|
||||
return { user: result.user };
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(200)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async logout(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
await this.auth.logout(readRefreshCookie(request.cookies), user);
|
||||
clearAuthCookies(response);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return this.auth.profile(user);
|
||||
}
|
||||
|
||||
private async finishGoogle(
|
||||
request: Request,
|
||||
response: Response,
|
||||
rawState: string,
|
||||
tenant: string | undefined,
|
||||
error: unknown,
|
||||
identity: GoogleIdentity | false,
|
||||
) {
|
||||
try {
|
||||
if (error || !identity) {
|
||||
return response.redirect(this.failUrl(tenant, 'google_denied'));
|
||||
}
|
||||
const state = readOAuthState(rawState, this.config.getOrThrow<string>('JWT_SECRET'));
|
||||
const { session, redirect } = await this.auth.loginWithGoogle(identity, state, request.ip);
|
||||
setAuthCookies(response, session.accessToken, session.refreshToken);
|
||||
return response.redirect(redirect);
|
||||
} catch (caught) {
|
||||
const message = caught instanceof Error ? caught.message : '';
|
||||
const code = message.includes('não tem acesso') ? 'google_unlinked' : 'google_denied';
|
||||
return response.redirect(this.failUrl(tenant, code));
|
||||
}
|
||||
}
|
||||
|
||||
private slugFromState(raw: string) {
|
||||
try {
|
||||
return readOAuthState(raw, this.config.getOrThrow<string>('JWT_SECRET')).slug;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private failUrl(tenant: string | undefined, code: string) {
|
||||
const path = tenant ? `/${tenant}/login` : '/admin/login';
|
||||
return `${this.auth.webOrigin()}${path}?error=${code}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,25 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { GoogleStrategy } from './strategies/google.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.getOrThrow<string>('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '15m' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, GoogleStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { GoogleStrategy } from './strategies/google.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.getOrThrow<string>('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '15m' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, GoogleStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@ -1,321 +1,321 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PlatformPrismaService } from '../prisma/platform-prisma.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
||||
import { normalizeSlug } from '../tenancy/tenant-url';
|
||||
import type { AuthUser, JwtPayload } from './auth.types';
|
||||
import type { LoginDto } from './dto/login.dto';
|
||||
import type { GoogleIdentity } from './strategies/google.strategy';
|
||||
import { permissionsFor, ROLE_LABEL } from './roles';
|
||||
import { type OAuthState, safeNext } from './oauth-state';
|
||||
|
||||
const LOGIN_WINDOW_SECONDS = 15 * 60;
|
||||
const LOGIN_MAX_ATTEMPTS = 10;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly platform: PlatformPrismaService,
|
||||
private readonly tenants: TenantConnectionService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly redis: RedisService,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto, ip?: string) {
|
||||
await this.assertRateLimit(dto.email, ip);
|
||||
|
||||
const slug = dto.tenantSlug?.trim();
|
||||
if (!slug || slug.toLowerCase() === 'platform') {
|
||||
return this.loginPlatform(dto.email, dto.password, ip);
|
||||
}
|
||||
|
||||
return this.loginTenant(normalizeSlug(slug), dto.email, dto.password, ip);
|
||||
}
|
||||
|
||||
googleConfigured() {
|
||||
return Boolean(this.config.get<string>('GOOGLE_CLIENT_ID') && this.config.get<string>('GOOGLE_CLIENT_SECRET'));
|
||||
}
|
||||
|
||||
webOrigin() {
|
||||
return this.config.get<string>('WEB_ORIGIN') ?? 'http://localhost:3000';
|
||||
}
|
||||
|
||||
async loginWithGoogle(identity: GoogleIdentity, state: OAuthState, ip?: string) {
|
||||
await this.assertRateLimit(identity.email, ip);
|
||||
if (state.kind === 'platform') {
|
||||
const session = await this.loginPlatformGoogle(identity, ip);
|
||||
return { session, redirect: `${this.webOrigin()}${safeNext(state.next, '/admin')}` };
|
||||
}
|
||||
const slug = normalizeSlug(state.slug ?? '');
|
||||
const session = await this.loginTenantGoogle(slug, identity, ip);
|
||||
return { session, redirect: `${this.webOrigin()}${safeNext(state.next, `/${slug}`)}` };
|
||||
}
|
||||
|
||||
async refresh(rawToken: string | undefined) {
|
||||
if (!rawToken) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const hashed = hashToken(rawToken);
|
||||
const platformToken = await this.platform.platformRefreshToken.findFirst({
|
||||
where: { hashed, revokedAt: null, expiresAt: { gt: new Date() } },
|
||||
include: { user: true },
|
||||
});
|
||||
if (platformToken) {
|
||||
await this.platform.platformRefreshToken.update({
|
||||
where: { id: platformToken.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return this.issuePlatformSession(platformToken.user.id, platformToken.user.email, platformToken.user.name);
|
||||
}
|
||||
|
||||
const tenants = await this.platform.tenant.findMany({ where: { status: 'ACTIVE' } });
|
||||
for (const tenant of tenants) {
|
||||
const db = this.tenants.getByDatabase(tenant.database);
|
||||
const token = await db.refreshToken.findFirst({
|
||||
where: { hashed, revokedAt: null, expiresAt: { gt: new Date() } },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
await db.refreshToken.update({
|
||||
where: { id: token.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return this.issueTenantSession(tenant.id, tenant.slug, token.user.id, token.user.email, token.user.name, token.user.role);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
async logout(rawToken: string | undefined, user: AuthUser) {
|
||||
if (!rawToken) {
|
||||
return;
|
||||
}
|
||||
const hashed = hashToken(rawToken);
|
||||
if (user.kind === 'platform') {
|
||||
await this.platform.platformRefreshToken.updateMany({
|
||||
where: { hashed, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!user.tenantId) {
|
||||
return;
|
||||
}
|
||||
const db = await this.tenants.getByTenantId(user.tenantId);
|
||||
await db.refreshToken.updateMany({
|
||||
where: { hashed, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async profile(user: AuthUser) {
|
||||
let tenantName: string | null = null;
|
||||
if (user.kind === 'tenant' && user.tenantId) {
|
||||
const tenant = await this.platform.tenant.findUnique({ where: { id: user.tenantId } });
|
||||
tenantName = tenant?.name ?? null;
|
||||
}
|
||||
return {
|
||||
id: user.sub,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
roleLabel: ROLE_LABEL[user.role],
|
||||
kind: user.kind,
|
||||
tenantId: user.tenantId ?? null,
|
||||
tenantSlug: user.tenantSlug ?? null,
|
||||
tenantName,
|
||||
permissions: permissionsFor(user.role),
|
||||
};
|
||||
}
|
||||
|
||||
private async loginPlatform(email: string, password: string, ip?: string) {
|
||||
const user = await this.platform.platformUser.findUnique({ where: { email } });
|
||||
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
|
||||
throw new UnauthorizedException('Credenciais inválidas.');
|
||||
}
|
||||
await this.platform.platformUser.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
await this.platform.platformAuditLog.create({
|
||||
data: { userId: user.id, action: 'login', ip, entity: 'PlatformUser', entityId: user.id },
|
||||
});
|
||||
return this.issuePlatformSession(user.id, user.email, user.name);
|
||||
}
|
||||
|
||||
private async loginTenant(slug: string, email: string, password: string, ip?: string) {
|
||||
const tenant = await this.platform.tenant.findUnique({ where: { slug } });
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Este cliente não existe.');
|
||||
}
|
||||
if (tenant.status !== 'ACTIVE') {
|
||||
throw new ForbiddenException('Empresa suspensa.');
|
||||
}
|
||||
const db = this.tenants.getByDatabase(tenant.database);
|
||||
const user = await db.user.findUnique({ where: { email } });
|
||||
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
|
||||
throw new UnauthorizedException('Credenciais inválidas.');
|
||||
}
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
await db.auditLog.create({
|
||||
data: { userId: user.id, action: 'login', ip, entity: 'User', entityId: user.id },
|
||||
});
|
||||
return this.issueTenantSession(tenant.id, tenant.slug, user.id, user.email, user.name, user.role);
|
||||
}
|
||||
|
||||
private async loginPlatformGoogle(identity: GoogleIdentity, ip?: string) {
|
||||
const user =
|
||||
(await this.platform.platformUser.findUnique({ where: { googleId: identity.googleId } })) ??
|
||||
(await this.platform.platformUser.findUnique({ where: { email: identity.email } }));
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Este e-mail Google não tem acesso à plataforma.');
|
||||
}
|
||||
if (!user.googleId) {
|
||||
await this.platform.platformUser.update({
|
||||
where: { id: user.id },
|
||||
data: { googleId: identity.googleId, lastLoginAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
await this.platform.platformUser.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
}
|
||||
await this.platform.platformAuditLog.create({
|
||||
data: { userId: user.id, action: 'login.google', ip, entity: 'PlatformUser', entityId: user.id },
|
||||
});
|
||||
return this.issuePlatformSession(user.id, user.email, user.name);
|
||||
}
|
||||
|
||||
private async loginTenantGoogle(slug: string, identity: GoogleIdentity, ip?: string) {
|
||||
const tenant = await this.platform.tenant.findUnique({ where: { slug } });
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Este cliente não existe.');
|
||||
}
|
||||
if (tenant.status !== 'ACTIVE') {
|
||||
throw new ForbiddenException('Empresa suspensa.');
|
||||
}
|
||||
const db = this.tenants.getByDatabase(tenant.database);
|
||||
let user =
|
||||
(await db.user.findUnique({ where: { googleId: identity.googleId } })) ??
|
||||
(await db.user.findUnique({ where: { email: identity.email } }));
|
||||
if (!user) {
|
||||
const issuedPassword = `Sinka-${randomBytes(3).toString('hex')}`;
|
||||
user = await db.user.create({
|
||||
data: {
|
||||
name: identity.name,
|
||||
email: identity.email,
|
||||
googleId: identity.googleId,
|
||||
passwordHash: await bcrypt.hash(issuedPassword, 10),
|
||||
issuedPassword,
|
||||
role: 'OPERATOR',
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
});
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: 'user.create',
|
||||
ip,
|
||||
entity: 'User',
|
||||
entityId: user.id,
|
||||
metadata: { via: 'google' },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { googleId: user.googleId ?? identity.googleId, lastLoginAt: new Date() },
|
||||
});
|
||||
}
|
||||
await db.auditLog.create({
|
||||
data: { userId: user.id, action: 'login.google', ip, entity: 'User', entityId: user.id },
|
||||
});
|
||||
return this.issueTenantSession(tenant.id, tenant.slug, user.id, user.email, user.name, user.role);
|
||||
}
|
||||
|
||||
private async issuePlatformSession(id: string, email: string, name: string) {
|
||||
const payload: JwtPayload = {
|
||||
sub: id,
|
||||
email,
|
||||
name,
|
||||
role: 'PLATFORM_ADMIN',
|
||||
};
|
||||
const accessToken = await this.jwt.signAsync(payload);
|
||||
const refreshToken = rawRefreshToken();
|
||||
await this.platform.platformRefreshToken.create({
|
||||
data: {
|
||||
userId: id,
|
||||
hashed: hashToken(refreshToken),
|
||||
expiresAt: refreshExpiry(),
|
||||
},
|
||||
});
|
||||
return { accessToken, refreshToken, user: await this.profile({ ...payload, kind: 'platform' }) };
|
||||
}
|
||||
|
||||
private async issueTenantSession(
|
||||
tenantId: string,
|
||||
tenantSlug: string,
|
||||
id: string,
|
||||
email: string,
|
||||
name: string,
|
||||
role: JwtPayload['role'],
|
||||
) {
|
||||
const payload: JwtPayload = {
|
||||
sub: id,
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
tenantId,
|
||||
tenantSlug,
|
||||
};
|
||||
const accessToken = await this.jwt.signAsync(payload);
|
||||
const refreshToken = rawRefreshToken();
|
||||
const db = await this.tenants.getByTenantId(tenantId);
|
||||
await db.refreshToken.create({
|
||||
data: {
|
||||
userId: id,
|
||||
hashed: hashToken(refreshToken),
|
||||
expiresAt: refreshExpiry(),
|
||||
},
|
||||
});
|
||||
return { accessToken, refreshToken, user: await this.profile({ ...payload, kind: 'tenant' }) };
|
||||
}
|
||||
|
||||
private async assertRateLimit(email: string, ip?: string) {
|
||||
const key = `login:${ip ?? 'unknown'}:${email.toLowerCase()}`;
|
||||
const count = await this.redis.increment(key, LOGIN_WINDOW_SECONDS);
|
||||
if (count !== null && count > LOGIN_MAX_ATTEMPTS) {
|
||||
throw new HttpException('Muitas tentativas. Aguarde alguns minutos.', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rawRefreshToken(): string {
|
||||
return randomBytes(48).toString('hex');
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
function refreshExpiry(): Date {
|
||||
return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PlatformPrismaService } from '../prisma/platform-prisma.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
||||
import { normalizeSlug } from '../tenancy/tenant-url';
|
||||
import type { AuthUser, JwtPayload } from './auth.types';
|
||||
import type { LoginDto } from './dto/login.dto';
|
||||
import type { GoogleIdentity } from './strategies/google.strategy';
|
||||
import { permissionsFor, ROLE_LABEL } from './roles';
|
||||
import { type OAuthState, safeNext } from './oauth-state';
|
||||
|
||||
const LOGIN_WINDOW_SECONDS = 15 * 60;
|
||||
const LOGIN_MAX_ATTEMPTS = 10;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly platform: PlatformPrismaService,
|
||||
private readonly tenants: TenantConnectionService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly redis: RedisService,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto, ip?: string) {
|
||||
await this.assertRateLimit(dto.email, ip);
|
||||
|
||||
const slug = dto.tenantSlug?.trim();
|
||||
if (!slug || slug.toLowerCase() === 'platform') {
|
||||
return this.loginPlatform(dto.email, dto.password, ip);
|
||||
}
|
||||
|
||||
return this.loginTenant(normalizeSlug(slug), dto.email, dto.password, ip);
|
||||
}
|
||||
|
||||
googleConfigured() {
|
||||
return Boolean(this.config.get<string>('GOOGLE_CLIENT_ID') && this.config.get<string>('GOOGLE_CLIENT_SECRET'));
|
||||
}
|
||||
|
||||
webOrigin() {
|
||||
return this.config.get<string>('WEB_ORIGIN') ?? 'http://localhost:3000';
|
||||
}
|
||||
|
||||
async loginWithGoogle(identity: GoogleIdentity, state: OAuthState, ip?: string) {
|
||||
await this.assertRateLimit(identity.email, ip);
|
||||
if (state.kind === 'platform') {
|
||||
const session = await this.loginPlatformGoogle(identity, ip);
|
||||
return { session, redirect: `${this.webOrigin()}${safeNext(state.next, '/admin')}` };
|
||||
}
|
||||
const slug = normalizeSlug(state.slug ?? '');
|
||||
const session = await this.loginTenantGoogle(slug, identity, ip);
|
||||
return { session, redirect: `${this.webOrigin()}${safeNext(state.next, `/${slug}`)}` };
|
||||
}
|
||||
|
||||
async refresh(rawToken: string | undefined) {
|
||||
if (!rawToken) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const hashed = hashToken(rawToken);
|
||||
const platformToken = await this.platform.platformRefreshToken.findFirst({
|
||||
where: { hashed, revokedAt: null, expiresAt: { gt: new Date() } },
|
||||
include: { user: true },
|
||||
});
|
||||
if (platformToken) {
|
||||
await this.platform.platformRefreshToken.update({
|
||||
where: { id: platformToken.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return this.issuePlatformSession(platformToken.user.id, platformToken.user.email, platformToken.user.name);
|
||||
}
|
||||
|
||||
const tenants = await this.platform.tenant.findMany({ where: { status: 'ACTIVE' } });
|
||||
for (const tenant of tenants) {
|
||||
const db = this.tenants.getByDatabase(tenant.database);
|
||||
const token = await db.refreshToken.findFirst({
|
||||
where: { hashed, revokedAt: null, expiresAt: { gt: new Date() } },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
await db.refreshToken.update({
|
||||
where: { id: token.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return this.issueTenantSession(tenant.id, tenant.slug, token.user.id, token.user.email, token.user.name, token.user.role);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
async logout(rawToken: string | undefined, user: AuthUser) {
|
||||
if (!rawToken) {
|
||||
return;
|
||||
}
|
||||
const hashed = hashToken(rawToken);
|
||||
if (user.kind === 'platform') {
|
||||
await this.platform.platformRefreshToken.updateMany({
|
||||
where: { hashed, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!user.tenantId) {
|
||||
return;
|
||||
}
|
||||
const db = await this.tenants.getByTenantId(user.tenantId);
|
||||
await db.refreshToken.updateMany({
|
||||
where: { hashed, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async profile(user: AuthUser) {
|
||||
let tenantName: string | null = null;
|
||||
if (user.kind === 'tenant' && user.tenantId) {
|
||||
const tenant = await this.platform.tenant.findUnique({ where: { id: user.tenantId } });
|
||||
tenantName = tenant?.name ?? null;
|
||||
}
|
||||
return {
|
||||
id: user.sub,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
roleLabel: ROLE_LABEL[user.role],
|
||||
kind: user.kind,
|
||||
tenantId: user.tenantId ?? null,
|
||||
tenantSlug: user.tenantSlug ?? null,
|
||||
tenantName,
|
||||
permissions: permissionsFor(user.role),
|
||||
};
|
||||
}
|
||||
|
||||
private async loginPlatform(email: string, password: string, ip?: string) {
|
||||
const user = await this.platform.platformUser.findUnique({ where: { email } });
|
||||
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
|
||||
throw new UnauthorizedException('Credenciais inválidas.');
|
||||
}
|
||||
await this.platform.platformUser.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
await this.platform.platformAuditLog.create({
|
||||
data: { userId: user.id, action: 'login', ip, entity: 'PlatformUser', entityId: user.id },
|
||||
});
|
||||
return this.issuePlatformSession(user.id, user.email, user.name);
|
||||
}
|
||||
|
||||
private async loginTenant(slug: string, email: string, password: string, ip?: string) {
|
||||
const tenant = await this.platform.tenant.findUnique({ where: { slug } });
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Este cliente não existe.');
|
||||
}
|
||||
if (tenant.status !== 'ACTIVE') {
|
||||
throw new ForbiddenException('Empresa suspensa.');
|
||||
}
|
||||
const db = this.tenants.getByDatabase(tenant.database);
|
||||
const user = await db.user.findUnique({ where: { email } });
|
||||
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
|
||||
throw new UnauthorizedException('Credenciais inválidas.');
|
||||
}
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
await db.auditLog.create({
|
||||
data: { userId: user.id, action: 'login', ip, entity: 'User', entityId: user.id },
|
||||
});
|
||||
return this.issueTenantSession(tenant.id, tenant.slug, user.id, user.email, user.name, user.role);
|
||||
}
|
||||
|
||||
private async loginPlatformGoogle(identity: GoogleIdentity, ip?: string) {
|
||||
const user =
|
||||
(await this.platform.platformUser.findUnique({ where: { googleId: identity.googleId } })) ??
|
||||
(await this.platform.platformUser.findUnique({ where: { email: identity.email } }));
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Este e-mail Google não tem acesso à plataforma.');
|
||||
}
|
||||
if (!user.googleId) {
|
||||
await this.platform.platformUser.update({
|
||||
where: { id: user.id },
|
||||
data: { googleId: identity.googleId, lastLoginAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
await this.platform.platformUser.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
}
|
||||
await this.platform.platformAuditLog.create({
|
||||
data: { userId: user.id, action: 'login.google', ip, entity: 'PlatformUser', entityId: user.id },
|
||||
});
|
||||
return this.issuePlatformSession(user.id, user.email, user.name);
|
||||
}
|
||||
|
||||
private async loginTenantGoogle(slug: string, identity: GoogleIdentity, ip?: string) {
|
||||
const tenant = await this.platform.tenant.findUnique({ where: { slug } });
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Este cliente não existe.');
|
||||
}
|
||||
if (tenant.status !== 'ACTIVE') {
|
||||
throw new ForbiddenException('Empresa suspensa.');
|
||||
}
|
||||
const db = this.tenants.getByDatabase(tenant.database);
|
||||
let user =
|
||||
(await db.user.findUnique({ where: { googleId: identity.googleId } })) ??
|
||||
(await db.user.findUnique({ where: { email: identity.email } }));
|
||||
if (!user) {
|
||||
const issuedPassword = `Sinka-${randomBytes(3).toString('hex')}`;
|
||||
user = await db.user.create({
|
||||
data: {
|
||||
name: identity.name,
|
||||
email: identity.email,
|
||||
googleId: identity.googleId,
|
||||
passwordHash: await bcrypt.hash(issuedPassword, 10),
|
||||
issuedPassword,
|
||||
role: 'OPERATOR',
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
});
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: 'user.create',
|
||||
ip,
|
||||
entity: 'User',
|
||||
entityId: user.id,
|
||||
metadata: { via: 'google' },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { googleId: user.googleId ?? identity.googleId, lastLoginAt: new Date() },
|
||||
});
|
||||
}
|
||||
await db.auditLog.create({
|
||||
data: { userId: user.id, action: 'login.google', ip, entity: 'User', entityId: user.id },
|
||||
});
|
||||
return this.issueTenantSession(tenant.id, tenant.slug, user.id, user.email, user.name, user.role);
|
||||
}
|
||||
|
||||
private async issuePlatformSession(id: string, email: string, name: string) {
|
||||
const payload: JwtPayload = {
|
||||
sub: id,
|
||||
email,
|
||||
name,
|
||||
role: 'PLATFORM_ADMIN',
|
||||
};
|
||||
const accessToken = await this.jwt.signAsync(payload);
|
||||
const refreshToken = rawRefreshToken();
|
||||
await this.platform.platformRefreshToken.create({
|
||||
data: {
|
||||
userId: id,
|
||||
hashed: hashToken(refreshToken),
|
||||
expiresAt: refreshExpiry(),
|
||||
},
|
||||
});
|
||||
return { accessToken, refreshToken, user: await this.profile({ ...payload, kind: 'platform' }) };
|
||||
}
|
||||
|
||||
private async issueTenantSession(
|
||||
tenantId: string,
|
||||
tenantSlug: string,
|
||||
id: string,
|
||||
email: string,
|
||||
name: string,
|
||||
role: JwtPayload['role'],
|
||||
) {
|
||||
const payload: JwtPayload = {
|
||||
sub: id,
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
tenantId,
|
||||
tenantSlug,
|
||||
};
|
||||
const accessToken = await this.jwt.signAsync(payload);
|
||||
const refreshToken = rawRefreshToken();
|
||||
const db = await this.tenants.getByTenantId(tenantId);
|
||||
await db.refreshToken.create({
|
||||
data: {
|
||||
userId: id,
|
||||
hashed: hashToken(refreshToken),
|
||||
expiresAt: refreshExpiry(),
|
||||
},
|
||||
});
|
||||
return { accessToken, refreshToken, user: await this.profile({ ...payload, kind: 'tenant' }) };
|
||||
}
|
||||
|
||||
private async assertRateLimit(email: string, ip?: string) {
|
||||
const key = `login:${ip ?? 'unknown'}:${email.toLowerCase()}`;
|
||||
const count = await this.redis.increment(key, LOGIN_WINDOW_SECONDS);
|
||||
if (count !== null && count > LOGIN_MAX_ATTEMPTS) {
|
||||
throw new HttpException('Muitas tentativas. Aguarde alguns minutos.', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rawRefreshToken(): string {
|
||||
return randomBytes(48).toString('hex');
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
function refreshExpiry(): Date {
|
||||
return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import type { AuthRole } from './roles';
|
||||
|
||||
export type JwtPayload = {
|
||||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: AuthRole;
|
||||
tenantId?: string;
|
||||
tenantSlug?: string;
|
||||
};
|
||||
|
||||
export type AuthUser = JwtPayload & {
|
||||
kind: 'platform' | 'tenant';
|
||||
};
|
||||
import type { AuthRole } from './roles';
|
||||
|
||||
export type JwtPayload = {
|
||||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: AuthRole;
|
||||
tenantId?: string;
|
||||
tenantSlug?: string;
|
||||
};
|
||||
|
||||
export type AuthUser = JwtPayload & {
|
||||
kind: 'platform' | 'tenant';
|
||||
};
|
||||
|
||||
@ -1,30 +1,30 @@
|
||||
import { clearAuthCookies, readRefreshCookie, setAuthCookies } from './cookies';
|
||||
|
||||
describe('auth cookies', () => {
|
||||
it('sets httpOnly access and refresh cookies', () => {
|
||||
const cookie = jest.fn();
|
||||
setAuthCookies({ cookie } as never, 'access', 'refresh');
|
||||
expect(cookie).toHaveBeenCalledWith(
|
||||
'sinka_access',
|
||||
'access',
|
||||
expect.objectContaining({ httpOnly: true, sameSite: 'lax', path: '/' }),
|
||||
);
|
||||
expect(cookie).toHaveBeenCalledWith(
|
||||
'sinka_refresh',
|
||||
'refresh',
|
||||
expect.objectContaining({ httpOnly: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads only the refresh cookie', () => {
|
||||
expect(readRefreshCookie({ sinka_refresh: 'abc', other: 'x' })).toBe('abc');
|
||||
expect(readRefreshCookie(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears both cookies on logout', () => {
|
||||
const clearCookie = jest.fn();
|
||||
clearAuthCookies({ clearCookie } as never);
|
||||
expect(clearCookie).toHaveBeenCalledWith('sinka_access', expect.objectContaining({ httpOnly: true }));
|
||||
expect(clearCookie).toHaveBeenCalledWith('sinka_refresh', expect.objectContaining({ httpOnly: true }));
|
||||
});
|
||||
});
|
||||
import { clearAuthCookies, readRefreshCookie, setAuthCookies } from './cookies';
|
||||
|
||||
describe('auth cookies', () => {
|
||||
it('sets httpOnly access and refresh cookies', () => {
|
||||
const cookie = jest.fn();
|
||||
setAuthCookies({ cookie } as never, 'access', 'refresh');
|
||||
expect(cookie).toHaveBeenCalledWith(
|
||||
'sinka_access',
|
||||
'access',
|
||||
expect.objectContaining({ httpOnly: true, sameSite: 'lax', path: '/' }),
|
||||
);
|
||||
expect(cookie).toHaveBeenCalledWith(
|
||||
'sinka_refresh',
|
||||
'refresh',
|
||||
expect.objectContaining({ httpOnly: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads only the refresh cookie', () => {
|
||||
expect(readRefreshCookie({ sinka_refresh: 'abc', other: 'x' })).toBe('abc');
|
||||
expect(readRefreshCookie(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears both cookies on logout', () => {
|
||||
const clearCookie = jest.fn();
|
||||
clearAuthCookies({ clearCookie } as never);
|
||||
expect(clearCookie).toHaveBeenCalledWith('sinka_access', expect.objectContaining({ httpOnly: true }));
|
||||
expect(clearCookie).toHaveBeenCalledWith('sinka_refresh', expect.objectContaining({ httpOnly: true }));
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,29 +1,29 @@
|
||||
import type { Response } from 'express';
|
||||
|
||||
const accessCookie = 'sinka_access';
|
||||
const refreshCookie = 'sinka_refresh';
|
||||
|
||||
function cookieBase() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
secure:
|
||||
process.env.COOKIE_SECURE === 'true' ||
|
||||
(process.env.NODE_ENV === 'production' && process.env.COOKIE_SECURE !== 'false'),
|
||||
path: '/',
|
||||
};
|
||||
}
|
||||
|
||||
export function setAuthCookies(res: Response, accessToken: string, refreshToken: string): void {
|
||||
res.cookie(accessCookie, accessToken, { ...cookieBase(), maxAge: 15 * 60 * 1000 });
|
||||
res.cookie(refreshCookie, refreshToken, { ...cookieBase(), maxAge: 7 * 24 * 60 * 60 * 1000 });
|
||||
}
|
||||
|
||||
export function clearAuthCookies(res: Response): void {
|
||||
res.clearCookie(accessCookie, cookieBase());
|
||||
res.clearCookie(refreshCookie, cookieBase());
|
||||
}
|
||||
|
||||
export function readRefreshCookie(cookies: Record<string, string> | undefined): string | undefined {
|
||||
return cookies?.[refreshCookie];
|
||||
}
|
||||
import type { Response } from 'express';
|
||||
|
||||
const accessCookie = 'sinka_access';
|
||||
const refreshCookie = 'sinka_refresh';
|
||||
|
||||
function cookieBase() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
secure:
|
||||
process.env.COOKIE_SECURE === 'true' ||
|
||||
(process.env.NODE_ENV === 'production' && process.env.COOKIE_SECURE !== 'false'),
|
||||
path: '/',
|
||||
};
|
||||
}
|
||||
|
||||
export function setAuthCookies(res: Response, accessToken: string, refreshToken: string): void {
|
||||
res.cookie(accessCookie, accessToken, { ...cookieBase(), maxAge: 15 * 60 * 1000 });
|
||||
res.cookie(refreshCookie, refreshToken, { ...cookieBase(), maxAge: 7 * 24 * 60 * 60 * 1000 });
|
||||
}
|
||||
|
||||
export function clearAuthCookies(res: Response): void {
|
||||
res.clearCookie(accessCookie, cookieBase());
|
||||
res.clearCookie(refreshCookie, cookieBase());
|
||||
}
|
||||
|
||||
export function readRefreshCookie(cookies: Record<string, string> | undefined): string | undefined {
|
||||
return cookies?.[refreshCookie];
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth.types';
|
||||
|
||||
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext): AuthUser => {
|
||||
return ctx.switchToHttp().getRequest<{ user: AuthUser }>().user;
|
||||
});
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth.types';
|
||||
|
||||
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext): AuthUser => {
|
||||
return ctx.switchToHttp().getRequest<{ user: AuthUser }>().user;
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { AuthRole } from '../roles';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: AuthRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { AuthRole } from '../roles';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: AuthRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tenantSlug?: string;
|
||||
}
|
||||
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tenantSlug?: string;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
|
||||
@ -1,37 +1,37 @@
|
||||
import { ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { RolesGuard } from './roles.guard';
|
||||
|
||||
function contextWith(user: unknown): ExecutionContext {
|
||||
return {
|
||||
getHandler: () => ({}),
|
||||
getClass: () => ({}),
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user }),
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
}
|
||||
|
||||
describe('RolesGuard', () => {
|
||||
const reflector = { getAllAndOverride: jest.fn() };
|
||||
const guard = new RolesGuard(reflector as unknown as Reflector);
|
||||
|
||||
beforeEach(() => {
|
||||
reflector.getAllAndOverride.mockReset();
|
||||
});
|
||||
|
||||
it('allows when the route has no role list', () => {
|
||||
reflector.getAllAndOverride.mockReturnValue(undefined);
|
||||
expect(guard.canActivate(contextWith(undefined))).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a matching role', () => {
|
||||
reflector.getAllAndOverride.mockReturnValue(['ADMIN', 'MANAGER']);
|
||||
expect(guard.canActivate(contextWith({ role: 'MANAGER' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks an operator on an admin route', () => {
|
||||
reflector.getAllAndOverride.mockReturnValue(['ADMIN']);
|
||||
expect(guard.canActivate(contextWith({ role: 'OPERATOR' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
import { ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { RolesGuard } from './roles.guard';
|
||||
|
||||
function contextWith(user: unknown): ExecutionContext {
|
||||
return {
|
||||
getHandler: () => ({}),
|
||||
getClass: () => ({}),
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user }),
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
}
|
||||
|
||||
describe('RolesGuard', () => {
|
||||
const reflector = { getAllAndOverride: jest.fn() };
|
||||
const guard = new RolesGuard(reflector as unknown as Reflector);
|
||||
|
||||
beforeEach(() => {
|
||||
reflector.getAllAndOverride.mockReset();
|
||||
});
|
||||
|
||||
it('allows when the route has no role list', () => {
|
||||
reflector.getAllAndOverride.mockReturnValue(undefined);
|
||||
expect(guard.canActivate(contextWith(undefined))).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a matching role', () => {
|
||||
reflector.getAllAndOverride.mockReturnValue(['ADMIN', 'MANAGER']);
|
||||
expect(guard.canActivate(contextWith({ role: 'MANAGER' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks an operator on an admin route', () => {
|
||||
reflector.getAllAndOverride.mockReturnValue(['ADMIN']);
|
||||
expect(guard.canActivate(contextWith({ role: 'OPERATOR' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
import type { AuthRole } from '../roles';
|
||||
import type { AuthUser } from '../auth.types';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const roles = this.reflector.getAllAndOverride<AuthRole[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!roles?.length) {
|
||||
return true;
|
||||
}
|
||||
const user = context.switchToHttp().getRequest<{ user?: AuthUser }>().user;
|
||||
return Boolean(user && roles.includes(user.role));
|
||||
}
|
||||
}
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
import type { AuthRole } from '../roles';
|
||||
import type { AuthUser } from '../auth.types';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const roles = this.reflector.getAllAndOverride<AuthRole[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!roles?.length) {
|
||||
return true;
|
||||
}
|
||||
const user = context.switchToHttp().getRequest<{ user?: AuthUser }>().user;
|
||||
return Boolean(user && roles.includes(user.role));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
import { readOAuthState, safeNext, signOAuthState } from './oauth-state';
|
||||
|
||||
describe('oauth state', () => {
|
||||
const secret = 'test-secret';
|
||||
|
||||
it('round-trips a signed tenant state', () => {
|
||||
const token = signOAuthState({ kind: 'tenant', slug: 'demo', next: '/demo' }, secret);
|
||||
expect(readOAuthState(token, secret)).toEqual({ kind: 'tenant', slug: 'demo', next: '/demo' });
|
||||
});
|
||||
|
||||
it('rejects a tampered state', () => {
|
||||
const token = signOAuthState({ kind: 'platform' }, secret);
|
||||
expect(() => readOAuthState(`${token}x`, secret)).toThrow();
|
||||
});
|
||||
|
||||
it('blocks open redirects', () => {
|
||||
expect(safeNext('https://evil.test', '/admin')).toBe('/admin');
|
||||
expect(safeNext('//evil.test', '/admin')).toBe('/admin');
|
||||
expect(safeNext('/admin/clientes', '/admin')).toBe('/admin/clientes');
|
||||
});
|
||||
});
|
||||
import { readOAuthState, safeNext, signOAuthState } from './oauth-state';
|
||||
|
||||
describe('oauth state', () => {
|
||||
const secret = 'test-secret';
|
||||
|
||||
it('round-trips a signed tenant state', () => {
|
||||
const token = signOAuthState({ kind: 'tenant', slug: 'demo', next: '/demo' }, secret);
|
||||
expect(readOAuthState(token, secret)).toEqual({ kind: 'tenant', slug: 'demo', next: '/demo' });
|
||||
});
|
||||
|
||||
it('rejects a tampered state', () => {
|
||||
const token = signOAuthState({ kind: 'platform' }, secret);
|
||||
expect(() => readOAuthState(`${token}x`, secret)).toThrow();
|
||||
});
|
||||
|
||||
it('blocks open redirects', () => {
|
||||
expect(safeNext('https://evil.test', '/admin')).toBe('/admin');
|
||||
expect(safeNext('//evil.test', '/admin')).toBe('/admin');
|
||||
expect(safeNext('/admin/clientes', '/admin')).toBe('/admin/clientes');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,41 +1,41 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export type OAuthState = {
|
||||
kind: 'platform' | 'tenant';
|
||||
slug?: string;
|
||||
next?: string;
|
||||
};
|
||||
|
||||
export function signOAuthState(payload: OAuthState, secret: string): string {
|
||||
const body = Buffer.from(JSON.stringify({ ...payload, exp: Date.now() + 10 * 60 * 1000 })).toString('base64url');
|
||||
const sig = createHmac('sha256', secret).update(body).digest('base64url');
|
||||
return `${body}.${sig}`;
|
||||
}
|
||||
|
||||
export function readOAuthState(raw: string | undefined, secret: string): OAuthState {
|
||||
if (!raw || !raw.includes('.')) {
|
||||
throw new Error('state');
|
||||
}
|
||||
const [body, sig] = raw.split('.');
|
||||
const expected = createHmac('sha256', secret).update(body).digest('base64url');
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error('state');
|
||||
}
|
||||
const parsed = JSON.parse(Buffer.from(body, 'base64url').toString()) as OAuthState & { exp?: number };
|
||||
if (!parsed.exp || parsed.exp < Date.now()) {
|
||||
throw new Error('state');
|
||||
}
|
||||
if (parsed.kind !== 'platform' && parsed.kind !== 'tenant') {
|
||||
throw new Error('state');
|
||||
}
|
||||
return { kind: parsed.kind, slug: parsed.slug, next: parsed.next };
|
||||
}
|
||||
|
||||
export function safeNext(raw: string | undefined, fallback: string): string {
|
||||
if (!raw || !raw.startsWith('/') || raw.startsWith('//') || raw.includes('://')) {
|
||||
return fallback;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export type OAuthState = {
|
||||
kind: 'platform' | 'tenant';
|
||||
slug?: string;
|
||||
next?: string;
|
||||
};
|
||||
|
||||
export function signOAuthState(payload: OAuthState, secret: string): string {
|
||||
const body = Buffer.from(JSON.stringify({ ...payload, exp: Date.now() + 10 * 60 * 1000 })).toString('base64url');
|
||||
const sig = createHmac('sha256', secret).update(body).digest('base64url');
|
||||
return `${body}.${sig}`;
|
||||
}
|
||||
|
||||
export function readOAuthState(raw: string | undefined, secret: string): OAuthState {
|
||||
if (!raw || !raw.includes('.')) {
|
||||
throw new Error('state');
|
||||
}
|
||||
const [body, sig] = raw.split('.');
|
||||
const expected = createHmac('sha256', secret).update(body).digest('base64url');
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error('state');
|
||||
}
|
||||
const parsed = JSON.parse(Buffer.from(body, 'base64url').toString()) as OAuthState & { exp?: number };
|
||||
if (!parsed.exp || parsed.exp < Date.now()) {
|
||||
throw new Error('state');
|
||||
}
|
||||
if (parsed.kind !== 'platform' && parsed.kind !== 'tenant') {
|
||||
throw new Error('state');
|
||||
}
|
||||
return { kind: parsed.kind, slug: parsed.slug, next: parsed.next };
|
||||
}
|
||||
|
||||
export function safeNext(raw: string | undefined, fallback: string): string {
|
||||
if (!raw || !raw.startsWith('/') || raw.startsWith('//') || raw.includes('://')) {
|
||||
return fallback;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
@ -1,23 +1,23 @@
|
||||
import { canManageUsers, permissionsFor, ROLE_LABEL } from './roles';
|
||||
|
||||
describe('roles', () => {
|
||||
it('isolates platform admin from company operations', () => {
|
||||
expect(permissionsFor('PLATFORM_ADMIN')).toEqual(['tenants:manage', 'audit:read']);
|
||||
expect(permissionsFor('ADMIN')).toContain('users:manage');
|
||||
expect(permissionsFor('MANAGER')).not.toContain('users:manage');
|
||||
expect(permissionsFor('OPERATOR')).toEqual(['customers:read', 'simulations:write']);
|
||||
});
|
||||
|
||||
it('labels the three company access levels', () => {
|
||||
expect(ROLE_LABEL.ADMIN).toBe('Administrador');
|
||||
expect(ROLE_LABEL.MANAGER).toBe('Manager');
|
||||
expect(ROLE_LABEL.OPERATOR).toBe('Operador');
|
||||
});
|
||||
|
||||
it('lets only the company admin manage users', () => {
|
||||
expect(canManageUsers('ADMIN')).toBe(true);
|
||||
expect(canManageUsers('MANAGER')).toBe(false);
|
||||
expect(canManageUsers('OPERATOR')).toBe(false);
|
||||
expect(canManageUsers('PLATFORM_ADMIN')).toBe(false);
|
||||
});
|
||||
});
|
||||
import { canManageUsers, permissionsFor, ROLE_LABEL } from './roles';
|
||||
|
||||
describe('roles', () => {
|
||||
it('isolates platform admin from company operations', () => {
|
||||
expect(permissionsFor('PLATFORM_ADMIN')).toEqual(['tenants:manage', 'audit:read']);
|
||||
expect(permissionsFor('ADMIN')).toContain('users:manage');
|
||||
expect(permissionsFor('MANAGER')).not.toContain('users:manage');
|
||||
expect(permissionsFor('OPERATOR')).toEqual(['customers:read', 'simulations:write']);
|
||||
});
|
||||
|
||||
it('labels the three company access levels', () => {
|
||||
expect(ROLE_LABEL.ADMIN).toBe('Administrador');
|
||||
expect(ROLE_LABEL.MANAGER).toBe('Manager');
|
||||
expect(ROLE_LABEL.OPERATOR).toBe('Operador');
|
||||
});
|
||||
|
||||
it('lets only the company admin manage users', () => {
|
||||
expect(canManageUsers('ADMIN')).toBe(true);
|
||||
expect(canManageUsers('MANAGER')).toBe(false);
|
||||
expect(canManageUsers('OPERATOR')).toBe(false);
|
||||
expect(canManageUsers('PLATFORM_ADMIN')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,34 +1,34 @@
|
||||
export const TENANT_ROLES = ['ADMIN', 'MANAGER', 'OPERATOR'] as const;
|
||||
export type TenantRole = (typeof TENANT_ROLES)[number];
|
||||
export type AuthRole = TenantRole | 'PLATFORM_ADMIN';
|
||||
|
||||
export const ROLE_LABEL: Record<AuthRole, string> = {
|
||||
PLATFORM_ADMIN: 'Administrador da plataforma',
|
||||
ADMIN: 'Administrador',
|
||||
MANAGER: 'Manager',
|
||||
OPERATOR: 'Operador',
|
||||
};
|
||||
|
||||
export function permissionsFor(role: AuthRole): string[] {
|
||||
if (role === 'PLATFORM_ADMIN') {
|
||||
return ['tenants:manage', 'audit:read'];
|
||||
}
|
||||
if (role === 'ADMIN') {
|
||||
return [
|
||||
'users:manage',
|
||||
'customers:write',
|
||||
'carriers:write',
|
||||
'simulations:write',
|
||||
'imports:write',
|
||||
'audit:read',
|
||||
];
|
||||
}
|
||||
if (role === 'MANAGER') {
|
||||
return ['customers:write', 'carriers:write', 'simulations:write', 'imports:write', 'audit:read'];
|
||||
}
|
||||
return ['customers:read', 'simulations:write'];
|
||||
}
|
||||
|
||||
export function canManageUsers(role: AuthRole): boolean {
|
||||
return role === 'ADMIN';
|
||||
}
|
||||
export const TENANT_ROLES = ['ADMIN', 'MANAGER', 'OPERATOR'] as const;
|
||||
export type TenantRole = (typeof TENANT_ROLES)[number];
|
||||
export type AuthRole = TenantRole | 'PLATFORM_ADMIN';
|
||||
|
||||
export const ROLE_LABEL: Record<AuthRole, string> = {
|
||||
PLATFORM_ADMIN: 'Administrador da plataforma',
|
||||
ADMIN: 'Administrador',
|
||||
MANAGER: 'Manager',
|
||||
OPERATOR: 'Operador',
|
||||
};
|
||||
|
||||
export function permissionsFor(role: AuthRole): string[] {
|
||||
if (role === 'PLATFORM_ADMIN') {
|
||||
return ['tenants:manage', 'audit:read'];
|
||||
}
|
||||
if (role === 'ADMIN') {
|
||||
return [
|
||||
'users:manage',
|
||||
'customers:write',
|
||||
'carriers:write',
|
||||
'simulations:write',
|
||||
'imports:write',
|
||||
'audit:read',
|
||||
];
|
||||
}
|
||||
if (role === 'MANAGER') {
|
||||
return ['customers:write', 'carriers:write', 'simulations:write', 'imports:write', 'audit:read'];
|
||||
}
|
||||
return ['customers:read', 'simulations:write'];
|
||||
}
|
||||
|
||||
export function canManageUsers(role: AuthRole): boolean {
|
||||
return role === 'ADMIN';
|
||||
}
|
||||
|
||||
@ -1,34 +1,34 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy, type Profile } from 'passport-google-oauth20';
|
||||
|
||||
export type GoogleIdentity = {
|
||||
googleId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
constructor(config: ConfigService) {
|
||||
super({
|
||||
clientID: config.get<string>('GOOGLE_CLIENT_ID') || 'not-configured',
|
||||
clientSecret: config.get<string>('GOOGLE_CLIENT_SECRET') || 'not-configured',
|
||||
callbackURL: config.get<string>('GOOGLE_CALLBACK_URL') || 'http://localhost:3000/api/auth/google/callback',
|
||||
scope: ['email', 'profile'],
|
||||
});
|
||||
}
|
||||
|
||||
validate(_accessToken: string, _refreshToken: string, profile: Profile): GoogleIdentity {
|
||||
const email = profile.emails?.[0]?.value?.toLowerCase();
|
||||
if (!email || !profile.id) {
|
||||
throw new UnauthorizedException('Conta Google sem e-mail.');
|
||||
}
|
||||
return {
|
||||
googleId: profile.id,
|
||||
email,
|
||||
name: profile.displayName?.trim() || email,
|
||||
};
|
||||
}
|
||||
}
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy, type Profile } from 'passport-google-oauth20';
|
||||
|
||||
export type GoogleIdentity = {
|
||||
googleId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
constructor(config: ConfigService) {
|
||||
super({
|
||||
clientID: config.get<string>('GOOGLE_CLIENT_ID') || 'not-configured',
|
||||
clientSecret: config.get<string>('GOOGLE_CLIENT_SECRET') || 'not-configured',
|
||||
callbackURL: config.get<string>('GOOGLE_CALLBACK_URL') || 'http://localhost:3000/api/auth/google/callback',
|
||||
scope: ['email', 'profile'],
|
||||
});
|
||||
}
|
||||
|
||||
validate(_accessToken: string, _refreshToken: string, profile: Profile): GoogleIdentity {
|
||||
const email = profile.emails?.[0]?.value?.toLowerCase();
|
||||
if (!email || !profile.id) {
|
||||
throw new UnauthorizedException('Conta Google sem e-mail.');
|
||||
}
|
||||
return {
|
||||
googleId: profile.id,
|
||||
email,
|
||||
name: profile.displayName?.trim() || email,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,54 +1,54 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import type { Request } from 'express';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import type { JwtPayload } from '../auth.types';
|
||||
import type { AuthUser } from '../auth.types';
|
||||
import { PlatformPrismaService } from '../../prisma/platform-prisma.service';
|
||||
import { TenantConnectionService } from '../../tenancy/tenant-connection.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly platform: PlatformPrismaService,
|
||||
private readonly tenants: TenantConnectionService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
(request: Request) => request?.cookies?.sinka_access ?? null,
|
||||
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
]),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.getOrThrow<string>('JWT_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<AuthUser> {
|
||||
if (payload.role === 'PLATFORM_ADMIN') {
|
||||
const user = await this.platform.platformUser.findUnique({ where: { id: payload.sub } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return { ...payload, kind: 'platform' };
|
||||
}
|
||||
|
||||
if (!payload.tenantId) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const tenant = await this.platform.tenant.findUnique({ where: { id: payload.tenantId } });
|
||||
if (!tenant || tenant.status !== 'ACTIVE') {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const db = await this.tenants.getByTenantId(tenant.id);
|
||||
const user = await db.user.findUnique({ where: { id: payload.sub } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return { ...payload, tenantSlug: tenant.slug, kind: 'tenant' };
|
||||
}
|
||||
}
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import type { Request } from 'express';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import type { JwtPayload } from '../auth.types';
|
||||
import type { AuthUser } from '../auth.types';
|
||||
import { PlatformPrismaService } from '../../prisma/platform-prisma.service';
|
||||
import { TenantConnectionService } from '../../tenancy/tenant-connection.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly platform: PlatformPrismaService,
|
||||
private readonly tenants: TenantConnectionService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
(request: Request) => request?.cookies?.sinka_access ?? null,
|
||||
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
]),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.getOrThrow<string>('JWT_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<AuthUser> {
|
||||
if (payload.role === 'PLATFORM_ADMIN') {
|
||||
const user = await this.platform.platformUser.findUnique({ where: { id: payload.sub } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return { ...payload, kind: 'platform' };
|
||||
}
|
||||
|
||||
if (!payload.tenantId) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const tenant = await this.platform.tenant.findUnique({ where: { id: payload.tenantId } });
|
||||
if (!tenant || tenant.status !== 'ACTIVE') {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const db = await this.tenants.getByTenantId(tenant.id);
|
||||
const user = await db.user.findUnique({ where: { id: payload.sub } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return { ...payload, tenantSlug: tenant.slug, kind: 'tenant' };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,44 +1,44 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { CarriersService } from './carriers.service';
|
||||
import { UpsertCarrierDto } from './dto/upsert-carrier.dto';
|
||||
|
||||
@Controller('carriers')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class CarriersController {
|
||||
constructor(private readonly carriers: CarriersService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('ADMIN', 'MANAGER', 'OPERATOR')
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.carriers.list(user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('ADMIN', 'MANAGER')
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: UpsertCarrierDto, @Req() request: Request) {
|
||||
return this.carriers.create(user, dto, request.ip);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('ADMIN', 'MANAGER')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpsertCarrierDto,
|
||||
@Req() request: Request,
|
||||
) {
|
||||
return this.carriers.update(user, id, dto, request.ip);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('ADMIN', 'MANAGER')
|
||||
remove(@CurrentUser() user: AuthUser, @Param('id') id: string, @Req() request: Request) {
|
||||
return this.carriers.remove(user, id, request.ip);
|
||||
}
|
||||
}
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { CarriersService } from './carriers.service';
|
||||
import { UpsertCarrierDto } from './dto/upsert-carrier.dto';
|
||||
|
||||
@Controller('carriers')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class CarriersController {
|
||||
constructor(private readonly carriers: CarriersService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('ADMIN', 'MANAGER', 'OPERATOR')
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.carriers.list(user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('ADMIN', 'MANAGER')
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: UpsertCarrierDto, @Req() request: Request) {
|
||||
return this.carriers.create(user, dto, request.ip);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('ADMIN', 'MANAGER')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpsertCarrierDto,
|
||||
@Req() request: Request,
|
||||
) {
|
||||
return this.carriers.update(user, id, dto, request.ip);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('ADMIN', 'MANAGER')
|
||||
remove(@CurrentUser() user: AuthUser, @Param('id') id: string, @Req() request: Request) {
|
||||
return this.carriers.remove(user, id, request.ip);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CarriersController } from './carriers.controller';
|
||||
import { CarriersService } from './carriers.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CarriersController],
|
||||
providers: [CarriersService],
|
||||
})
|
||||
export class CarriersModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CarriersController } from './carriers.controller';
|
||||
import { CarriersService } from './carriers.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CarriersController],
|
||||
providers: [CarriersService],
|
||||
})
|
||||
export class CarriersModule {}
|
||||
|
||||
@ -1,101 +1,101 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { tenantClient } from '../tenancy/actor';
|
||||
import { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
||||
import type { UpsertCarrierDto } from './dto/upsert-carrier.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CarriersService {
|
||||
constructor(private readonly tenants: TenantConnectionService) {}
|
||||
|
||||
async list(actor: AuthUser) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const rows = await db.carrier.findMany({ orderBy: { name: 'asc' } });
|
||||
return rows.map(serializeCarrier);
|
||||
}
|
||||
|
||||
async create(actor: AuthUser, dto: UpsertCarrierDto, ip?: string) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const carrier = await db.carrier.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
document: dto.document,
|
||||
phone: dto.phone,
|
||||
baseFee: dto.baseFee ?? 0,
|
||||
pricePerKg: dto.pricePerKg ?? 0,
|
||||
pricePerKm: dto.pricePerKm ?? 0,
|
||||
adValoremRate: dto.adValoremRate ?? 0,
|
||||
active: dto.active ?? true,
|
||||
},
|
||||
});
|
||||
await db.auditLog.create({
|
||||
data: { userId: actor.sub, action: 'carrier.create', entity: 'Carrier', entityId: carrier.id, ip },
|
||||
});
|
||||
return serializeCarrier(carrier);
|
||||
}
|
||||
|
||||
async update(actor: AuthUser, id: string, dto: UpsertCarrierDto, ip?: string) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
await this.ensure(db, id);
|
||||
const carrier = await db.carrier.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
document: dto.document,
|
||||
phone: dto.phone,
|
||||
baseFee: dto.baseFee,
|
||||
pricePerKg: dto.pricePerKg,
|
||||
pricePerKm: dto.pricePerKm,
|
||||
adValoremRate: dto.adValoremRate,
|
||||
active: dto.active,
|
||||
},
|
||||
});
|
||||
await db.auditLog.create({
|
||||
data: { userId: actor.sub, action: 'carrier.update', entity: 'Carrier', entityId: id, ip },
|
||||
});
|
||||
return serializeCarrier(carrier);
|
||||
}
|
||||
|
||||
async remove(actor: AuthUser, id: string, ip?: string) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
await this.ensure(db, id);
|
||||
await db.carrier.delete({ where: { id } });
|
||||
await db.auditLog.create({
|
||||
data: { userId: actor.sub, action: 'carrier.delete', entity: 'Carrier', entityId: id, ip },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async ensure(db: Awaited<ReturnType<TenantConnectionService['getByTenantId']>>, id: string) {
|
||||
const row = await db.carrier.findUnique({ where: { id } });
|
||||
if (!row) {
|
||||
throw new NotFoundException('Transportadora não encontrada.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serializeCarrier(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
document: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
baseFee: { toString(): string };
|
||||
pricePerKg: { toString(): string };
|
||||
pricePerKm: { toString(): string };
|
||||
adValoremRate: { toString(): string };
|
||||
active: boolean;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
document: row.document,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
baseFee: Number(row.baseFee),
|
||||
pricePerKg: Number(row.pricePerKg),
|
||||
pricePerKm: Number(row.pricePerKm),
|
||||
adValoremRate: Number(row.adValoremRate),
|
||||
active: row.active,
|
||||
};
|
||||
}
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { tenantClient } from '../tenancy/actor';
|
||||
import { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
||||
import type { UpsertCarrierDto } from './dto/upsert-carrier.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CarriersService {
|
||||
constructor(private readonly tenants: TenantConnectionService) {}
|
||||
|
||||
async list(actor: AuthUser) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const rows = await db.carrier.findMany({ orderBy: { name: 'asc' } });
|
||||
return rows.map(serializeCarrier);
|
||||
}
|
||||
|
||||
async create(actor: AuthUser, dto: UpsertCarrierDto, ip?: string) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const carrier = await db.carrier.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
document: dto.document,
|
||||
phone: dto.phone,
|
||||
baseFee: dto.baseFee ?? 0,
|
||||
pricePerKg: dto.pricePerKg ?? 0,
|
||||
pricePerKm: dto.pricePerKm ?? 0,
|
||||
adValoremRate: dto.adValoremRate ?? 0,
|
||||
active: dto.active ?? true,
|
||||
},
|
||||
});
|
||||
await db.auditLog.create({
|
||||
data: { userId: actor.sub, action: 'carrier.create', entity: 'Carrier', entityId: carrier.id, ip },
|
||||
});
|
||||
return serializeCarrier(carrier);
|
||||
}
|
||||
|
||||
async update(actor: AuthUser, id: string, dto: UpsertCarrierDto, ip?: string) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
await this.ensure(db, id);
|
||||
const carrier = await db.carrier.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
document: dto.document,
|
||||
phone: dto.phone,
|
||||
baseFee: dto.baseFee,
|
||||
pricePerKg: dto.pricePerKg,
|
||||
pricePerKm: dto.pricePerKm,
|
||||
adValoremRate: dto.adValoremRate,
|
||||
active: dto.active,
|
||||
},
|
||||
});
|
||||
await db.auditLog.create({
|
||||
data: { userId: actor.sub, action: 'carrier.update', entity: 'Carrier', entityId: id, ip },
|
||||
});
|
||||
return serializeCarrier(carrier);
|
||||
}
|
||||
|
||||
async remove(actor: AuthUser, id: string, ip?: string) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
await this.ensure(db, id);
|
||||
await db.carrier.delete({ where: { id } });
|
||||
await db.auditLog.create({
|
||||
data: { userId: actor.sub, action: 'carrier.delete', entity: 'Carrier', entityId: id, ip },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async ensure(db: Awaited<ReturnType<TenantConnectionService['getByTenantId']>>, id: string) {
|
||||
const row = await db.carrier.findUnique({ where: { id } });
|
||||
if (!row) {
|
||||
throw new NotFoundException('Transportadora não encontrada.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serializeCarrier(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
document: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
baseFee: { toString(): string };
|
||||
pricePerKg: { toString(): string };
|
||||
pricePerKm: { toString(): string };
|
||||
adValoremRate: { toString(): string };
|
||||
active: boolean;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
document: row.document,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
baseFee: Number(row.baseFee),
|
||||
pricePerKg: Number(row.pricePerKg),
|
||||
pricePerKm: Number(row.pricePerKm),
|
||||
adValoremRate: Number(row.adValoremRate),
|
||||
active: row.active,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,50 +1,50 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
|
||||
|
||||
const toNumber = ({ value }: { value: unknown }) => (value === '' || value === null || value === undefined ? value : Number(value));
|
||||
|
||||
export class UpsertCarrierDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
document?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
baseFee?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
pricePerKg?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
pricePerKm?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
adValoremRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
|
||||
|
||||
const toNumber = ({ value }: { value: unknown }) => (value === '' || value === null || value === undefined ? value : Number(value));
|
||||
|
||||
export class UpsertCarrierDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
document?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
baseFee?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
pricePerKg?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
pricePerKm?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
adValoremRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
@ -1,49 +1,49 @@
|
||||
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { resolveChatTenant } from './chat-access';
|
||||
|
||||
const demo = { name: 'Demo Log', slug: 'demo' };
|
||||
const armando = { name: 'Armando', slug: 'armando' };
|
||||
|
||||
const tenantUser = (slug = 'demo'): AuthUser => ({
|
||||
kind: 'tenant',
|
||||
sub: 'u1',
|
||||
email: 'ops@demo.test',
|
||||
name: 'Ana',
|
||||
role: 'OPERATOR',
|
||||
tenantId: 't1',
|
||||
tenantSlug: slug,
|
||||
});
|
||||
|
||||
const platformUser: AuthUser = {
|
||||
kind: 'platform',
|
||||
sub: 'p1',
|
||||
email: 'tina.r@example.net',
|
||||
name: 'Sinka',
|
||||
role: 'PLATFORM_ADMIN',
|
||||
};
|
||||
|
||||
describe('resolveChatTenant', () => {
|
||||
it('gives a tenant only its own company', async () => {
|
||||
const findBySlug = jest.fn().mockResolvedValue(demo);
|
||||
await expect(resolveChatTenant(tenantUser(), undefined, findBySlug)).resolves.toEqual(demo);
|
||||
expect(findBySlug).toHaveBeenCalledWith('demo');
|
||||
});
|
||||
|
||||
it('blocks a tenant from opening another company chat', async () => {
|
||||
const findBySlug = jest.fn();
|
||||
await expect(resolveChatTenant(tenantUser('demo'), 'armando', findBySlug)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(findBySlug).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets the platform admin open a specific company', async () => {
|
||||
const findBySlug = jest.fn().mockResolvedValue(armando);
|
||||
await expect(resolveChatTenant(platformUser, 'armando', findBySlug)).resolves.toEqual(armando);
|
||||
});
|
||||
|
||||
it('refuses the platform admin without a company', async () => {
|
||||
await expect(resolveChatTenant(platformUser, undefined, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { resolveChatTenant } from './chat-access';
|
||||
|
||||
const demo = { name: 'Demo Log', slug: 'demo' };
|
||||
const armando = { name: 'Armando', slug: 'armando' };
|
||||
|
||||
const tenantUser = (slug = 'demo'): AuthUser => ({
|
||||
kind: 'tenant',
|
||||
sub: 'u1',
|
||||
email: 'ops@demo.test',
|
||||
name: 'Ana',
|
||||
role: 'OPERATOR',
|
||||
tenantId: 't1',
|
||||
tenantSlug: slug,
|
||||
});
|
||||
|
||||
const platformUser: AuthUser = {
|
||||
kind: 'platform',
|
||||
sub: 'p1',
|
||||
email: 'tina.r@example.net',
|
||||
name: 'Sinka',
|
||||
role: 'PLATFORM_ADMIN',
|
||||
};
|
||||
|
||||
describe('resolveChatTenant', () => {
|
||||
it('gives a tenant only its own company', async () => {
|
||||
const findBySlug = jest.fn().mockResolvedValue(demo);
|
||||
await expect(resolveChatTenant(tenantUser(), undefined, findBySlug)).resolves.toEqual(demo);
|
||||
expect(findBySlug).toHaveBeenCalledWith('demo');
|
||||
});
|
||||
|
||||
it('blocks a tenant from opening another company chat', async () => {
|
||||
const findBySlug = jest.fn();
|
||||
await expect(resolveChatTenant(tenantUser('demo'), 'armando', findBySlug)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(findBySlug).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets the platform admin open a specific company', async () => {
|
||||
const findBySlug = jest.fn().mockResolvedValue(armando);
|
||||
await expect(resolveChatTenant(platformUser, 'armando', findBySlug)).resolves.toEqual(armando);
|
||||
});
|
||||
|
||||
it('refuses the platform admin without a company', async () => {
|
||||
await expect(resolveChatTenant(platformUser, undefined, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,37 +1,37 @@
|
||||
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
|
||||
export type ChatTenant = {
|
||||
slug: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export async function resolveChatTenant(
|
||||
actor: AuthUser,
|
||||
requestedSlug: string | undefined,
|
||||
findBySlug: (slug: string) => Promise<ChatTenant | null>,
|
||||
): Promise<ChatTenant> {
|
||||
if (actor.kind === 'tenant') {
|
||||
if (!actor.tenantSlug) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const requested = requestedSlug?.trim().toLowerCase();
|
||||
if (requested && requested !== actor.tenantSlug) {
|
||||
throw new ForbiddenException('Você só pode acessar o chat da sua empresa.');
|
||||
}
|
||||
const tenant = await findBySlug(actor.tenantSlug);
|
||||
if (!tenant) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
const slug = requestedSlug?.trim().toLowerCase();
|
||||
if (!slug) {
|
||||
throw new UnauthorizedException('Informe o cliente.');
|
||||
}
|
||||
const tenant = await findBySlug(slug);
|
||||
if (!tenant) {
|
||||
throw new UnauthorizedException('Cliente não encontrado.');
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
|
||||
export type ChatTenant = {
|
||||
slug: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export async function resolveChatTenant(
|
||||
actor: AuthUser,
|
||||
requestedSlug: string | undefined,
|
||||
findBySlug: (slug: string) => Promise<ChatTenant | null>,
|
||||
): Promise<ChatTenant> {
|
||||
if (actor.kind === 'tenant') {
|
||||
if (!actor.tenantSlug) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const requested = requestedSlug?.trim().toLowerCase();
|
||||
if (requested && requested !== actor.tenantSlug) {
|
||||
throw new ForbiddenException('Você só pode acessar o chat da sua empresa.');
|
||||
}
|
||||
const tenant = await findBySlug(actor.tenantSlug);
|
||||
if (!tenant) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
const slug = requestedSlug?.trim().toLowerCase();
|
||||
if (!slug) {
|
||||
throw new UnauthorizedException('Informe o cliente.');
|
||||
}
|
||||
const tenant = await findBySlug(slug);
|
||||
if (!tenant) {
|
||||
throw new UnauthorizedException('Cliente não encontrado.');
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import { chatChannelId } from './chat-channel';
|
||||
|
||||
describe('chatChannelId', () => {
|
||||
it('is stable for the same company and secret', () => {
|
||||
expect(chatChannelId('demo', 'secret')).toBe(chatChannelId('demo', 'secret'));
|
||||
});
|
||||
|
||||
it('isolates companies from each other', () => {
|
||||
expect(chatChannelId('demo', 'secret')).not.toBe(chatChannelId('armando', 'secret'));
|
||||
});
|
||||
|
||||
it('does not expose the slug in the channel id', () => {
|
||||
expect(chatChannelId('demo', 'secret')).not.toContain('demo');
|
||||
});
|
||||
});
|
||||
import { chatChannelId } from './chat-channel';
|
||||
|
||||
describe('chatChannelId', () => {
|
||||
it('is stable for the same company and secret', () => {
|
||||
expect(chatChannelId('demo', 'secret')).toBe(chatChannelId('demo', 'secret'));
|
||||
});
|
||||
|
||||
it('isolates companies from each other', () => {
|
||||
expect(chatChannelId('demo', 'secret')).not.toBe(chatChannelId('armando', 'secret'));
|
||||
});
|
||||
|
||||
it('does not expose the slug in the channel id', () => {
|
||||
expect(chatChannelId('demo', 'secret')).not.toContain('demo');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { createHmac } from 'crypto';
|
||||
|
||||
export function chatChannelId(slug: string, secret: string): string {
|
||||
return createHmac('sha256', secret).update(`chat:${slug}`).digest('hex').slice(0, 32);
|
||||
}
|
||||
import { createHmac } from 'crypto';
|
||||
|
||||
export function chatChannelId(slug: string, secret: string): string {
|
||||
return createHmac('sha256', secret).update(`chat:${slug}`).digest('hex').slice(0, 32);
|
||||
}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { countChatMessages } from './chat-count';
|
||||
|
||||
describe('countChatMessages', () => {
|
||||
it('counts keys in a firebase snapshot', () => {
|
||||
expect(countChatMessages({ a: {}, b: {} })).toBe(2);
|
||||
});
|
||||
|
||||
it('treats empty chat as zero', () => {
|
||||
expect(countChatMessages(null)).toBe(0);
|
||||
expect(countChatMessages({})).toBe(0);
|
||||
});
|
||||
});
|
||||
import { countChatMessages } from './chat-count';
|
||||
|
||||
describe('countChatMessages', () => {
|
||||
it('counts keys in a firebase snapshot', () => {
|
||||
expect(countChatMessages({ a: {}, b: {} })).toBe(2);
|
||||
});
|
||||
|
||||
it('treats empty chat as zero', () => {
|
||||
expect(countChatMessages(null)).toBe(0);
|
||||
expect(countChatMessages({})).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export function countChatMessages(data: unknown): number {
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return 0;
|
||||
}
|
||||
return Object.keys(data).length;
|
||||
}
|
||||
export function countChatMessages(data: unknown): number {
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return 0;
|
||||
}
|
||||
return Object.keys(data).length;
|
||||
}
|
||||
|
||||
@ -1,31 +1,31 @@
|
||||
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { ChatService } from './chat.service';
|
||||
import { SendMessageDto } from './dto/send-message.dto';
|
||||
|
||||
@Controller('chat')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('PLATFORM_ADMIN', 'ADMIN', 'MANAGER', 'OPERATOR')
|
||||
export class ChatController {
|
||||
constructor(private readonly chat: ChatService) {}
|
||||
|
||||
@Get('counts')
|
||||
@Roles('PLATFORM_ADMIN')
|
||||
counts(@CurrentUser() user: AuthUser) {
|
||||
return this.chat.counts(user);
|
||||
}
|
||||
|
||||
@Get('channel')
|
||||
channel(@CurrentUser() user: AuthUser, @Query('slug') slug?: string) {
|
||||
return this.chat.channel(user, slug);
|
||||
}
|
||||
|
||||
@Post('messages')
|
||||
send(@CurrentUser() user: AuthUser, @Body() dto: SendMessageDto) {
|
||||
return this.chat.send(user, dto.text, dto.slug);
|
||||
}
|
||||
}
|
||||
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { ChatService } from './chat.service';
|
||||
import { SendMessageDto } from './dto/send-message.dto';
|
||||
|
||||
@Controller('chat')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('PLATFORM_ADMIN', 'ADMIN', 'MANAGER', 'OPERATOR')
|
||||
export class ChatController {
|
||||
constructor(private readonly chat: ChatService) {}
|
||||
|
||||
@Get('counts')
|
||||
@Roles('PLATFORM_ADMIN')
|
||||
counts(@CurrentUser() user: AuthUser) {
|
||||
return this.chat.counts(user);
|
||||
}
|
||||
|
||||
@Get('channel')
|
||||
channel(@CurrentUser() user: AuthUser, @Query('slug') slug?: string) {
|
||||
return this.chat.channel(user, slug);
|
||||
}
|
||||
|
||||
@Post('messages')
|
||||
send(@CurrentUser() user: AuthUser, @Body() dto: SendMessageDto) {
|
||||
return this.chat.send(user, dto.text, dto.slug);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatController } from './chat.controller';
|
||||
import { ChatService } from './chat.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ChatController],
|
||||
providers: [ChatService],
|
||||
})
|
||||
export class ChatModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatController } from './chat.controller';
|
||||
import { ChatService } from './chat.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ChatController],
|
||||
providers: [ChatService],
|
||||
})
|
||||
export class ChatModule {}
|
||||
|
||||
@ -1,84 +1,84 @@
|
||||
import { ForbiddenException, Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { PlatformPrismaService } from '../prisma/platform-prisma.service';
|
||||
import { resolveChatTenant } from './chat-access';
|
||||
import { chatChannelId } from './chat-channel';
|
||||
import { countChatMessages } from './chat-count';
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly platform: PlatformPrismaService,
|
||||
) {}
|
||||
|
||||
configured() {
|
||||
return Boolean(this.databaseUrl());
|
||||
}
|
||||
|
||||
async channel(actor: AuthUser, slugFromQuery?: string) {
|
||||
const databaseURL = this.databaseUrl();
|
||||
if (!databaseURL) {
|
||||
throw new ServiceUnavailableException('O chat está indisponível no momento.');
|
||||
}
|
||||
const tenant = await resolveChatTenant(actor, slugFromQuery, (slug) =>
|
||||
this.platform.tenant.findUnique({ where: { slug } }),
|
||||
);
|
||||
return { slug: tenant.slug, name: tenant.name, channel: chatChannelId(tenant.slug, this.secret()), databaseURL };
|
||||
}
|
||||
|
||||
async send(actor: AuthUser, text: string, slugFromBody?: string) {
|
||||
const room = await this.channel(actor, slugFromBody);
|
||||
const response = await fetch(`${room.databaseURL}/chats/${room.channel}/messages.json`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
authorId: actor.sub,
|
||||
authorName: actor.name,
|
||||
authorKind: actor.kind,
|
||||
text: text.trim(),
|
||||
createdAt: Date.now(),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new ServiceUnavailableException('Não foi possível enviar a mensagem.');
|
||||
}
|
||||
return { ok: true, slug: room.slug };
|
||||
}
|
||||
|
||||
async counts(actor: AuthUser) {
|
||||
if (actor.kind !== 'platform') {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
const databaseURL = this.databaseUrl();
|
||||
if (!databaseURL) {
|
||||
return {};
|
||||
}
|
||||
const tenants = await this.platform.tenant.findMany({ select: { slug: true } });
|
||||
const entries = await Promise.all(
|
||||
tenants.map(async (tenant) => {
|
||||
const channel = chatChannelId(tenant.slug, this.secret());
|
||||
try {
|
||||
const response = await fetch(`${databaseURL}/chats/${channel}/messages.json`);
|
||||
if (!response.ok) {
|
||||
return [tenant.slug, 0] as const;
|
||||
}
|
||||
return [tenant.slug, countChatMessages(await response.json())] as const;
|
||||
} catch {
|
||||
return [tenant.slug, 0] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return Object.fromEntries(entries) as Record<string, number>;
|
||||
}
|
||||
|
||||
private secret() {
|
||||
return this.config.get<string>('JWT_SECRET') ?? 'sinka-chat';
|
||||
}
|
||||
|
||||
private databaseUrl() {
|
||||
const configured = this.config.get<string>('FIREBASE_DATABASE_URL')?.replace(/\/$/, '') ?? '';
|
||||
return configured || 'https://sinka-8ec10-default-rtdb.firebaseio.com';
|
||||
}
|
||||
}
|
||||
import { ForbiddenException, Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { PlatformPrismaService } from '../prisma/platform-prisma.service';
|
||||
import { resolveChatTenant } from './chat-access';
|
||||
import { chatChannelId } from './chat-channel';
|
||||
import { countChatMessages } from './chat-count';
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly platform: PlatformPrismaService,
|
||||
) {}
|
||||
|
||||
configured() {
|
||||
return Boolean(this.databaseUrl());
|
||||
}
|
||||
|
||||
async channel(actor: AuthUser, slugFromQuery?: string) {
|
||||
const databaseURL = this.databaseUrl();
|
||||
if (!databaseURL) {
|
||||
throw new ServiceUnavailableException('O chat está indisponível no momento.');
|
||||
}
|
||||
const tenant = await resolveChatTenant(actor, slugFromQuery, (slug) =>
|
||||
this.platform.tenant.findUnique({ where: { slug } }),
|
||||
);
|
||||
return { slug: tenant.slug, name: tenant.name, channel: chatChannelId(tenant.slug, this.secret()), databaseURL };
|
||||
}
|
||||
|
||||
async send(actor: AuthUser, text: string, slugFromBody?: string) {
|
||||
const room = await this.channel(actor, slugFromBody);
|
||||
const response = await fetch(`${room.databaseURL}/chats/${room.channel}/messages.json`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
authorId: actor.sub,
|
||||
authorName: actor.name,
|
||||
authorKind: actor.kind,
|
||||
text: text.trim(),
|
||||
createdAt: Date.now(),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new ServiceUnavailableException('Não foi possível enviar a mensagem.');
|
||||
}
|
||||
return { ok: true, slug: room.slug };
|
||||
}
|
||||
|
||||
async counts(actor: AuthUser) {
|
||||
if (actor.kind !== 'platform') {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
const databaseURL = this.databaseUrl();
|
||||
if (!databaseURL) {
|
||||
return {};
|
||||
}
|
||||
const tenants = await this.platform.tenant.findMany({ select: { slug: true } });
|
||||
const entries = await Promise.all(
|
||||
tenants.map(async (tenant) => {
|
||||
const channel = chatChannelId(tenant.slug, this.secret());
|
||||
try {
|
||||
const response = await fetch(`${databaseURL}/chats/${channel}/messages.json`);
|
||||
if (!response.ok) {
|
||||
return [tenant.slug, 0] as const;
|
||||
}
|
||||
return [tenant.slug, countChatMessages(await response.json())] as const;
|
||||
} catch {
|
||||
return [tenant.slug, 0] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return Object.fromEntries(entries) as Record<string, number>;
|
||||
}
|
||||
|
||||
private secret() {
|
||||
return this.config.get<string>('JWT_SECRET') ?? 'sinka-chat';
|
||||
}
|
||||
|
||||
private databaseUrl() {
|
||||
const configured = this.config.get<string>('FIREBASE_DATABASE_URL')?.replace(/\/$/, '') ?? '';
|
||||
return configured || 'https://sinka-8ec10-default-rtdb.firebaseio.com';
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class SendMessageDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(2000)
|
||||
text!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
slug?: string;
|
||||
}
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class SendMessageDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(2000)
|
||||
text!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
@ -1,44 +1,44 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { CustomersService } from './customers.service';
|
||||
import { UpsertCustomerDto } from './dto/upsert-customer.dto';
|
||||
|
||||
@Controller('customers')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class CustomersController {
|
||||
constructor(private readonly customers: CustomersService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('ADMIN', 'MANAGER', 'OPERATOR')
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.customers.list(user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('ADMIN', 'MANAGER')
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: UpsertCustomerDto, @Req() request: Request) {
|
||||
return this.customers.create(user, dto, request.ip);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('ADMIN', 'MANAGER')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpsertCustomerDto,
|
||||
@Req() request: Request,
|
||||
) {
|
||||
return this.customers.update(user, id, dto, request.ip);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('ADMIN', 'MANAGER')
|
||||
remove(@CurrentUser() user: AuthUser, @Param('id') id: string, @Req() request: Request) {
|
||||
return this.customers.remove(user, id, request.ip);
|
||||
}
|
||||
}
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { CustomersService } from './customers.service';
|
||||
import { UpsertCustomerDto } from './dto/upsert-customer.dto';
|
||||
|
||||
@Controller('customers')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class CustomersController {
|
||||
constructor(private readonly customers: CustomersService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('ADMIN', 'MANAGER', 'OPERATOR')
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.customers.list(user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('ADMIN', 'MANAGER')
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: UpsertCustomerDto, @Req() request: Request) {
|
||||
return this.customers.create(user, dto, request.ip);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('ADMIN', 'MANAGER')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpsertCustomerDto,
|
||||
@Req() request: Request,
|
||||
) {
|
||||
return this.customers.update(user, id, dto, request.ip);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('ADMIN', 'MANAGER')
|
||||
remove(@CurrentUser() user: AuthUser, @Param('id') id: string, @Req() request: Request) {
|
||||
return this.customers.remove(user, id, request.ip);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CustomersController } from './customers.controller';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService],
|
||||
})
|
||||
export class CustomersModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CustomersController } from './customers.controller';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService],
|
||||
})
|
||||
export class CustomersModule {}
|
||||
|
||||
@ -1,52 +1,52 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { tenantClient } from '../tenancy/actor';
|
||||
import { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
||||
import type { UpsertCustomerDto } from './dto/upsert-customer.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(private readonly tenants: TenantConnectionService) {}
|
||||
|
||||
async list(actor: AuthUser) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
return db.customer.findMany({ orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async create(actor: AuthUser, dto: UpsertCustomerDto, ip?: string) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const customer = await db.customer.create({ data: dto });
|
||||
await db.auditLog.create({
|
||||
data: { userId: actor.sub, action: 'customer.create', entity: 'Customer', entityId: customer.id, ip },
|
||||
});
|
||||
return customer;
|
||||
}
|
||||
|
||||
async update(actor: AuthUser, id: string, dto: UpsertCustomerDto, ip?: string) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
await this.ensure(db, id);
|
||||
const customer = await db.customer.update({ where: { id }, data: dto });
|
||||
await db.auditLog.create({
|
||||
data: { userId: actor.sub, action: 'customer.update', entity: 'Customer', entityId: id, ip },
|
||||
});
|
||||
return customer;
|
||||
}
|
||||
|
||||
async remove(actor: AuthUser, id: string, ip?: string) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
await this.ensure(db, id);
|
||||
await db.customer.delete({ where: { id } });
|
||||
await db.auditLog.create({
|
||||
data: { userId: actor.sub, action: 'customer.delete', entity: 'Customer', entityId: id, ip },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async ensure(db: Awaited<ReturnType<TenantConnectionService['getByTenantId']>>, id: string) {
|
||||
const row = await db.customer.findUnique({ where: { id } });
|
||||
if (!row) {
|
||||
throw new NotFoundException('Cliente não encontrado.');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
}
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { tenantClient } from '../tenancy/actor';
|
||||
import { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
||||
import type { UpsertCustomerDto } from './dto/upsert-customer.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(private readonly tenants: TenantConnectionService) {}
|
||||
|
||||
async list(actor: AuthUser) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
return db.customer.findMany({ orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async create(actor: AuthUser, dto: UpsertCustomerDto, ip?: string) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const customer = await db.customer.create({ data: dto });
|
||||
await db.auditLog.create({
|
||||
data: { userId: actor.sub, action: 'customer.create', entity: 'Customer', entityId: customer.id, ip },
|
||||
});
|
||||
return customer;
|
||||
}
|
||||
|
||||
async update(actor: AuthUser, id: string, dto: UpsertCustomerDto, ip?: string) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
await this.ensure(db, id);
|
||||
const customer = await db.customer.update({ where: { id }, data: dto });
|
||||
await db.auditLog.create({
|
||||
data: { userId: actor.sub, action: 'customer.update', entity: 'Customer', entityId: id, ip },
|
||||
});
|
||||
return customer;
|
||||
}
|
||||
|
||||
async remove(actor: AuthUser, id: string, ip?: string) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
await this.ensure(db, id);
|
||||
await db.customer.delete({ where: { id } });
|
||||
await db.auditLog.create({
|
||||
data: { userId: actor.sub, action: 'customer.delete', entity: 'Customer', entityId: id, ip },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async ensure(db: Awaited<ReturnType<TenantConnectionService['getByTenantId']>>, id: string) {
|
||||
const row = await db.customer.findUnique({ where: { id } });
|
||||
if (!row) {
|
||||
throw new NotFoundException('Cliente não encontrado.');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,34 +1,34 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class UpsertCustomerDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
document?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
city?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2)
|
||||
@Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.toUpperCase() : value))
|
||||
state?: string;
|
||||
}
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class UpsertCustomerDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
document?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
city?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2)
|
||||
@Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.toUpperCase() : value))
|
||||
state?: string;
|
||||
}
|
||||
|
||||
@ -1,47 +1,47 @@
|
||||
import { buildDashboard, type DashboardSimulation } from './dashboard-metrics';
|
||||
|
||||
function sim(partial: Partial<DashboardSimulation>): DashboardSimulation {
|
||||
return {
|
||||
quotedPrice: 400,
|
||||
distanceKm: 400,
|
||||
bestCarrierName: 'Rota Sul',
|
||||
originLabel: 'São Paulo',
|
||||
destinationLabel: 'Rio de Janeiro',
|
||||
originZip: '01310-100',
|
||||
destinationZip: '20040-020',
|
||||
quotes: [
|
||||
{ price: 400, carrierName: 'Rota Sul' },
|
||||
{ price: 520, carrierName: 'Express RJ' },
|
||||
],
|
||||
createdAt: new Date(),
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('dashboard metrics', () => {
|
||||
it('measures leftover savings as worst minus best quote', () => {
|
||||
const dash = buildDashboard({
|
||||
current: [sim({}), sim({ quotedPrice: 300, quotes: [{ price: 300, carrierName: 'A' }, { price: 360, carrierName: 'B' }] })],
|
||||
previous: [sim({ quotedPrice: 500 })],
|
||||
customerCount: 4,
|
||||
carrierCount: 3,
|
||||
});
|
||||
|
||||
expect(dash.kpis.leftoverSavings.value).toBe(180);
|
||||
expect(dash.kpis.averageCost.value).toBe(350);
|
||||
expect(dash.kpis.averageCost.deltaPct).toBe(-30);
|
||||
expect(dash.carriers[0].name).toBe('Rota Sul');
|
||||
});
|
||||
|
||||
it('explains empty periods instead of inventing numbers', () => {
|
||||
const dash = buildDashboard({
|
||||
current: [],
|
||||
previous: [],
|
||||
customerCount: 0,
|
||||
carrierCount: 1,
|
||||
});
|
||||
|
||||
expect(dash.kpis.averageCost.value).toBeNull();
|
||||
expect(dash.insights[0]).toMatch(/Ainda não há simulações/);
|
||||
});
|
||||
});
|
||||
import { buildDashboard, type DashboardSimulation } from './dashboard-metrics';
|
||||
|
||||
function sim(partial: Partial<DashboardSimulation>): DashboardSimulation {
|
||||
return {
|
||||
quotedPrice: 400,
|
||||
distanceKm: 400,
|
||||
bestCarrierName: 'Rota Sul',
|
||||
originLabel: 'São Paulo',
|
||||
destinationLabel: 'Rio de Janeiro',
|
||||
originZip: '01310-100',
|
||||
destinationZip: '20040-020',
|
||||
quotes: [
|
||||
{ price: 400, carrierName: 'Rota Sul' },
|
||||
{ price: 520, carrierName: 'Express RJ' },
|
||||
],
|
||||
createdAt: new Date(),
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('dashboard metrics', () => {
|
||||
it('measures leftover savings as worst minus best quote', () => {
|
||||
const dash = buildDashboard({
|
||||
current: [sim({}), sim({ quotedPrice: 300, quotes: [{ price: 300, carrierName: 'A' }, { price: 360, carrierName: 'B' }] })],
|
||||
previous: [sim({ quotedPrice: 500 })],
|
||||
customerCount: 4,
|
||||
carrierCount: 3,
|
||||
});
|
||||
|
||||
expect(dash.kpis.leftoverSavings.value).toBe(180);
|
||||
expect(dash.kpis.averageCost.value).toBe(350);
|
||||
expect(dash.kpis.averageCost.deltaPct).toBe(-30);
|
||||
expect(dash.carriers[0].name).toBe('Rota Sul');
|
||||
});
|
||||
|
||||
it('explains empty periods instead of inventing numbers', () => {
|
||||
const dash = buildDashboard({
|
||||
current: [],
|
||||
previous: [],
|
||||
customerCount: 0,
|
||||
carrierCount: 1,
|
||||
});
|
||||
|
||||
expect(dash.kpis.averageCost.value).toBeNull();
|
||||
expect(dash.insights[0]).toMatch(/Ainda não há simulações/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,181 +1,181 @@
|
||||
export type DashboardQuote = {
|
||||
price: number;
|
||||
carrierName: string;
|
||||
};
|
||||
|
||||
export type DashboardSimulation = {
|
||||
quotedPrice: number | null;
|
||||
distanceKm: number | null;
|
||||
bestCarrierName: string | null;
|
||||
originLabel: string | null;
|
||||
destinationLabel: string | null;
|
||||
originZip: string;
|
||||
destinationZip: string;
|
||||
quotes: DashboardQuote[];
|
||||
createdAt: Date | string;
|
||||
};
|
||||
|
||||
export type DashboardPayload = {
|
||||
periodDays: number;
|
||||
kpis: {
|
||||
simulations: { value: number; deltaPct: number | null };
|
||||
averageCost: { value: number | null; deltaPct: number | null };
|
||||
costPerKm: { value: number | null; deltaPct: number | null };
|
||||
leftoverSavings: { value: number; sharePct: number | null };
|
||||
};
|
||||
insights: string[];
|
||||
carriers: { name: string; wins: number; sharePct: number; averageWin: number }[];
|
||||
routes: { label: string; count: number; averageCost: number }[];
|
||||
customers: number;
|
||||
activeCarriers: number;
|
||||
};
|
||||
|
||||
function round2(value: number) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function average(values: number[]) {
|
||||
if (!values.length) {
|
||||
return null;
|
||||
}
|
||||
return round2(values.reduce((sum, value) => sum + value, 0) / values.length);
|
||||
}
|
||||
|
||||
function deltaPct(current: number | null, previous: number | null) {
|
||||
if (current === null || previous === null || previous === 0) {
|
||||
return null;
|
||||
}
|
||||
return round2(((current - previous) / previous) * 100);
|
||||
}
|
||||
|
||||
function money(value: number) {
|
||||
return value.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
function pricesOf(sim: DashboardSimulation) {
|
||||
if (sim.quotedPrice !== null) {
|
||||
return [sim.quotedPrice];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function costPerKmValues(rows: DashboardSimulation[]) {
|
||||
return rows
|
||||
.filter((row) => row.quotedPrice !== null && row.distanceKm !== null && row.distanceKm > 0)
|
||||
.map((row) => (row.quotedPrice as number) / (row.distanceKm as number));
|
||||
}
|
||||
|
||||
function leftoverOf(sim: DashboardSimulation) {
|
||||
const prices = sim.quotes.map((quote) => quote.price).filter((price) => price > 0);
|
||||
if (prices.length < 2) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(...prices) - Math.min(...prices);
|
||||
}
|
||||
|
||||
function routeKey(sim: DashboardSimulation) {
|
||||
const origin = sim.originLabel || sim.originZip;
|
||||
const destination = sim.destinationLabel || sim.destinationZip;
|
||||
return `${origin} → ${destination}`;
|
||||
}
|
||||
|
||||
export function buildDashboard(input: {
|
||||
current: DashboardSimulation[];
|
||||
previous: DashboardSimulation[];
|
||||
customerCount: number;
|
||||
carrierCount: number;
|
||||
periodDays?: number;
|
||||
}): DashboardPayload {
|
||||
const periodDays = input.periodDays ?? 30;
|
||||
const currentCosts = input.current.flatMap(pricesOf);
|
||||
const previousCosts = input.previous.flatMap(pricesOf);
|
||||
const averageCost = average(currentCosts);
|
||||
const previousAverage = average(previousCosts);
|
||||
const costPerKm = average(costPerKmValues(input.current));
|
||||
const previousCostPerKm = average(costPerKmValues(input.previous));
|
||||
const leftoverSavings = round2(input.current.reduce((sum, sim) => sum + leftoverOf(sim), 0));
|
||||
const comparable = input.current.filter((sim) => leftoverOf(sim) > 0).length;
|
||||
const sharePct = input.current.length ? round2((comparable / input.current.length) * 100) : null;
|
||||
|
||||
const wins = new Map<string, { wins: number; total: number }>();
|
||||
for (const sim of input.current) {
|
||||
if (!sim.bestCarrierName || sim.quotedPrice === null) {
|
||||
continue;
|
||||
}
|
||||
const row = wins.get(sim.bestCarrierName) ?? { wins: 0, total: 0 };
|
||||
row.wins += 1;
|
||||
row.total += sim.quotedPrice;
|
||||
wins.set(sim.bestCarrierName, row);
|
||||
}
|
||||
const winTotal = [...wins.values()].reduce((sum, row) => sum + row.wins, 0);
|
||||
const carriers = [...wins.entries()]
|
||||
.map(([name, row]) => ({
|
||||
name,
|
||||
wins: row.wins,
|
||||
sharePct: winTotal ? round2((row.wins / winTotal) * 100) : 0,
|
||||
averageWin: round2(row.total / row.wins),
|
||||
}))
|
||||
.sort((a, b) => b.wins - a.wins);
|
||||
|
||||
const routesMap = new Map<string, { count: number; total: number }>();
|
||||
for (const sim of input.current) {
|
||||
if (sim.quotedPrice === null) {
|
||||
continue;
|
||||
}
|
||||
const key = routeKey(sim);
|
||||
const row = routesMap.get(key) ?? { count: 0, total: 0 };
|
||||
row.count += 1;
|
||||
row.total += sim.quotedPrice;
|
||||
routesMap.set(key, row);
|
||||
}
|
||||
const routes = [...routesMap.entries()]
|
||||
.map(([label, row]) => ({
|
||||
label,
|
||||
count: row.count,
|
||||
averageCost: round2(row.total / row.count),
|
||||
}))
|
||||
.sort((a, b) => b.averageCost - a.averageCost)
|
||||
.slice(0, 5);
|
||||
|
||||
const insights: string[] = [];
|
||||
const costDelta = deltaPct(averageCost, previousAverage);
|
||||
if (costDelta !== null && costDelta > 0) {
|
||||
insights.push(`O custo médio da melhor cotação subiu ${costDelta}% frente aos ${periodDays} dias anteriores.`);
|
||||
} else if (costDelta !== null && costDelta < 0) {
|
||||
insights.push(`O custo médio da melhor cotação caiu ${Math.abs(costDelta)}% frente aos ${periodDays} dias anteriores.`);
|
||||
}
|
||||
if (leftoverSavings > 0) {
|
||||
insights.push(
|
||||
`A diferença entre a pior e a melhor opção soma ${money(leftoverSavings)} no período. Comparar cotações evita pagar o teto.`,
|
||||
);
|
||||
}
|
||||
const leader = carriers[0];
|
||||
if (leader && leader.sharePct >= 50) {
|
||||
insights.push(
|
||||
`${leader.name} venceu ${leader.sharePct}% das simulações. Concentração alta: negocie prazo e dependência.`,
|
||||
);
|
||||
} else if (leader) {
|
||||
insights.push(`${leader.name} lidera as melhores cotações (${leader.sharePct}%). Vale cruzar com prazo, não só preço.`);
|
||||
}
|
||||
if (!input.current.length) {
|
||||
insights.push('Ainda não há simulações neste período. As primeiras cotações passam a alimentar custo, rota e dependência.');
|
||||
}
|
||||
|
||||
return {
|
||||
periodDays,
|
||||
kpis: {
|
||||
simulations: {
|
||||
value: input.current.length,
|
||||
deltaPct: deltaPct(input.current.length || null, input.previous.length || null),
|
||||
},
|
||||
averageCost: { value: averageCost, deltaPct: costDelta },
|
||||
costPerKm: { value: costPerKm, deltaPct: deltaPct(costPerKm, previousCostPerKm) },
|
||||
leftoverSavings: { value: leftoverSavings, sharePct },
|
||||
},
|
||||
insights,
|
||||
carriers,
|
||||
routes,
|
||||
customers: input.customerCount,
|
||||
activeCarriers: input.carrierCount,
|
||||
};
|
||||
}
|
||||
export type DashboardQuote = {
|
||||
price: number;
|
||||
carrierName: string;
|
||||
};
|
||||
|
||||
export type DashboardSimulation = {
|
||||
quotedPrice: number | null;
|
||||
distanceKm: number | null;
|
||||
bestCarrierName: string | null;
|
||||
originLabel: string | null;
|
||||
destinationLabel: string | null;
|
||||
originZip: string;
|
||||
destinationZip: string;
|
||||
quotes: DashboardQuote[];
|
||||
createdAt: Date | string;
|
||||
};
|
||||
|
||||
export type DashboardPayload = {
|
||||
periodDays: number;
|
||||
kpis: {
|
||||
simulations: { value: number; deltaPct: number | null };
|
||||
averageCost: { value: number | null; deltaPct: number | null };
|
||||
costPerKm: { value: number | null; deltaPct: number | null };
|
||||
leftoverSavings: { value: number; sharePct: number | null };
|
||||
};
|
||||
insights: string[];
|
||||
carriers: { name: string; wins: number; sharePct: number; averageWin: number }[];
|
||||
routes: { label: string; count: number; averageCost: number }[];
|
||||
customers: number;
|
||||
activeCarriers: number;
|
||||
};
|
||||
|
||||
function round2(value: number) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function average(values: number[]) {
|
||||
if (!values.length) {
|
||||
return null;
|
||||
}
|
||||
return round2(values.reduce((sum, value) => sum + value, 0) / values.length);
|
||||
}
|
||||
|
||||
function deltaPct(current: number | null, previous: number | null) {
|
||||
if (current === null || previous === null || previous === 0) {
|
||||
return null;
|
||||
}
|
||||
return round2(((current - previous) / previous) * 100);
|
||||
}
|
||||
|
||||
function money(value: number) {
|
||||
return value.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
function pricesOf(sim: DashboardSimulation) {
|
||||
if (sim.quotedPrice !== null) {
|
||||
return [sim.quotedPrice];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function costPerKmValues(rows: DashboardSimulation[]) {
|
||||
return rows
|
||||
.filter((row) => row.quotedPrice !== null && row.distanceKm !== null && row.distanceKm > 0)
|
||||
.map((row) => (row.quotedPrice as number) / (row.distanceKm as number));
|
||||
}
|
||||
|
||||
function leftoverOf(sim: DashboardSimulation) {
|
||||
const prices = sim.quotes.map((quote) => quote.price).filter((price) => price > 0);
|
||||
if (prices.length < 2) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(...prices) - Math.min(...prices);
|
||||
}
|
||||
|
||||
function routeKey(sim: DashboardSimulation) {
|
||||
const origin = sim.originLabel || sim.originZip;
|
||||
const destination = sim.destinationLabel || sim.destinationZip;
|
||||
return `${origin} → ${destination}`;
|
||||
}
|
||||
|
||||
export function buildDashboard(input: {
|
||||
current: DashboardSimulation[];
|
||||
previous: DashboardSimulation[];
|
||||
customerCount: number;
|
||||
carrierCount: number;
|
||||
periodDays?: number;
|
||||
}): DashboardPayload {
|
||||
const periodDays = input.periodDays ?? 30;
|
||||
const currentCosts = input.current.flatMap(pricesOf);
|
||||
const previousCosts = input.previous.flatMap(pricesOf);
|
||||
const averageCost = average(currentCosts);
|
||||
const previousAverage = average(previousCosts);
|
||||
const costPerKm = average(costPerKmValues(input.current));
|
||||
const previousCostPerKm = average(costPerKmValues(input.previous));
|
||||
const leftoverSavings = round2(input.current.reduce((sum, sim) => sum + leftoverOf(sim), 0));
|
||||
const comparable = input.current.filter((sim) => leftoverOf(sim) > 0).length;
|
||||
const sharePct = input.current.length ? round2((comparable / input.current.length) * 100) : null;
|
||||
|
||||
const wins = new Map<string, { wins: number; total: number }>();
|
||||
for (const sim of input.current) {
|
||||
if (!sim.bestCarrierName || sim.quotedPrice === null) {
|
||||
continue;
|
||||
}
|
||||
const row = wins.get(sim.bestCarrierName) ?? { wins: 0, total: 0 };
|
||||
row.wins += 1;
|
||||
row.total += sim.quotedPrice;
|
||||
wins.set(sim.bestCarrierName, row);
|
||||
}
|
||||
const winTotal = [...wins.values()].reduce((sum, row) => sum + row.wins, 0);
|
||||
const carriers = [...wins.entries()]
|
||||
.map(([name, row]) => ({
|
||||
name,
|
||||
wins: row.wins,
|
||||
sharePct: winTotal ? round2((row.wins / winTotal) * 100) : 0,
|
||||
averageWin: round2(row.total / row.wins),
|
||||
}))
|
||||
.sort((a, b) => b.wins - a.wins);
|
||||
|
||||
const routesMap = new Map<string, { count: number; total: number }>();
|
||||
for (const sim of input.current) {
|
||||
if (sim.quotedPrice === null) {
|
||||
continue;
|
||||
}
|
||||
const key = routeKey(sim);
|
||||
const row = routesMap.get(key) ?? { count: 0, total: 0 };
|
||||
row.count += 1;
|
||||
row.total += sim.quotedPrice;
|
||||
routesMap.set(key, row);
|
||||
}
|
||||
const routes = [...routesMap.entries()]
|
||||
.map(([label, row]) => ({
|
||||
label,
|
||||
count: row.count,
|
||||
averageCost: round2(row.total / row.count),
|
||||
}))
|
||||
.sort((a, b) => b.averageCost - a.averageCost)
|
||||
.slice(0, 5);
|
||||
|
||||
const insights: string[] = [];
|
||||
const costDelta = deltaPct(averageCost, previousAverage);
|
||||
if (costDelta !== null && costDelta > 0) {
|
||||
insights.push(`O custo médio da melhor cotação subiu ${costDelta}% frente aos ${periodDays} dias anteriores.`);
|
||||
} else if (costDelta !== null && costDelta < 0) {
|
||||
insights.push(`O custo médio da melhor cotação caiu ${Math.abs(costDelta)}% frente aos ${periodDays} dias anteriores.`);
|
||||
}
|
||||
if (leftoverSavings > 0) {
|
||||
insights.push(
|
||||
`A diferença entre a pior e a melhor opção soma ${money(leftoverSavings)} no período. Comparar cotações evita pagar o teto.`,
|
||||
);
|
||||
}
|
||||
const leader = carriers[0];
|
||||
if (leader && leader.sharePct >= 50) {
|
||||
insights.push(
|
||||
`${leader.name} venceu ${leader.sharePct}% das simulações. Concentração alta: negocie prazo e dependência.`,
|
||||
);
|
||||
} else if (leader) {
|
||||
insights.push(`${leader.name} lidera as melhores cotações (${leader.sharePct}%). Vale cruzar com prazo, não só preço.`);
|
||||
}
|
||||
if (!input.current.length) {
|
||||
insights.push('Ainda não há simulações neste período. As primeiras cotações passam a alimentar custo, rota e dependência.');
|
||||
}
|
||||
|
||||
return {
|
||||
periodDays,
|
||||
kpis: {
|
||||
simulations: {
|
||||
value: input.current.length,
|
||||
deltaPct: deltaPct(input.current.length || null, input.previous.length || null),
|
||||
},
|
||||
averageCost: { value: averageCost, deltaPct: costDelta },
|
||||
costPerKm: { value: costPerKm, deltaPct: deltaPct(costPerKm, previousCostPerKm) },
|
||||
leftoverSavings: { value: leftoverSavings, sharePct },
|
||||
},
|
||||
insights,
|
||||
carriers,
|
||||
routes,
|
||||
customers: input.customerCount,
|
||||
activeCarriers: input.carrierCount,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@Controller('dashboard')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN', 'MANAGER', 'OPERATOR')
|
||||
export class DashboardController {
|
||||
constructor(private readonly dashboard: DashboardService) {}
|
||||
|
||||
@Get()
|
||||
summary(@CurrentUser() user: AuthUser) {
|
||||
return this.dashboard.summary(user);
|
||||
}
|
||||
}
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@Controller('dashboard')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN', 'MANAGER', 'OPERATOR')
|
||||
export class DashboardController {
|
||||
constructor(private readonly dashboard: DashboardService) {}
|
||||
|
||||
@Get()
|
||||
summary(@CurrentUser() user: AuthUser) {
|
||||
return this.dashboard.summary(user);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
|
||||
@ -1,73 +1,73 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import type { CarrierQuote } from '../freight/freight-calculator';
|
||||
import { tenantClient } from '../tenancy/actor';
|
||||
import { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
||||
import { buildDashboard, type DashboardSimulation } from './dashboard-metrics';
|
||||
|
||||
const PERIOD_DAYS = 30;
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(private readonly tenants: TenantConnectionService) {}
|
||||
|
||||
async summary(actor: AuthUser) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const now = new Date();
|
||||
const currentFrom = daysAgo(now, PERIOD_DAYS);
|
||||
const previousFrom = daysAgo(now, PERIOD_DAYS * 2);
|
||||
|
||||
const [current, previous, customerCount, carrierCount] = await Promise.all([
|
||||
db.freightSimulation.findMany({
|
||||
where: { status: 'CALCULATED', createdAt: { gte: currentFrom } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
db.freightSimulation.findMany({
|
||||
where: { status: 'CALCULATED', createdAt: { gte: previousFrom, lt: currentFrom } },
|
||||
}),
|
||||
db.customer.count(),
|
||||
db.carrier.count({ where: { active: true } }),
|
||||
]);
|
||||
|
||||
return buildDashboard({
|
||||
current: current.map(toSimulation),
|
||||
previous: previous.map(toSimulation),
|
||||
customerCount,
|
||||
carrierCount,
|
||||
periodDays: PERIOD_DAYS,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function daysAgo(now: Date, days: number) {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() - days);
|
||||
return date;
|
||||
}
|
||||
|
||||
function toSimulation(row: {
|
||||
quotedPrice: { toString(): string } | null;
|
||||
distanceKm: { toString(): string } | null;
|
||||
bestCarrierName: string | null;
|
||||
originLabel: string | null;
|
||||
destinationLabel: string | null;
|
||||
originZip: string;
|
||||
destinationZip: string;
|
||||
quotes: unknown;
|
||||
createdAt: Date;
|
||||
}): DashboardSimulation {
|
||||
return {
|
||||
quotedPrice: row.quotedPrice === null ? null : Number(row.quotedPrice),
|
||||
distanceKm: row.distanceKm === null ? null : Number(row.distanceKm),
|
||||
bestCarrierName: row.bestCarrierName,
|
||||
originLabel: row.originLabel,
|
||||
destinationLabel: row.destinationLabel,
|
||||
originZip: row.originZip,
|
||||
destinationZip: row.destinationZip,
|
||||
quotes: ((row.quotes as CarrierQuote[] | null) ?? []).map((quote) => ({
|
||||
price: Number(quote.price),
|
||||
carrierName: quote.carrierName,
|
||||
})),
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import type { CarrierQuote } from '../freight/freight-calculator';
|
||||
import { tenantClient } from '../tenancy/actor';
|
||||
import { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
||||
import { buildDashboard, type DashboardSimulation } from './dashboard-metrics';
|
||||
|
||||
const PERIOD_DAYS = 30;
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(private readonly tenants: TenantConnectionService) {}
|
||||
|
||||
async summary(actor: AuthUser) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const now = new Date();
|
||||
const currentFrom = daysAgo(now, PERIOD_DAYS);
|
||||
const previousFrom = daysAgo(now, PERIOD_DAYS * 2);
|
||||
|
||||
const [current, previous, customerCount, carrierCount] = await Promise.all([
|
||||
db.freightSimulation.findMany({
|
||||
where: { status: 'CALCULATED', createdAt: { gte: currentFrom } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
db.freightSimulation.findMany({
|
||||
where: { status: 'CALCULATED', createdAt: { gte: previousFrom, lt: currentFrom } },
|
||||
}),
|
||||
db.customer.count(),
|
||||
db.carrier.count({ where: { active: true } }),
|
||||
]);
|
||||
|
||||
return buildDashboard({
|
||||
current: current.map(toSimulation),
|
||||
previous: previous.map(toSimulation),
|
||||
customerCount,
|
||||
carrierCount,
|
||||
periodDays: PERIOD_DAYS,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function daysAgo(now: Date, days: number) {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() - days);
|
||||
return date;
|
||||
}
|
||||
|
||||
function toSimulation(row: {
|
||||
quotedPrice: { toString(): string } | null;
|
||||
distanceKm: { toString(): string } | null;
|
||||
bestCarrierName: string | null;
|
||||
originLabel: string | null;
|
||||
destinationLabel: string | null;
|
||||
originZip: string;
|
||||
destinationZip: string;
|
||||
quotes: unknown;
|
||||
createdAt: Date;
|
||||
}): DashboardSimulation {
|
||||
return {
|
||||
quotedPrice: row.quotedPrice === null ? null : Number(row.quotedPrice),
|
||||
distanceKm: row.distanceKm === null ? null : Number(row.distanceKm),
|
||||
bestCarrierName: row.bestCarrierName,
|
||||
originLabel: row.originLabel,
|
||||
destinationLabel: row.destinationLabel,
|
||||
originZip: row.originZip,
|
||||
destinationZip: row.destinationZip,
|
||||
quotes: ((row.quotes as CarrierQuote[] | null) ?? []).map((quote) => ({
|
||||
price: Number(quote.price),
|
||||
carrierName: quote.carrierName,
|
||||
})),
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
import { fileExtension, isAllowedUpload, safeOriginalName } from './file-allow';
|
||||
|
||||
describe('file upload allow list', () => {
|
||||
it('accepts csv and xlsx', () => {
|
||||
expect(isAllowedUpload('tabela.csv')).toBe(true);
|
||||
expect(isAllowedUpload('planilha.XLSX')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects other extensions', () => {
|
||||
expect(isAllowedUpload('foto.exe')).toBe(false);
|
||||
expect(isAllowedUpload('sem-extensao')).toBe(false);
|
||||
});
|
||||
|
||||
it('strips path from the original name', () => {
|
||||
expect(fileExtension('C:\\tmp\\a.csv')).toBe('csv');
|
||||
expect(safeOriginalName('C:\\tmp\\carga.csv')).toBe('carga.csv');
|
||||
});
|
||||
});
|
||||
import { fileExtension, isAllowedUpload, safeOriginalName } from './file-allow';
|
||||
|
||||
describe('file upload allow list', () => {
|
||||
it('accepts csv and xlsx', () => {
|
||||
expect(isAllowedUpload('tabela.csv')).toBe(true);
|
||||
expect(isAllowedUpload('planilha.XLSX')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects other extensions', () => {
|
||||
expect(isAllowedUpload('foto.exe')).toBe(false);
|
||||
expect(isAllowedUpload('sem-extensao')).toBe(false);
|
||||
});
|
||||
|
||||
it('strips path from the original name', () => {
|
||||
expect(fileExtension('C:\\tmp\\a.csv')).toBe('csv');
|
||||
expect(safeOriginalName('C:\\tmp\\carga.csv')).toBe('carga.csv');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
const ALLOWED = new Set(['csv', 'xlsx', 'xls', 'pdf', 'txt']);
|
||||
|
||||
export function fileExtension(name: string): string {
|
||||
const base = name.replace(/\\/g, '/').split('/').pop() ?? '';
|
||||
const dot = base.lastIndexOf('.');
|
||||
return dot >= 0 ? base.slice(dot + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function isAllowedUpload(name: string): boolean {
|
||||
return ALLOWED.has(fileExtension(name));
|
||||
}
|
||||
|
||||
export function safeOriginalName(name: string): string {
|
||||
const base = name.replace(/\\/g, '/').split('/').pop()?.trim() || 'arquivo';
|
||||
return base.replace(/[^\w.\- ()[\]]+/g, '_').slice(0, 180);
|
||||
}
|
||||
const ALLOWED = new Set(['csv', 'xlsx', 'xls', 'pdf', 'txt']);
|
||||
|
||||
export function fileExtension(name: string): string {
|
||||
const base = name.replace(/\\/g, '/').split('/').pop() ?? '';
|
||||
const dot = base.lastIndexOf('.');
|
||||
return dot >= 0 ? base.slice(dot + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function isAllowedUpload(name: string): boolean {
|
||||
return ALLOWED.has(fileExtension(name));
|
||||
}
|
||||
|
||||
export function safeOriginalName(name: string): string {
|
||||
const base = name.replace(/\\/g, '/').split('/').pop()?.trim() || 'arquivo';
|
||||
return base.replace(/[^\w.\- ()[\]]+/g, '_').slice(0, 180);
|
||||
}
|
||||
|
||||
@ -1,50 +1,50 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { FilesService } from './files.service';
|
||||
|
||||
@Controller('files')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN', 'MANAGER', 'OPERATOR')
|
||||
export class FilesController {
|
||||
constructor(private readonly files: FilesService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.files.list(user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 8 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
create(@CurrentUser() user: AuthUser, @UploadedFile() file: Express.Multer.File) {
|
||||
return this.files.create(user, file);
|
||||
}
|
||||
|
||||
@Get(':id/download')
|
||||
async download(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
const file = await this.files.download(user, id);
|
||||
return new StreamableFile(file.stream, {
|
||||
type: file.mimeType,
|
||||
disposition: `attachment; filename="${file.name.replace(/"/g, '')}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { FilesService } from './files.service';
|
||||
|
||||
@Controller('files')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN', 'MANAGER', 'OPERATOR')
|
||||
export class FilesController {
|
||||
constructor(private readonly files: FilesService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.files.list(user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 8 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
create(@CurrentUser() user: AuthUser, @UploadedFile() file: Express.Multer.File) {
|
||||
return this.files.create(user, file);
|
||||
}
|
||||
|
||||
@Get(':id/download')
|
||||
async download(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
const file = await this.files.download(user, id);
|
||||
return new StreamableFile(file.stream, {
|
||||
type: file.mimeType,
|
||||
disposition: `attachment; filename="${file.name.replace(/"/g, '')}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FilesController } from './files.controller';
|
||||
import { FilesService } from './files.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FilesController],
|
||||
providers: [FilesService],
|
||||
})
|
||||
export class FilesModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FilesController } from './files.controller';
|
||||
import { FilesService } from './files.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FilesController],
|
||||
providers: [FilesService],
|
||||
})
|
||||
export class FilesModule {}
|
||||
|
||||
@ -1,105 +1,105 @@
|
||||
import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { mkdir, unlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { tenantClient } from '../tenancy/actor';
|
||||
import { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
||||
import { fileExtension, isAllowedUpload, safeOriginalName } from './file-allow';
|
||||
|
||||
const MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
type IncomingFile = {
|
||||
originalname: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
constructor(
|
||||
private readonly tenants: TenantConnectionService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async list(actor: AuthUser) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const rows = await db.operationFile.findMany({ orderBy: { createdAt: 'desc' } });
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.originalName,
|
||||
type: fileExtension(row.originalName).toUpperCase() || '-',
|
||||
sizeBytes: row.sizeBytes,
|
||||
createdAt: row.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async create(actor: AuthUser, file: IncomingFile | undefined) {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('Selecione um arquivo.');
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
throw new BadRequestException('Arquivo acima de 8 MB.');
|
||||
}
|
||||
if (!isAllowedUpload(file.originalname)) {
|
||||
throw new BadRequestException('Use CSV, XLSX, XLS, PDF ou TXT.');
|
||||
}
|
||||
const slug = actor.tenantSlug;
|
||||
if (!slug) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const originalName = safeOriginalName(file.originalname);
|
||||
const storedName = `${id}.${fileExtension(originalName)}`;
|
||||
const folder = this.folder(slug);
|
||||
await mkdir(folder, { recursive: true });
|
||||
const diskPath = join(folder, storedName);
|
||||
await writeFile(diskPath, file.buffer);
|
||||
|
||||
try {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const row = await db.operationFile.create({
|
||||
data: {
|
||||
id,
|
||||
originalName,
|
||||
storedName,
|
||||
mimeType: file.mimetype || 'application/octet-stream',
|
||||
sizeBytes: file.size,
|
||||
},
|
||||
});
|
||||
await db.auditLog.create({
|
||||
data: { userId: actor.sub, action: 'file.create', entity: 'OperationFile', entityId: row.id },
|
||||
});
|
||||
return { id: row.id, name: row.originalName, type: fileExtension(row.originalName).toUpperCase(), sizeBytes: row.sizeBytes, createdAt: row.createdAt };
|
||||
} catch (error) {
|
||||
await unlink(diskPath).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async download(actor: AuthUser, id: string) {
|
||||
const slug = actor.tenantSlug;
|
||||
if (!slug) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const row = await db.operationFile.findUnique({ where: { id } });
|
||||
if (!row) {
|
||||
throw new NotFoundException('Arquivo não encontrado.');
|
||||
}
|
||||
return {
|
||||
name: row.originalName,
|
||||
mimeType: row.mimeType,
|
||||
stream: createReadStream(join(this.folder(slug), row.storedName)),
|
||||
};
|
||||
}
|
||||
|
||||
private folder(slug: string) {
|
||||
const root = this.config.get<string>('UPLOAD_DIR') ?? join(process.cwd(), 'uploads');
|
||||
return join(root, slug);
|
||||
}
|
||||
}
|
||||
import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { mkdir, unlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { tenantClient } from '../tenancy/actor';
|
||||
import { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
||||
import { fileExtension, isAllowedUpload, safeOriginalName } from './file-allow';
|
||||
|
||||
const MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
type IncomingFile = {
|
||||
originalname: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
constructor(
|
||||
private readonly tenants: TenantConnectionService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async list(actor: AuthUser) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const rows = await db.operationFile.findMany({ orderBy: { createdAt: 'desc' } });
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.originalName,
|
||||
type: fileExtension(row.originalName).toUpperCase() || '-',
|
||||
sizeBytes: row.sizeBytes,
|
||||
createdAt: row.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async create(actor: AuthUser, file: IncomingFile | undefined) {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('Selecione um arquivo.');
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
throw new BadRequestException('Arquivo acima de 8 MB.');
|
||||
}
|
||||
if (!isAllowedUpload(file.originalname)) {
|
||||
throw new BadRequestException('Use CSV, XLSX, XLS, PDF ou TXT.');
|
||||
}
|
||||
const slug = actor.tenantSlug;
|
||||
if (!slug) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const originalName = safeOriginalName(file.originalname);
|
||||
const storedName = `${id}.${fileExtension(originalName)}`;
|
||||
const folder = this.folder(slug);
|
||||
await mkdir(folder, { recursive: true });
|
||||
const diskPath = join(folder, storedName);
|
||||
await writeFile(diskPath, file.buffer);
|
||||
|
||||
try {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const row = await db.operationFile.create({
|
||||
data: {
|
||||
id,
|
||||
originalName,
|
||||
storedName,
|
||||
mimeType: file.mimetype || 'application/octet-stream',
|
||||
sizeBytes: file.size,
|
||||
},
|
||||
});
|
||||
await db.auditLog.create({
|
||||
data: { userId: actor.sub, action: 'file.create', entity: 'OperationFile', entityId: row.id },
|
||||
});
|
||||
return { id: row.id, name: row.originalName, type: fileExtension(row.originalName).toUpperCase(), sizeBytes: row.sizeBytes, createdAt: row.createdAt };
|
||||
} catch (error) {
|
||||
await unlink(diskPath).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async download(actor: AuthUser, id: string) {
|
||||
const slug = actor.tenantSlug;
|
||||
if (!slug) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const row = await db.operationFile.findUnique({ where: { id } });
|
||||
if (!row) {
|
||||
throw new NotFoundException('Arquivo não encontrado.');
|
||||
}
|
||||
return {
|
||||
name: row.originalName,
|
||||
mimeType: row.mimeType,
|
||||
stream: createReadStream(join(this.folder(slug), row.storedName)),
|
||||
};
|
||||
}
|
||||
|
||||
private folder(slug: string) {
|
||||
const root = this.config.get<string>('UPLOAD_DIR') ?? join(process.cwd(), 'uploads');
|
||||
return join(root, slug);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,47 +1,47 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNumber, IsOptional, IsString, IsUUID, Matches, Min } from 'class-validator';
|
||||
|
||||
const toNumber = ({ value }: { value: unknown }) => Number(value);
|
||||
const zip = ({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value.replace(/\D/g, '').slice(0, 8) : value;
|
||||
|
||||
export class SimulateFreightDto {
|
||||
@Transform(zip)
|
||||
@IsString()
|
||||
@Matches(/^\d{8}$/)
|
||||
originZip!: string;
|
||||
|
||||
@Transform(zip)
|
||||
@IsString()
|
||||
@Matches(/^\d{8}$/)
|
||||
destinationZip!: string;
|
||||
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
weightKg!: number;
|
||||
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
widthCm!: number;
|
||||
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
heightCm!: number;
|
||||
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
lengthCm!: number;
|
||||
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
cargoValue!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
customerId?: string;
|
||||
}
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNumber, IsOptional, IsString, IsUUID, Matches, Min } from 'class-validator';
|
||||
|
||||
const toNumber = ({ value }: { value: unknown }) => Number(value);
|
||||
const zip = ({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value.replace(/\D/g, '').slice(0, 8) : value;
|
||||
|
||||
export class SimulateFreightDto {
|
||||
@Transform(zip)
|
||||
@IsString()
|
||||
@Matches(/^\d{8}$/)
|
||||
originZip!: string;
|
||||
|
||||
@Transform(zip)
|
||||
@IsString()
|
||||
@Matches(/^\d{8}$/)
|
||||
destinationZip!: string;
|
||||
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
weightKg!: number;
|
||||
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
widthCm!: number;
|
||||
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
heightCm!: number;
|
||||
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
lengthCm!: number;
|
||||
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
cargoValue!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
customerId?: string;
|
||||
}
|
||||
|
||||
@ -1,50 +1,50 @@
|
||||
import { chargeableWeightKg, haversineKm, quoteCarrier, rankQuotes } from './freight-calculator';
|
||||
|
||||
describe('freight calculator', () => {
|
||||
const cargo = {
|
||||
weightKg: 10,
|
||||
widthCm: 40,
|
||||
heightCm: 30,
|
||||
lengthCm: 50,
|
||||
cargoValue: 1000,
|
||||
distanceKm: 200,
|
||||
};
|
||||
|
||||
it('uses volumetric weight when it exceeds real weight', () => {
|
||||
expect(chargeableWeightKg({ weightKg: 1, widthCm: 100, heightCm: 100, lengthCm: 100 })).toBe(166.667);
|
||||
});
|
||||
|
||||
it('quotes base + kg + km + ad valorem', () => {
|
||||
const quote = quoteCarrier(
|
||||
{ id: '1', name: 'Rota Sul', baseFee: 30, pricePerKg: 2, pricePerKm: 0.5, adValoremRate: 0.01 },
|
||||
cargo,
|
||||
);
|
||||
expect(quote.price).toBe(160);
|
||||
});
|
||||
|
||||
it('ranks cheapest first', () => {
|
||||
const ranked = rankQuotes([
|
||||
{ carrierId: 'a', carrierName: 'A', price: 90, chargeableKg: 10 },
|
||||
{ carrierId: 'b', carrierName: 'B', price: 40, chargeableKg: 10 },
|
||||
]);
|
||||
expect(ranked[0].carrierId).toBe('b');
|
||||
});
|
||||
|
||||
it('uses real weight when it exceeds volumetric weight', () => {
|
||||
expect(chargeableWeightKg({ weightKg: 80, widthCm: 10, heightCm: 10, lengthCm: 10 })).toBe(80);
|
||||
});
|
||||
|
||||
it('never quotes a negative price', () => {
|
||||
const quote = quoteCarrier(
|
||||
{ id: '1', name: 'X', baseFee: 0, pricePerKg: 0, pricePerKm: 0, adValoremRate: 0 },
|
||||
{ ...cargo, cargoValue: 0, distanceKm: 0, weightKg: 0, widthCm: 0, heightCm: 0, lengthCm: 0 },
|
||||
);
|
||||
expect(quote.price).toBe(0);
|
||||
});
|
||||
|
||||
it('computes haversine distance in km', () => {
|
||||
const km = haversineKm({ lat: -23.55, lon: -46.63 }, { lat: -22.9, lon: -43.2 });
|
||||
expect(km).toBeGreaterThan(350);
|
||||
expect(km).toBeLessThan(450);
|
||||
});
|
||||
});
|
||||
import { chargeableWeightKg, haversineKm, quoteCarrier, rankQuotes } from './freight-calculator';
|
||||
|
||||
describe('freight calculator', () => {
|
||||
const cargo = {
|
||||
weightKg: 10,
|
||||
widthCm: 40,
|
||||
heightCm: 30,
|
||||
lengthCm: 50,
|
||||
cargoValue: 1000,
|
||||
distanceKm: 200,
|
||||
};
|
||||
|
||||
it('uses volumetric weight when it exceeds real weight', () => {
|
||||
expect(chargeableWeightKg({ weightKg: 1, widthCm: 100, heightCm: 100, lengthCm: 100 })).toBe(166.667);
|
||||
});
|
||||
|
||||
it('quotes base + kg + km + ad valorem', () => {
|
||||
const quote = quoteCarrier(
|
||||
{ id: '1', name: 'Rota Sul', baseFee: 30, pricePerKg: 2, pricePerKm: 0.5, adValoremRate: 0.01 },
|
||||
cargo,
|
||||
);
|
||||
expect(quote.price).toBe(160);
|
||||
});
|
||||
|
||||
it('ranks cheapest first', () => {
|
||||
const ranked = rankQuotes([
|
||||
{ carrierId: 'a', carrierName: 'A', price: 90, chargeableKg: 10 },
|
||||
{ carrierId: 'b', carrierName: 'B', price: 40, chargeableKg: 10 },
|
||||
]);
|
||||
expect(ranked[0].carrierId).toBe('b');
|
||||
});
|
||||
|
||||
it('uses real weight when it exceeds volumetric weight', () => {
|
||||
expect(chargeableWeightKg({ weightKg: 80, widthCm: 10, heightCm: 10, lengthCm: 10 })).toBe(80);
|
||||
});
|
||||
|
||||
it('never quotes a negative price', () => {
|
||||
const quote = quoteCarrier(
|
||||
{ id: '1', name: 'X', baseFee: 0, pricePerKg: 0, pricePerKm: 0, adValoremRate: 0 },
|
||||
{ ...cargo, cargoValue: 0, distanceKm: 0, weightKg: 0, widthCm: 0, heightCm: 0, lengthCm: 0 },
|
||||
);
|
||||
expect(quote.price).toBe(0);
|
||||
});
|
||||
|
||||
it('computes haversine distance in km', () => {
|
||||
const km = haversineKm({ lat: -23.55, lon: -46.63 }, { lat: -22.9, lon: -43.2 });
|
||||
expect(km).toBeGreaterThan(350);
|
||||
expect(km).toBeLessThan(450);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,68 +1,68 @@
|
||||
export type CarrierRate = {
|
||||
id: string;
|
||||
name: string;
|
||||
baseFee: number;
|
||||
pricePerKg: number;
|
||||
pricePerKm: number;
|
||||
adValoremRate: number;
|
||||
};
|
||||
|
||||
export type CargoInput = {
|
||||
weightKg: number;
|
||||
widthCm: number;
|
||||
heightCm: number;
|
||||
lengthCm: number;
|
||||
cargoValue: number;
|
||||
distanceKm: number;
|
||||
};
|
||||
|
||||
export type CarrierQuote = {
|
||||
carrierId: string;
|
||||
carrierName: string;
|
||||
price: number;
|
||||
chargeableKg: number;
|
||||
};
|
||||
|
||||
const VOLUME_DIVISOR = 6000;
|
||||
|
||||
export function chargeableWeightKg(input: Pick<CargoInput, 'weightKg' | 'widthCm' | 'heightCm' | 'lengthCm'>): number {
|
||||
const volumetric = (input.widthCm * input.heightCm * input.lengthCm) / VOLUME_DIVISOR;
|
||||
return round3(Math.max(input.weightKg, volumetric));
|
||||
}
|
||||
|
||||
export function quoteCarrier(carrier: CarrierRate, cargo: CargoInput): CarrierQuote {
|
||||
const chargeableKg = chargeableWeightKg(cargo);
|
||||
const raw =
|
||||
carrier.baseFee +
|
||||
carrier.pricePerKg * chargeableKg +
|
||||
carrier.pricePerKm * cargo.distanceKm +
|
||||
carrier.adValoremRate * cargo.cargoValue;
|
||||
return {
|
||||
carrierId: carrier.id,
|
||||
carrierName: carrier.name,
|
||||
chargeableKg,
|
||||
price: round2(Math.max(raw, 0)),
|
||||
};
|
||||
}
|
||||
|
||||
export function rankQuotes(quotes: CarrierQuote[]): CarrierQuote[] {
|
||||
return [...quotes].sort((a, b) => a.price - b.price);
|
||||
}
|
||||
|
||||
export function haversineKm(a: { lat: number; lon: number }, b: { lat: number; lon: number }): number {
|
||||
const toRad = (value: number) => (value * Math.PI) / 180;
|
||||
const dLat = toRad(b.lat - a.lat);
|
||||
const dLon = toRad(b.lon - a.lon);
|
||||
const sinLat = Math.sin(dLat / 2);
|
||||
const sinLon = Math.sin(dLon / 2);
|
||||
const h = sinLat * sinLat + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * sinLon * sinLon;
|
||||
return round2(6371 * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)));
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function round3(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
export type CarrierRate = {
|
||||
id: string;
|
||||
name: string;
|
||||
baseFee: number;
|
||||
pricePerKg: number;
|
||||
pricePerKm: number;
|
||||
adValoremRate: number;
|
||||
};
|
||||
|
||||
export type CargoInput = {
|
||||
weightKg: number;
|
||||
widthCm: number;
|
||||
heightCm: number;
|
||||
lengthCm: number;
|
||||
cargoValue: number;
|
||||
distanceKm: number;
|
||||
};
|
||||
|
||||
export type CarrierQuote = {
|
||||
carrierId: string;
|
||||
carrierName: string;
|
||||
price: number;
|
||||
chargeableKg: number;
|
||||
};
|
||||
|
||||
const VOLUME_DIVISOR = 6000;
|
||||
|
||||
export function chargeableWeightKg(input: Pick<CargoInput, 'weightKg' | 'widthCm' | 'heightCm' | 'lengthCm'>): number {
|
||||
const volumetric = (input.widthCm * input.heightCm * input.lengthCm) / VOLUME_DIVISOR;
|
||||
return round3(Math.max(input.weightKg, volumetric));
|
||||
}
|
||||
|
||||
export function quoteCarrier(carrier: CarrierRate, cargo: CargoInput): CarrierQuote {
|
||||
const chargeableKg = chargeableWeightKg(cargo);
|
||||
const raw =
|
||||
carrier.baseFee +
|
||||
carrier.pricePerKg * chargeableKg +
|
||||
carrier.pricePerKm * cargo.distanceKm +
|
||||
carrier.adValoremRate * cargo.cargoValue;
|
||||
return {
|
||||
carrierId: carrier.id,
|
||||
carrierName: carrier.name,
|
||||
chargeableKg,
|
||||
price: round2(Math.max(raw, 0)),
|
||||
};
|
||||
}
|
||||
|
||||
export function rankQuotes(quotes: CarrierQuote[]): CarrierQuote[] {
|
||||
return [...quotes].sort((a, b) => a.price - b.price);
|
||||
}
|
||||
|
||||
export function haversineKm(a: { lat: number; lon: number }, b: { lat: number; lon: number }): number {
|
||||
const toRad = (value: number) => (value * Math.PI) / 180;
|
||||
const dLat = toRad(b.lat - a.lat);
|
||||
const dLon = toRad(b.lon - a.lon);
|
||||
const sinLat = Math.sin(dLat / 2);
|
||||
const sinLon = Math.sin(dLon / 2);
|
||||
const h = sinLat * sinLat + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * sinLon * sinLon;
|
||||
return round2(6371 * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)));
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function round3(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
@ -1,26 +1,26 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { SimulateFreightDto } from './dto/simulate-freight.dto';
|
||||
import { FreightService } from './freight.service';
|
||||
|
||||
@Controller('freight')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN', 'MANAGER', 'OPERATOR')
|
||||
export class FreightController {
|
||||
constructor(private readonly freight: FreightService) {}
|
||||
|
||||
@Get()
|
||||
history(@CurrentUser() user: AuthUser) {
|
||||
return this.freight.history(user);
|
||||
}
|
||||
|
||||
@Post('simulate')
|
||||
simulate(@CurrentUser() user: AuthUser, @Body() dto: SimulateFreightDto, @Req() request: Request) {
|
||||
return this.freight.simulate(user, dto, request.ip);
|
||||
}
|
||||
}
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { SimulateFreightDto } from './dto/simulate-freight.dto';
|
||||
import { FreightService } from './freight.service';
|
||||
|
||||
@Controller('freight')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN', 'MANAGER', 'OPERATOR')
|
||||
export class FreightController {
|
||||
constructor(private readonly freight: FreightService) {}
|
||||
|
||||
@Get()
|
||||
history(@CurrentUser() user: AuthUser) {
|
||||
return this.freight.history(user);
|
||||
}
|
||||
|
||||
@Post('simulate')
|
||||
simulate(@CurrentUser() user: AuthUser, @Body() dto: SimulateFreightDto, @Req() request: Request) {
|
||||
return this.freight.simulate(user, dto, request.ip);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GeoModule } from '../geo/geo.module';
|
||||
import { FreightController } from './freight.controller';
|
||||
import { FreightService } from './freight.service';
|
||||
|
||||
@Module({
|
||||
imports: [GeoModule],
|
||||
controllers: [FreightController],
|
||||
providers: [FreightService],
|
||||
})
|
||||
export class FreightModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GeoModule } from '../geo/geo.module';
|
||||
import { FreightController } from './freight.controller';
|
||||
import { FreightService } from './freight.service';
|
||||
|
||||
@Module({
|
||||
imports: [GeoModule],
|
||||
controllers: [FreightController],
|
||||
providers: [FreightService],
|
||||
})
|
||||
export class FreightModule {}
|
||||
|
||||
@ -1,144 +1,144 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '../generated/tenant';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { GeoService } from '../geo/geo.service';
|
||||
import { tenantClient } from '../tenancy/actor';
|
||||
import { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
||||
import type { SimulateFreightDto } from './dto/simulate-freight.dto';
|
||||
import { quoteCarrier, rankQuotes, type CarrierQuote } from './freight-calculator';
|
||||
|
||||
@Injectable()
|
||||
export class FreightService {
|
||||
constructor(
|
||||
private readonly tenants: TenantConnectionService,
|
||||
private readonly geo: GeoService,
|
||||
) {}
|
||||
|
||||
async history(actor: AuthUser) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const rows = await db.freightSimulation.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
include: { customer: { select: { id: true, name: true } } },
|
||||
});
|
||||
return rows.map(serializeSimulation);
|
||||
}
|
||||
|
||||
async simulate(actor: AuthUser, dto: SimulateFreightDto, ip?: string) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const carriers = await db.carrier.findMany({ where: { active: true } });
|
||||
if (!carriers.length) {
|
||||
throw new BadRequestException('Cadastre ao menos uma transportadora ativa.');
|
||||
}
|
||||
|
||||
if (dto.customerId) {
|
||||
const customer = await db.customer.findUnique({ where: { id: dto.customerId } });
|
||||
if (!customer) {
|
||||
throw new BadRequestException('Cliente não encontrado.');
|
||||
}
|
||||
}
|
||||
|
||||
const route = await this.geo.distanceKm(dto.originZip, dto.destinationZip);
|
||||
const cargo = {
|
||||
weightKg: dto.weightKg,
|
||||
widthCm: dto.widthCm,
|
||||
heightCm: dto.heightCm,
|
||||
lengthCm: dto.lengthCm,
|
||||
cargoValue: dto.cargoValue,
|
||||
distanceKm: route.km,
|
||||
};
|
||||
const quotes = rankQuotes(
|
||||
carriers.map((carrier) =>
|
||||
quoteCarrier(
|
||||
{
|
||||
id: carrier.id,
|
||||
name: carrier.name,
|
||||
baseFee: Number(carrier.baseFee),
|
||||
pricePerKg: Number(carrier.pricePerKg),
|
||||
pricePerKm: Number(carrier.pricePerKm),
|
||||
adValoremRate: Number(carrier.adValoremRate),
|
||||
},
|
||||
cargo,
|
||||
),
|
||||
),
|
||||
);
|
||||
const best = quotes[0];
|
||||
|
||||
const saved = await db.freightSimulation.create({
|
||||
data: {
|
||||
originZip: route.origin.zip,
|
||||
destinationZip: route.destination.zip,
|
||||
originLabel: route.origin.label,
|
||||
destinationLabel: route.destination.label,
|
||||
weightKg: dto.weightKg,
|
||||
widthCm: dto.widthCm,
|
||||
heightCm: dto.heightCm,
|
||||
lengthCm: dto.lengthCm,
|
||||
cargoValue: dto.cargoValue,
|
||||
distanceKm: route.km,
|
||||
chargeableKg: best.chargeableKg,
|
||||
quotedPrice: best.price,
|
||||
bestCarrierName: best.carrierName,
|
||||
quotes: quotes as unknown as Prisma.InputJsonValue,
|
||||
status: 'CALCULATED',
|
||||
customerId: dto.customerId,
|
||||
},
|
||||
include: { customer: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: actor.sub,
|
||||
action: 'freight.simulate',
|
||||
entity: 'FreightSimulation',
|
||||
entityId: saved.id,
|
||||
ip,
|
||||
metadata: { best: best.carrierName, price: best.price, km: route.km },
|
||||
},
|
||||
});
|
||||
|
||||
return { ...serializeSimulation(saved), distanceSource: route.source };
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSimulation(row: {
|
||||
id: string;
|
||||
originZip: string;
|
||||
destinationZip: string;
|
||||
originLabel: string | null;
|
||||
destinationLabel: string | null;
|
||||
weightKg: { toString(): string };
|
||||
widthCm: { toString(): string };
|
||||
heightCm: { toString(): string };
|
||||
lengthCm: { toString(): string };
|
||||
cargoValue: { toString(): string };
|
||||
quotedPrice: { toString(): string } | null;
|
||||
distanceKm: { toString(): string } | null;
|
||||
chargeableKg: { toString(): string } | null;
|
||||
bestCarrierName: string | null;
|
||||
quotes: unknown;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
customer: { id: string; name: string } | null;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
originZip: row.originZip,
|
||||
destinationZip: row.destinationZip,
|
||||
originLabel: row.originLabel,
|
||||
destinationLabel: row.destinationLabel,
|
||||
weightKg: Number(row.weightKg),
|
||||
widthCm: Number(row.widthCm),
|
||||
heightCm: Number(row.heightCm),
|
||||
lengthCm: Number(row.lengthCm),
|
||||
cargoValue: Number(row.cargoValue),
|
||||
quotedPrice: row.quotedPrice === null ? null : Number(row.quotedPrice),
|
||||
distanceKm: row.distanceKm === null ? null : Number(row.distanceKm),
|
||||
chargeableKg: row.chargeableKg === null ? null : Number(row.chargeableKg),
|
||||
bestCarrierName: row.bestCarrierName,
|
||||
quotes: (row.quotes as CarrierQuote[] | null) ?? [],
|
||||
status: row.status,
|
||||
createdAt: row.createdAt,
|
||||
customer: row.customer,
|
||||
};
|
||||
}
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '../generated/tenant';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { GeoService } from '../geo/geo.service';
|
||||
import { tenantClient } from '../tenancy/actor';
|
||||
import { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
||||
import type { SimulateFreightDto } from './dto/simulate-freight.dto';
|
||||
import { quoteCarrier, rankQuotes, type CarrierQuote } from './freight-calculator';
|
||||
|
||||
@Injectable()
|
||||
export class FreightService {
|
||||
constructor(
|
||||
private readonly tenants: TenantConnectionService,
|
||||
private readonly geo: GeoService,
|
||||
) {}
|
||||
|
||||
async history(actor: AuthUser) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const rows = await db.freightSimulation.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
include: { customer: { select: { id: true, name: true } } },
|
||||
});
|
||||
return rows.map(serializeSimulation);
|
||||
}
|
||||
|
||||
async simulate(actor: AuthUser, dto: SimulateFreightDto, ip?: string) {
|
||||
const db = await tenantClient(this.tenants, actor);
|
||||
const carriers = await db.carrier.findMany({ where: { active: true } });
|
||||
if (!carriers.length) {
|
||||
throw new BadRequestException('Cadastre ao menos uma transportadora ativa.');
|
||||
}
|
||||
|
||||
if (dto.customerId) {
|
||||
const customer = await db.customer.findUnique({ where: { id: dto.customerId } });
|
||||
if (!customer) {
|
||||
throw new BadRequestException('Cliente não encontrado.');
|
||||
}
|
||||
}
|
||||
|
||||
const route = await this.geo.distanceKm(dto.originZip, dto.destinationZip);
|
||||
const cargo = {
|
||||
weightKg: dto.weightKg,
|
||||
widthCm: dto.widthCm,
|
||||
heightCm: dto.heightCm,
|
||||
lengthCm: dto.lengthCm,
|
||||
cargoValue: dto.cargoValue,
|
||||
distanceKm: route.km,
|
||||
};
|
||||
const quotes = rankQuotes(
|
||||
carriers.map((carrier) =>
|
||||
quoteCarrier(
|
||||
{
|
||||
id: carrier.id,
|
||||
name: carrier.name,
|
||||
baseFee: Number(carrier.baseFee),
|
||||
pricePerKg: Number(carrier.pricePerKg),
|
||||
pricePerKm: Number(carrier.pricePerKm),
|
||||
adValoremRate: Number(carrier.adValoremRate),
|
||||
},
|
||||
cargo,
|
||||
),
|
||||
),
|
||||
);
|
||||
const best = quotes[0];
|
||||
|
||||
const saved = await db.freightSimulation.create({
|
||||
data: {
|
||||
originZip: route.origin.zip,
|
||||
destinationZip: route.destination.zip,
|
||||
originLabel: route.origin.label,
|
||||
destinationLabel: route.destination.label,
|
||||
weightKg: dto.weightKg,
|
||||
widthCm: dto.widthCm,
|
||||
heightCm: dto.heightCm,
|
||||
lengthCm: dto.lengthCm,
|
||||
cargoValue: dto.cargoValue,
|
||||
distanceKm: route.km,
|
||||
chargeableKg: best.chargeableKg,
|
||||
quotedPrice: best.price,
|
||||
bestCarrierName: best.carrierName,
|
||||
quotes: quotes as unknown as Prisma.InputJsonValue,
|
||||
status: 'CALCULATED',
|
||||
customerId: dto.customerId,
|
||||
},
|
||||
include: { customer: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: actor.sub,
|
||||
action: 'freight.simulate',
|
||||
entity: 'FreightSimulation',
|
||||
entityId: saved.id,
|
||||
ip,
|
||||
metadata: { best: best.carrierName, price: best.price, km: route.km },
|
||||
},
|
||||
});
|
||||
|
||||
return { ...serializeSimulation(saved), distanceSource: route.source };
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSimulation(row: {
|
||||
id: string;
|
||||
originZip: string;
|
||||
destinationZip: string;
|
||||
originLabel: string | null;
|
||||
destinationLabel: string | null;
|
||||
weightKg: { toString(): string };
|
||||
widthCm: { toString(): string };
|
||||
heightCm: { toString(): string };
|
||||
lengthCm: { toString(): string };
|
||||
cargoValue: { toString(): string };
|
||||
quotedPrice: { toString(): string } | null;
|
||||
distanceKm: { toString(): string } | null;
|
||||
chargeableKg: { toString(): string } | null;
|
||||
bestCarrierName: string | null;
|
||||
quotes: unknown;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
customer: { id: string; name: string } | null;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
originZip: row.originZip,
|
||||
destinationZip: row.destinationZip,
|
||||
originLabel: row.originLabel,
|
||||
destinationLabel: row.destinationLabel,
|
||||
weightKg: Number(row.weightKg),
|
||||
widthCm: Number(row.widthCm),
|
||||
heightCm: Number(row.heightCm),
|
||||
lengthCm: Number(row.lengthCm),
|
||||
cargoValue: Number(row.cargoValue),
|
||||
quotedPrice: row.quotedPrice === null ? null : Number(row.quotedPrice),
|
||||
distanceKm: row.distanceKm === null ? null : Number(row.distanceKm),
|
||||
chargeableKg: row.chargeableKg === null ? null : Number(row.chargeableKg),
|
||||
bestCarrierName: row.bestCarrierName,
|
||||
quotes: (row.quotes as CarrierQuote[] | null) ?? [],
|
||||
status: row.status,
|
||||
createdAt: row.createdAt,
|
||||
customer: row.customer,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GeoService } from './geo.service';
|
||||
|
||||
@Module({
|
||||
providers: [GeoService],
|
||||
exports: [GeoService],
|
||||
})
|
||||
export class GeoModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GeoService } from './geo.service';
|
||||
|
||||
@Module({
|
||||
providers: [GeoService],
|
||||
exports: [GeoService],
|
||||
})
|
||||
export class GeoModule {}
|
||||
|
||||
@ -1,84 +1,84 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { digitsZip, fallbackDistanceKm, type ZipPlace } from './geo.types';
|
||||
import { haversineKm } from '../freight/freight-calculator';
|
||||
|
||||
@Injectable()
|
||||
export class GeoService {
|
||||
constructor(private readonly redis: RedisService) {}
|
||||
|
||||
async placeFromZip(zip: string): Promise<ZipPlace> {
|
||||
const cep = digitsZip(zip);
|
||||
if (!/^\d{8}$/.test(cep)) {
|
||||
throw new BadRequestException('CEP inválido.');
|
||||
}
|
||||
|
||||
const cached = await this.redis.getJson<ZipPlace>(`geo:cep:${cep}`);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const viaCep = await this.lookupViaCep(cep);
|
||||
const coords = await this.lookupNominatim(viaCep.city, viaCep.state);
|
||||
const place: ZipPlace = {
|
||||
zip: cep,
|
||||
city: viaCep.city,
|
||||
state: viaCep.state,
|
||||
label: `${viaCep.city}/${viaCep.state}`,
|
||||
lat: coords?.lat ?? 0,
|
||||
lon: coords?.lon ?? 0,
|
||||
};
|
||||
await this.redis.setJson(`geo:cep:${cep}`, place, 60 * 60 * 24 * 7);
|
||||
return place;
|
||||
}
|
||||
|
||||
async distanceKm(originZip: string, destinationZip: string): Promise<{
|
||||
origin: ZipPlace;
|
||||
destination: ZipPlace;
|
||||
km: number;
|
||||
source: 'nominatim' | 'fallback';
|
||||
}> {
|
||||
const origin = await this.placeFromZip(originZip);
|
||||
const destination = await this.placeFromZip(destinationZip);
|
||||
if (origin.lat && origin.lon && destination.lat && destination.lon) {
|
||||
return { origin, destination, km: Math.max(haversineKm(origin, destination), 1), source: 'nominatim' };
|
||||
}
|
||||
return { origin, destination, km: fallbackDistanceKm(origin, destination), source: 'fallback' };
|
||||
}
|
||||
|
||||
private async lookupViaCep(cep: string): Promise<{ city: string; state: string }> {
|
||||
const response = await fetch(`https://viacep.com.br/ws/${cep}/json/`, {
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new BadRequestException('Não foi possível consultar o CEP.');
|
||||
}
|
||||
const data = (await response.json()) as { erro?: boolean; localidade?: string; uf?: string };
|
||||
if (data.erro || !data.localidade || !data.uf) {
|
||||
throw new BadRequestException('CEP não encontrado.');
|
||||
}
|
||||
return { city: data.localidade, state: data.uf };
|
||||
}
|
||||
|
||||
private async lookupNominatim(city: string, state: string): Promise<{ lat: number; lon: number } | null> {
|
||||
const query = new URLSearchParams({
|
||||
format: 'json',
|
||||
limit: '1',
|
||||
countrycodes: 'br',
|
||||
q: `${city}, ${state}, Brasil`,
|
||||
});
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?${query.toString()}`, {
|
||||
headers: { 'User-Agent': 'SinkaLogistica/1.0 (desafio; tina.r@example.net)' },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const rows = (await response.json()) as Array<{ lat: string; lon: string }>;
|
||||
const first = rows[0];
|
||||
if (!first) {
|
||||
return null;
|
||||
}
|
||||
return { lat: Number(first.lat), lon: Number(first.lon) };
|
||||
}
|
||||
}
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { digitsZip, fallbackDistanceKm, type ZipPlace } from './geo.types';
|
||||
import { haversineKm } from '../freight/freight-calculator';
|
||||
|
||||
@Injectable()
|
||||
export class GeoService {
|
||||
constructor(private readonly redis: RedisService) {}
|
||||
|
||||
async placeFromZip(zip: string): Promise<ZipPlace> {
|
||||
const cep = digitsZip(zip);
|
||||
if (!/^\d{8}$/.test(cep)) {
|
||||
throw new BadRequestException('CEP inválido.');
|
||||
}
|
||||
|
||||
const cached = await this.redis.getJson<ZipPlace>(`geo:cep:${cep}`);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const viaCep = await this.lookupViaCep(cep);
|
||||
const coords = await this.lookupNominatim(viaCep.city, viaCep.state);
|
||||
const place: ZipPlace = {
|
||||
zip: cep,
|
||||
city: viaCep.city,
|
||||
state: viaCep.state,
|
||||
label: `${viaCep.city}/${viaCep.state}`,
|
||||
lat: coords?.lat ?? 0,
|
||||
lon: coords?.lon ?? 0,
|
||||
};
|
||||
await this.redis.setJson(`geo:cep:${cep}`, place, 60 * 60 * 24 * 7);
|
||||
return place;
|
||||
}
|
||||
|
||||
async distanceKm(originZip: string, destinationZip: string): Promise<{
|
||||
origin: ZipPlace;
|
||||
destination: ZipPlace;
|
||||
km: number;
|
||||
source: 'nominatim' | 'fallback';
|
||||
}> {
|
||||
const origin = await this.placeFromZip(originZip);
|
||||
const destination = await this.placeFromZip(destinationZip);
|
||||
if (origin.lat && origin.lon && destination.lat && destination.lon) {
|
||||
return { origin, destination, km: Math.max(haversineKm(origin, destination), 1), source: 'nominatim' };
|
||||
}
|
||||
return { origin, destination, km: fallbackDistanceKm(origin, destination), source: 'fallback' };
|
||||
}
|
||||
|
||||
private async lookupViaCep(cep: string): Promise<{ city: string; state: string }> {
|
||||
const response = await fetch(`https://viacep.com.br/ws/${cep}/json/`, {
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new BadRequestException('Não foi possível consultar o CEP.');
|
||||
}
|
||||
const data = (await response.json()) as { erro?: boolean; localidade?: string; uf?: string };
|
||||
if (data.erro || !data.localidade || !data.uf) {
|
||||
throw new BadRequestException('CEP não encontrado.');
|
||||
}
|
||||
return { city: data.localidade, state: data.uf };
|
||||
}
|
||||
|
||||
private async lookupNominatim(city: string, state: string): Promise<{ lat: number; lon: number } | null> {
|
||||
const query = new URLSearchParams({
|
||||
format: 'json',
|
||||
limit: '1',
|
||||
countrycodes: 'br',
|
||||
q: `${city}, ${state}, Brasil`,
|
||||
});
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?${query.toString()}`, {
|
||||
headers: { 'User-Agent': 'SinkaLogistica/1.0 (desafio; tina.r@example.net)' },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const rows = (await response.json()) as Array<{ lat: string; lon: string }>;
|
||||
const first = rows[0];
|
||||
if (!first) {
|
||||
return null;
|
||||
}
|
||||
return { lat: Number(first.lat), lon: Number(first.lon) };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,31 +1,31 @@
|
||||
import { digitsZip, fallbackDistanceKm, type ZipPlace } from './geo.types';
|
||||
|
||||
const place = (city: string, state: string): ZipPlace => ({
|
||||
zip: '01001000',
|
||||
label: `${city}/${state}`,
|
||||
city,
|
||||
state,
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
});
|
||||
|
||||
describe('digitsZip', () => {
|
||||
it('keeps only the last 8 digits', () => {
|
||||
expect(digitsZip('01310-100')).toBe('01310100');
|
||||
expect(digitsZip('1310100')).toBe('01310100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallbackDistanceKm', () => {
|
||||
it('uses a short hop in the same city', () => {
|
||||
expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('São Paulo', 'SP'))).toBe(12);
|
||||
});
|
||||
|
||||
it('uses a regional hop in the same state', () => {
|
||||
expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('Campinas', 'SP'))).toBe(180);
|
||||
});
|
||||
|
||||
it('uses a long hop between states', () => {
|
||||
expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('Rio de Janeiro', 'RJ'))).toBe(850);
|
||||
});
|
||||
});
|
||||
import { digitsZip, fallbackDistanceKm, type ZipPlace } from './geo.types';
|
||||
|
||||
const place = (city: string, state: string): ZipPlace => ({
|
||||
zip: '01001000',
|
||||
label: `${city}/${state}`,
|
||||
city,
|
||||
state,
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
});
|
||||
|
||||
describe('digitsZip', () => {
|
||||
it('keeps only the last 8 digits', () => {
|
||||
expect(digitsZip('01310-100')).toBe('01310100');
|
||||
expect(digitsZip('1310100')).toBe('01310100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallbackDistanceKm', () => {
|
||||
it('uses a short hop in the same city', () => {
|
||||
expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('São Paulo', 'SP'))).toBe(12);
|
||||
});
|
||||
|
||||
it('uses a regional hop in the same state', () => {
|
||||
expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('Campinas', 'SP'))).toBe(180);
|
||||
});
|
||||
|
||||
it('uses a long hop between states', () => {
|
||||
expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('Rio de Janeiro', 'RJ'))).toBe(850);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
export type ZipPlace = {
|
||||
zip: string;
|
||||
label: string;
|
||||
city: string;
|
||||
state: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
};
|
||||
|
||||
export function digitsZip(value: string): string {
|
||||
return value.replace(/\D/g, '').padStart(8, '0').slice(-8);
|
||||
}
|
||||
|
||||
export function fallbackDistanceKm(origin: ZipPlace, destination: ZipPlace): number {
|
||||
if (origin.city === destination.city && origin.state === destination.state) {
|
||||
return 12;
|
||||
}
|
||||
if (origin.state === destination.state) {
|
||||
return 180;
|
||||
}
|
||||
return 850;
|
||||
}
|
||||
export type ZipPlace = {
|
||||
zip: string;
|
||||
label: string;
|
||||
city: string;
|
||||
state: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
};
|
||||
|
||||
export function digitsZip(value: string): string {
|
||||
return value.replace(/\D/g, '').padStart(8, '0').slice(-8);
|
||||
}
|
||||
|
||||
export function fallbackDistanceKm(origin: ZipPlace, destination: ZipPlace): number {
|
||||
if (origin.city === destination.city && origin.state === destination.state) {
|
||||
return 12;
|
||||
}
|
||||
if (origin.state === destination.state) {
|
||||
return 180;
|
||||
}
|
||||
return 850;
|
||||
}
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
describe('HealthController', () => {
|
||||
let controller: HealthController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [HealthController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get(HealthController);
|
||||
});
|
||||
|
||||
it('returns api health payload', () => {
|
||||
expect(controller.check()).toEqual({
|
||||
status: 'ok',
|
||||
service: 'sinka-api',
|
||||
stack: 'nestjs',
|
||||
});
|
||||
});
|
||||
});
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
describe('HealthController', () => {
|
||||
let controller: HealthController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [HealthController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get(HealthController);
|
||||
});
|
||||
|
||||
it('returns api health payload', () => {
|
||||
expect(controller.check()).toEqual({
|
||||
status: 'ok',
|
||||
service: 'sinka-api',
|
||||
stack: 'nestjs',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'sinka-api',
|
||||
stack: 'nestjs',
|
||||
};
|
||||
}
|
||||
}
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'sinka-api',
|
||||
stack: 'nestjs',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
||||
@ -1,26 +1,26 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
app.use(cookieParser());
|
||||
app.enableCors({
|
||||
origin: process.env.WEB_ORIGIN ?? 'http://localhost:3000',
|
||||
credentials: true,
|
||||
});
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await app.listen(process.env.PORT ?? 3001);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
app.use(cookieParser());
|
||||
app.enableCors({
|
||||
origin: process.env.WEB_ORIGIN ?? 'http://localhost:3000',
|
||||
credentials: true,
|
||||
});
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await app.listen(process.env.PORT ?? 3001);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateTenantDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^[a-z][a-z0-9-]{1,30}$/)
|
||||
slug!: string;
|
||||
}
|
||||
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateTenantDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^[a-z][a-z0-9-]{1,30}$/)
|
||||
slug!: string;
|
||||
}
|
||||
|
||||
17
api/src/platform/integrations.controller.ts
Normal file
17
api/src/platform/integrations.controller.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { IntegrationsService } from './integrations.service';
|
||||
|
||||
@Controller('platform')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('PLATFORM_ADMIN')
|
||||
export class IntegrationsController {
|
||||
constructor(private readonly integrations: IntegrationsService) {}
|
||||
|
||||
@Get('integrations')
|
||||
list() {
|
||||
return this.integrations.list();
|
||||
}
|
||||
}
|
||||
80
api/src/platform/integrations.service.ts
Normal file
80
api/src/platform/integrations.service.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { buildIntegrations, type Integration } from './integrations';
|
||||
|
||||
@Injectable()
|
||||
export class IntegrationsService {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
async list(): Promise<{ items: Integration[] }> {
|
||||
const [viaCep, nominatim, firebase] = await Promise.all([
|
||||
this.probeViaCep(),
|
||||
this.probeNominatim(),
|
||||
this.probeFirebase(),
|
||||
]);
|
||||
return {
|
||||
items: buildIntegrations({
|
||||
viaCep,
|
||||
nominatim,
|
||||
google: this.googleConfigured(),
|
||||
firebase,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private googleConfigured() {
|
||||
return Boolean(this.config.get<string>('GOOGLE_CLIENT_ID') && this.config.get<string>('GOOGLE_CLIENT_SECRET'));
|
||||
}
|
||||
|
||||
private async probeViaCep() {
|
||||
return this.ok(async () => {
|
||||
const response = await fetch('https://viacep.com.br/ws/01001000/json/', {
|
||||
signal: AbortSignal.timeout(4000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
const data = (await response.json()) as { erro?: boolean; localidade?: string };
|
||||
return !data.erro && Boolean(data.localidade);
|
||||
});
|
||||
}
|
||||
|
||||
private async probeNominatim() {
|
||||
return this.ok(async () => {
|
||||
const query = new URLSearchParams({
|
||||
format: 'json',
|
||||
limit: '1',
|
||||
countrycodes: 'br',
|
||||
q: 'Sao Paulo, SP, Brasil',
|
||||
});
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?${query.toString()}`, {
|
||||
headers: { 'User-Agent': 'SinkaLogistica/1.0 (desafio; tina.r@example.net)' },
|
||||
signal: AbortSignal.timeout(4000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
const rows = (await response.json()) as unknown[];
|
||||
return rows.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
private async probeFirebase() {
|
||||
const url = (this.config.get<string>('FIREBASE_DATABASE_URL')?.replace(/\/$/, '') ||
|
||||
'https://sinka-8ec10-default-rtdb.firebaseio.com');
|
||||
return this.ok(async () => {
|
||||
const response = await fetch(`${url}/.json?shallow=true`, {
|
||||
signal: AbortSignal.timeout(4000),
|
||||
});
|
||||
return response.ok;
|
||||
});
|
||||
}
|
||||
|
||||
private async ok(probe: () => Promise<boolean>) {
|
||||
try {
|
||||
return await probe();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
19
api/src/platform/integrations.spec.ts
Normal file
19
api/src/platform/integrations.spec.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { buildIntegrations } from './integrations';
|
||||
|
||||
describe('buildIntegrations', () => {
|
||||
it('marks each integration from the probe result', () => {
|
||||
expect(
|
||||
buildIntegrations({
|
||||
viaCep: true,
|
||||
nominatim: false,
|
||||
google: true,
|
||||
firebase: false,
|
||||
}),
|
||||
).toEqual([
|
||||
expect.objectContaining({ id: 'viacep', status: 'active' }),
|
||||
expect.objectContaining({ id: 'nominatim', status: 'inactive' }),
|
||||
expect.objectContaining({ id: 'google', status: 'active' }),
|
||||
expect.objectContaining({ id: 'firebase', status: 'inactive' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
42
api/src/platform/integrations.ts
Normal file
42
api/src/platform/integrations.ts
Normal file
@ -0,0 +1,42 @@
|
||||
export type IntegrationStatus = 'active' | 'inactive';
|
||||
|
||||
export type Integration = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: IntegrationStatus;
|
||||
};
|
||||
|
||||
export function buildIntegrations(probes: {
|
||||
viaCep: boolean;
|
||||
nominatim: boolean;
|
||||
google: boolean;
|
||||
firebase: boolean;
|
||||
}): Integration[] {
|
||||
return [
|
||||
{
|
||||
id: 'viacep',
|
||||
name: 'ViaCEP',
|
||||
description: 'Cidade e UF a partir do CEP de origem e destino.',
|
||||
status: probes.viaCep ? 'active' : 'inactive',
|
||||
},
|
||||
{
|
||||
id: 'nominatim',
|
||||
name: 'OpenStreetMap Nominatim',
|
||||
description: 'Coordenadas da cidade para calcular a distância do frete.',
|
||||
status: probes.nominatim ? 'active' : 'inactive',
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
description: 'Login com conta Google no admin e nas empresas.',
|
||||
status: probes.google ? 'active' : 'inactive',
|
||||
},
|
||||
{
|
||||
id: 'firebase',
|
||||
name: 'Firebase Realtime Database',
|
||||
description: 'Chat em tempo real entre o admin e o cliente.',
|
||||
status: probes.firebase ? 'active' : 'inactive',
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -1,17 +1,17 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { PlatformService } from './platform.service';
|
||||
|
||||
@Controller('platform')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('PLATFORM_ADMIN')
|
||||
export class PlatformOverviewController {
|
||||
constructor(private readonly platform: PlatformService) {}
|
||||
|
||||
@Get('overview')
|
||||
overview() {
|
||||
return this.platform.overview();
|
||||
}
|
||||
}
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { PlatformService } from './platform.service';
|
||||
|
||||
@Controller('platform')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('PLATFORM_ADMIN')
|
||||
export class PlatformOverviewController {
|
||||
constructor(private readonly platform: PlatformService) {}
|
||||
|
||||
@Get('overview')
|
||||
overview() {
|
||||
return this.platform.overview();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
import { buildPlatformOverview } from './platform-overview';
|
||||
|
||||
describe('buildPlatformOverview', () => {
|
||||
it('counts clients, active companies and users', () => {
|
||||
expect(
|
||||
buildPlatformOverview(
|
||||
[
|
||||
{ status: 'ACTIVE' },
|
||||
{ status: 'ACTIVE' },
|
||||
{ status: 'SUSPENDED' },
|
||||
],
|
||||
8,
|
||||
),
|
||||
).toEqual({ clients: 3, active: 2, users: 8 });
|
||||
});
|
||||
|
||||
it('starts empty when the platform has no companies', () => {
|
||||
expect(buildPlatformOverview([], 0)).toEqual({ clients: 0, active: 0, users: 0 });
|
||||
});
|
||||
});
|
||||
import { buildPlatformOverview } from './platform-overview';
|
||||
|
||||
describe('buildPlatformOverview', () => {
|
||||
it('counts clients, active companies and users', () => {
|
||||
expect(
|
||||
buildPlatformOverview(
|
||||
[
|
||||
{ status: 'ACTIVE' },
|
||||
{ status: 'ACTIVE' },
|
||||
{ status: 'SUSPENDED' },
|
||||
],
|
||||
8,
|
||||
),
|
||||
).toEqual({ clients: 3, active: 2, users: 8 });
|
||||
});
|
||||
|
||||
it('starts empty when the platform has no companies', () => {
|
||||
expect(buildPlatformOverview([], 0)).toEqual({ clients: 0, active: 0, users: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
export type PlatformOverview = {
|
||||
clients: number;
|
||||
active: number;
|
||||
users: number;
|
||||
};
|
||||
|
||||
export function buildPlatformOverview(
|
||||
tenants: { status: string }[],
|
||||
users: number,
|
||||
): PlatformOverview {
|
||||
return {
|
||||
clients: tenants.length,
|
||||
active: tenants.filter((tenant) => tenant.status === 'ACTIVE').length,
|
||||
users,
|
||||
};
|
||||
}
|
||||
export type PlatformOverview = {
|
||||
clients: number;
|
||||
active: number;
|
||||
users: number;
|
||||
};
|
||||
|
||||
export function buildPlatformOverview(
|
||||
tenants: { status: string }[],
|
||||
users: number,
|
||||
): PlatformOverview {
|
||||
return {
|
||||
clients: tenants.length,
|
||||
active: tenants.filter((tenant) => tenant.status === 'ACTIVE').length,
|
||||
users,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,41 +1,41 @@
|
||||
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { CreateTenantDto } from './dto/create-tenant.dto';
|
||||
import { PlatformService } from './platform.service';
|
||||
|
||||
@Controller('platform/tenants')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('PLATFORM_ADMIN')
|
||||
export class PlatformController {
|
||||
constructor(private readonly platform: PlatformService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.platform.list();
|
||||
}
|
||||
|
||||
@Get(':id/users')
|
||||
listUsers(@CurrentUser() user: AuthUser, @Param('id') id: string, @Req() request: Request) {
|
||||
return this.platform.listUsers(user.sub, id, request.ip);
|
||||
}
|
||||
|
||||
@Post(':id/users/:userId/password')
|
||||
issuePassword(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Param('userId') userId: string,
|
||||
@Req() request: Request,
|
||||
) {
|
||||
return this.platform.issuePassword(user.sub, id, userId, request.ip);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateTenantDto, @Req() request: Request) {
|
||||
return this.platform.create(user.sub, dto, request.ip);
|
||||
}
|
||||
}
|
||||
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { CreateTenantDto } from './dto/create-tenant.dto';
|
||||
import { PlatformService } from './platform.service';
|
||||
|
||||
@Controller('platform/tenants')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('PLATFORM_ADMIN')
|
||||
export class PlatformController {
|
||||
constructor(private readonly platform: PlatformService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.platform.list();
|
||||
}
|
||||
|
||||
@Get(':id/users')
|
||||
listUsers(@CurrentUser() user: AuthUser, @Param('id') id: string, @Req() request: Request) {
|
||||
return this.platform.listUsers(user.sub, id, request.ip);
|
||||
}
|
||||
|
||||
@Post(':id/users/:userId/password')
|
||||
issuePassword(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Param('userId') userId: string,
|
||||
@Req() request: Request,
|
||||
) {
|
||||
return this.platform.issuePassword(user.sub, id, userId, request.ip);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateTenantDto, @Req() request: Request) {
|
||||
return this.platform.create(user.sub, dto, request.ip);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlatformController } from './platform.controller';
|
||||
import { PlatformOverviewController } from './platform-overview.controller';
|
||||
import { PlatformService } from './platform.service';
|
||||
import { PublicTenantsController } from './public-tenants.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [PlatformController, PlatformOverviewController, PublicTenantsController],
|
||||
providers: [PlatformService],
|
||||
})
|
||||
export class PlatformModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IntegrationsController } from './integrations.controller';
|
||||
import { IntegrationsService } from './integrations.service';
|
||||
import { PlatformController } from './platform.controller';
|
||||
import { PlatformOverviewController } from './platform-overview.controller';
|
||||
import { PlatformService } from './platform.service';
|
||||
import { PublicTenantsController } from './public-tenants.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [
|
||||
PlatformController,
|
||||
PlatformOverviewController,
|
||||
IntegrationsController,
|
||||
PublicTenantsController,
|
||||
],
|
||||
providers: [PlatformService, IntegrationsService],
|
||||
})
|
||||
export class PlatformModule {}
|
||||
|
||||
@ -1,161 +1,161 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { PlatformPrismaService } from '../prisma/platform-prisma.service';
|
||||
import { createMysqlDatabase, databaseForSlug, pushTenantSchema } from '../tenancy/provision-database';
|
||||
import { publicTenantView } from '../tenancy/public-tenant';
|
||||
import { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
||||
import { normalizeSlug } from '../tenancy/tenant-url';
|
||||
import type { CreateTenantDto } from './dto/create-tenant.dto';
|
||||
import { buildPlatformOverview } from './platform-overview';
|
||||
|
||||
@Injectable()
|
||||
export class PlatformService {
|
||||
constructor(
|
||||
private readonly platform: PlatformPrismaService,
|
||||
private readonly tenants: TenantConnectionService,
|
||||
) {}
|
||||
|
||||
list() {
|
||||
return this.platform.tenant.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { id: true, name: true, slug: true, database: true, status: true, createdAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
async overview() {
|
||||
const tenants = await this.platform.tenant.findMany({
|
||||
select: { status: true, database: true },
|
||||
});
|
||||
const users = (
|
||||
await Promise.all(tenants.map((tenant) => this.tenants.getByDatabase(tenant.database).user.count()))
|
||||
).reduce((sum, count) => sum + count, 0);
|
||||
return buildPlatformOverview(tenants, users);
|
||||
}
|
||||
|
||||
async publicBySlug(raw: string) {
|
||||
let slug: string;
|
||||
try {
|
||||
slug = normalizeSlug(raw);
|
||||
} catch {
|
||||
throw new NotFoundException('Este cliente não existe.');
|
||||
}
|
||||
const tenant = await this.platform.tenant.findUnique({
|
||||
where: { slug },
|
||||
select: { name: true, slug: true, status: true },
|
||||
});
|
||||
return publicTenantView(tenant);
|
||||
}
|
||||
|
||||
async create(actorId: string, dto: CreateTenantDto, ip?: string) {
|
||||
const slug = normalizeSlug(dto.slug);
|
||||
const exists = await this.platform.tenant.findUnique({ where: { slug } });
|
||||
if (exists) {
|
||||
throw new ConflictException('Já existe uma empresa com esse código.');
|
||||
}
|
||||
|
||||
const database = databaseForSlug(slug);
|
||||
await createMysqlDatabase(database);
|
||||
await pushTenantSchema(database);
|
||||
|
||||
const tenant = await this.platform.tenant.create({
|
||||
data: { name: dto.name.trim(), slug, database },
|
||||
});
|
||||
|
||||
const adminEmail = `admin@${slug}.sinka`;
|
||||
const adminPassword = `Sinka-${randomBytes(3).toString('hex')}`;
|
||||
const db = this.tenants.getByDatabase(database);
|
||||
await db.user.create({
|
||||
data: {
|
||||
name: 'Administrador',
|
||||
email: adminEmail,
|
||||
passwordHash: await bcrypt.hash(adminPassword, 10),
|
||||
issuedPassword: adminPassword,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
await this.platform.platformAuditLog.create({
|
||||
data: {
|
||||
userId: actorId,
|
||||
action: 'tenant.create',
|
||||
entity: 'Tenant',
|
||||
entityId: tenant.id,
|
||||
ip,
|
||||
metadata: { slug, database },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...tenant,
|
||||
access: {
|
||||
email: adminEmail,
|
||||
password: adminPassword,
|
||||
path: `/${slug}/login`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async listUsers(actorId: string, tenantId: string, ip?: string) {
|
||||
const tenant = await this.platform.tenant.findUnique({ where: { id: tenantId } });
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Cliente não encontrado.');
|
||||
}
|
||||
const db = this.tenants.getByDatabase(tenant.database);
|
||||
const users = await db.user.findMany({
|
||||
select: { id: true, name: true, email: true, role: true, issuedPassword: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
await this.platform.platformAuditLog.create({
|
||||
data: {
|
||||
userId: actorId,
|
||||
action: 'tenant.users',
|
||||
entity: 'Tenant',
|
||||
entityId: tenant.id,
|
||||
ip,
|
||||
metadata: { slug: tenant.slug, count: users.length },
|
||||
},
|
||||
});
|
||||
return {
|
||||
tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug },
|
||||
users: users.map((user) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
password: user.issuedPassword,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async issuePassword(actorId: string, tenantId: string, userId: string, ip?: string) {
|
||||
const tenant = await this.platform.tenant.findUnique({ where: { id: tenantId } });
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Cliente não encontrado.');
|
||||
}
|
||||
const db = this.tenants.getByDatabase(tenant.database);
|
||||
const user = await db.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('Usuário não encontrado.');
|
||||
}
|
||||
const password = `Sinka-${randomBytes(3).toString('hex')}`;
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
passwordHash: await bcrypt.hash(password, 10),
|
||||
issuedPassword: password,
|
||||
},
|
||||
});
|
||||
await this.platform.platformAuditLog.create({
|
||||
data: {
|
||||
userId: actorId,
|
||||
action: 'tenant.user.password',
|
||||
entity: 'User',
|
||||
entityId: userId,
|
||||
ip,
|
||||
metadata: { slug: tenant.slug, email: user.email },
|
||||
},
|
||||
});
|
||||
return { id: user.id, email: user.email, password };
|
||||
}
|
||||
}
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { PlatformPrismaService } from '../prisma/platform-prisma.service';
|
||||
import { createMysqlDatabase, databaseForSlug, pushTenantSchema } from '../tenancy/provision-database';
|
||||
import { publicTenantView } from '../tenancy/public-tenant';
|
||||
import { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
||||
import { normalizeSlug } from '../tenancy/tenant-url';
|
||||
import type { CreateTenantDto } from './dto/create-tenant.dto';
|
||||
import { buildPlatformOverview } from './platform-overview';
|
||||
|
||||
@Injectable()
|
||||
export class PlatformService {
|
||||
constructor(
|
||||
private readonly platform: PlatformPrismaService,
|
||||
private readonly tenants: TenantConnectionService,
|
||||
) {}
|
||||
|
||||
list() {
|
||||
return this.platform.tenant.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { id: true, name: true, slug: true, database: true, status: true, createdAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
async overview() {
|
||||
const tenants = await this.platform.tenant.findMany({
|
||||
select: { status: true, database: true },
|
||||
});
|
||||
const users = (
|
||||
await Promise.all(tenants.map((tenant) => this.tenants.getByDatabase(tenant.database).user.count()))
|
||||
).reduce((sum, count) => sum + count, 0);
|
||||
return buildPlatformOverview(tenants, users);
|
||||
}
|
||||
|
||||
async publicBySlug(raw: string) {
|
||||
let slug: string;
|
||||
try {
|
||||
slug = normalizeSlug(raw);
|
||||
} catch {
|
||||
throw new NotFoundException('Este cliente não existe.');
|
||||
}
|
||||
const tenant = await this.platform.tenant.findUnique({
|
||||
where: { slug },
|
||||
select: { name: true, slug: true, status: true },
|
||||
});
|
||||
return publicTenantView(tenant);
|
||||
}
|
||||
|
||||
async create(actorId: string, dto: CreateTenantDto, ip?: string) {
|
||||
const slug = normalizeSlug(dto.slug);
|
||||
const exists = await this.platform.tenant.findUnique({ where: { slug } });
|
||||
if (exists) {
|
||||
throw new ConflictException('Já existe uma empresa com esse código.');
|
||||
}
|
||||
|
||||
const database = databaseForSlug(slug);
|
||||
await createMysqlDatabase(database);
|
||||
await pushTenantSchema(database);
|
||||
|
||||
const tenant = await this.platform.tenant.create({
|
||||
data: { name: dto.name.trim(), slug, database },
|
||||
});
|
||||
|
||||
const adminEmail = `admin@${slug}.sinka`;
|
||||
const adminPassword = `Sinka-${randomBytes(3).toString('hex')}`;
|
||||
const db = this.tenants.getByDatabase(database);
|
||||
await db.user.create({
|
||||
data: {
|
||||
name: 'Administrador',
|
||||
email: adminEmail,
|
||||
passwordHash: await bcrypt.hash(adminPassword, 10),
|
||||
issuedPassword: adminPassword,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
await this.platform.platformAuditLog.create({
|
||||
data: {
|
||||
userId: actorId,
|
||||
action: 'tenant.create',
|
||||
entity: 'Tenant',
|
||||
entityId: tenant.id,
|
||||
ip,
|
||||
metadata: { slug, database },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...tenant,
|
||||
access: {
|
||||
email: adminEmail,
|
||||
password: adminPassword,
|
||||
path: `/${slug}/login`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async listUsers(actorId: string, tenantId: string, ip?: string) {
|
||||
const tenant = await this.platform.tenant.findUnique({ where: { id: tenantId } });
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Cliente não encontrado.');
|
||||
}
|
||||
const db = this.tenants.getByDatabase(tenant.database);
|
||||
const users = await db.user.findMany({
|
||||
select: { id: true, name: true, email: true, role: true, issuedPassword: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
await this.platform.platformAuditLog.create({
|
||||
data: {
|
||||
userId: actorId,
|
||||
action: 'tenant.users',
|
||||
entity: 'Tenant',
|
||||
entityId: tenant.id,
|
||||
ip,
|
||||
metadata: { slug: tenant.slug, count: users.length },
|
||||
},
|
||||
});
|
||||
return {
|
||||
tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug },
|
||||
users: users.map((user) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
password: user.issuedPassword,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async issuePassword(actorId: string, tenantId: string, userId: string, ip?: string) {
|
||||
const tenant = await this.platform.tenant.findUnique({ where: { id: tenantId } });
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Cliente não encontrado.');
|
||||
}
|
||||
const db = this.tenants.getByDatabase(tenant.database);
|
||||
const user = await db.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('Usuário não encontrado.');
|
||||
}
|
||||
const password = `Sinka-${randomBytes(3).toString('hex')}`;
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
passwordHash: await bcrypt.hash(password, 10),
|
||||
issuedPassword: password,
|
||||
},
|
||||
});
|
||||
await this.platform.platformAuditLog.create({
|
||||
data: {
|
||||
userId: actorId,
|
||||
action: 'tenant.user.password',
|
||||
entity: 'User',
|
||||
entityId: userId,
|
||||
ip,
|
||||
metadata: { slug: tenant.slug, email: user.email },
|
||||
},
|
||||
});
|
||||
return { id: user.id, email: user.email, password };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { PlatformService } from './platform.service';
|
||||
|
||||
@Controller('tenants')
|
||||
export class PublicTenantsController {
|
||||
constructor(private readonly platform: PlatformService) {}
|
||||
|
||||
@Get(':slug')
|
||||
bySlug(@Param('slug') slug: string) {
|
||||
return this.platform.publicBySlug(slug);
|
||||
}
|
||||
}
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { PlatformService } from './platform.service';
|
||||
|
||||
@Controller('tenants')
|
||||
export class PublicTenantsController {
|
||||
constructor(private readonly platform: PlatformService) {}
|
||||
|
||||
@Get(':slug')
|
||||
bySlug(@Param('slug') slug: string) {
|
||||
return this.platform.publicBySlug(slug);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '../generated/platform';
|
||||
|
||||
@Injectable()
|
||||
export class PlatformPrismaService extends PrismaClient implements OnModuleDestroy {
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '../generated/platform';
|
||||
|
||||
@Injectable()
|
||||
export class PlatformPrismaService extends PrismaClient implements OnModuleDestroy {
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [RedisService],
|
||||
exports: [RedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [RedisService],
|
||||
exports: [RedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
|
||||
@ -1,71 +1,71 @@
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleDestroy {
|
||||
private readonly client: Redis | null;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
const url = config.get<string>('REDIS_URL');
|
||||
this.client = url ? new Redis(url, { maxRetriesPerRequest: 1, lazyConnect: true }) : null;
|
||||
}
|
||||
|
||||
async increment(key: string, ttlSeconds: number): Promise<number | null> {
|
||||
if (!(await this.ready())) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const count = await this.client!.incr(key);
|
||||
if (count === 1) {
|
||||
await this.client!.expire(key, ttlSeconds);
|
||||
}
|
||||
return count;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getJson<T>(key: string): Promise<T | null> {
|
||||
if (!(await this.ready())) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = await this.client!.get(key);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async setJson(key: string, value: unknown, ttlSeconds: number): Promise<void> {
|
||||
if (!(await this.ready())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.client!.set(key, JSON.stringify(value), 'EX', ttlSeconds);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async ready(): Promise<boolean> {
|
||||
if (!this.client) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (this.client.status === 'wait') {
|
||||
await this.client.connect();
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.client) {
|
||||
this.client.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleDestroy {
|
||||
private readonly client: Redis | null;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
const url = config.get<string>('REDIS_URL');
|
||||
this.client = url ? new Redis(url, { maxRetriesPerRequest: 1, lazyConnect: true }) : null;
|
||||
}
|
||||
|
||||
async increment(key: string, ttlSeconds: number): Promise<number | null> {
|
||||
if (!(await this.ready())) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const count = await this.client!.incr(key);
|
||||
if (count === 1) {
|
||||
await this.client!.expire(key, ttlSeconds);
|
||||
}
|
||||
return count;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getJson<T>(key: string): Promise<T | null> {
|
||||
if (!(await this.ready())) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = await this.client!.get(key);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async setJson(key: string, value: unknown, ttlSeconds: number): Promise<void> {
|
||||
if (!(await this.ready())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.client!.set(key, JSON.stringify(value), 'EX', ttlSeconds);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async ready(): Promise<boolean> {
|
||||
if (!this.client) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (this.client.status === 'wait') {
|
||||
await this.client.connect();
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.client) {
|
||||
this.client.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,31 +1,31 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { tenantIdOf } from './actor';
|
||||
|
||||
const tenant: AuthUser = {
|
||||
kind: 'tenant',
|
||||
sub: '1',
|
||||
email: 'a@b.c',
|
||||
name: 'Ana',
|
||||
role: 'ADMIN',
|
||||
tenantId: 't-demo',
|
||||
tenantSlug: 'demo',
|
||||
};
|
||||
|
||||
describe('tenantIdOf', () => {
|
||||
it('returns the company id for a tenant actor', () => {
|
||||
expect(tenantIdOf(tenant)).toBe('t-demo');
|
||||
});
|
||||
|
||||
it('blocks platform staff from company data', () => {
|
||||
expect(() =>
|
||||
tenantIdOf({
|
||||
kind: 'platform',
|
||||
sub: 'p',
|
||||
email: 'p@x',
|
||||
name: 'Sinka',
|
||||
role: 'PLATFORM_ADMIN',
|
||||
}),
|
||||
).toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import { tenantIdOf } from './actor';
|
||||
|
||||
const tenant: AuthUser = {
|
||||
kind: 'tenant',
|
||||
sub: '1',
|
||||
email: 'a@b.c',
|
||||
name: 'Ana',
|
||||
role: 'ADMIN',
|
||||
tenantId: 't-demo',
|
||||
tenantSlug: 'demo',
|
||||
};
|
||||
|
||||
describe('tenantIdOf', () => {
|
||||
it('returns the company id for a tenant actor', () => {
|
||||
expect(tenantIdOf(tenant)).toBe('t-demo');
|
||||
});
|
||||
|
||||
it('blocks platform staff from company data', () => {
|
||||
expect(() =>
|
||||
tenantIdOf({
|
||||
kind: 'platform',
|
||||
sub: 'p',
|
||||
email: 'p@x',
|
||||
name: 'Sinka',
|
||||
role: 'PLATFORM_ADMIN',
|
||||
}),
|
||||
).toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import type { TenantConnectionService } from './tenant-connection.service';
|
||||
|
||||
export function tenantIdOf(actor: AuthUser): string {
|
||||
if (actor.kind !== 'tenant' || !actor.tenantId) {
|
||||
throw new ForbiddenException('Acesso restrito à empresa.');
|
||||
}
|
||||
return actor.tenantId;
|
||||
}
|
||||
|
||||
export function tenantClient(tenants: TenantConnectionService, actor: AuthUser) {
|
||||
return tenants.getByTenantId(tenantIdOf(actor));
|
||||
}
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import type { TenantConnectionService } from './tenant-connection.service';
|
||||
|
||||
export function tenantIdOf(actor: AuthUser): string {
|
||||
if (actor.kind !== 'tenant' || !actor.tenantId) {
|
||||
throw new ForbiddenException('Acesso restrito à empresa.');
|
||||
}
|
||||
return actor.tenantId;
|
||||
}
|
||||
|
||||
export function tenantClient(tenants: TenantConnectionService, actor: AuthUser) {
|
||||
return tenants.getByTenantId(tenantIdOf(actor));
|
||||
}
|
||||
|
||||
@ -1,35 +1,35 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { PrismaClient as PlatformPrismaClient } from '../generated/platform';
|
||||
import { adminDatabaseUrl, tenantDatabaseName, urlForDatabase } from './tenant-url';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export async function createMysqlDatabase(database: string): Promise<void> {
|
||||
const admin = new PlatformPrismaClient({
|
||||
datasources: { db: { url: adminDatabaseUrl() } },
|
||||
});
|
||||
try {
|
||||
await admin.$executeRawUnsafe(
|
||||
`CREATE DATABASE IF NOT EXISTS \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`,
|
||||
);
|
||||
await admin.$executeRawUnsafe(`GRANT ALL PRIVILEGES ON \`${database}\`.* TO 'sinka'@'%'`);
|
||||
await admin.$executeRawUnsafe('FLUSH PRIVILEGES');
|
||||
} finally {
|
||||
await admin.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export async function pushTenantSchema(database: string): Promise<void> {
|
||||
const prismaCli = join(process.cwd(), 'node_modules', 'prisma', 'build', 'index.js');
|
||||
const schema = join(process.cwd(), 'prisma', 'tenant', 'schema.prisma');
|
||||
await execFileAsync(process.execPath, [prismaCli, 'db', 'push', '--schema', schema, '--skip-generate'], {
|
||||
env: { ...process.env, DATABASE_URL: urlForDatabase(database) },
|
||||
timeout: 90_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function databaseForSlug(slug: string): string {
|
||||
return tenantDatabaseName(slug);
|
||||
}
|
||||
import { execFile } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { PrismaClient as PlatformPrismaClient } from '../generated/platform';
|
||||
import { adminDatabaseUrl, tenantDatabaseName, urlForDatabase } from './tenant-url';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export async function createMysqlDatabase(database: string): Promise<void> {
|
||||
const admin = new PlatformPrismaClient({
|
||||
datasources: { db: { url: adminDatabaseUrl() } },
|
||||
});
|
||||
try {
|
||||
await admin.$executeRawUnsafe(
|
||||
`CREATE DATABASE IF NOT EXISTS \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`,
|
||||
);
|
||||
await admin.$executeRawUnsafe(`GRANT ALL PRIVILEGES ON \`${database}\`.* TO 'sinka'@'%'`);
|
||||
await admin.$executeRawUnsafe('FLUSH PRIVILEGES');
|
||||
} finally {
|
||||
await admin.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export async function pushTenantSchema(database: string): Promise<void> {
|
||||
const prismaCli = join(process.cwd(), 'node_modules', 'prisma', 'build', 'index.js');
|
||||
const schema = join(process.cwd(), 'prisma', 'tenant', 'schema.prisma');
|
||||
await execFileAsync(process.execPath, [prismaCli, 'db', 'push', '--schema', schema, '--skip-generate'], {
|
||||
env: { ...process.env, DATABASE_URL: urlForDatabase(database) },
|
||||
timeout: 90_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function databaseForSlug(slug: string): string {
|
||||
return tenantDatabaseName(slug);
|
||||
}
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { publicTenantView } from './public-tenant';
|
||||
|
||||
describe('publicTenantView', () => {
|
||||
it('hides internal fields and returns the public company', () => {
|
||||
expect(
|
||||
publicTenantView({
|
||||
name: 'Cliente 1',
|
||||
slug: 'cliente-1',
|
||||
status: 'ACTIVE',
|
||||
}),
|
||||
).toEqual({ name: 'Cliente 1', slug: 'cliente-1', status: 'ACTIVE' });
|
||||
});
|
||||
|
||||
it('tells the visitor when the company is missing', () => {
|
||||
expect(() => publicTenantView(null)).toThrow(NotFoundException);
|
||||
expect(() => publicTenantView(null)).toThrow('Este cliente não existe.');
|
||||
});
|
||||
});
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { publicTenantView } from './public-tenant';
|
||||
|
||||
describe('publicTenantView', () => {
|
||||
it('hides internal fields and returns the public company', () => {
|
||||
expect(
|
||||
publicTenantView({
|
||||
name: 'Cliente 1',
|
||||
slug: 'cliente-1',
|
||||
status: 'ACTIVE',
|
||||
}),
|
||||
).toEqual({ name: 'Cliente 1', slug: 'cliente-1', status: 'ACTIVE' });
|
||||
});
|
||||
|
||||
it('tells the visitor when the company is missing', () => {
|
||||
expect(() => publicTenantView(null)).toThrow(NotFoundException);
|
||||
expect(() => publicTenantView(null)).toThrow('Este cliente não existe.');
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user