Commit inicial - upload de todos os arquivos da pasta
This commit is contained in:
15
.env
Normal file
15
.env
Normal file
@@ -0,0 +1,15 @@
|
||||
DATABASE_URL="postgres://postgres:%23R11Amixxam%23@31.97.64.165:5432/gestao_iasis"
|
||||
JWT_SECRET="dev-secret"
|
||||
PORT=3000
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
# PASS_ADMIN_USER=
|
||||
# Email — MAILER_PROVIDER=log (dev/homologação) | smtp (produção)
|
||||
MAILER_PROVIDER=log
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=wandermottasiqueira@gmail.com
|
||||
SMTP_PASS=qzugwwjudfqoxzed
|
||||
SMTP_FROM=wander@iasis.tech.com
|
||||
FRONTEND_URL=https://seudominio.com
|
||||
# Token de reset de senha (minutos)
|
||||
PASSWORD_RESET_TOKEN_TTL_MINUTES=60
|
||||
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# Banco de Dados
|
||||
DATABASE_URL=
|
||||
JWT_SECRET= 3 -PORT=
|
||||
PASS_ADMIN_USER=
|
||||
# Servidor
|
||||
PORT=3000
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
# Email — MAILER_PROVIDER=log (dev/homologação) | smtp (produção)
|
||||
MAILER_PROVIDER=log
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=
|
||||
FRONTEND_URL=https://seudominio.com
|
||||
# Token de reset de senha (minutos)
|
||||
PASSWORD_RESET_TOKEN_TTL_MINUTES=60
|
||||
202
Deploy-Coolify.md
Normal file
202
Deploy-Coolify.md
Normal file
@@ -0,0 +1,202 @@
|
||||
Sim, dá para usar **Nixpacks**. Você **não deve compactar `node_modules`, `dist`, `.git`, logs, cache, uploads grandes**.
|
||||
|
||||
Compacte somente o código-fonte necessário:
|
||||
|
||||
```txt
|
||||
meu-projeto/
|
||||
├─ frontend/
|
||||
│ ├─ package.json
|
||||
│ ├─ package-lock.json
|
||||
│ ├─ index.html
|
||||
│ ├─ vite.config.*
|
||||
│ ├─ src/
|
||||
│ └─ public/
|
||||
├─ backend/
|
||||
│ ├─ package.json
|
||||
│ ├─ package-lock.json
|
||||
│ ├─ src/
|
||||
│ └─ .env.example
|
||||
prisma
|
||||
nest-cli.json
|
||||
tsconfig.build.json
|
||||
tsconfig.json
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
Ignore no `.zip`:
|
||||
|
||||
```txt
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.git/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.cache/
|
||||
.vite/
|
||||
coverage/
|
||||
uploads/
|
||||
tmp/
|
||||
```
|
||||
|
||||
## Recomendado no Coolify
|
||||
|
||||
Crie **2 aplicações separadas**:
|
||||
|
||||
```txt
|
||||
App 1: frontend React + Vite
|
||||
App 2: backend API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 1. Deploy do Backend
|
||||
|
||||
No Coolify:
|
||||
|
||||
**Source:** Upload ZIP ou Git
|
||||
**Build Pack:** Nixpacks
|
||||
**Base Directory:** `backend`
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Build Command, se usar TypeScript:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Start Command:
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
Se o backend roda em dev com `npm run dev`, crie um script de produção no `backend/package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ou, se for JavaScript puro:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"start": "node src/server.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No backend, use a porta via variável:
|
||||
|
||||
```js
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, "0.0.0.0");
|
||||
```
|
||||
|
||||
No Coolify, configure as variáveis de ambiente do backend, exemplo:
|
||||
|
||||
```env
|
||||
DATABASE_URL=...
|
||||
JWT_SECRET=...
|
||||
NODE_ENV=production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 2. Deploy do Frontend React + Vite
|
||||
|
||||
No Coolify:
|
||||
|
||||
**Build Pack:** Nixpacks
|
||||
**Base Directory:** `frontend`
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Build Command:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Start Command:
|
||||
|
||||
```bash
|
||||
npm run preview -- --host 0.0.0.0 --port $PORT
|
||||
```
|
||||
|
||||
No `frontend/package.json`, deixe assim:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Variável para apontar para o backend:
|
||||
|
||||
```env
|
||||
VITE_API_URL=https://api.seudominio.com
|
||||
```
|
||||
|
||||
No código React:
|
||||
|
||||
```js
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 3. O que compactar
|
||||
|
||||
Compacte a pasta raiz do projeto contendo `frontend` e `backend`, mas sem arquivos pesados.
|
||||
|
||||
Exemplo:
|
||||
|
||||
```bash
|
||||
zip -r projeto.zip . \
|
||||
-x "*/node_modules/*" \
|
||||
-x "*/dist/*" \
|
||||
-x "*/build/*" \
|
||||
-x ".git/*" \
|
||||
-x "*/.env*" \
|
||||
-x "*/.cache/*"
|
||||
```
|
||||
|
||||
Melhor ainda: suba no **GitHub/GitLab** e conecte o repositório no Coolify. É mais fácil de atualizar depois.
|
||||
|
||||
Configuração da imagem:
|
||||
|
||||
Frontend:
|
||||
|
||||
```txt
|
||||
Install Command: npm install
|
||||
Build Command: npm run build
|
||||
Start Command: npm run preview -- --host 0.0.0.0 --port $PORT
|
||||
```
|
||||
|
||||
Backend:
|
||||
|
||||
```txt
|
||||
Install Command: npm install
|
||||
Build Command: npm run build
|
||||
Start Command: npm run start
|
||||
```
|
||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# iasis-gestao-backend
|
||||
|
||||
NestJS 11 REST API for managing service orders, contracts, professionals, and clients.
|
||||
Uses PostgreSQL via Prisma ORM and JWT authentication with role-based access control (ADMIN, OPERATOR, CLIENT).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 22
|
||||
- PostgreSQL
|
||||
- Copy `.env.example` to `.env` and set `DATABASE_URL` and `JWT_SECRET`
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run prisma:migrate
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run start:dev # Dev server with watch mode
|
||||
npm run build # Compile to dist/
|
||||
npm run start:prod # Run compiled production build
|
||||
npm run lint:fix # ESLint with auto-fix
|
||||
npm run format # Prettier formatting
|
||||
npm run test # Unit tests
|
||||
npm run test:e2e # E2E tests
|
||||
npm run prisma:migrate # Create and apply DB migrations
|
||||
npm run prisma:generate # Regenerate Prisma client after schema changes
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
See [DEPLOY.md](DEPLOY.md) for the full Azure App Service deployment guide, including GitHub Actions CI/CD, pre-commit hooks, and database migration instructions.
|
||||
29
github.bat
Normal file
29
github.bat
Normal file
@@ -0,0 +1,29 @@
|
||||
@echo off
|
||||
echo === INICIANDO UPLOAD PARA GITHUB ===
|
||||
|
||||
REM Inicializar repositório Git
|
||||
echo Inicializando repositorio Git...
|
||||
git init
|
||||
|
||||
REM Adicionar todos os arquivos
|
||||
echo Adicionando todos os arquivos...
|
||||
git add .
|
||||
|
||||
REM Fazer commit inicial
|
||||
echo Realizando commit inicial...
|
||||
git commit -m "Commit inicial - upload de todos os arquivos da pasta"
|
||||
|
||||
REM Adicionar repositório remoto
|
||||
echo Conectando ao repositorio remoto...
|
||||
git remote add origin https://gitea.aplicativopro.com/wander/Backend-Iasis.git
|
||||
|
||||
REM Definir branch principal
|
||||
echo Definindo branch principal como 'main'...
|
||||
git branch -M main
|
||||
|
||||
REM Fazer push para o GitHub
|
||||
echo Fazendo upload para o GitHub...
|
||||
git push -u origin main --force
|
||||
|
||||
echo === UPLOAD CONCLUIDO COM SUCESSO! ===
|
||||
pause
|
||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
10467
package-lock.json
generated
Normal file
10467
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
102
package.json
Normal file
102
package.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"name": "iasis-gestao-backend",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "prisma generate",
|
||||
"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\"",
|
||||
"lint:fix": "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",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@prisma/client": "5.22.0",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"joi": "^18.1.2",
|
||||
"nodemailer": "^8.0.11",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"prisma": "5.22.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"overrides": {
|
||||
"fast-uri": "^3.1.2"
|
||||
},
|
||||
"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": "^6.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@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",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
15
prisma/check-password.ts
Normal file
15
prisma/check-password.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const user = await prisma.user.findUnique({ where: { email: 'admin@iasis.com.br' } });
|
||||
if (!user) { console.log('Usuário não encontrado'); return; }
|
||||
|
||||
console.log('Hash no banco:', user.password);
|
||||
const match = await bcrypt.compare('Admin@123456', user.password);
|
||||
console.log('Senha confere:', match);
|
||||
}
|
||||
|
||||
main().finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,401 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Role" AS ENUM ('ADMIN', 'OPERATOR', 'CLIENT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DeliverableStatus" AS ENUM ('RASCUNHO', 'EMITIDA', 'EM_EXECUCAO', 'AGUARDANDO_VALIDACAO', 'EM_REVISAO', 'APROVADA', 'GLOSADA', 'ENCERRADA', 'CANCELADA', 'AGUARDANDO_PAGAMENTO', 'PAGA');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TimelineEventType" AS ENUM ('CRIACAO', 'EDICAO', 'STATUS_CHANGE', 'ASSIGNMENT', 'BACKLOG', 'ANOTACAO', 'ALOCACAO');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BacklogItemStatus" AS ENUM ('PENDENTE', 'ACEITO', 'REJEITADO');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NoteType" AS ENUM ('OBSERVACAO', 'RISCO', 'IMPEDIMENTO', 'DECISAO');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SprintType" AS ENUM ('DESCOBERTA', 'DESIGN', 'ARQUITETURA', 'CONSTRUCAO');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SprintStatus" AS ENUM ('RASCUNHO', 'EM_EXECUCAO', 'FINALIZADA', 'CANCELADA');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SprintHistoryEventType" AS ENUM ('ENTREGAVEL_ADICIONADO', 'ENTREGAVEL_REMOVIDO', 'STATUS_CHANGE', 'ENTREGAVEL_MOVIDO');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"role" "Role" NOT NULL DEFAULT 'OPERATOR',
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"mustChangePassword" BOOLEAN NOT NULL DEFAULT false,
|
||||
"client_id" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "clients" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"document" TEXT,
|
||||
"email" TEXT,
|
||||
"phone" TEXT,
|
||||
"contact_name" TEXT,
|
||||
"description" TEXT,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "clients_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "contract_items" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"total_ust" DECIMAL(65,30) NOT NULL,
|
||||
"ust_value" DECIMAL(65,30),
|
||||
"timebox_descoberta" DECIMAL(65,30),
|
||||
"timebox_design" DECIMAL(65,30),
|
||||
"timebox_arquitetura" DECIMAL(65,30),
|
||||
"timebox_construcao" DECIMAL(65,30),
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"client_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "contract_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "professionals" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"document" TEXT,
|
||||
"email" TEXT,
|
||||
"phone" TEXT,
|
||||
"role" TEXT,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "professionals_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "contracts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"client_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"code" TEXT,
|
||||
"description" TEXT,
|
||||
"start_date" TIMESTAMP(3),
|
||||
"end_date" TIMESTAMP(3),
|
||||
"ust_value" DECIMAL(65,30),
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "contracts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "projects" (
|
||||
"id" TEXT NOT NULL,
|
||||
"contract_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"code" TEXT,
|
||||
"description" TEXT,
|
||||
"start_date" TIMESTAMP(3),
|
||||
"end_date" TIMESTAMP(3),
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sprints" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"code" TEXT,
|
||||
"type" "SprintType",
|
||||
"status" "SprintStatus" NOT NULL DEFAULT 'RASCUNHO',
|
||||
"start_date" TIMESTAMP(3) NOT NULL,
|
||||
"end_date" TIMESTAMP(3) NOT NULL,
|
||||
"goal" TEXT,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "sprints_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "deliverables" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"status" "DeliverableStatus" NOT NULL DEFAULT 'RASCUNHO',
|
||||
"client_id" TEXT NOT NULL,
|
||||
"contract_id" TEXT NOT NULL,
|
||||
"project_id" TEXT NOT NULL,
|
||||
"contract_item_id" TEXT NOT NULL,
|
||||
"sprint_id" TEXT NOT NULL,
|
||||
"start_date" TIMESTAMP(3),
|
||||
"expected_end_date" TIMESTAMP(3),
|
||||
"ust_value" DECIMAL(65,30),
|
||||
"ust_quantity" DECIMAL(65,30),
|
||||
"total_value" DECIMAL(65,30),
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_by" TEXT,
|
||||
"updated_by" TEXT,
|
||||
|
||||
CONSTRAINT "deliverables_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "deliverable_status_history" (
|
||||
"id" TEXT NOT NULL,
|
||||
"deliverable_id" TEXT NOT NULL,
|
||||
"previous_status" "DeliverableStatus",
|
||||
"new_status" "DeliverableStatus" NOT NULL,
|
||||
"observation" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" TEXT,
|
||||
|
||||
CONSTRAINT "deliverable_status_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "deliverable_assignments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"deliverable_id" TEXT NOT NULL,
|
||||
"professional_id" TEXT NOT NULL,
|
||||
"role" TEXT,
|
||||
"start_date" TIMESTAMP(3),
|
||||
"end_date" TIMESTAMP(3),
|
||||
"observation" TEXT,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_by" TEXT,
|
||||
|
||||
CONSTRAINT "deliverable_assignments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "deliverable_backlog_items" (
|
||||
"id" TEXT NOT NULL,
|
||||
"deliverable_id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"acceptance_criteria" TEXT,
|
||||
"status" "BacklogItemStatus" NOT NULL DEFAULT 'PENDENTE',
|
||||
"rejection_reason" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_by" TEXT,
|
||||
"updated_by" TEXT,
|
||||
|
||||
CONSTRAINT "deliverable_backlog_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "timeline_events" (
|
||||
"id" TEXT NOT NULL,
|
||||
"deliverable_id" TEXT NOT NULL,
|
||||
"type" "TimelineEventType" NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"metadata" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" TEXT,
|
||||
|
||||
CONSTRAINT "timeline_events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notes" (
|
||||
"id" TEXT NOT NULL,
|
||||
"deliverable_id" TEXT NOT NULL,
|
||||
"type" "NoteType" NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"is_relevant" BOOLEAN NOT NULL DEFAULT false,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_by" TEXT,
|
||||
"updated_by" TEXT,
|
||||
|
||||
CONSTRAINT "notes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sprint_history" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sprint_id" TEXT NOT NULL,
|
||||
"event_type" "SprintHistoryEventType" NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"deliverable_id" TEXT,
|
||||
"target_sprint_id" TEXT,
|
||||
"previous_status" "SprintStatus",
|
||||
"new_status" "SprintStatus",
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" TEXT,
|
||||
|
||||
CONSTRAINT "sprint_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "client_profiles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"client_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "client_profiles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "allocation_templates" (
|
||||
"id" TEXT NOT NULL,
|
||||
"client_id" TEXT NOT NULL,
|
||||
"sprint_type" "SprintType" NOT NULL,
|
||||
"contract_item_id" TEXT NOT NULL,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "allocation_templates_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "allocation_template_items" (
|
||||
"id" TEXT NOT NULL,
|
||||
"template_id" TEXT NOT NULL,
|
||||
"profile_id" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"allocation_percentage" DECIMAL(65,30) NOT NULL,
|
||||
|
||||
CONSTRAINT "allocation_template_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "deliverable_allocations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"deliverable_id" TEXT NOT NULL,
|
||||
"profile_id" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"allocation_percentage" DECIMAL(65,30) NOT NULL,
|
||||
"calculated_value" DECIMAL(65,30),
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_by" TEXT,
|
||||
|
||||
CONSTRAINT "deliverable_allocations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "contract_items_code_client_id_key" ON "contract_items"("code", "client_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "deliverables_code_key" ON "deliverables"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "allocation_templates_client_id_sprint_type_contract_item_id_key" ON "allocation_templates"("client_id", "sprint_type", "contract_item_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "contract_items" ADD CONSTRAINT "contract_items_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_contract_id_fkey" FOREIGN KEY ("contract_id") REFERENCES "contracts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "deliverables" ADD CONSTRAINT "deliverables_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "deliverables" ADD CONSTRAINT "deliverables_contract_id_fkey" FOREIGN KEY ("contract_id") REFERENCES "contracts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "deliverables" ADD CONSTRAINT "deliverables_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "deliverables" ADD CONSTRAINT "deliverables_contract_item_id_fkey" FOREIGN KEY ("contract_item_id") REFERENCES "contract_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "deliverables" ADD CONSTRAINT "deliverables_sprint_id_fkey" FOREIGN KEY ("sprint_id") REFERENCES "sprints"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "deliverable_status_history" ADD CONSTRAINT "deliverable_status_history_deliverable_id_fkey" FOREIGN KEY ("deliverable_id") REFERENCES "deliverables"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "deliverable_assignments" ADD CONSTRAINT "deliverable_assignments_deliverable_id_fkey" FOREIGN KEY ("deliverable_id") REFERENCES "deliverables"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "deliverable_assignments" ADD CONSTRAINT "deliverable_assignments_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "deliverable_backlog_items" ADD CONSTRAINT "deliverable_backlog_items_deliverable_id_fkey" FOREIGN KEY ("deliverable_id") REFERENCES "deliverables"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "timeline_events" ADD CONSTRAINT "timeline_events_deliverable_id_fkey" FOREIGN KEY ("deliverable_id") REFERENCES "deliverables"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notes" ADD CONSTRAINT "notes_deliverable_id_fkey" FOREIGN KEY ("deliverable_id") REFERENCES "deliverables"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sprint_history" ADD CONSTRAINT "sprint_history_sprint_id_fkey" FOREIGN KEY ("sprint_id") REFERENCES "sprints"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sprint_history" ADD CONSTRAINT "sprint_history_target_sprint_id_fkey" FOREIGN KEY ("target_sprint_id") REFERENCES "sprints"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sprint_history" ADD CONSTRAINT "sprint_history_deliverable_id_fkey" FOREIGN KEY ("deliverable_id") REFERENCES "deliverables"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "client_profiles" ADD CONSTRAINT "client_profiles_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "allocation_templates" ADD CONSTRAINT "allocation_templates_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "allocation_templates" ADD CONSTRAINT "allocation_templates_contract_item_id_fkey" FOREIGN KEY ("contract_item_id") REFERENCES "contract_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "allocation_template_items" ADD CONSTRAINT "allocation_template_items_template_id_fkey" FOREIGN KEY ("template_id") REFERENCES "allocation_templates"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "allocation_template_items" ADD CONSTRAINT "allocation_template_items_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "client_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "deliverable_allocations" ADD CONSTRAINT "deliverable_allocations_deliverable_id_fkey" FOREIGN KEY ("deliverable_id") REFERENCES "deliverables"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "deliverable_allocations" ADD CONSTRAINT "deliverable_allocations_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "client_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `type` on the `sprints` table. All the data in the column will be lost.
|
||||
- Added the required column `type` to the `deliverables` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `work_order_id` to the `deliverables` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WorkOrderStatus" AS ENUM ('RASCUNHO', 'EMITIDA', 'EM_EXECUCAO', 'TOTALMENTE_PAGA', 'CANCELADA');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DeliverableType" AS ENUM ('DESCOBERTA', 'DESIGN', 'ARQUITETURA', 'CONSTRUCAO', 'MANUTENCAO', 'LICENCA');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "deliverables" ADD COLUMN "type" "DeliverableType" NOT NULL,
|
||||
ADD COLUMN "work_order_id" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "sprints" DROP COLUMN "type";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "work_orders" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"contract_id" TEXT NOT NULL,
|
||||
"contract_item_id" TEXT NOT NULL,
|
||||
"reserved_ust" DECIMAL(65,30) NOT NULL,
|
||||
"status" "WorkOrderStatus" NOT NULL DEFAULT 'RASCUNHO',
|
||||
"start_date" TIMESTAMP(3) NOT NULL,
|
||||
"end_date" TIMESTAMP(3),
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_by" TEXT,
|
||||
"updated_by" TEXT,
|
||||
|
||||
CONSTRAINT "work_orders_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "work_order_projects" (
|
||||
"work_order_id" TEXT NOT NULL,
|
||||
"project_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "work_order_projects_pkey" PRIMARY KEY ("work_order_id","project_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "work_order_status_history" (
|
||||
"id" TEXT NOT NULL,
|
||||
"work_order_id" TEXT NOT NULL,
|
||||
"previous_status" "WorkOrderStatus",
|
||||
"new_status" "WorkOrderStatus" NOT NULL,
|
||||
"observation" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" TEXT,
|
||||
|
||||
CONSTRAINT "work_order_status_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "work_orders_code_contract_id_key" ON "work_orders"("code", "contract_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "deliverables" ADD CONSTRAINT "deliverables_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "work_orders" ADD CONSTRAINT "work_orders_contract_id_fkey" FOREIGN KEY ("contract_id") REFERENCES "contracts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "work_orders" ADD CONSTRAINT "work_orders_contract_item_id_fkey" FOREIGN KEY ("contract_item_id") REFERENCES "contract_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "work_order_projects" ADD CONSTRAINT "work_order_projects_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "work_order_projects" ADD CONSTRAINT "work_order_projects_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "work_order_status_history" ADD CONSTRAINT "work_order_status_history_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ContractItemType" AS ENUM ('UST', 'SAAS_LICENSE');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "contract_items" ADD COLUMN "item_type" "ContractItemType" NOT NULL DEFAULT 'UST';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "deliverables" ADD COLUMN "num_weeks" INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN "timebox_manutencao" DECIMAL(65,30);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "deliverables" ALTER COLUMN "num_weeks" DROP DEFAULT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "TimelineEventType" ADD VALUE 'VALOR_RECALCULADO';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "work_orders" ADD COLUMN "total_value" DECIMAL(65,30);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Backfill nullable rows before enforcing NOT NULL.
|
||||
UPDATE "deliverables" SET "start_date" = "created_at" WHERE "start_date" IS NULL;
|
||||
UPDATE "deliverables"
|
||||
SET "expected_end_date" = "start_date" + INTERVAL '7 days'
|
||||
WHERE "expected_end_date" IS NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "deliverables" ALTER COLUMN "start_date" SET NOT NULL,
|
||||
ALTER COLUMN "expected_end_date" SET NOT NULL;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "IntegrationScope" AS ENUM ('DELIVERABLES_READ', 'CLIENTS_READ');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "integration_api_keys" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"hashed_key" TEXT NOT NULL,
|
||||
"last_four_chars" TEXT NOT NULL,
|
||||
"scopes" "IntegrationScope"[],
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"expires_at" TIMESTAMP(3),
|
||||
"last_used_at" TIMESTAMP(3),
|
||||
"rate_limit_per_minute" INTEGER NOT NULL DEFAULT 60,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_by" TEXT,
|
||||
"updated_by" TEXT,
|
||||
|
||||
CONSTRAINT "integration_api_keys_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "integration_api_key_usage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"api_key_id" TEXT NOT NULL,
|
||||
"endpoint" TEXT NOT NULL,
|
||||
"status_code" INTEGER NOT NULL,
|
||||
"ip_address" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "integration_api_key_usage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "integration_api_keys_hashed_key_key" ON "integration_api_keys"("hashed_key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "integration_api_key_usage_api_key_id_idx" ON "integration_api_key_usage"("api_key_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "integration_api_key_usage" ADD CONSTRAINT "integration_api_key_usage_api_key_id_fkey" FOREIGN KEY ("api_key_id") REFERENCES "integration_api_keys"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "password_reset_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"used_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "password_reset_tokens_token_hash_key" ON "password_reset_tokens"("token_hash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens"("user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable: change default role from OPERATOR to GESTOR_PROJETOS
|
||||
-- This must be in a separate migration from the ADD VALUE statements
|
||||
-- because PostgreSQL requires enum values to be committed before use.
|
||||
ALTER TABLE "users" ALTER COLUMN "role" SET DEFAULT 'GESTOR_PROJETOS';
|
||||
@@ -0,0 +1,17 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "Role" ADD VALUE 'GESTOR_PROJETOS';
|
||||
ALTER TYPE "Role" ADD VALUE 'PO';
|
||||
ALTER TYPE "Role" ADD VALUE 'FISCAL_CONTRATO';
|
||||
ALTER TYPE "Role" ADD VALUE 'GESTOR_CONTRATO';
|
||||
|
||||
-- AlterTable: add temporary clientSubRole column only
|
||||
-- Note: SET DEFAULT 'GESTOR_PROJETOS' must be in a separate migration
|
||||
-- after the new enum values are committed.
|
||||
ALTER TABLE "users" ADD COLUMN "client_sub_role" TEXT;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
561
prisma/schema.prisma
Normal file
561
prisma/schema.prisma
Normal file
@@ -0,0 +1,561 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
OPERATOR
|
||||
CLIENT
|
||||
GESTOR_PROJETOS
|
||||
PO
|
||||
FISCAL_CONTRATO
|
||||
GESTOR_CONTRATO
|
||||
}
|
||||
|
||||
enum DeliverableStatus {
|
||||
RASCUNHO
|
||||
EMITIDA
|
||||
EM_EXECUCAO
|
||||
AGUARDANDO_VALIDACAO
|
||||
EM_REVISAO
|
||||
APROVADA
|
||||
GLOSADA
|
||||
ENCERRADA
|
||||
CANCELADA
|
||||
AGUARDANDO_PAGAMENTO
|
||||
PAGA
|
||||
}
|
||||
|
||||
enum TimelineEventType {
|
||||
CRIACAO
|
||||
EDICAO
|
||||
STATUS_CHANGE
|
||||
ASSIGNMENT
|
||||
BACKLOG
|
||||
ANOTACAO
|
||||
ALOCACAO
|
||||
VALOR_RECALCULADO
|
||||
}
|
||||
|
||||
enum BacklogItemStatus {
|
||||
PENDENTE
|
||||
ACEITO
|
||||
REJEITADO
|
||||
}
|
||||
|
||||
enum NoteType {
|
||||
OBSERVACAO
|
||||
RISCO
|
||||
IMPEDIMENTO
|
||||
DECISAO
|
||||
}
|
||||
|
||||
enum SprintType {
|
||||
DESCOBERTA
|
||||
DESIGN
|
||||
ARQUITETURA
|
||||
CONSTRUCAO
|
||||
}
|
||||
|
||||
enum WorkOrderStatus {
|
||||
RASCUNHO
|
||||
EMITIDA
|
||||
EM_EXECUCAO
|
||||
TOTALMENTE_PAGA
|
||||
CANCELADA
|
||||
}
|
||||
|
||||
enum DeliverableType {
|
||||
DESCOBERTA
|
||||
DESIGN
|
||||
ARQUITETURA
|
||||
CONSTRUCAO
|
||||
MANUTENCAO
|
||||
LICENCA
|
||||
}
|
||||
|
||||
enum ContractItemType {
|
||||
UST
|
||||
SAAS_LICENSE
|
||||
}
|
||||
|
||||
enum SprintStatus {
|
||||
RASCUNHO
|
||||
EM_EXECUCAO
|
||||
FINALIZADA
|
||||
CANCELADA
|
||||
}
|
||||
|
||||
enum SprintHistoryEventType {
|
||||
ENTREGAVEL_ADICIONADO
|
||||
ENTREGAVEL_REMOVIDO
|
||||
STATUS_CHANGE
|
||||
ENTREGAVEL_MOVIDO
|
||||
}
|
||||
|
||||
enum IntegrationScope {
|
||||
DELIVERABLES_READ
|
||||
CLIENTS_READ
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
email String @unique
|
||||
password String
|
||||
role Role @default(GESTOR_PROJETOS)
|
||||
clientSubRole String? @map("client_sub_role")
|
||||
isActive Boolean @default(true)
|
||||
mustChangePassword Boolean @default(false)
|
||||
clientId String? @map("client_id")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
client Client? @relation(fields: [clientId], references: [id])
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model PasswordResetToken {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
tokenHash String @unique @map("token_hash")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
usedAt DateTime? @map("used_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([userId])
|
||||
@@map("password_reset_tokens")
|
||||
}
|
||||
|
||||
model Client {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
document String?
|
||||
email String?
|
||||
phone String?
|
||||
contactName String? @map("contact_name")
|
||||
description String?
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
contracts Contract[]
|
||||
contractItems ContractItem[]
|
||||
deliverables Deliverable[]
|
||||
users User[]
|
||||
profiles ClientProfile[]
|
||||
allocationTemplates AllocationTemplate[]
|
||||
|
||||
@@map("clients")
|
||||
}
|
||||
|
||||
model ContractItem {
|
||||
id String @id @default(uuid())
|
||||
code String
|
||||
name String
|
||||
description String?
|
||||
totalUst Decimal @map("total_ust")
|
||||
ustValue Decimal? @map("ust_value")
|
||||
itemType ContractItemType @default(UST) @map("item_type")
|
||||
timeboxDescoberta Decimal? @map("timebox_descoberta")
|
||||
timeboxDesign Decimal? @map("timebox_design")
|
||||
timeboxArquitetura Decimal? @map("timebox_arquitetura")
|
||||
timeboxConstrucao Decimal? @map("timebox_construcao")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
clientId String @map("client_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
client Client @relation(fields: [clientId], references: [id])
|
||||
deliverables Deliverable[]
|
||||
allocationTemplates AllocationTemplate[]
|
||||
workOrders WorkOrder[]
|
||||
|
||||
@@unique([code, clientId])
|
||||
@@map("contract_items")
|
||||
}
|
||||
|
||||
model Professional {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
document String?
|
||||
email String?
|
||||
phone String?
|
||||
role String?
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
deliverableAssignments DeliverableAssignment[]
|
||||
|
||||
@@map("professionals")
|
||||
}
|
||||
|
||||
model Contract {
|
||||
id String @id @default(uuid())
|
||||
clientId String @map("client_id")
|
||||
name String
|
||||
code String?
|
||||
description String?
|
||||
startDate DateTime? @map("start_date")
|
||||
endDate DateTime? @map("end_date")
|
||||
ustValue Decimal? @map("ust_value")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
client Client @relation(fields: [clientId], references: [id])
|
||||
projects Project[]
|
||||
deliverables Deliverable[]
|
||||
workOrders WorkOrder[]
|
||||
|
||||
@@map("contracts")
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(uuid())
|
||||
contractId String @map("contract_id")
|
||||
name String
|
||||
code String?
|
||||
description String?
|
||||
startDate DateTime? @map("start_date")
|
||||
endDate DateTime? @map("end_date")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
contract Contract @relation(fields: [contractId], references: [id])
|
||||
deliverables Deliverable[]
|
||||
workOrders WorkOrderProject[]
|
||||
|
||||
@@map("projects")
|
||||
}
|
||||
|
||||
model Sprint {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
code String?
|
||||
status SprintStatus @default(RASCUNHO)
|
||||
startDate DateTime @map("start_date")
|
||||
endDate DateTime @map("end_date")
|
||||
goal String?
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
deliverables Deliverable[]
|
||||
history SprintHistory[] @relation("SprintHistory")
|
||||
targetHistory SprintHistory[] @relation("SprintHistoryTarget")
|
||||
|
||||
@@map("sprints")
|
||||
}
|
||||
|
||||
model Deliverable {
|
||||
id String @id @default(uuid())
|
||||
code String @unique
|
||||
title String
|
||||
description String?
|
||||
status DeliverableStatus @default(RASCUNHO)
|
||||
type DeliverableType
|
||||
clientId String @map("client_id")
|
||||
contractId String @map("contract_id")
|
||||
projectId String @map("project_id")
|
||||
contractItemId String @map("contract_item_id")
|
||||
workOrderId String @map("work_order_id")
|
||||
sprintId String @map("sprint_id")
|
||||
startDate DateTime @map("start_date")
|
||||
expectedEndDate DateTime @map("expected_end_date")
|
||||
numWeeks Int @map("num_weeks")
|
||||
timeboxManutencao Decimal? @map("timebox_manutencao")
|
||||
ustValue Decimal? @map("ust_value")
|
||||
ustQuantity Decimal? @map("ust_quantity")
|
||||
totalValue Decimal? @map("total_value")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdBy String? @map("created_by")
|
||||
updatedBy String? @map("updated_by")
|
||||
|
||||
client Client @relation(fields: [clientId], references: [id])
|
||||
contract Contract @relation(fields: [contractId], references: [id])
|
||||
project Project @relation(fields: [projectId], references: [id])
|
||||
contractItem ContractItem @relation(fields: [contractItemId], references: [id])
|
||||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id])
|
||||
sprint Sprint @relation(fields: [sprintId], references: [id])
|
||||
statusHistory DeliverableStatusHistory[]
|
||||
assignments DeliverableAssignment[]
|
||||
backlogItems DeliverableBacklogItem[]
|
||||
timelineEvents TimelineEvent[]
|
||||
notes Note[]
|
||||
sprintHistory SprintHistory[]
|
||||
allocations DeliverableAllocation[]
|
||||
|
||||
@@map("deliverables")
|
||||
}
|
||||
|
||||
model DeliverableStatusHistory {
|
||||
id String @id @default(uuid())
|
||||
deliverableId String @map("deliverable_id")
|
||||
previousStatus DeliverableStatus? @map("previous_status")
|
||||
newStatus DeliverableStatus @map("new_status")
|
||||
observation String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by")
|
||||
|
||||
deliverable Deliverable @relation(fields: [deliverableId], references: [id])
|
||||
|
||||
@@map("deliverable_status_history")
|
||||
}
|
||||
|
||||
model DeliverableAssignment {
|
||||
id String @id @default(uuid())
|
||||
deliverableId String @map("deliverable_id")
|
||||
professionalId String @map("professional_id")
|
||||
role String?
|
||||
startDate DateTime? @map("start_date")
|
||||
endDate DateTime? @map("end_date")
|
||||
observation String?
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdBy String? @map("created_by")
|
||||
|
||||
deliverable Deliverable @relation(fields: [deliverableId], references: [id])
|
||||
professional Professional @relation(fields: [professionalId], references: [id])
|
||||
|
||||
@@map("deliverable_assignments")
|
||||
}
|
||||
|
||||
model DeliverableBacklogItem {
|
||||
id String @id @default(uuid())
|
||||
deliverableId String @map("deliverable_id")
|
||||
title String
|
||||
description String?
|
||||
acceptanceCriteria String? @map("acceptance_criteria")
|
||||
status BacklogItemStatus @default(PENDENTE)
|
||||
rejectionReason String? @map("rejection_reason")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdBy String? @map("created_by")
|
||||
updatedBy String? @map("updated_by")
|
||||
|
||||
deliverable Deliverable @relation(fields: [deliverableId], references: [id])
|
||||
|
||||
@@map("deliverable_backlog_items")
|
||||
}
|
||||
|
||||
model TimelineEvent {
|
||||
id String @id @default(uuid())
|
||||
deliverableId String @map("deliverable_id")
|
||||
type TimelineEventType
|
||||
title String
|
||||
description String?
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by")
|
||||
|
||||
deliverable Deliverable @relation(fields: [deliverableId], references: [id])
|
||||
|
||||
@@map("timeline_events")
|
||||
}
|
||||
|
||||
model Note {
|
||||
id String @id @default(uuid())
|
||||
deliverableId String @map("deliverable_id")
|
||||
type NoteType
|
||||
title String
|
||||
description String?
|
||||
isRelevant Boolean @default(false) @map("is_relevant")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdBy String? @map("created_by")
|
||||
updatedBy String? @map("updated_by")
|
||||
|
||||
deliverable Deliverable @relation(fields: [deliverableId], references: [id])
|
||||
|
||||
@@map("notes")
|
||||
}
|
||||
|
||||
model SprintHistory {
|
||||
id String @id @default(uuid())
|
||||
sprintId String @map("sprint_id")
|
||||
eventType SprintHistoryEventType @map("event_type")
|
||||
description String
|
||||
deliverableId String? @map("deliverable_id")
|
||||
targetSprintId String? @map("target_sprint_id")
|
||||
previousStatus SprintStatus? @map("previous_status")
|
||||
newStatus SprintStatus? @map("new_status")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by")
|
||||
|
||||
sprint Sprint @relation("SprintHistory", fields: [sprintId], references: [id])
|
||||
targetSprint Sprint? @relation("SprintHistoryTarget", fields: [targetSprintId], references: [id])
|
||||
deliverable Deliverable? @relation(fields: [deliverableId], references: [id])
|
||||
|
||||
@@map("sprint_history")
|
||||
}
|
||||
|
||||
model ClientProfile {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
clientId String @map("client_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
client Client @relation(fields: [clientId], references: [id])
|
||||
allocationTemplateItems AllocationTemplateItem[]
|
||||
deliverableAllocations DeliverableAllocation[]
|
||||
|
||||
@@map("client_profiles")
|
||||
}
|
||||
|
||||
model AllocationTemplate {
|
||||
id String @id @default(uuid())
|
||||
clientId String @map("client_id")
|
||||
sprintType SprintType @map("sprint_type")
|
||||
contractItemId String @map("contract_item_id")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
client Client @relation(fields: [clientId], references: [id])
|
||||
contractItem ContractItem @relation(fields: [contractItemId], references: [id])
|
||||
items AllocationTemplateItem[]
|
||||
|
||||
@@unique([clientId, sprintType, contractItemId])
|
||||
@@map("allocation_templates")
|
||||
}
|
||||
|
||||
model AllocationTemplateItem {
|
||||
id String @id @default(uuid())
|
||||
templateId String @map("template_id")
|
||||
profileId String @map("profile_id")
|
||||
quantity Int
|
||||
allocationPercentage Decimal @map("allocation_percentage")
|
||||
|
||||
template AllocationTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id])
|
||||
|
||||
@@map("allocation_template_items")
|
||||
}
|
||||
|
||||
model DeliverableAllocation {
|
||||
id String @id @default(uuid())
|
||||
deliverableId String @map("deliverable_id")
|
||||
profileId String @map("profile_id")
|
||||
quantity Int
|
||||
allocationPercentage Decimal @map("allocation_percentage")
|
||||
calculatedValue Decimal? @map("calculated_value")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdBy String? @map("created_by")
|
||||
|
||||
deliverable Deliverable @relation(fields: [deliverableId], references: [id])
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id])
|
||||
|
||||
@@map("deliverable_allocations")
|
||||
}
|
||||
|
||||
model WorkOrder {
|
||||
id String @id @default(uuid())
|
||||
code String
|
||||
name String
|
||||
description String?
|
||||
contractId String @map("contract_id")
|
||||
contractItemId String @map("contract_item_id")
|
||||
reservedUst Decimal @map("reserved_ust")
|
||||
totalValue Decimal? @map("total_value")
|
||||
status WorkOrderStatus @default(RASCUNHO)
|
||||
startDate DateTime @map("start_date")
|
||||
endDate DateTime? @map("end_date")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdBy String? @map("created_by")
|
||||
updatedBy String? @map("updated_by")
|
||||
|
||||
contract Contract @relation(fields: [contractId], references: [id])
|
||||
contractItem ContractItem @relation(fields: [contractItemId], references: [id])
|
||||
projects WorkOrderProject[]
|
||||
deliverables Deliverable[]
|
||||
statusHistory WorkOrderStatusHistory[]
|
||||
|
||||
@@unique([code, contractId])
|
||||
@@map("work_orders")
|
||||
}
|
||||
|
||||
model WorkOrderProject {
|
||||
workOrderId String @map("work_order_id")
|
||||
projectId String @map("project_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id])
|
||||
|
||||
@@id([workOrderId, projectId])
|
||||
@@map("work_order_projects")
|
||||
}
|
||||
|
||||
model WorkOrderStatusHistory {
|
||||
id String @id @default(uuid())
|
||||
workOrderId String @map("work_order_id")
|
||||
previousStatus WorkOrderStatus? @map("previous_status")
|
||||
newStatus WorkOrderStatus @map("new_status")
|
||||
observation String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by")
|
||||
|
||||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id])
|
||||
|
||||
@@map("work_order_status_history")
|
||||
}
|
||||
|
||||
model IntegrationApiKey {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
hashedKey String @unique @map("hashed_key")
|
||||
lastFourChars String @map("last_four_chars")
|
||||
scopes IntegrationScope[]
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
lastUsedAt DateTime? @map("last_used_at")
|
||||
rateLimitPerMinute Int @default(60) @map("rate_limit_per_minute")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdBy String? @map("created_by")
|
||||
updatedBy String? @map("updated_by")
|
||||
|
||||
usages IntegrationApiKeyUsage[]
|
||||
|
||||
@@map("integration_api_keys")
|
||||
}
|
||||
|
||||
model IntegrationApiKeyUsage {
|
||||
id String @id @default(uuid())
|
||||
apiKeyId String @map("api_key_id")
|
||||
endpoint String
|
||||
statusCode Int @map("status_code")
|
||||
ipAddress String? @map("ip_address")
|
||||
userAgent String? @map("user_agent")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
apiKey IntegrationApiKey @relation(fields: [apiKeyId], references: [id])
|
||||
|
||||
@@index([apiKeyId])
|
||||
@@map("integration_api_key_usage")
|
||||
}
|
||||
30
prisma/seed-admin.ts
Normal file
30
prisma/seed-admin.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const password = await bcrypt.hash('Admin@123456', 10);
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: 'admin@iasis.com.br' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Administrador',
|
||||
email: 'admin@iasis.com.br',
|
||||
password,
|
||||
role: 'ADMIN',
|
||||
isActive: true,
|
||||
mustChangePassword: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Usuário admin criado:', user.email);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
186
prisma/seed.ts
Normal file
186
prisma/seed.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { ContractItemType, PrismaClient, Role } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function seedAdmin(): Promise<void> {
|
||||
const passwordHash = await bcrypt.hash(process.env.PASS_ADMIN_USER || '', 10);
|
||||
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: 'admin@iasis.com.br' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Admin',
|
||||
email: 'admin@iasis.com.br',
|
||||
password: passwordHash,
|
||||
role: Role.ADMIN,
|
||||
mustChangePassword: false,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Admin: ${admin.email} (${admin.id})`);
|
||||
}
|
||||
|
||||
async function seedSesMg(): Promise<void> {
|
||||
const clientData = {
|
||||
name: 'Secretaria de Estado de Saúde de Minas Gerais (SES-MG)',
|
||||
document: '18.715.516/0001-88',
|
||||
email: 'rafael.paiva@saude.mg.gov.br',
|
||||
phone: '(31) 39152-0221',
|
||||
contactName: 'Rafael Matos Paiva',
|
||||
description: 'Cliente SESMG',
|
||||
};
|
||||
|
||||
const existingClient = await prisma.client.findFirst({
|
||||
where: { document: clientData.document },
|
||||
});
|
||||
|
||||
const client = existingClient
|
||||
? await prisma.client.update({ where: { id: existingClient.id }, data: clientData })
|
||||
: await prisma.client.create({ data: clientData });
|
||||
|
||||
console.log(`Cliente: ${client.name} (${client.id})`);
|
||||
|
||||
const contractData = {
|
||||
clientId: client.id,
|
||||
name: 'Contrato SESMG',
|
||||
code: 'CTSES001',
|
||||
startDate: new Date('2026-04-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
const existingContract = await prisma.contract.findFirst({
|
||||
where: { clientId: client.id, code: contractData.code },
|
||||
});
|
||||
|
||||
const contract = existingContract
|
||||
? await prisma.contract.update({ where: { id: existingContract.id }, data: contractData })
|
||||
: await prisma.contract.create({ data: contractData });
|
||||
|
||||
console.log(`Contrato: ${contract.code} (${contract.id})`);
|
||||
|
||||
const items = [
|
||||
{
|
||||
code: 'I-01',
|
||||
name: 'Licença SaaS',
|
||||
description: 'Licença SaaS',
|
||||
itemType: ContractItemType.SAAS_LICENSE,
|
||||
totalUst: 5,
|
||||
ustValue: 35000000,
|
||||
timeboxDescoberta: null,
|
||||
timeboxDesign: null,
|
||||
timeboxArquitetura: null,
|
||||
timeboxConstrucao: null,
|
||||
},
|
||||
{
|
||||
code: 'I-02',
|
||||
name: 'Desenvolvimento e implantação da tecnologia blockchain',
|
||||
description: 'Desenvolvimento e implantação da tecnologia blockchain',
|
||||
itemType: ContractItemType.UST,
|
||||
totalUst: 50400,
|
||||
ustValue: 533.33,
|
||||
timeboxDescoberta: 40,
|
||||
timeboxDesign: 40,
|
||||
timeboxArquitetura: 40,
|
||||
timeboxConstrucao: 40,
|
||||
},
|
||||
{
|
||||
code: 'I-03',
|
||||
name: 'Desenv/Manutenção (treinamento/consultoria pós-implantação)',
|
||||
description: 'Desenvolvimento e Manutenção (treinamento/consultoria pós-implantação)',
|
||||
itemType: ContractItemType.UST,
|
||||
totalUst: 4800,
|
||||
ustValue: 388.88,
|
||||
timeboxDescoberta: 40,
|
||||
timeboxDesign: 40,
|
||||
timeboxArquitetura: 40,
|
||||
timeboxConstrucao: 40,
|
||||
},
|
||||
{
|
||||
code: 'I-04',
|
||||
name: 'Desenvolvimento e Manutenção (manutenção, integrações e automações)',
|
||||
description: 'Desenvolvimento e Manutenção (manutenção, integrações e automações)',
|
||||
itemType: ContractItemType.UST,
|
||||
totalUst: 15120,
|
||||
ustValue: 533.33,
|
||||
timeboxDescoberta: 40,
|
||||
timeboxDesign: 40,
|
||||
timeboxArquitetura: 40,
|
||||
timeboxConstrucao: 80,
|
||||
},
|
||||
{
|
||||
code: 'I-05',
|
||||
name: 'Serviços Técnicos de Inteligência Artificial (IA)',
|
||||
description: 'Serviços Técnicos de Inteligência Artificial (IA)',
|
||||
itemType: ContractItemType.UST,
|
||||
totalUst: 30240,
|
||||
ustValue: 555.55,
|
||||
timeboxDescoberta: 40,
|
||||
timeboxDesign: 40,
|
||||
timeboxArquitetura: 40,
|
||||
timeboxConstrucao: 80,
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of items) {
|
||||
const saved = await prisma.contractItem.upsert({
|
||||
where: { code_clientId: { code: item.code, clientId: client.id } },
|
||||
update: { ...item, clientId: client.id },
|
||||
create: { ...item, clientId: client.id },
|
||||
});
|
||||
console.log(` Item: ${saved.code} - ${saved.name}`);
|
||||
}
|
||||
|
||||
const projectData = {
|
||||
contractId: contract.id,
|
||||
name: 'ISIS Platform',
|
||||
code: 'ISIS-001',
|
||||
description: 'Plataforma de interoperabilidade ISIS',
|
||||
};
|
||||
|
||||
const existingProject = await prisma.project.findFirst({
|
||||
where: { contractId: contract.id, code: projectData.code },
|
||||
});
|
||||
|
||||
const project = existingProject
|
||||
? await prisma.project.update({ where: { id: existingProject.id }, data: projectData })
|
||||
: await prisma.project.create({ data: projectData });
|
||||
|
||||
console.log(`Projeto: ${project.code} (${project.id})`);
|
||||
|
||||
const profiles = [
|
||||
'Especialista de Inteligência (Cientista de Dados)',
|
||||
'Analista de Negócio/Processo',
|
||||
'Especialista de Negócio (Saúde)',
|
||||
'Especialista de Tecnologia / Arquiteto',
|
||||
'Gerente de Projeto',
|
||||
'Desenvolvedor / Engenheiro de Dados',
|
||||
'Especialista de Infraestrutura',
|
||||
'Scrum Master',
|
||||
];
|
||||
|
||||
for (const name of profiles) {
|
||||
const existing = await prisma.clientProfile.findFirst({
|
||||
where: { clientId: client.id, name },
|
||||
});
|
||||
const profile = existing
|
||||
? await prisma.clientProfile.update({ where: { id: existing.id }, data: { name } })
|
||||
: await prisma.clientProfile.create({ data: { name, clientId: client.id } });
|
||||
console.log(` Perfil: ${profile.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await seedAdmin();
|
||||
await seedSesMg();
|
||||
console.log('Seed concluído.');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
22
src/app.controller.spec.ts
Normal file
22
src/app.controller.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
20
src/app.controller.ts
Normal file
20
src/app.controller.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { JwtAuthGuard } from './config/auth/jwt-auth.guard';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('protected')
|
||||
getProtected(@Req() req: Request): { message: string; user: unknown } {
|
||||
return { message: 'authorized', user: req.user };
|
||||
}
|
||||
}
|
||||
62
src/app.module.ts
Normal file
62
src/app.module.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { AuthConfigModule } from './config/auth/auth-config.module';
|
||||
import { PrismaModule } from './config/database/prisma.module';
|
||||
import { EnvModule } from './config/env/env.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { ProfessionalsModule } from './modules/professionals/professionals.module';
|
||||
import { ClientsModule } from './modules/clients/clients.module';
|
||||
import { AllocationTemplatesModule } from './modules/allocation-templates/allocation-templates.module';
|
||||
import { ClientProfilesModule } from './modules/client-profiles/client-profiles.module';
|
||||
import { ContractItemsModule } from './modules/contract-items/contract-items.module';
|
||||
import { ContractsModule } from './modules/contracts/contracts.module';
|
||||
import { ProjectsModule } from './modules/projects/projects.module';
|
||||
import { SprintsModule } from './modules/sprints/sprints.module';
|
||||
import { DeliverablesModule } from './modules/deliverables/deliverables.module';
|
||||
import { WorkOrdersModule } from './modules/work-orders/work-orders.module';
|
||||
import { DeliverableBacklogModule } from './modules/deliverable-backlog/deliverable-backlog.module';
|
||||
import { TimelineModule } from './modules/timeline/timeline.module';
|
||||
import { NotesModule } from './modules/notes/notes.module';
|
||||
import { DashboardModule } from './modules/dashboard/dashboard.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { IntegrationApiKeysModule } from './modules/integration-api-keys/integration-api-keys.module';
|
||||
import { IntegrationsModule } from './modules/integrations/integrations.module';
|
||||
import { MailerModule } from './modules/mailer/mailer.module';
|
||||
import { ProfileVisibilityInterceptor } from './common/interceptors/profile-visibility.interceptor';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
EnvModule,
|
||||
PrismaModule,
|
||||
AuthConfigModule,
|
||||
MailerModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
ProfessionalsModule,
|
||||
ClientsModule,
|
||||
AllocationTemplatesModule,
|
||||
ClientProfilesModule,
|
||||
ContractItemsModule,
|
||||
ContractsModule,
|
||||
ProjectsModule,
|
||||
SprintsModule,
|
||||
DeliverablesModule,
|
||||
WorkOrdersModule,
|
||||
DeliverableBacklogModule,
|
||||
TimelineModule,
|
||||
NotesModule,
|
||||
DashboardModule,
|
||||
HealthModule,
|
||||
IntegrationApiKeysModule,
|
||||
IntegrationsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
AppService,
|
||||
{ provide: APP_INTERCEPTOR, useClass: ProfileVisibilityInterceptor },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
src/app.service.ts
Normal file
8
src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
89
src/common/constants/visibility-map.ts
Normal file
89
src/common/constants/visibility-map.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Role } from '../enums/role.enum';
|
||||
|
||||
export const FINANCIAL_FIELDS = new Set([
|
||||
'totalValue',
|
||||
'ustValue',
|
||||
'ustQuantity',
|
||||
'reservedUst',
|
||||
'calculatedValue',
|
||||
'timeboxDescoberta',
|
||||
'timeboxDesign',
|
||||
'timeboxArquitetura',
|
||||
'timeboxConstrucao',
|
||||
'timeboxManutencao',
|
||||
'numWeeks',
|
||||
]);
|
||||
|
||||
// Whitelist of fields allowed per profile.
|
||||
// ADMIN has no restrictions (handled inline — not in this map).
|
||||
// GESTOR_PROJETOS sees all non-financial fields (same as ADMIN minus FINANCIAL_FIELDS).
|
||||
// For CLIENT_ROLES: explicit whitelist per RF-19 (default-deny — field absent = invisible).
|
||||
// Fields not listed for a client role are silently removed from responses.
|
||||
export const VISIBILITY_MAP: Partial<Record<Role, Set<string>>> = {
|
||||
[Role.GESTOR_PROJETOS]: new Set([
|
||||
// Identity / metadata
|
||||
'id', 'code', 'name', 'title', 'description', 'status', 'type',
|
||||
'isActive', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy',
|
||||
// Date fields
|
||||
'startDate', 'endDate', 'expectedEndDate',
|
||||
// Relation IDs
|
||||
'clientId', 'contractId', 'projectId', 'contractItemId', 'workOrderId', 'sprintId',
|
||||
// Relation objects (non-financial)
|
||||
'client', 'contract', 'project', 'contractItem', 'workOrder', 'sprint',
|
||||
'statusHistory', 'assignments', 'backlogItems', 'timelineEvents', 'notes',
|
||||
'sprintHistory', 'allocations', 'deliverables', 'projects', 'statusHistory',
|
||||
'contractItems', 'users', 'profiles', 'allocationTemplates',
|
||||
// User fields
|
||||
'email', 'role', 'clientSubRole', 'mustChangePassword',
|
||||
// WorkOrder non-financial
|
||||
'contractItemId', 'workOrders',
|
||||
// Sprint
|
||||
'goal', 'sprintType',
|
||||
// ContractItem non-financial
|
||||
'totalUst', 'itemType',
|
||||
// Client
|
||||
'document', 'phone', 'contactName',
|
||||
// Professional
|
||||
'professional', 'professionals',
|
||||
// Backlog
|
||||
'acceptanceCriteria', 'rejectionReason', 'sortOrder',
|
||||
// Note
|
||||
'noteType', 'isRelevant',
|
||||
// Allocation
|
||||
'profileId', 'quantity', 'allocationPercentage', 'profile',
|
||||
// Token / integration
|
||||
'token', 'scopes', 'lastFourChars', 'expiresAt', 'lastUsedAt', 'rateLimitPerMinute',
|
||||
]),
|
||||
|
||||
[Role.PO]: new Set([
|
||||
'id', 'code', 'title', 'description', 'status', 'type',
|
||||
'isActive', 'createdAt', 'updatedAt',
|
||||
'startDate', 'expectedEndDate',
|
||||
'clientId', 'contractId', 'projectId',
|
||||
'client', 'contract', 'project',
|
||||
'backlogItems', 'timelineEvents',
|
||||
'acceptanceCriteria', 'rejectionReason', 'sortOrder',
|
||||
'name', 'email', 'role',
|
||||
]),
|
||||
|
||||
[Role.FISCAL_CONTRATO]: new Set([
|
||||
'id', 'code', 'title', 'description', 'status', 'type',
|
||||
'isActive', 'createdAt', 'updatedAt',
|
||||
'startDate', 'expectedEndDate',
|
||||
'clientId', 'contractId', 'projectId',
|
||||
'client', 'contract', 'project',
|
||||
'statusHistory', 'timelineEvents',
|
||||
'name', 'email', 'role',
|
||||
]),
|
||||
|
||||
[Role.GESTOR_CONTRATO]: new Set([
|
||||
'id', 'code', 'name', 'description', 'status',
|
||||
'isActive', 'createdAt', 'updatedAt',
|
||||
'startDate', 'endDate',
|
||||
'contractId', 'contractItemId',
|
||||
'contract', 'contractItem', 'projects', 'deliverables', 'statusHistory',
|
||||
// ContractItem non-financial
|
||||
'totalUst', 'itemType',
|
||||
'email', 'role',
|
||||
]),
|
||||
};
|
||||
0
src/common/decorators/.gitkeep
Normal file
0
src/common/decorators/.gitkeep
Normal file
7
src/common/decorators/require-scope.decorator.ts
Normal file
7
src/common/decorators/require-scope.decorator.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { IntegrationScope } from '@prisma/client';
|
||||
|
||||
export const REQUIRE_SCOPE_KEY = 'requireScope';
|
||||
|
||||
export const RequireScope = (scope: IntegrationScope): MethodDecorator & ClassDecorator =>
|
||||
SetMetadata(REQUIRE_SCOPE_KEY, scope);
|
||||
6
src/common/decorators/roles.decorator.ts
Normal file
6
src/common/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Role } from '../enums/role.enum';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
17
src/common/decorators/user.decorator.ts
Normal file
17
src/common/decorators/user.decorator.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { Role } from '../enums/role.enum';
|
||||
|
||||
export interface CurrentUserPayload {
|
||||
id: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
clientId: string | null;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): CurrentUserPayload => {
|
||||
const request = ctx.switchToHttp().getRequest<Request & { user: CurrentUserPayload }>();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
0
src/common/enums/.gitkeep
Normal file
0
src/common/enums/.gitkeep
Normal file
28
src/common/enums/role.enum.spec.ts
Normal file
28
src/common/enums/role.enum.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Role, ISIS_ROLES, CLIENT_ROLES, ALL_ROLES } from './role.enum';
|
||||
|
||||
describe('Role enum groups', () => {
|
||||
it('ISIS_ROLES and CLIENT_ROLES have no overlap', () => {
|
||||
const intersection = ISIS_ROLES.filter((r) => (CLIENT_ROLES as readonly Role[]).includes(r));
|
||||
expect(intersection).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ALL_ROLES contains exactly the 5 active profiles', () => {
|
||||
expect(ALL_ROLES).toHaveLength(5);
|
||||
expect(ALL_ROLES).toContain(Role.ADMIN);
|
||||
expect(ALL_ROLES).toContain(Role.GESTOR_PROJETOS);
|
||||
expect(ALL_ROLES).toContain(Role.PO);
|
||||
expect(ALL_ROLES).toContain(Role.FISCAL_CONTRATO);
|
||||
expect(ALL_ROLES).toContain(Role.GESTOR_CONTRATO);
|
||||
});
|
||||
|
||||
it('ALL_ROLES does not contain legacy roles', () => {
|
||||
expect(ALL_ROLES).not.toContain(Role.OPERATOR);
|
||||
expect(ALL_ROLES).not.toContain(Role.CLIENT);
|
||||
});
|
||||
|
||||
it('ISIS_ROLES union CLIENT_ROLES equals ALL_ROLES', () => {
|
||||
const union = new Set([...ISIS_ROLES, ...CLIENT_ROLES]);
|
||||
const allRolesSet = new Set(ALL_ROLES);
|
||||
expect(union).toEqual(allRolesSet);
|
||||
});
|
||||
});
|
||||
14
src/common/enums/role.enum.ts
Normal file
14
src/common/enums/role.enum.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export enum Role {
|
||||
ADMIN = 'ADMIN',
|
||||
GESTOR_PROJETOS = 'GESTOR_PROJETOS',
|
||||
PO = 'PO',
|
||||
FISCAL_CONTRATO = 'FISCAL_CONTRATO',
|
||||
GESTOR_CONTRATO = 'GESTOR_CONTRATO',
|
||||
// Legacy values kept during migration; removed in migration-finalize (Tarefa 5.0)
|
||||
OPERATOR = 'OPERATOR',
|
||||
CLIENT = 'CLIENT',
|
||||
}
|
||||
|
||||
export const ISIS_ROLES = [Role.ADMIN, Role.GESTOR_PROJETOS] as const;
|
||||
export const CLIENT_ROLES = [Role.PO, Role.FISCAL_CONTRATO, Role.GESTOR_CONTRATO] as const;
|
||||
export const ALL_ROLES = [...ISIS_ROLES, ...CLIENT_ROLES] as const;
|
||||
0
src/common/filters/.gitkeep
Normal file
0
src/common/filters/.gitkeep
Normal file
40
src/common/filters/http-exception.filter.ts
Normal file
40
src/common/filters/http-exception.filter.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message: string | string[] = 'Internal server error';
|
||||
let errors: string[] = [];
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
statusCode = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
message = exceptionResponse;
|
||||
} else if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
|
||||
const body = exceptionResponse as Record<string, unknown>;
|
||||
if (Array.isArray(body.message)) {
|
||||
errors = body.message as string[];
|
||||
message = 'Validation error';
|
||||
} else if (typeof body.message === 'string') {
|
||||
message = body.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.status(statusCode).json({
|
||||
statusCode,
|
||||
message,
|
||||
errors,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
0
src/common/guards/.gitkeep
Normal file
0
src/common/guards/.gitkeep
Normal file
85
src/common/guards/api-key-throttler.guard.spec.ts
Normal file
85
src/common/guards/api-key-throttler.guard.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { ExecutionContext, HttpException } from '@nestjs/common';
|
||||
import { ApiKeyThrottlerGuard } from './api-key-throttler.guard';
|
||||
|
||||
interface MockResponse {
|
||||
setHeader: jest.Mock;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
function buildContext(
|
||||
apiKeyId: string | null,
|
||||
rateLimit: number,
|
||||
): {
|
||||
context: ExecutionContext;
|
||||
response: MockResponse;
|
||||
} {
|
||||
const response: MockResponse = { setHeader: jest.fn() };
|
||||
const request = apiKeyId ? { apiKey: { id: apiKeyId, rateLimitPerMinute: rateLimit } } : {};
|
||||
const context = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
getResponse: () => response,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
return { context, response };
|
||||
}
|
||||
|
||||
describe('ApiKeyThrottlerGuard', () => {
|
||||
let guard: ApiKeyThrottlerGuard;
|
||||
|
||||
beforeEach(() => {
|
||||
guard = new ApiKeyThrottlerGuard();
|
||||
});
|
||||
|
||||
it('permite requisições dentro do limite', () => {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const { context } = buildContext('k1', 5);
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejeita 6ª requisição quando limite=5 e adiciona Retry-After', () => {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const { context } = buildContext('k1', 5);
|
||||
guard.canActivate(context);
|
||||
}
|
||||
const { context, response } = buildContext('k1', 5);
|
||||
const lastResponse: MockResponse = response;
|
||||
expect(() => guard.canActivate(context)).toThrow(HttpException);
|
||||
expect(lastResponse.setHeader).toHaveBeenCalledWith('Retry-After', expect.any(String));
|
||||
});
|
||||
|
||||
it('contabiliza chaves diferentes separadamente', () => {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
guard.canActivate(buildContext('k1', 3).context);
|
||||
}
|
||||
expect(() => guard.canActivate(buildContext('k1', 3).context)).toThrow(HttpException);
|
||||
expect(guard.canActivate(buildContext('k2', 3).context)).toBe(true);
|
||||
});
|
||||
|
||||
it('reseta janela após expirar', () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
guard.canActivate(buildContext('k1', 2).context);
|
||||
}
|
||||
expect(() => guard.canActivate(buildContext('k1', 2).context)).toThrow(HttpException);
|
||||
jest.advanceTimersByTime(60_001);
|
||||
expect(guard.canActivate(buildContext('k1', 2).context)).toBe(true);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('respeita rateLimitPerMinute específico da chave', () => {
|
||||
const limit = 10;
|
||||
for (let i = 0; i < limit; i += 1) {
|
||||
expect(guard.canActivate(buildContext('k1', limit).context)).toBe(true);
|
||||
}
|
||||
expect(() => guard.canActivate(buildContext('k1', limit).context)).toThrow(HttpException);
|
||||
});
|
||||
|
||||
it('passa quando request.apiKey ausente (sem-op)', () => {
|
||||
expect(guard.canActivate(buildContext(null, 60).context)).toBe(true);
|
||||
});
|
||||
});
|
||||
55
src/common/guards/api-key-throttler.guard.ts
Normal file
55
src/common/guards/api-key-throttler.guard.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { RequestWithApiKey } from './api-key.guard';
|
||||
|
||||
interface Bucket {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const WINDOW_MS = 60_000;
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyThrottlerGuard implements CanActivate {
|
||||
private readonly buckets = new Map<string, Bucket>();
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const http = context.switchToHttp();
|
||||
const request = http.getRequest<RequestWithApiKey>();
|
||||
const response = http.getResponse<Response>();
|
||||
const apiKey = request.apiKey;
|
||||
|
||||
if (!apiKey) return true;
|
||||
|
||||
const limit = apiKey.rateLimitPerMinute > 0 ? apiKey.rateLimitPerMinute : 60;
|
||||
const now = Date.now();
|
||||
const bucket = this.buckets.get(apiKey.id);
|
||||
|
||||
if (!bucket || bucket.resetAt <= now) {
|
||||
this.buckets.set(apiKey.id, { count: 1, resetAt: now + WINDOW_MS });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (bucket.count >= limit) {
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000));
|
||||
response.setHeader('Retry-After', String(retryAfterSeconds));
|
||||
throw new HttpException(
|
||||
{ statusCode: HttpStatus.TOO_MANY_REQUESTS, message: 'Limite de requisições excedido' },
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
|
||||
bucket.count += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.buckets.clear();
|
||||
}
|
||||
}
|
||||
220
src/common/guards/api-key.guard.spec.ts
Normal file
220
src/common/guards/api-key.guard.spec.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { IntegrationScope } from '@prisma/client';
|
||||
import { createHash } from 'crypto';
|
||||
import { ApiKeyGuard } from './api-key.guard';
|
||||
import {
|
||||
IntegrationApiKeyForGuard,
|
||||
IntegrationApiKeysRepository,
|
||||
} from '../../modules/integration-api-keys/integration-api-keys.repository';
|
||||
|
||||
function hashOf(plain: string): string {
|
||||
return createHash('sha256').update(plain).digest('hex');
|
||||
}
|
||||
|
||||
interface BuildContextOptions {
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
scopeRequired?: IntegrationScope;
|
||||
}
|
||||
|
||||
function buildContext({ headers = {}, scopeRequired }: BuildContextOptions): {
|
||||
context: ExecutionContext;
|
||||
request: { headers: Record<string, string | string[] | undefined>; apiKey?: unknown };
|
||||
reflector: Reflector;
|
||||
} {
|
||||
const request: { headers: Record<string, string | string[] | undefined>; apiKey?: unknown } = {
|
||||
headers,
|
||||
};
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValue(scopeRequired),
|
||||
} as unknown as Reflector;
|
||||
const context = {
|
||||
getHandler: jest.fn(),
|
||||
getClass: jest.fn(),
|
||||
switchToHttp: () => ({ getRequest: () => request }),
|
||||
} as unknown as ExecutionContext;
|
||||
return { context, request, reflector };
|
||||
}
|
||||
|
||||
function makeRepository(
|
||||
overrides: Partial<jest.Mocked<IntegrationApiKeysRepository>> = {},
|
||||
): jest.Mocked<IntegrationApiKeysRepository> {
|
||||
return {
|
||||
findByHashedKey: jest.fn(),
|
||||
touchLastUsed: jest.fn().mockResolvedValue(undefined),
|
||||
create: jest.fn(),
|
||||
findManyPaginated: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
revoke: jest.fn(),
|
||||
logUsage: jest.fn(),
|
||||
...overrides,
|
||||
} as unknown as jest.Mocked<IntegrationApiKeysRepository>;
|
||||
}
|
||||
|
||||
const validKey: IntegrationApiKeyForGuard = {
|
||||
id: 'key-1',
|
||||
scopes: [IntegrationScope.DELIVERABLES_READ],
|
||||
isActive: true,
|
||||
expiresAt: null,
|
||||
rateLimitPerMinute: 60,
|
||||
hashedKey: hashOf('valid-plain'),
|
||||
};
|
||||
|
||||
describe('ApiKeyGuard', () => {
|
||||
it('rejeita com 401 quando header X-API-Key ausente', async () => {
|
||||
const repository = makeRepository();
|
||||
const { context, reflector } = buildContext({
|
||||
scopeRequired: IntegrationScope.DELIVERABLES_READ,
|
||||
});
|
||||
const guard = new ApiKeyGuard(reflector, repository);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('permite chave válida com escopo correto e popula request.apiKey', async () => {
|
||||
const repository = makeRepository({
|
||||
findByHashedKey: jest.fn().mockResolvedValue(validKey),
|
||||
});
|
||||
const { context, request, reflector } = buildContext({
|
||||
headers: { 'x-api-key': 'valid-plain' },
|
||||
scopeRequired: IntegrationScope.DELIVERABLES_READ,
|
||||
});
|
||||
const guard = new ApiKeyGuard(reflector, repository);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
expect(request.apiKey).toEqual(validKey);
|
||||
expect(repository.touchLastUsed).toHaveBeenCalledWith('key-1');
|
||||
});
|
||||
|
||||
it('rejeita com 401 quando chave não encontrada', async () => {
|
||||
const repository = makeRepository({
|
||||
findByHashedKey: jest.fn().mockResolvedValue(null),
|
||||
});
|
||||
const { context, reflector } = buildContext({
|
||||
headers: { 'x-api-key': 'unknown-plain' },
|
||||
scopeRequired: IntegrationScope.DELIVERABLES_READ,
|
||||
});
|
||||
const guard = new ApiKeyGuard(reflector, repository);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejeita com 401 quando chave inativa', async () => {
|
||||
const repository = makeRepository({
|
||||
findByHashedKey: jest.fn().mockResolvedValue({ ...validKey, isActive: false }),
|
||||
});
|
||||
const { context, reflector } = buildContext({
|
||||
headers: { 'x-api-key': 'valid-plain' },
|
||||
scopeRequired: IntegrationScope.DELIVERABLES_READ,
|
||||
});
|
||||
const guard = new ApiKeyGuard(reflector, repository);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejeita com 401 quando chave expirada', async () => {
|
||||
const past = new Date(Date.now() - 60_000);
|
||||
const repository = makeRepository({
|
||||
findByHashedKey: jest.fn().mockResolvedValue({ ...validKey, expiresAt: past }),
|
||||
});
|
||||
const { context, reflector } = buildContext({
|
||||
headers: { 'x-api-key': 'valid-plain' },
|
||||
scopeRequired: IntegrationScope.DELIVERABLES_READ,
|
||||
});
|
||||
const guard = new ApiKeyGuard(reflector, repository);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejeita com 403 quando escopo insuficiente', async () => {
|
||||
const repository = makeRepository({
|
||||
findByHashedKey: jest.fn().mockResolvedValue(validKey),
|
||||
});
|
||||
const { context, reflector } = buildContext({
|
||||
headers: { 'x-api-key': 'valid-plain' },
|
||||
scopeRequired: IntegrationScope.CLIENTS_READ,
|
||||
});
|
||||
const guard = new ApiKeyGuard(reflector, repository);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('rejeita com 403 quando handler não declara @RequireScope (fail-closed)', async () => {
|
||||
const repository = makeRepository({
|
||||
findByHashedKey: jest.fn().mockResolvedValue(validKey),
|
||||
});
|
||||
const { context, reflector } = buildContext({
|
||||
headers: { 'x-api-key': 'valid-plain' },
|
||||
scopeRequired: undefined,
|
||||
});
|
||||
const guard = new ApiKeyGuard(reflector, repository);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('atualiza lastUsedAt em sucesso (fire-and-forget)', async () => {
|
||||
const repository = makeRepository({
|
||||
findByHashedKey: jest.fn().mockResolvedValue(validKey),
|
||||
});
|
||||
const { context, reflector } = buildContext({
|
||||
headers: { 'x-api-key': 'valid-plain' },
|
||||
scopeRequired: IntegrationScope.DELIVERABLES_READ,
|
||||
});
|
||||
const guard = new ApiKeyGuard(reflector, repository);
|
||||
|
||||
await guard.canActivate(context);
|
||||
expect(repository.touchLastUsed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falha em touchLastUsed não derruba a requisição', async () => {
|
||||
const repository = makeRepository({
|
||||
findByHashedKey: jest.fn().mockResolvedValue(validKey),
|
||||
touchLastUsed: jest.fn().mockRejectedValue(new Error('db down')),
|
||||
});
|
||||
const { context, reflector } = buildContext({
|
||||
headers: { 'x-api-key': 'valid-plain' },
|
||||
scopeRequired: IntegrationScope.DELIVERABLES_READ,
|
||||
});
|
||||
const guard = new ApiKeyGuard(reflector, repository);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('latência similar para chave válida vs inválida (timing attack mitigation)', async () => {
|
||||
const repository = makeRepository({
|
||||
findByHashedKey: jest.fn().mockImplementation((hash: string) => {
|
||||
if (hash === validKey.hashedKey) return Promise.resolve(validKey);
|
||||
return Promise.resolve(null);
|
||||
}),
|
||||
});
|
||||
const guardOk = new ApiKeyGuard(
|
||||
buildContext({
|
||||
headers: { 'x-api-key': 'valid-plain' },
|
||||
scopeRequired: IntegrationScope.DELIVERABLES_READ,
|
||||
}).reflector,
|
||||
repository,
|
||||
);
|
||||
|
||||
const okStart = process.hrtime.bigint();
|
||||
await guardOk.canActivate(
|
||||
buildContext({
|
||||
headers: { 'x-api-key': 'valid-plain' },
|
||||
scopeRequired: IntegrationScope.DELIVERABLES_READ,
|
||||
}).context,
|
||||
);
|
||||
const okElapsed = Number(process.hrtime.bigint() - okStart) / 1e6;
|
||||
|
||||
const badStart = process.hrtime.bigint();
|
||||
await expect(
|
||||
guardOk.canActivate(
|
||||
buildContext({
|
||||
headers: { 'x-api-key': 'wrong-plain' },
|
||||
scopeRequired: IntegrationScope.DELIVERABLES_READ,
|
||||
}).context,
|
||||
),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
const badElapsed = Number(process.hrtime.bigint() - badStart) / 1e6;
|
||||
|
||||
expect(Math.abs(okElapsed - badElapsed)).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
96
src/common/guards/api-key.guard.ts
Normal file
96
src/common/guards/api-key.guard.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { IntegrationScope } from '@prisma/client';
|
||||
import { createHash, timingSafeEqual } from 'crypto';
|
||||
import { Request } from 'express';
|
||||
import { REQUIRE_SCOPE_KEY } from '../decorators/require-scope.decorator';
|
||||
import {
|
||||
IntegrationApiKeyForGuard,
|
||||
IntegrationApiKeysRepository,
|
||||
} from '../../modules/integration-api-keys/integration-api-keys.repository';
|
||||
|
||||
export interface RequestWithApiKey extends Request {
|
||||
apiKey?: IntegrationApiKeyForGuard;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyGuard implements CanActivate {
|
||||
private readonly logger = new Logger(ApiKeyGuard.name);
|
||||
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly repository: IntegrationApiKeysRepository,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<RequestWithApiKey>();
|
||||
const headerValue = this.extractHeader(request);
|
||||
|
||||
if (!headerValue) {
|
||||
throw new UnauthorizedException('API Key ausente');
|
||||
}
|
||||
|
||||
const computedHash = createHash('sha256').update(headerValue).digest('hex');
|
||||
const apiKey = await this.repository.findByHashedKey(computedHash);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new UnauthorizedException('API Key inválida');
|
||||
}
|
||||
|
||||
if (!this.constantTimeEqualsHex(computedHash, apiKey.hashedKey)) {
|
||||
throw new UnauthorizedException('API Key inválida');
|
||||
}
|
||||
|
||||
if (!apiKey.isActive) {
|
||||
throw new UnauthorizedException('API Key revogada');
|
||||
}
|
||||
|
||||
if (apiKey.expiresAt && apiKey.expiresAt.getTime() < Date.now()) {
|
||||
throw new UnauthorizedException('API Key expirada');
|
||||
}
|
||||
|
||||
const requiredScope = this.reflector.getAllAndOverride<IntegrationScope | undefined>(
|
||||
REQUIRE_SCOPE_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredScope) {
|
||||
throw new ForbiddenException('Endpoint sem escopo declarado');
|
||||
}
|
||||
|
||||
if (!apiKey.scopes.includes(requiredScope)) {
|
||||
throw new ForbiddenException('Escopo insuficiente');
|
||||
}
|
||||
|
||||
request.apiKey = apiKey;
|
||||
|
||||
void this.repository.touchLastUsed(apiKey.id).catch((err: unknown) => {
|
||||
this.logger.warn(`Falha ao atualizar lastUsedAt: ${(err as Error).message}`);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractHeader(request: Request): string | null {
|
||||
const raw = request.headers['x-api-key'];
|
||||
if (!raw) return null;
|
||||
if (Array.isArray(raw)) return raw[0] ?? null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
private constantTimeEqualsHex(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
53
src/common/guards/read-only-client.guard.spec.ts
Normal file
53
src/common/guards/read-only-client.guard.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { ReadOnlyClientGuard } from './read-only-client.guard';
|
||||
import { Role } from '../enums/role.enum';
|
||||
|
||||
function buildContext(role: Role, method: string): ExecutionContext {
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user: { role }, method }),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
describe('ReadOnlyClientGuard', () => {
|
||||
let guard: ReadOnlyClientGuard;
|
||||
|
||||
beforeEach(() => {
|
||||
guard = new ReadOnlyClientGuard();
|
||||
});
|
||||
|
||||
it('deve permitir GET para CLIENT', () => {
|
||||
expect(guard.canActivate(buildContext(Role.CLIENT, 'GET'))).toBe(true);
|
||||
});
|
||||
|
||||
it('deve bloquear POST para CLIENT', () => {
|
||||
expect(() => guard.canActivate(buildContext(Role.CLIENT, 'POST'))).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('deve bloquear PATCH para CLIENT', () => {
|
||||
expect(() => guard.canActivate(buildContext(Role.CLIENT, 'PATCH'))).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('deve bloquear PUT para CLIENT', () => {
|
||||
expect(() => guard.canActivate(buildContext(Role.CLIENT, 'PUT'))).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('deve bloquear DELETE para CLIENT', () => {
|
||||
expect(() => guard.canActivate(buildContext(Role.CLIENT, 'DELETE'))).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('deve permitir POST para ADMIN', () => {
|
||||
expect(guard.canActivate(buildContext(Role.ADMIN, 'POST'))).toBe(true);
|
||||
});
|
||||
|
||||
it('deve permitir POST para OPERATOR', () => {
|
||||
expect(guard.canActivate(buildContext(Role.OPERATOR, 'POST'))).toBe(true);
|
||||
});
|
||||
|
||||
it('deve permitir PATCH para ADMIN', () => {
|
||||
expect(guard.canActivate(buildContext(Role.ADMIN, 'PATCH'))).toBe(true);
|
||||
});
|
||||
});
|
||||
30
src/common/guards/read-only-client.guard.ts
Normal file
30
src/common/guards/read-only-client.guard.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* ReadOnlyClientGuard — bloqueia operações de escrita para usuários CLIENT.
|
||||
*
|
||||
* Deve ser usado junto com JwtAuthGuard (que popula request.user).
|
||||
* Permite apenas GET para role CLIENT. Outros roles passam livremente.
|
||||
*
|
||||
* Exemplo:
|
||||
* @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard)
|
||||
* @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT)
|
||||
*/
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Role, CLIENT_ROLES } from '../enums/role.enum';
|
||||
|
||||
@Injectable()
|
||||
export class ReadOnlyClientGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{ user?: { role: Role }; method: string }>();
|
||||
const user = request.user;
|
||||
|
||||
if (!user || !(CLIENT_ROLES as readonly Role[]).includes(user.role)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (request.method !== 'GET') {
|
||||
throw new ForbiddenException('Acesso somente leitura para perfil cliente');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
47
src/common/guards/roles.guard.spec.ts
Normal file
47
src/common/guards/roles.guard.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { RolesGuard } from './roles.guard';
|
||||
import { Role } from '../enums/role.enum';
|
||||
|
||||
describe('RolesGuard', () => {
|
||||
let guard: RolesGuard;
|
||||
let reflector: jest.Mocked<Reflector>;
|
||||
|
||||
beforeEach(() => {
|
||||
reflector = { getAllAndOverride: jest.fn() } as unknown as jest.Mocked<Reflector>;
|
||||
guard = new RolesGuard(reflector);
|
||||
});
|
||||
|
||||
it('retorna true para rota sem @Roles (qualquer usuário autenticado)', () => {
|
||||
reflector.getAllAndOverride.mockReturnValue(undefined);
|
||||
const context = {
|
||||
getHandler: jest.fn(),
|
||||
getClass: jest.fn(),
|
||||
switchToHttp: () => ({ getRequest: () => ({ user: { role: Role.OPERATOR } }) }),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('retorna true para usuário ADMIN em rota @Roles(Role.ADMIN)', () => {
|
||||
reflector.getAllAndOverride.mockReturnValue([Role.ADMIN]);
|
||||
const context = {
|
||||
getHandler: jest.fn(),
|
||||
getClass: jest.fn(),
|
||||
switchToHttp: () => ({ getRequest: () => ({ user: { role: Role.ADMIN } }) }),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('retorna false para usuário OPERATOR em rota @Roles(Role.ADMIN)', () => {
|
||||
reflector.getAllAndOverride.mockReturnValue([Role.ADMIN]);
|
||||
const context = {
|
||||
getHandler: jest.fn(),
|
||||
getClass: jest.fn(),
|
||||
switchToHttp: () => ({ getRequest: () => ({ user: { role: Role.OPERATOR } }) }),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
expect(guard.canActivate(context)).toBe(false);
|
||||
});
|
||||
});
|
||||
30
src/common/guards/roles.guard.ts
Normal file
30
src/common/guards/roles.guard.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* RolesGuard — must always be used together with JwtAuthGuard.
|
||||
*
|
||||
* Example:
|
||||
* @UseGuards(JwtAuthGuard, RolesGuard)
|
||||
* @Roles(Role.ADMIN)
|
||||
* @Get()
|
||||
* findAll() { ... }
|
||||
*/
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
import { Role } from '../enums/role.enum';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) return true;
|
||||
|
||||
const { user } = context.switchToHttp().getRequest<{ user?: { role: Role } }>();
|
||||
return requiredRoles.includes(user?.role as Role);
|
||||
}
|
||||
}
|
||||
0
src/common/interceptors/.gitkeep
Normal file
0
src/common/interceptors/.gitkeep
Normal file
@@ -0,0 +1,96 @@
|
||||
import { CallHandler, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { firstValueFrom, of, throwError } from 'rxjs';
|
||||
import { IntegrationApiKeyUsageInterceptor } from './integration-api-key-usage.interceptor';
|
||||
import { IntegrationApiKeysRepository } from '../../modules/integration-api-keys/integration-api-keys.repository';
|
||||
|
||||
function buildContext(opts: {
|
||||
apiKeyId?: string;
|
||||
method?: string;
|
||||
url?: string;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
statusCode?: number;
|
||||
}): ExecutionContext {
|
||||
const request = {
|
||||
method: opts.method ?? 'GET',
|
||||
originalUrl: opts.url ?? '/api/v1/integrations/clients',
|
||||
url: opts.url ?? '/api/v1/integrations/clients',
|
||||
ip: opts.ip ?? '127.0.0.1',
|
||||
socket: { remoteAddress: opts.ip ?? '127.0.0.1' },
|
||||
headers: { 'user-agent': opts.userAgent ?? 'test-agent' },
|
||||
apiKey: opts.apiKeyId ? { id: opts.apiKeyId } : undefined,
|
||||
};
|
||||
const response = { statusCode: opts.statusCode ?? 200 };
|
||||
return {
|
||||
switchToHttp: () => ({ getRequest: () => request, getResponse: () => response }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
function makeRepository(): jest.Mocked<IntegrationApiKeysRepository> {
|
||||
return {
|
||||
logUsage: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as jest.Mocked<IntegrationApiKeysRepository>;
|
||||
}
|
||||
|
||||
describe('IntegrationApiKeyUsageInterceptor', () => {
|
||||
it('registra uso em sucesso', async () => {
|
||||
const repository = makeRepository();
|
||||
const interceptor = new IntegrationApiKeyUsageInterceptor(repository);
|
||||
const context = buildContext({ apiKeyId: 'k1', statusCode: 200 });
|
||||
const next: CallHandler = { handle: () => of({ data: [] }) };
|
||||
|
||||
const result = await firstValueFrom(interceptor.intercept(context, next));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(result).toEqual({ data: [] });
|
||||
expect(repository.logUsage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKeyId: 'k1',
|
||||
endpoint: 'GET /api/v1/integrations/clients',
|
||||
statusCode: 200,
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'test-agent',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('registra uso quando handler lança HttpException', async () => {
|
||||
const repository = makeRepository();
|
||||
const interceptor = new IntegrationApiKeyUsageInterceptor(repository);
|
||||
const context = buildContext({ apiKeyId: 'k1', statusCode: 200 });
|
||||
const err = new ForbiddenException('Escopo insuficiente');
|
||||
const next: CallHandler = { handle: () => throwError(() => err) };
|
||||
|
||||
await expect(firstValueFrom(interceptor.intercept(context, next))).rejects.toBe(err);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(repository.logUsage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ apiKeyId: 'k1', statusCode: 403 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('não registra quando request.apiKey ausente', async () => {
|
||||
const repository = makeRepository();
|
||||
const interceptor = new IntegrationApiKeyUsageInterceptor(repository);
|
||||
const context = buildContext({ apiKeyId: undefined });
|
||||
const next: CallHandler = { handle: () => of({}) };
|
||||
|
||||
await firstValueFrom(interceptor.intercept(context, next));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(repository.logUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falha em logUsage não derruba a requisição', async () => {
|
||||
const repository = makeRepository();
|
||||
repository.logUsage.mockRejectedValue(new Error('db down'));
|
||||
const interceptor = new IntegrationApiKeyUsageInterceptor(repository);
|
||||
const context = buildContext({ apiKeyId: 'k1' });
|
||||
const next: CallHandler = { handle: () => of({ ok: true }) };
|
||||
|
||||
const result = await firstValueFrom(interceptor.intercept(context, next));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
HttpException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { Observable, catchError, tap, throwError } from 'rxjs';
|
||||
import { RequestWithApiKey } from '../guards/api-key.guard';
|
||||
import { IntegrationApiKeysRepository } from '../../modules/integration-api-keys/integration-api-keys.repository';
|
||||
|
||||
@Injectable()
|
||||
export class IntegrationApiKeyUsageInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(IntegrationApiKeyUsageInterceptor.name);
|
||||
|
||||
constructor(private readonly repository: IntegrationApiKeysRepository) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const http = context.switchToHttp();
|
||||
const request = http.getRequest<RequestWithApiKey>();
|
||||
const response = http.getResponse<Response>();
|
||||
|
||||
const endpoint = `${request.method} ${request.originalUrl ?? request.url}`;
|
||||
const ipAddress = (request.ip ?? request.socket?.remoteAddress) || null;
|
||||
const userAgent = request.headers['user-agent'] ?? null;
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
const apiKey = request.apiKey;
|
||||
if (!apiKey) return;
|
||||
this.persistUsage({
|
||||
apiKeyId: apiKey.id,
|
||||
endpoint,
|
||||
statusCode: response.statusCode,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
}),
|
||||
catchError((err: unknown) => {
|
||||
const apiKey = request.apiKey;
|
||||
if (apiKey) {
|
||||
const statusCode =
|
||||
err instanceof HttpException ? err.getStatus() : response.statusCode || 500;
|
||||
this.persistUsage({
|
||||
apiKeyId: apiKey.id,
|
||||
endpoint,
|
||||
statusCode,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
}
|
||||
return throwError(() => err);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private persistUsage(payload: {
|
||||
apiKeyId: string;
|
||||
endpoint: string;
|
||||
statusCode: number;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
}): void {
|
||||
void this.repository.logUsage(payload).catch((err: unknown) => {
|
||||
this.logger.warn(`Falha ao registrar uso da API Key: ${(err as Error).message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
25
src/common/interceptors/logging.interceptor.ts
Normal file
25
src/common/interceptors/logging.interceptor.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger('HTTP');
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const { method, url } = req;
|
||||
const start = Date.now();
|
||||
|
||||
this.logger.log(`[${method}] ${url}`);
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
const res = context.switchToHttp().getResponse<Response>();
|
||||
const ms = Date.now() - start;
|
||||
this.logger.log(`[${method}] ${url} — ${res.statusCode} — ${ms}ms`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
124
src/common/interceptors/profile-visibility.interceptor.spec.ts
Normal file
124
src/common/interceptors/profile-visibility.interceptor.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { ExecutionContext } from '@nestjs/common';
|
||||
import { of } from 'rxjs';
|
||||
import { ProfileVisibilityInterceptor } from './profile-visibility.interceptor';
|
||||
import { Role } from '../enums/role.enum';
|
||||
import { FINANCIAL_FIELDS } from '../constants/visibility-map';
|
||||
|
||||
function buildContext(role: Role | undefined, url = '/deliverables'): ExecutionContext {
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user: role ? { role } : undefined, url }),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
function buildCallHandler(data: unknown) {
|
||||
return { handle: () => of(data) };
|
||||
}
|
||||
|
||||
describe('ProfileVisibilityInterceptor', () => {
|
||||
let interceptor: ProfileVisibilityInterceptor;
|
||||
|
||||
beforeEach(() => {
|
||||
interceptor = new ProfileVisibilityInterceptor();
|
||||
});
|
||||
|
||||
it('ADMIN receives data intact', (done) => {
|
||||
const data = { id: '1', totalValue: 100, ustValue: 50 };
|
||||
interceptor
|
||||
.intercept(buildContext(Role.ADMIN), buildCallHandler(data))
|
||||
.subscribe((result) => {
|
||||
expect(result).toEqual(data);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('PO does not receive totalValue or ustValue', (done) => {
|
||||
const data = { id: '1', title: 'Test', totalValue: 100, ustValue: 50 };
|
||||
interceptor
|
||||
.intercept(buildContext(Role.PO), buildCallHandler(data))
|
||||
.subscribe((result) => {
|
||||
expect(result).not.toHaveProperty('totalValue');
|
||||
expect(result).not.toHaveProperty('ustValue');
|
||||
expect(result).toHaveProperty('id');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('GESTOR_PROJETOS does not receive FINANCIAL_FIELDS', (done) => {
|
||||
const data: Record<string, unknown> = { id: '1', title: 'Test' };
|
||||
for (const field of FINANCIAL_FIELDS) {
|
||||
data[field] = 999;
|
||||
}
|
||||
interceptor
|
||||
.intercept(buildContext(Role.GESTOR_PROJETOS), buildCallHandler(data))
|
||||
.subscribe((result) => {
|
||||
for (const field of FINANCIAL_FIELDS) {
|
||||
expect(result).not.toHaveProperty(field);
|
||||
}
|
||||
expect(result).toHaveProperty('id');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters nested objects recursively', (done) => {
|
||||
// client IS in the PO whitelist; totalValue inside client should be stripped
|
||||
const data = {
|
||||
id: '1',
|
||||
title: 'Test',
|
||||
client: { id: 'c-1', name: 'ACME', totalValue: 500 },
|
||||
};
|
||||
interceptor
|
||||
.intercept(buildContext(Role.PO), buildCallHandler(data))
|
||||
.subscribe((result: Record<string, unknown>) => {
|
||||
const client = result['client'] as Record<string, unknown>;
|
||||
expect(client).toBeDefined();
|
||||
expect(client).not.toHaveProperty('totalValue');
|
||||
expect(client).toHaveProperty('id');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters arrays of objects', (done) => {
|
||||
const data = [
|
||||
{ id: '1', title: 'A', totalValue: 100 },
|
||||
{ id: '2', title: 'B', ustValue: 50 },
|
||||
];
|
||||
interceptor
|
||||
.intercept(buildContext(Role.PO), buildCallHandler(data))
|
||||
.subscribe((result: unknown[]) => {
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).not.toHaveProperty('totalValue');
|
||||
expect(result[1]).not.toHaveProperty('ustValue');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('/auth routes pass through unfiltered regardless of role', (done) => {
|
||||
const data = { token: 'abc123', role: Role.PO, totalValue: 99 };
|
||||
interceptor
|
||||
.intercept(buildContext(Role.PO, '/auth/login'), buildCallHandler(data))
|
||||
.subscribe((result) => {
|
||||
expect(result).toEqual(data);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([Role.FISCAL_CONTRATO, Role.GESTOR_CONTRATO])(
|
||||
'%s does not receive FINANCIAL_FIELDS',
|
||||
(role, done: jest.DoneCallback) => {
|
||||
const data: Record<string, unknown> = { id: '1' };
|
||||
for (const field of FINANCIAL_FIELDS) {
|
||||
data[field] = 999;
|
||||
}
|
||||
interceptor
|
||||
.intercept(buildContext(role), buildCallHandler(data))
|
||||
.subscribe((result) => {
|
||||
for (const field of FINANCIAL_FIELDS) {
|
||||
expect(result).not.toHaveProperty(field);
|
||||
}
|
||||
done();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
48
src/common/interceptors/profile-visibility.interceptor.ts
Normal file
48
src/common/interceptors/profile-visibility.interceptor.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { Role } from '../enums/role.enum';
|
||||
import { VISIBILITY_MAP } from '../constants/visibility-map';
|
||||
|
||||
@Injectable()
|
||||
export class ProfileVisibilityInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(ProfileVisibilityInterceptor.name);
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const request = context.switchToHttp().getRequest<{ user?: { role?: Role }; url?: string }>();
|
||||
const role = request?.user?.role;
|
||||
const url = request?.url ?? '';
|
||||
|
||||
// Auth routes and no-user requests pass through unfiltered
|
||||
if (!role || role === Role.ADMIN || url.startsWith('/auth')) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
return next.handle().pipe(map((data) => this.filterFields(data, role)));
|
||||
}
|
||||
|
||||
private filterFields(obj: unknown, role: Role): unknown {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => this.filterFields(item, role));
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const allowed = VISIBILITY_MAP[role];
|
||||
if (!allowed) return obj;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||
if (allowed.has(key)) {
|
||||
result[key] = this.filterFields(value, role);
|
||||
} else {
|
||||
this.logger.debug(`Blocked field "${key}" for role ${role}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
0
src/common/pipes/.gitkeep
Normal file
0
src/common/pipes/.gitkeep
Normal file
7
src/common/pipes/validation.pipe.ts
Normal file
7
src/common/pipes/validation.pipe.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
|
||||
export const validationPipe = new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
});
|
||||
0
src/common/utils/.gitkeep
Normal file
0
src/common/utils/.gitkeep
Normal file
38
src/common/utils/password-generator.spec.ts
Normal file
38
src/common/utils/password-generator.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { generatePassword } from './password-generator';
|
||||
|
||||
describe('generatePassword', () => {
|
||||
it('should generate a password with at least 12 characters by default', () => {
|
||||
const password = generatePassword();
|
||||
expect(password.length).toBe(12);
|
||||
});
|
||||
|
||||
it('should generate a password with the specified length', () => {
|
||||
const password = generatePassword(16);
|
||||
expect(password.length).toBe(16);
|
||||
});
|
||||
|
||||
it('should contain at least one uppercase letter', () => {
|
||||
const password = generatePassword();
|
||||
expect(password).toMatch(/[A-Z]/);
|
||||
});
|
||||
|
||||
it('should contain at least one lowercase letter', () => {
|
||||
const password = generatePassword();
|
||||
expect(password).toMatch(/[a-z]/);
|
||||
});
|
||||
|
||||
it('should contain at least one digit', () => {
|
||||
const password = generatePassword();
|
||||
expect(password).toMatch(/[0-9]/);
|
||||
});
|
||||
|
||||
it('should contain at least one special character', () => {
|
||||
const password = generatePassword();
|
||||
expect(password).toMatch(/[!@#$%&*]/);
|
||||
});
|
||||
|
||||
it('should generate different passwords on each call', () => {
|
||||
const passwords = new Set(Array.from({ length: 10 }, () => generatePassword()));
|
||||
expect(passwords.size).toBe(10);
|
||||
});
|
||||
});
|
||||
30
src/common/utils/password-generator.ts
Normal file
30
src/common/utils/password-generator.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { randomInt } from 'crypto';
|
||||
|
||||
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const DIGITS = '0123456789';
|
||||
const SPECIALS = '!@#$%&*';
|
||||
const ALL_CHARS = UPPERCASE + LOWERCASE + DIGITS + SPECIALS;
|
||||
|
||||
export function generatePassword(length = 12): string {
|
||||
const mandatory = [
|
||||
UPPERCASE[randomInt(UPPERCASE.length)],
|
||||
LOWERCASE[randomInt(LOWERCASE.length)],
|
||||
DIGITS[randomInt(DIGITS.length)],
|
||||
SPECIALS[randomInt(SPECIALS.length)],
|
||||
];
|
||||
|
||||
const remaining = Array.from(
|
||||
{ length: length - mandatory.length },
|
||||
() => ALL_CHARS[randomInt(ALL_CHARS.length)],
|
||||
);
|
||||
|
||||
const chars = [...mandatory, ...remaining];
|
||||
|
||||
for (let i = chars.length - 1; i > 0; i--) {
|
||||
const j = randomInt(i + 1);
|
||||
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||
}
|
||||
|
||||
return chars.join('');
|
||||
}
|
||||
0
src/config/auth/.gitkeep
Normal file
0
src/config/auth/.gitkeep
Normal file
25
src/config/auth/auth-config.module.ts
Normal file
25
src/config/auth/auth-config.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { EnvModule } from '../env/env.module';
|
||||
import { EnvService } from '../env/env.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
EnvModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [EnvModule],
|
||||
inject: [EnvService],
|
||||
useFactory: (envService: EnvService) => ({
|
||||
secret: envService.jwtSecret,
|
||||
signOptions: { expiresIn: '8h' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [JwtStrategy, JwtAuthGuard],
|
||||
exports: [JwtModule, JwtStrategy, JwtAuthGuard],
|
||||
})
|
||||
export class AuthConfigModule {}
|
||||
5
src/config/auth/jwt-auth.guard.ts
Normal file
5
src/config/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
38
src/config/auth/jwt.strategy.spec.ts
Normal file
38
src/config/auth/jwt.strategy.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
const mockEnvService = { jwtSecret: 'test-secret' };
|
||||
|
||||
describe('JwtStrategy', () => {
|
||||
let strategy: JwtStrategy;
|
||||
|
||||
beforeEach(() => {
|
||||
strategy = new JwtStrategy(mockEnvService as never);
|
||||
});
|
||||
|
||||
it('validate() returns id, email, role and clientId from payload', () => {
|
||||
const payload = { sub: 'user-123', email: 'user@example.com', role: 'ADMIN', clientId: null };
|
||||
const result = strategy.validate(payload);
|
||||
expect(result).toEqual({
|
||||
id: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'ADMIN',
|
||||
clientId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('validate() returns clientId for CLIENT payload', () => {
|
||||
const payload = {
|
||||
sub: 'user-456',
|
||||
email: 'client@empresa.com',
|
||||
role: 'CLIENT',
|
||||
clientId: 'client-id-1',
|
||||
};
|
||||
const result = strategy.validate(payload);
|
||||
expect(result).toEqual({
|
||||
id: 'user-456',
|
||||
email: 'client@empresa.com',
|
||||
role: 'CLIENT',
|
||||
clientId: 'client-id-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
36
src/config/auth/jwt.strategy.ts
Normal file
36
src/config/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { EnvService } from '../env/env.service';
|
||||
|
||||
interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
clientId: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(envService: EnvService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: envService.jwtSecret,
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload): {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
clientId: string | null;
|
||||
} {
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
clientId: payload.clientId ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
0
src/config/database/.gitkeep
Normal file
0
src/config/database/.gitkeep
Normal file
9
src/config/database/prisma.module.ts
Normal file
9
src/config/database/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
25
src/config/database/prisma.service.spec.ts
Normal file
25
src/config/database/prisma.service.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
describe('PrismaService', () => {
|
||||
let service: PrismaService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PrismaService();
|
||||
jest.spyOn(service, '$connect').mockResolvedValue();
|
||||
jest.spyOn(service, '$disconnect').mockResolvedValue();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should call $connect on module init', async () => {
|
||||
await service.onModuleInit();
|
||||
expect(service.$connect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call $disconnect on module destroy', async () => {
|
||||
await service.onModuleDestroy();
|
||||
expect(service.$disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
13
src/config/database/prisma.service.ts
Normal file
13
src/config/database/prisma.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
0
src/config/env/.gitkeep
vendored
Normal file
0
src/config/env/.gitkeep
vendored
Normal file
61
src/config/env/env.module.spec.ts
vendored
Normal file
61
src/config/env/env.module.spec.ts
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import * as Joi from 'joi';
|
||||
import { EnvService } from './env.service';
|
||||
|
||||
const validationSchema = Joi.object({
|
||||
DATABASE_URL: Joi.string().required(),
|
||||
JWT_SECRET: Joi.string().required(),
|
||||
PORT: Joi.number().default(3000),
|
||||
});
|
||||
|
||||
async function buildTestModule() {
|
||||
return Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
ignoreEnvFile: true,
|
||||
validationSchema,
|
||||
validationOptions: { abortEarly: true },
|
||||
}),
|
||||
],
|
||||
providers: [EnvService],
|
||||
}).compile();
|
||||
}
|
||||
|
||||
describe('EnvModule', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.DATABASE_URL;
|
||||
delete process.env.JWT_SECRET;
|
||||
delete process.env.PORT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should fail to initialize without JWT_SECRET', async () => {
|
||||
process.env.DATABASE_URL = 'postgresql://localhost:5432/iasis_gestao';
|
||||
await expect(buildTestModule()).rejects.toThrow(/JWT_SECRET/);
|
||||
});
|
||||
|
||||
it('should fail to initialize without DATABASE_URL', async () => {
|
||||
process.env.JWT_SECRET = 'super-secret';
|
||||
await expect(buildTestModule()).rejects.toThrow(/DATABASE_URL/);
|
||||
});
|
||||
|
||||
it('should initialize normally with valid env vars', async () => {
|
||||
process.env.DATABASE_URL = 'postgresql://localhost:5432/iasis_gestao';
|
||||
process.env.JWT_SECRET = 'super-secret';
|
||||
|
||||
const module = await buildTestModule();
|
||||
const envService = module.get(EnvService);
|
||||
|
||||
expect(envService.databaseUrl).toBe('postgresql://localhost:5432/iasis_gestao');
|
||||
expect(envService.jwtSecret).toBe('super-secret');
|
||||
expect(envService.port).toBe(3000);
|
||||
});
|
||||
});
|
||||
17
src/config/env/env.module.ts
vendored
Normal file
17
src/config/env/env.module.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EnvService } from './env.service';
|
||||
import { envValidationSchema } from './env.validation';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validationSchema: envValidationSchema,
|
||||
validationOptions: { abortEarly: true },
|
||||
}),
|
||||
],
|
||||
providers: [EnvService],
|
||||
exports: [EnvService],
|
||||
})
|
||||
export class EnvModule {}
|
||||
184
src/config/env/env.service.spec.ts
vendored
Normal file
184
src/config/env/env.service.spec.ts
vendored
Normal file
@@ -0,0 +1,184 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { EnvModule } from './env.module';
|
||||
import { EnvService } from './env.service';
|
||||
import { envValidationSchema } from './env.validation';
|
||||
|
||||
// ─── Helpers para testar o schema Joi diretamente ────────────────────────────
|
||||
|
||||
const BASE_VALID = {
|
||||
DATABASE_URL: 'postgresql://user:pass@localhost:5432/testdb',
|
||||
JWT_SECRET: 'test-secret',
|
||||
};
|
||||
|
||||
function validateEnv(values: Record<string, unknown>) {
|
||||
return envValidationSchema.validate({ ...BASE_VALID, ...values }, { abortEarly: false });
|
||||
}
|
||||
|
||||
// ─── Joi schema — validação condicional SMTP ──────────────────────────────────
|
||||
|
||||
describe('envValidationSchema — Joi validation', () => {
|
||||
it('aceita ausência de variáveis SMTP quando MAILER_PROVIDER não é smtp', () => {
|
||||
const { error } = validateEnv({ MAILER_PROVIDER: 'log' });
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('aceita ausência de MAILER_PROVIDER (usa default log)', () => {
|
||||
const { error } = validateEnv({});
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('aceita MAILER_PROVIDER=smtp com todas as vars SMTP presentes', () => {
|
||||
const { error } = validateEnv({
|
||||
MAILER_PROVIDER: 'smtp',
|
||||
SMTP_HOST: 'smtp.example.com',
|
||||
SMTP_PORT: 465,
|
||||
SMTP_USER: 'user@example.com',
|
||||
SMTP_PASS: 'secret',
|
||||
SMTP_FROM: 'noreply@example.com',
|
||||
});
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejeita MAILER_PROVIDER=smtp sem SMTP_HOST', () => {
|
||||
const { error } = validateEnv({
|
||||
MAILER_PROVIDER: 'smtp',
|
||||
SMTP_USER: 'user@example.com',
|
||||
SMTP_PASS: 'secret',
|
||||
SMTP_FROM: 'noreply@example.com',
|
||||
});
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toContain('SMTP_HOST');
|
||||
});
|
||||
|
||||
it('rejeita MAILER_PROVIDER=smtp sem SMTP_USER', () => {
|
||||
const { error } = validateEnv({
|
||||
MAILER_PROVIDER: 'smtp',
|
||||
SMTP_HOST: 'smtp.example.com',
|
||||
SMTP_PASS: 'secret',
|
||||
SMTP_FROM: 'noreply@example.com',
|
||||
});
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toContain('SMTP_USER');
|
||||
});
|
||||
|
||||
it('rejeita MAILER_PROVIDER=smtp sem SMTP_PASS', () => {
|
||||
const { error } = validateEnv({
|
||||
MAILER_PROVIDER: 'smtp',
|
||||
SMTP_HOST: 'smtp.example.com',
|
||||
SMTP_USER: 'user@example.com',
|
||||
SMTP_FROM: 'noreply@example.com',
|
||||
});
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toContain('SMTP_PASS');
|
||||
});
|
||||
|
||||
it('rejeita MAILER_PROVIDER=smtp sem SMTP_FROM', () => {
|
||||
const { error } = validateEnv({
|
||||
MAILER_PROVIDER: 'smtp',
|
||||
SMTP_HOST: 'smtp.example.com',
|
||||
SMTP_USER: 'user@example.com',
|
||||
SMTP_PASS: 'secret',
|
||||
});
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toContain('SMTP_FROM');
|
||||
});
|
||||
|
||||
it('aplica defaults: MAILER_PROVIDER=log, SMTP_PORT=587, PASSWORD_RESET_TOKEN_TTL_MINUTES=60', () => {
|
||||
const { value } = validateEnv({});
|
||||
expect(value.MAILER_PROVIDER).toBe('log');
|
||||
expect(value.SMTP_PORT).toBe(587);
|
||||
expect(value.FRONTEND_URL).toBe('http://localhost:5173');
|
||||
expect(value.PASSWORD_RESET_TOKEN_TTL_MINUTES).toBe(60);
|
||||
});
|
||||
|
||||
it('rejeita MAILER_PROVIDER inválido', () => {
|
||||
const { error } = validateEnv({ MAILER_PROVIDER: 'sendgrid' });
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejeita FRONTEND_URL que não é URI válida', () => {
|
||||
const { error } = validateEnv({ FRONTEND_URL: 'not-a-url' });
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejeita PASSWORD_RESET_TOKEN_TTL_MINUTES=0', () => {
|
||||
const { error } = validateEnv({ PASSWORD_RESET_TOKEN_TTL_MINUTES: 0 });
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── EnvService getters — ConfigService mockado ──────────────────────────────
|
||||
|
||||
function makeService(values: Record<string, unknown>): EnvService {
|
||||
const configService = {
|
||||
get: (key: string, defaultValue?: unknown) => values[key] ?? defaultValue,
|
||||
getOrThrow: (key: string) => {
|
||||
if (values[key] === undefined) throw new Error(`Missing: ${key}`);
|
||||
return values[key];
|
||||
},
|
||||
} as unknown as ConfigService;
|
||||
return new EnvService(configService);
|
||||
}
|
||||
|
||||
describe('EnvService — getters com valores explícitos', () => {
|
||||
let svc: EnvService;
|
||||
|
||||
beforeAll(() => {
|
||||
svc = makeService({
|
||||
DATABASE_URL: 'postgresql://test',
|
||||
JWT_SECRET: 'secret',
|
||||
PORT: 4000,
|
||||
MAILER_PROVIDER: 'smtp',
|
||||
SMTP_HOST: 'smtp.host.com',
|
||||
SMTP_PORT: 2525,
|
||||
SMTP_USER: 'usr',
|
||||
SMTP_PASS: 'pwd',
|
||||
SMTP_FROM: 'from@host.com',
|
||||
FRONTEND_URL: 'https://app.example.com',
|
||||
PASSWORD_RESET_TOKEN_TTL_MINUTES: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('mailerProvider', () => expect(svc.mailerProvider).toBe('smtp'));
|
||||
it('smtpHost', () => expect(svc.smtpHost).toBe('smtp.host.com'));
|
||||
it('smtpPort como número', () => expect(svc.smtpPort).toBe(2525));
|
||||
it('smtpUser', () => expect(svc.smtpUser).toBe('usr'));
|
||||
it('smtpPass', () => expect(svc.smtpPass).toBe('pwd'));
|
||||
it('smtpFrom', () => expect(svc.smtpFrom).toBe('from@host.com'));
|
||||
it('frontendUrl', () => expect(svc.frontendUrl).toBe('https://app.example.com'));
|
||||
it('passwordResetTtlMinutes como número', () => expect(svc.passwordResetTtlMinutes).toBe(30));
|
||||
});
|
||||
|
||||
describe('EnvService — defaults quando vars ausentes', () => {
|
||||
let svc: EnvService;
|
||||
|
||||
beforeAll(() => {
|
||||
svc = makeService({
|
||||
DATABASE_URL: 'postgresql://test',
|
||||
JWT_SECRET: 'secret',
|
||||
});
|
||||
});
|
||||
|
||||
it('mailerProvider default = log', () => expect(svc.mailerProvider).toBe('log'));
|
||||
it('smtpPort default = 587', () => expect(svc.smtpPort).toBe(587));
|
||||
it('frontendUrl default = http://localhost:5173', () =>
|
||||
expect(svc.frontendUrl).toBe('http://localhost:5173'));
|
||||
it('passwordResetTtlMinutes default = 60', () => expect(svc.passwordResetTtlMinutes).toBe(60));
|
||||
it('smtpHost undefined quando não definido', () => expect(svc.smtpHost).toBeUndefined());
|
||||
it('smtpUser undefined quando não definido', () => expect(svc.smtpUser).toBeUndefined());
|
||||
it('smtpPass undefined quando não definido', () => expect(svc.smtpPass).toBeUndefined());
|
||||
it('smtpFrom undefined quando não definido', () => expect(svc.smtpFrom).toBeUndefined());
|
||||
});
|
||||
|
||||
// ─── Boot real do EnvModule (smoke test) ─────────────────────────────────────
|
||||
|
||||
describe('EnvModule — boot de integração', () => {
|
||||
it('compila o módulo com as vars obrigatórias do .env', async () => {
|
||||
const module = await Test.createTestingModule({ imports: [EnvModule] }).compile();
|
||||
const svc = module.get(EnvService);
|
||||
expect(svc).toBeInstanceOf(EnvService);
|
||||
expect(svc.databaseUrl).toBeTruthy();
|
||||
expect(svc.jwtSecret).toBeTruthy();
|
||||
});
|
||||
});
|
||||
51
src/config/env/env.service.ts
vendored
Normal file
51
src/config/env/env.service.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class EnvService {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
get databaseUrl(): string {
|
||||
return this.config.getOrThrow<string>('DATABASE_URL');
|
||||
}
|
||||
|
||||
get jwtSecret(): string {
|
||||
return this.config.getOrThrow<string>('JWT_SECRET');
|
||||
}
|
||||
|
||||
get port(): number {
|
||||
return this.config.get<number>('PORT', 3000);
|
||||
}
|
||||
|
||||
get mailerProvider(): string {
|
||||
return this.config.get<string>('MAILER_PROVIDER', 'log');
|
||||
}
|
||||
|
||||
get smtpHost(): string | undefined {
|
||||
return this.config.get<string>('SMTP_HOST');
|
||||
}
|
||||
|
||||
get smtpPort(): number {
|
||||
return this.config.get<number>('SMTP_PORT', 587);
|
||||
}
|
||||
|
||||
get smtpUser(): string | undefined {
|
||||
return this.config.get<string>('SMTP_USER');
|
||||
}
|
||||
|
||||
get smtpPass(): string | undefined {
|
||||
return this.config.get<string>('SMTP_PASS');
|
||||
}
|
||||
|
||||
get smtpFrom(): string | undefined {
|
||||
return this.config.get<string>('SMTP_FROM');
|
||||
}
|
||||
|
||||
get frontendUrl(): string {
|
||||
return this.config.get<string>('FRONTEND_URL', 'http://localhost:5173');
|
||||
}
|
||||
|
||||
get passwordResetTtlMinutes(): number {
|
||||
return this.config.get<number>('PASSWORD_RESET_TOKEN_TTL_MINUTES', 60);
|
||||
}
|
||||
}
|
||||
31
src/config/env/env.validation.ts
vendored
Normal file
31
src/config/env/env.validation.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as Joi from 'joi';
|
||||
|
||||
export const envValidationSchema = Joi.object({
|
||||
DATABASE_URL: Joi.string().required(),
|
||||
JWT_SECRET: Joi.string().required(),
|
||||
PORT: Joi.number().default(3000),
|
||||
MAILER_PROVIDER: Joi.string().valid('smtp', 'log').default('log'),
|
||||
SMTP_HOST: Joi.string().when('MAILER_PROVIDER', {
|
||||
is: 'smtp',
|
||||
then: Joi.required(),
|
||||
otherwise: Joi.optional(),
|
||||
}),
|
||||
SMTP_PORT: Joi.number().default(587),
|
||||
SMTP_USER: Joi.string().when('MAILER_PROVIDER', {
|
||||
is: 'smtp',
|
||||
then: Joi.required(),
|
||||
otherwise: Joi.optional(),
|
||||
}),
|
||||
SMTP_PASS: Joi.string().when('MAILER_PROVIDER', {
|
||||
is: 'smtp',
|
||||
then: Joi.required(),
|
||||
otherwise: Joi.optional(),
|
||||
}),
|
||||
SMTP_FROM: Joi.string().when('MAILER_PROVIDER', {
|
||||
is: 'smtp',
|
||||
then: Joi.required(),
|
||||
otherwise: Joi.optional(),
|
||||
}),
|
||||
FRONTEND_URL: Joi.string().uri().default('http://localhost:5173'),
|
||||
PASSWORD_RESET_TOKEN_TTL_MINUTES: Joi.number().integer().min(1).default(60),
|
||||
});
|
||||
31
src/main.ts
Normal file
31
src/main.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { validationPipe } from './common/pipes/validation.pipe';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
|
||||
import { IntegrationsModule } from './modules/integrations/integrations.module';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableCors({ origin: process.env.CORS_ORIGIN ?? 'http://localhost:5173' });
|
||||
app.useGlobalPipes(validationPipe);
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new LoggingInterceptor());
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const integrationConfig = new DocumentBuilder()
|
||||
.setTitle('Iasis Gestão — API de Integração')
|
||||
.setVersion('1.0')
|
||||
.addApiKey({ type: 'apiKey', name: 'X-API-Key', in: 'header' }, 'api-key')
|
||||
.build();
|
||||
|
||||
const integrationDoc = SwaggerModule.createDocument(app, integrationConfig, {
|
||||
include: [IntegrationsModule],
|
||||
});
|
||||
SwaggerModule.setup('api/v1/integrations/docs', app, integrationDoc);
|
||||
}
|
||||
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
void bootstrap();
|
||||
0
src/modules/.gitkeep
Normal file
0
src/modules/.gitkeep
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { SprintType } from '@prisma/client';
|
||||
import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard';
|
||||
import { ReadOnlyClientGuard } from '../../common/guards/read-only-client.guard';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { Role } from '../../common/enums/role.enum';
|
||||
import { AllocationTemplatesService } from './allocation-templates.service';
|
||||
import { CreateAllocationTemplateDto } from './dto/create-allocation-template.dto';
|
||||
import { UpdateAllocationTemplateDto } from './dto/update-allocation-template.dto';
|
||||
import { ListAllocationTemplatesQueryDto } from './dto/list-allocation-templates-query.dto';
|
||||
|
||||
@Controller('clients/:clientId/allocation-templates')
|
||||
export class AllocationTemplatesController {
|
||||
constructor(private readonly service: AllocationTemplatesService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT)
|
||||
async findAll(
|
||||
@Param('clientId') clientId: string,
|
||||
@Query() query: ListAllocationTemplatesQueryDto,
|
||||
) {
|
||||
return this.service.findByClientId(clientId, query);
|
||||
}
|
||||
|
||||
@Get('match')
|
||||
@UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT)
|
||||
async findMatch(
|
||||
@Param('clientId') clientId: string,
|
||||
@Query('sprintType') sprintType: SprintType,
|
||||
@Query('contractItemId') contractItemId: string,
|
||||
) {
|
||||
return this.service.findMatch(clientId, sprintType, contractItemId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.OPERATOR)
|
||||
async create(@Param('clientId') clientId: string, @Body() dto: CreateAllocationTemplateDto) {
|
||||
return this.service.create(clientId, dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT)
|
||||
async findOne(@Param('clientId') clientId: string, @Param('id') id: string) {
|
||||
return this.service.findOne(clientId, id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.OPERATOR)
|
||||
async update(
|
||||
@Param('clientId') clientId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAllocationTemplateDto,
|
||||
) {
|
||||
return this.service.update(clientId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.OPERATOR)
|
||||
async remove(@Param('clientId') clientId: string, @Param('id') id: string) {
|
||||
return this.service.remove(clientId, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AllocationTemplatesController } from './allocation-templates.controller';
|
||||
import { AllocationTemplatesService } from './allocation-templates.service';
|
||||
import { AllocationTemplatesRepository } from './allocation-templates.repository';
|
||||
|
||||
@Module({
|
||||
controllers: [AllocationTemplatesController],
|
||||
providers: [AllocationTemplatesService, AllocationTemplatesRepository],
|
||||
exports: [AllocationTemplatesService],
|
||||
})
|
||||
export class AllocationTemplatesModule {}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, SprintType } from '@prisma/client';
|
||||
import { PrismaService } from '../../config/database/prisma.service';
|
||||
|
||||
const templateSelect = {
|
||||
id: true,
|
||||
sprintType: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
contractItem: { select: { id: true, code: true, name: true, itemType: true } },
|
||||
items: {
|
||||
select: {
|
||||
id: true,
|
||||
quantity: true,
|
||||
allocationPercentage: true,
|
||||
profile: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const templateListSelect = {
|
||||
id: true,
|
||||
sprintType: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
contractItem: { select: { id: true, code: true, name: true, itemType: true } },
|
||||
_count: { select: { items: true } },
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class AllocationTemplatesRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findByClientId(params: {
|
||||
clientId: string;
|
||||
sprintType?: SprintType;
|
||||
contractItemId?: string;
|
||||
page: number;
|
||||
limit: number;
|
||||
}) {
|
||||
const where: Prisma.AllocationTemplateWhereInput = {
|
||||
clientId: params.clientId,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
if (params.sprintType) where.sprintType = params.sprintType;
|
||||
if (params.contractItemId) where.contractItemId = params.contractItemId;
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.allocationTemplate.findMany({
|
||||
where,
|
||||
select: templateListSelect,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (params.page - 1) * params.limit,
|
||||
take: params.limit,
|
||||
}),
|
||||
this.prisma.allocationTemplate.count({ where }),
|
||||
]);
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
return this.prisma.allocationTemplate.findUnique({
|
||||
where: { id },
|
||||
select: templateSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async findMatch(clientId: string, sprintType: SprintType, contractItemId: string) {
|
||||
return this.prisma.allocationTemplate.findUnique({
|
||||
where: {
|
||||
clientId_sprintType_contractItemId: { clientId, sprintType, contractItemId },
|
||||
},
|
||||
select: templateSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async create(
|
||||
data: {
|
||||
clientId: string;
|
||||
sprintType: SprintType;
|
||||
contractItemId: string;
|
||||
},
|
||||
items: { profileId: string; quantity: number; allocationPercentage: number }[],
|
||||
) {
|
||||
return this.prisma.allocationTemplate.create({
|
||||
data: {
|
||||
clientId: data.clientId,
|
||||
sprintType: data.sprintType,
|
||||
contractItemId: data.contractItemId,
|
||||
items: {
|
||||
create: items,
|
||||
},
|
||||
},
|
||||
select: templateSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
items: { profileId: string; quantity: number; allocationPercentage: number }[],
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.allocationTemplateItem.deleteMany({ where: { templateId: id } });
|
||||
|
||||
return tx.allocationTemplate.update({
|
||||
where: { id },
|
||||
data: {
|
||||
items: { create: items },
|
||||
},
|
||||
select: templateSelect,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async softDelete(id: string) {
|
||||
return this.prisma.allocationTemplate.update({
|
||||
where: { id },
|
||||
data: { isActive: false },
|
||||
select: templateSelect,
|
||||
});
|
||||
}
|
||||
}
|
||||
118
src/modules/allocation-templates/allocation-templates.service.ts
Normal file
118
src/modules/allocation-templates/allocation-templates.service.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { SprintType } from '@prisma/client';
|
||||
import { AllocationTemplatesRepository } from './allocation-templates.repository';
|
||||
import { PrismaService } from '../../config/database/prisma.service';
|
||||
import { CreateAllocationTemplateDto } from './dto/create-allocation-template.dto';
|
||||
import { UpdateAllocationTemplateDto } from './dto/update-allocation-template.dto';
|
||||
import { ListAllocationTemplatesQueryDto } from './dto/list-allocation-templates-query.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AllocationTemplatesService {
|
||||
constructor(
|
||||
private readonly repository: AllocationTemplatesRepository,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async findByClientId(clientId: string, query: ListAllocationTemplatesQueryDto) {
|
||||
await this.validateClientExists(clientId);
|
||||
const { page, limit, ...filters } = query;
|
||||
const { data, total } = await this.repository.findByClientId({
|
||||
clientId,
|
||||
...filters,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
async findOne(clientId: string, id: string) {
|
||||
await this.validateClientExists(clientId);
|
||||
const template = await this.repository.findById(id);
|
||||
|
||||
if (!template) {
|
||||
throw new NotFoundException('Template de alocação não encontrado');
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
async findMatch(clientId: string, sprintType: SprintType, contractItemId: string) {
|
||||
await this.validateClientExists(clientId);
|
||||
return this.repository.findMatch(clientId, sprintType, contractItemId);
|
||||
}
|
||||
|
||||
async create(clientId: string, dto: CreateAllocationTemplateDto) {
|
||||
await this.validateClientExists(clientId);
|
||||
|
||||
const existing = await this.repository.findMatch(clientId, dto.sprintType, dto.contractItemId);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
'Já existe um template para esta combinação de tipo de sprint e item de contrato',
|
||||
);
|
||||
}
|
||||
|
||||
await this.validateContractItemBelongsToClient(dto.contractItemId, clientId);
|
||||
await this.validateProfilesBelongToClient(
|
||||
dto.items.map((i) => i.profileId),
|
||||
clientId,
|
||||
);
|
||||
|
||||
return this.repository.create(
|
||||
{ clientId, sprintType: dto.sprintType, contractItemId: dto.contractItemId },
|
||||
dto.items,
|
||||
);
|
||||
}
|
||||
|
||||
async update(clientId: string, id: string, dto: UpdateAllocationTemplateDto) {
|
||||
const template = await this.findOne(clientId, id);
|
||||
|
||||
if (!template) {
|
||||
throw new NotFoundException('Template de alocação não encontrado');
|
||||
}
|
||||
|
||||
await this.validateProfilesBelongToClient(
|
||||
dto.items.map((i) => i.profileId),
|
||||
clientId,
|
||||
);
|
||||
|
||||
return this.repository.update(id, dto.items);
|
||||
}
|
||||
|
||||
async remove(clientId: string, id: string) {
|
||||
await this.findOne(clientId, id);
|
||||
return this.repository.softDelete(id);
|
||||
}
|
||||
|
||||
private async validateClientExists(clientId: string) {
|
||||
const client = await this.prisma.client.findUnique({
|
||||
where: { id: clientId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new NotFoundException('Cliente não encontrado');
|
||||
}
|
||||
}
|
||||
|
||||
private async validateContractItemBelongsToClient(contractItemId: string, clientId: string) {
|
||||
const item = await this.prisma.contractItem.findUnique({
|
||||
where: { id: contractItemId },
|
||||
select: { clientId: true },
|
||||
});
|
||||
|
||||
if (!item || item.clientId !== clientId) {
|
||||
throw new NotFoundException('Item de contrato não encontrado para este cliente');
|
||||
}
|
||||
}
|
||||
|
||||
private async validateProfilesBelongToClient(profileIds: string[], clientId: string) {
|
||||
const profiles = await this.prisma.clientProfile.findMany({
|
||||
where: { id: { in: profileIds }, clientId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (profiles.length !== profileIds.length) {
|
||||
throw new NotFoundException('Um ou mais perfis não pertencem a este cliente');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { IsEnum, IsInt, IsNumber, IsUUID, Max, Min, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { SprintType } from '@prisma/client';
|
||||
|
||||
export class CreateAllocationTemplateItemDto {
|
||||
@IsUUID()
|
||||
profileId!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity!: number;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
allocationPercentage!: number;
|
||||
}
|
||||
|
||||
export class CreateAllocationTemplateDto {
|
||||
@IsEnum(SprintType)
|
||||
sprintType!: SprintType;
|
||||
|
||||
@IsUUID()
|
||||
contractItemId!: string;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateAllocationTemplateItemDto)
|
||||
items!: CreateAllocationTemplateItemDto[];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IsEnum, IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { SprintType } from '@prisma/client';
|
||||
|
||||
export class ListAllocationTemplatesQueryDto {
|
||||
@IsOptional()
|
||||
@IsEnum(SprintType)
|
||||
sprintType?: SprintType;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
contractItemId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit: number = 20;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IsInt, IsNumber, IsUUID, Max, Min, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class UpdateAllocationTemplateItemDto {
|
||||
@IsUUID()
|
||||
profileId!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity!: number;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
allocationPercentage!: number;
|
||||
}
|
||||
|
||||
export class UpdateAllocationTemplateDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UpdateAllocationTemplateItemDto)
|
||||
items!: UpdateAllocationTemplateItemDto[];
|
||||
}
|
||||
72
src/modules/auth/auth.controller.spec.ts
Normal file
72
src/modules/auth/auth.controller.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import type { CurrentUserPayload } from '../../common/decorators/user.decorator';
|
||||
import { Role } from '../../common/enums/role.enum';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
const mockLoginResult = {
|
||||
accessToken: 'jwt-token',
|
||||
user: {
|
||||
id: 'user-id-1',
|
||||
name: 'Admin',
|
||||
email: 'admin@iasis.com.br',
|
||||
role: 'ADMIN',
|
||||
},
|
||||
};
|
||||
|
||||
const mockMeResult = {
|
||||
id: 'user-id-1',
|
||||
name: 'Admin',
|
||||
email: 'admin@iasis.com.br',
|
||||
role: 'ADMIN',
|
||||
};
|
||||
|
||||
describe('AuthController', () => {
|
||||
let controller: AuthController;
|
||||
let authService: jest.Mocked<AuthService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: {
|
||||
login: jest.fn().mockResolvedValue(mockLoginResult),
|
||||
getMe: jest.fn().mockResolvedValue(mockMeResult),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AuthController>(AuthController);
|
||||
authService = module.get(AuthService);
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should call authService.login and return the result', async () => {
|
||||
const dto = { email: 'admin@iasis.com.br', password: 'admin123' };
|
||||
|
||||
const result = await controller.login(dto);
|
||||
|
||||
expect(authService.login).toHaveBeenCalledWith(dto);
|
||||
expect(result).toEqual(mockLoginResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMe', () => {
|
||||
it('should call authService.getMe and return user data', async () => {
|
||||
const user: CurrentUserPayload = {
|
||||
id: 'user-id-1',
|
||||
email: 'admin@iasis.com.br',
|
||||
role: Role.ADMIN,
|
||||
clientId: null,
|
||||
};
|
||||
|
||||
const result = await controller.getMe(user);
|
||||
|
||||
expect(authService.getMe).toHaveBeenCalledWith('user-id-1');
|
||||
expect(result).toEqual(mockMeResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
53
src/modules/auth/auth.controller.ts
Normal file
53
src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard';
|
||||
import { CurrentUser, type CurrentUserPayload } from '../../common/decorators/user.decorator';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async changePassword(@CurrentUser() user: CurrentUserPayload, @Body() dto: ChangePasswordDto) {
|
||||
return this.authService.changePassword(user.id, dto);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getMe(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.authService.getMe(user.id);
|
||||
}
|
||||
|
||||
@Post('forgot-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async forgotPassword(@Body() dto: ForgotPasswordDto) {
|
||||
return this.authService.forgotPassword(dto.email);
|
||||
}
|
||||
|
||||
@Post('reset-password/:token')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async resetPassword(@Param('token') token: string, @Body() dto: ResetPasswordDto) {
|
||||
return this.authService.resetPassword(token, dto);
|
||||
}
|
||||
}
|
||||
16
src/modules/auth/auth.module.ts
Normal file
16
src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthConfigModule } from '../../config/auth/auth-config.module';
|
||||
import { EnvModule } from '../../config/env/env.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthRepository } from './auth.repository';
|
||||
import { PasswordPolicyService } from './password-policy.service';
|
||||
import { PasswordResetRepository } from './password-reset.repository';
|
||||
|
||||
@Module({
|
||||
imports: [AuthConfigModule, EnvModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthRepository, PasswordPolicyService, PasswordResetRepository],
|
||||
exports: [PasswordPolicyService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
36
src/modules/auth/auth.repository.ts
Normal file
36
src/modules/auth/auth.repository.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../config/database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findByEmail(email: string) {
|
||||
return this.prisma.user.findUnique({ where: { email } });
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
mustChangePassword: true,
|
||||
clientId: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByIdWithPassword(id: string) {
|
||||
return this.prisma.user.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async updatePassword(id: string, hashedPassword: string) {
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { password: hashedPassword, mustChangePassword: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
450
src/modules/auth/auth.service.spec.ts
Normal file
450
src/modules/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash } from 'crypto';
|
||||
import { Role } from '../../common/enums/role.enum';
|
||||
import { AuthRepository } from './auth.repository';
|
||||
import { AuthService } from './auth.service';
|
||||
import { PasswordPolicyService } from './password-policy.service';
|
||||
import { PasswordResetRepository } from './password-reset.repository';
|
||||
import { MailerService } from '../mailer/mailer.service';
|
||||
import { EnvService } from '../../config/env/env.service';
|
||||
|
||||
jest.mock('bcrypt');
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-id-1',
|
||||
name: 'Admin',
|
||||
email: 'admin@iasis.com.br',
|
||||
password: 'hashed-password',
|
||||
role: Role.ADMIN as Role,
|
||||
isActive: true,
|
||||
mustChangePassword: false,
|
||||
clientId: null as string | null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockToken = {
|
||||
id: 'token-id-1',
|
||||
userId: 'user-id-1',
|
||||
tokenHash: 'hash',
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
usedAt: null as Date | null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let repository: jest.Mocked<AuthRepository>;
|
||||
let jwtService: jest.Mocked<JwtService>;
|
||||
let passwordResetRepository: jest.Mocked<PasswordResetRepository>;
|
||||
let mailerService: jest.Mocked<MailerService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
PasswordPolicyService,
|
||||
{
|
||||
provide: AuthRepository,
|
||||
useValue: {
|
||||
findByEmail: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findByIdWithPassword: jest.fn(),
|
||||
updatePassword: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: JwtService,
|
||||
useValue: {
|
||||
sign: jest.fn().mockReturnValue('jwt-token'),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PasswordResetRepository,
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
findByHash: jest.fn(),
|
||||
invalidatePrevious: jest.fn(),
|
||||
markAsUsed: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MailerService,
|
||||
useValue: {
|
||||
sendMail: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EnvService,
|
||||
useValue: {
|
||||
frontendUrl: 'http://localhost:5173',
|
||||
passwordResetTtlMinutes: 60,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
repository = module.get(AuthRepository);
|
||||
jwtService = module.get(JwtService);
|
||||
passwordResetRepository = module.get(PasswordResetRepository);
|
||||
mailerService = module.get(MailerService);
|
||||
});
|
||||
|
||||
describe('getMe', () => {
|
||||
it('should return user data when user exists', async () => {
|
||||
const expectedUser = {
|
||||
id: mockUser.id,
|
||||
name: mockUser.name,
|
||||
email: mockUser.email,
|
||||
role: mockUser.role,
|
||||
mustChangePassword: mockUser.mustChangePassword,
|
||||
clientId: null,
|
||||
};
|
||||
repository.findById.mockResolvedValue(expectedUser);
|
||||
|
||||
const result = await service.getMe(mockUser.id);
|
||||
|
||||
expect(repository.findById).toHaveBeenCalledWith(mockUser.id);
|
||||
expect(result).toEqual(expectedUser);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when user is not found', async () => {
|
||||
repository.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getMe('invalid-id')).rejects.toThrow(UnauthorizedException);
|
||||
await expect(service.getMe('invalid-id')).rejects.toThrow('Usuário não encontrado');
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should return access token and user data with valid credentials', async () => {
|
||||
repository.findByEmail.mockResolvedValue(mockUser);
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const result = await service.login({
|
||||
email: 'admin@iasis.com.br',
|
||||
password: 'admin123',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
accessToken: 'jwt-token',
|
||||
user: {
|
||||
id: mockUser.id,
|
||||
name: mockUser.name,
|
||||
email: mockUser.email,
|
||||
role: mockUser.role,
|
||||
mustChangePassword: mockUser.mustChangePassword,
|
||||
clientId: null,
|
||||
},
|
||||
});
|
||||
expect(jwtService.sign).toHaveBeenCalledWith({
|
||||
sub: mockUser.id,
|
||||
email: mockUser.email,
|
||||
role: mockUser.role,
|
||||
clientId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when email does not exist', async () => {
|
||||
repository.findByEmail.mockResolvedValue(null);
|
||||
|
||||
await expect(service.login({ email: 'invalid@test.com', password: 'any' })).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
|
||||
await expect(service.login({ email: 'invalid@test.com', password: 'any' })).rejects.toThrow(
|
||||
'Credenciais inválidas',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when password is incorrect', async () => {
|
||||
repository.findByEmail.mockResolvedValue(mockUser);
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
service.login({ email: 'admin@iasis.com.br', password: 'wrong' }),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
|
||||
await expect(
|
||||
service.login({ email: 'admin@iasis.com.br', password: 'wrong' }),
|
||||
).rejects.toThrow('Credenciais inválidas');
|
||||
});
|
||||
|
||||
it('should include clientId in JWT payload and response for CLIENT user', async () => {
|
||||
const clientUser = {
|
||||
...mockUser,
|
||||
role: Role.CLIENT as Role,
|
||||
clientId: 'client-id-1',
|
||||
};
|
||||
repository.findByEmail.mockResolvedValue(clientUser);
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const result = await service.login({
|
||||
email: 'admin@iasis.com.br',
|
||||
password: 'admin123',
|
||||
});
|
||||
|
||||
expect(jwtService.sign).toHaveBeenCalledWith({
|
||||
sub: clientUser.id,
|
||||
email: clientUser.email,
|
||||
role: clientUser.role,
|
||||
clientId: 'client-id-1',
|
||||
});
|
||||
expect(result.user.clientId).toBe('client-id-1');
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException when user is inactive', async () => {
|
||||
repository.findByEmail.mockResolvedValue({
|
||||
...mockUser,
|
||||
isActive: false,
|
||||
});
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
service.login({ email: 'admin@iasis.com.br', password: 'admin123' }),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
|
||||
await expect(
|
||||
service.login({ email: 'admin@iasis.com.br', password: 'admin123' }),
|
||||
).rejects.toThrow('Usuário inativo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('changePassword', () => {
|
||||
it('should change password and set mustChangePassword to false', async () => {
|
||||
repository.findByIdWithPassword.mockResolvedValue(mockUser);
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
|
||||
(bcrypt.hash as jest.Mock).mockResolvedValue('new-hashed-password');
|
||||
repository.updatePassword.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.changePassword(mockUser.id, {
|
||||
currentPassword: 'admin123',
|
||||
newPassword: 'Newpassword1',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ message: 'Senha alterada com sucesso' });
|
||||
expect(bcrypt.compare).toHaveBeenCalledWith('admin123', mockUser.password);
|
||||
expect(bcrypt.hash).toHaveBeenCalledWith('Newpassword1', 10);
|
||||
expect(repository.updatePassword).toHaveBeenCalledWith(mockUser.id, 'new-hashed-password');
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when new password fails policy', async () => {
|
||||
repository.findByIdWithPassword.mockResolvedValue(mockUser);
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
service.changePassword(mockUser.id, {
|
||||
currentPassword: 'admin123',
|
||||
newPassword: 'weakpassword',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
|
||||
await expect(
|
||||
service.changePassword(mockUser.id, {
|
||||
currentPassword: 'admin123',
|
||||
newPassword: 'weakpassword',
|
||||
}),
|
||||
).rejects.toThrow('A senha deve conter ao menos uma letra maiúscula');
|
||||
|
||||
expect(bcrypt.hash).not.toHaveBeenCalled();
|
||||
expect(repository.updatePassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when current password is wrong', async () => {
|
||||
repository.findByIdWithPassword.mockResolvedValue(mockUser);
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
service.changePassword(mockUser.id, {
|
||||
currentPassword: 'wrong',
|
||||
newPassword: 'newpassword123',
|
||||
}),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
|
||||
await expect(
|
||||
service.changePassword(mockUser.id, {
|
||||
currentPassword: 'wrong',
|
||||
newPassword: 'newpassword123',
|
||||
}),
|
||||
).rejects.toThrow('Senha atual incorreta');
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when user is not found', async () => {
|
||||
repository.findByIdWithPassword.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.changePassword('invalid-id', {
|
||||
currentPassword: 'any',
|
||||
newPassword: 'newpassword123',
|
||||
}),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forgotPassword', () => {
|
||||
const GENERIC_MSG = 'Se o email existir no sistema, um link de redefinição será enviado.';
|
||||
|
||||
it('should return generic message when email does not exist', async () => {
|
||||
repository.findByEmail.mockResolvedValue(null);
|
||||
|
||||
const result = await service.forgotPassword('unknown@test.com');
|
||||
|
||||
expect(result).toEqual({ message: GENERIC_MSG });
|
||||
expect(mailerService.sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return generic message when user is inactive (no email sent)', async () => {
|
||||
repository.findByEmail.mockResolvedValue({ ...mockUser, isActive: false });
|
||||
|
||||
const result = await service.forgotPassword(mockUser.email);
|
||||
|
||||
expect(result).toEqual({ message: GENERIC_MSG });
|
||||
expect(mailerService.sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should invalidate previous tokens, persist hash and send email for active user', async () => {
|
||||
repository.findByEmail.mockResolvedValue(mockUser);
|
||||
passwordResetRepository.invalidatePrevious.mockResolvedValue({ count: 1 } as never);
|
||||
passwordResetRepository.create.mockResolvedValue(mockToken);
|
||||
|
||||
const result = await service.forgotPassword(mockUser.email);
|
||||
|
||||
expect(passwordResetRepository.invalidatePrevious).toHaveBeenCalledWith(mockUser.id);
|
||||
expect(passwordResetRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: mockUser.id,
|
||||
tokenHash: expect.any(String),
|
||||
expiresAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
expect(mailerService.sendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: mockUser.email }),
|
||||
);
|
||||
expect(result).toEqual({ message: GENERIC_MSG });
|
||||
});
|
||||
|
||||
it('should still return generic message when mail sending fails', async () => {
|
||||
repository.findByEmail.mockResolvedValue(mockUser);
|
||||
passwordResetRepository.invalidatePrevious.mockResolvedValue({ count: 0 } as never);
|
||||
passwordResetRepository.create.mockResolvedValue(mockToken);
|
||||
mailerService.sendMail.mockRejectedValue(new Error('SMTP error'));
|
||||
|
||||
const result = await service.forgotPassword(mockUser.email);
|
||||
|
||||
expect(result).toEqual({ message: GENERIC_MSG });
|
||||
});
|
||||
|
||||
it('should store SHA-256 hash, not raw token', async () => {
|
||||
repository.findByEmail.mockResolvedValue(mockUser);
|
||||
passwordResetRepository.invalidatePrevious.mockResolvedValue({ count: 0 } as never);
|
||||
|
||||
let capturedHash = '';
|
||||
passwordResetRepository.create.mockImplementation(async (data) => {
|
||||
capturedHash = data.tokenHash;
|
||||
return mockToken;
|
||||
});
|
||||
|
||||
let emailLink = '';
|
||||
mailerService.sendMail.mockImplementation(async (payload) => {
|
||||
emailLink = payload.html;
|
||||
});
|
||||
|
||||
await service.forgotPassword(mockUser.email);
|
||||
|
||||
const rawTokenFromLink = emailLink.match(/redefinir-senha\/([a-f0-9]+)/)?.[1] ?? '';
|
||||
expect(rawTokenFromLink).toBeTruthy();
|
||||
expect(capturedHash).toBe(createHash('sha256').update(rawTokenFromLink).digest('hex'));
|
||||
expect(capturedHash).not.toBe(rawTokenFromLink);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetPassword', () => {
|
||||
const validDto = { newPassword: 'ValidPass1', confirmPassword: 'ValidPass1' };
|
||||
|
||||
it('should throw BadRequestException when token does not exist', async () => {
|
||||
passwordResetRepository.findByHash.mockResolvedValue(null);
|
||||
|
||||
await expect(service.resetPassword('invalid-token', validDto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(service.resetPassword('invalid-token', validDto)).rejects.toThrow(
|
||||
'Token de redefinição inválido',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when token is already used', async () => {
|
||||
passwordResetRepository.findByHash.mockResolvedValue({
|
||||
...mockToken,
|
||||
usedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(service.resetPassword('some-token', validDto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(service.resetPassword('some-token', validDto)).rejects.toThrow(
|
||||
'Token de redefinição já utilizado',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when token is expired', async () => {
|
||||
passwordResetRepository.findByHash.mockResolvedValue({
|
||||
...mockToken,
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
});
|
||||
|
||||
await expect(service.resetPassword('some-token', validDto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(service.resetPassword('some-token', validDto)).rejects.toThrow(
|
||||
'Token de redefinição expirado',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when passwords do not match', async () => {
|
||||
passwordResetRepository.findByHash.mockResolvedValue(mockToken);
|
||||
|
||||
await expect(
|
||||
service.resetPassword('some-token', {
|
||||
newPassword: 'ValidPass1',
|
||||
confirmPassword: 'DifferentPass1',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
await expect(
|
||||
service.resetPassword('some-token', {
|
||||
newPassword: 'ValidPass1',
|
||||
confirmPassword: 'DifferentPass1',
|
||||
}),
|
||||
).rejects.toThrow('As senhas não coincidem');
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when password fails complexity policy', async () => {
|
||||
passwordResetRepository.findByHash.mockResolvedValue(mockToken);
|
||||
|
||||
await expect(
|
||||
service.resetPassword('some-token', {
|
||||
newPassword: 'weakpassword',
|
||||
confirmPassword: 'weakpassword',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should reset password, mark token as used and set mustChangePassword=false on happy path', async () => {
|
||||
passwordResetRepository.findByHash.mockResolvedValue(mockToken);
|
||||
(bcrypt.hash as jest.Mock).mockResolvedValue('new-hashed-password');
|
||||
repository.updatePassword.mockResolvedValue(undefined);
|
||||
passwordResetRepository.markAsUsed.mockResolvedValue({ ...mockToken, usedAt: new Date() });
|
||||
|
||||
const result = await service.resetPassword('some-token', validDto);
|
||||
|
||||
expect(bcrypt.hash).toHaveBeenCalledWith(validDto.newPassword, 10);
|
||||
expect(repository.updatePassword).toHaveBeenCalledWith(mockToken.userId, 'new-hashed-password');
|
||||
expect(passwordResetRepository.markAsUsed).toHaveBeenCalledWith(mockToken.id);
|
||||
expect(result).toEqual({ message: 'Senha redefinida com sucesso. Você já pode fazer login.' });
|
||||
});
|
||||
});
|
||||
});
|
||||
172
src/modules/auth/auth.service.ts
Normal file
172
src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { AuthRepository } from './auth.repository';
|
||||
import { PasswordPolicyService } from './password-policy.service';
|
||||
import { PasswordResetRepository } from './password-reset.repository';
|
||||
import { MailerService } from '../mailer/mailer.service';
|
||||
import { EnvService } from '../../config/env/env.service';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly repository: AuthRepository,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly passwordPolicy: PasswordPolicyService,
|
||||
private readonly passwordResetRepository: PasswordResetRepository,
|
||||
private readonly mailerService: MailerService,
|
||||
private readonly env: EnvService,
|
||||
) {}
|
||||
|
||||
private async validateUser(email: string, plainPassword: string) {
|
||||
const user = await this.repository.findByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Credenciais inválidas');
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(plainPassword, user.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('Credenciais inválidas');
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new ForbiddenException('Usuário inativo');
|
||||
}
|
||||
|
||||
const { password, ...userWithoutPassword } = user;
|
||||
void password;
|
||||
return userWithoutPassword;
|
||||
}
|
||||
|
||||
async getMe(userId: string) {
|
||||
const user = await this.repository.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Usuário não encontrado');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async changePassword(userId: string, dto: ChangePasswordDto) {
|
||||
const user = await this.repository.findByIdWithPassword(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Usuário não encontrado');
|
||||
}
|
||||
|
||||
const isCurrentPasswordValid = await bcrypt.compare(dto.currentPassword, user.password);
|
||||
|
||||
if (!isCurrentPasswordValid) {
|
||||
throw new UnauthorizedException('Senha atual incorreta');
|
||||
}
|
||||
|
||||
this.passwordPolicy.validate(dto.newPassword);
|
||||
|
||||
const hashedPassword = await bcrypt.hash(dto.newPassword, 10);
|
||||
await this.repository.updatePassword(userId, hashedPassword);
|
||||
|
||||
return { message: 'Senha alterada com sucesso' };
|
||||
}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
const user = await this.validateUser(dto.email, dto.password);
|
||||
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
clientId: user.clientId ?? null,
|
||||
};
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
clientId: user.clientId ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async forgotPassword(email: string): Promise<{ message: string }> {
|
||||
const GENERIC_MSG = 'Se o email existir no sistema, um link de redefinição será enviado.';
|
||||
|
||||
const user = await this.repository.findByEmail(email);
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return { message: GENERIC_MSG };
|
||||
}
|
||||
|
||||
const tokenBruto = randomBytes(32).toString('hex');
|
||||
const tokenHash = createHash('sha256').update(tokenBruto).digest('hex');
|
||||
const expiresAt = new Date(Date.now() + this.env.passwordResetTtlMinutes * 60 * 1000);
|
||||
|
||||
await this.passwordResetRepository.invalidatePrevious(user.id);
|
||||
await this.passwordResetRepository.create({ userId: user.id, tokenHash, expiresAt });
|
||||
|
||||
const link = `${this.env.frontendUrl}/redefinir-senha/${tokenBruto}`;
|
||||
|
||||
try {
|
||||
await this.mailerService.sendMail({
|
||||
to: user.email,
|
||||
subject: 'Redefinição de senha — ISIS Gestão',
|
||||
html: `<p>Olá, ${user.name}!</p><p>Clique no link abaixo para redefinir sua senha:</p><p><a href="${link}">${link}</a></p><p>O link expira em ${this.env.passwordResetTtlMinutes} minutos e é de uso único.</p>`,
|
||||
});
|
||||
this.logger.log('Email de reset enviado', { userId: user.id });
|
||||
} catch (e) {
|
||||
this.logger.error('Falha ao enviar email', { error: (e as Error).message });
|
||||
}
|
||||
|
||||
return { message: GENERIC_MSG };
|
||||
}
|
||||
|
||||
async resetPassword(token: string, dto: ResetPasswordDto): Promise<{ message: string }> {
|
||||
const tokenHash = createHash('sha256').update(token).digest('hex');
|
||||
const tokenRecord = await this.passwordResetRepository.findByHash(tokenHash);
|
||||
|
||||
if (!tokenRecord) {
|
||||
throw new BadRequestException('Token de redefinição inválido');
|
||||
}
|
||||
|
||||
if (tokenRecord.usedAt) {
|
||||
throw new BadRequestException('Token de redefinição já utilizado');
|
||||
}
|
||||
|
||||
if (tokenRecord.expiresAt < new Date()) {
|
||||
throw new BadRequestException('Token de redefinição expirado');
|
||||
}
|
||||
|
||||
if (dto.newPassword !== dto.confirmPassword) {
|
||||
throw new BadRequestException('As senhas não coincidem');
|
||||
}
|
||||
|
||||
this.passwordPolicy.validate(dto.newPassword);
|
||||
|
||||
const hashedPassword = await bcrypt.hash(dto.newPassword, 10);
|
||||
await this.repository.updatePassword(tokenRecord.userId, hashedPassword);
|
||||
await this.passwordResetRepository.markAsUsed(tokenRecord.id);
|
||||
|
||||
this.logger.log('Token de reset consumido', { userId: tokenRecord.userId });
|
||||
|
||||
return { message: 'Senha redefinida com sucesso. Você já pode fazer login.' };
|
||||
}
|
||||
}
|
||||
0
src/modules/auth/dto/.gitkeep
Normal file
0
src/modules/auth/dto/.gitkeep
Normal file
11
src/modules/auth/dto/change-password.dto.ts
Normal file
11
src/modules/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
currentPassword!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword!: string;
|
||||
}
|
||||
6
src/modules/auth/dto/forgot-password.dto.ts
Normal file
6
src/modules/auth/dto/forgot-password.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsEmail } from 'class-validator';
|
||||
|
||||
export class ForgotPasswordDto {
|
||||
@IsEmail({}, { message: 'Email inválido' })
|
||||
email!: string;
|
||||
}
|
||||
10
src/modules/auth/dto/login.dto.ts
Normal file
10
src/modules/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password!: string;
|
||||
}
|
||||
11
src/modules/auth/dto/reset-password.dto.ts
Normal file
11
src/modules/auth/dto/reset-password.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Nova senha é obrigatória' })
|
||||
newPassword!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Confirmação de senha é obrigatória' })
|
||||
confirmPassword!: string;
|
||||
}
|
||||
34
src/modules/auth/password-policy.service.spec.ts
Normal file
34
src/modules/auth/password-policy.service.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { PasswordPolicyService } from './password-policy.service';
|
||||
|
||||
describe('PasswordPolicyService', () => {
|
||||
let service: PasswordPolicyService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PasswordPolicyService();
|
||||
});
|
||||
|
||||
it('does not throw for a valid password', () => {
|
||||
expect(() => service.validate('ValidPass1')).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws when password is shorter than 8 characters', () => {
|
||||
expect(() => service.validate('Val1A')).toThrow(BadRequestException);
|
||||
expect(() => service.validate('Val1A')).toThrow('A senha deve ter no mínimo 8 caracteres');
|
||||
});
|
||||
|
||||
it('throws when password has no uppercase letter', () => {
|
||||
expect(() => service.validate('validpass1')).toThrow(BadRequestException);
|
||||
expect(() => service.validate('validpass1')).toThrow('A senha deve conter ao menos uma letra maiúscula');
|
||||
});
|
||||
|
||||
it('throws when password has no lowercase letter', () => {
|
||||
expect(() => service.validate('VALIDPASS1')).toThrow(BadRequestException);
|
||||
expect(() => service.validate('VALIDPASS1')).toThrow('A senha deve conter ao menos uma letra minúscula');
|
||||
});
|
||||
|
||||
it('throws when password has no digit', () => {
|
||||
expect(() => service.validate('ValidPass')).toThrow(BadRequestException);
|
||||
expect(() => service.validate('ValidPass')).toThrow('A senha deve conter ao menos um número');
|
||||
});
|
||||
});
|
||||
21
src/modules/auth/password-policy.service.ts
Normal file
21
src/modules/auth/password-policy.service.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class PasswordPolicyService {
|
||||
private static readonly MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
validate(password: string): void {
|
||||
if (password.length < PasswordPolicyService.MIN_PASSWORD_LENGTH) {
|
||||
throw new BadRequestException('A senha deve ter no mínimo 8 caracteres');
|
||||
}
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
throw new BadRequestException('A senha deve conter ao menos uma letra maiúscula');
|
||||
}
|
||||
if (!/[a-z]/.test(password)) {
|
||||
throw new BadRequestException('A senha deve conter ao menos uma letra minúscula');
|
||||
}
|
||||
if (!/[0-9]/.test(password)) {
|
||||
throw new BadRequestException('A senha deve conter ao menos um número');
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src/modules/auth/password-reset.repository.spec.ts
Normal file
102
src/modules/auth/password-reset.repository.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { PrismaService } from '../../config/database/prisma.service';
|
||||
import { PasswordResetRepository } from './password-reset.repository';
|
||||
|
||||
const mockPrisma = {
|
||||
passwordResetToken: {
|
||||
create: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
describe('PasswordResetRepository', () => {
|
||||
let repository: PasswordResetRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PasswordResetRepository,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
repository = module.get<PasswordResetRepository>(PasswordResetRepository);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a password reset token', async () => {
|
||||
const data = {
|
||||
userId: 'user-id-1',
|
||||
tokenHash: 'hash-abc',
|
||||
expiresAt: new Date('2026-01-01T01:00:00Z'),
|
||||
};
|
||||
const created = { id: 'token-id-1', ...data, usedAt: null, createdAt: new Date() };
|
||||
mockPrisma.passwordResetToken.create.mockResolvedValue(created);
|
||||
|
||||
const result = await repository.create(data);
|
||||
|
||||
expect(mockPrisma.passwordResetToken.create).toHaveBeenCalledWith({ data });
|
||||
expect(result).toEqual(created);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByHash', () => {
|
||||
it('should return token when hash exists', async () => {
|
||||
const token = {
|
||||
id: 'token-id-1',
|
||||
userId: 'user-id-1',
|
||||
tokenHash: 'hash-abc',
|
||||
expiresAt: new Date(),
|
||||
usedAt: null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
mockPrisma.passwordResetToken.findUnique.mockResolvedValue(token);
|
||||
|
||||
const result = await repository.findByHash('hash-abc');
|
||||
|
||||
expect(mockPrisma.passwordResetToken.findUnique).toHaveBeenCalledWith({
|
||||
where: { tokenHash: 'hash-abc' },
|
||||
});
|
||||
expect(result).toEqual(token);
|
||||
});
|
||||
|
||||
it('should return null when hash does not exist', async () => {
|
||||
mockPrisma.passwordResetToken.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await repository.findByHash('nonexistent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidatePrevious', () => {
|
||||
it('should set usedAt on all unused tokens for a user', async () => {
|
||||
mockPrisma.passwordResetToken.updateMany.mockResolvedValue({ count: 2 });
|
||||
|
||||
await repository.invalidatePrevious('user-id-1');
|
||||
|
||||
expect(mockPrisma.passwordResetToken.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-id-1', usedAt: null },
|
||||
data: { usedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsUsed', () => {
|
||||
it('should set usedAt on the token', async () => {
|
||||
const updated = { id: 'token-id-1', usedAt: new Date() };
|
||||
mockPrisma.passwordResetToken.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await repository.markAsUsed('token-id-1');
|
||||
|
||||
expect(mockPrisma.passwordResetToken.update).toHaveBeenCalledWith({
|
||||
where: { id: 'token-id-1' },
|
||||
data: { usedAt: expect.any(Date) },
|
||||
});
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
});
|
||||
});
|
||||
29
src/modules/auth/password-reset.repository.ts
Normal file
29
src/modules/auth/password-reset.repository.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../config/database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class PasswordResetRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(data: { userId: string; tokenHash: string; expiresAt: Date }) {
|
||||
return this.prisma.passwordResetToken.create({ data });
|
||||
}
|
||||
|
||||
async findByHash(tokenHash: string) {
|
||||
return this.prisma.passwordResetToken.findUnique({ where: { tokenHash } });
|
||||
}
|
||||
|
||||
async invalidatePrevious(userId: string) {
|
||||
return this.prisma.passwordResetToken.updateMany({
|
||||
where: { userId, usedAt: null },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async markAsUsed(id: string) {
|
||||
return this.prisma.passwordResetToken.update({
|
||||
where: { id },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
61
src/modules/client-profiles/client-profiles.controller.ts
Normal file
61
src/modules/client-profiles/client-profiles.controller.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard';
|
||||
import { ReadOnlyClientGuard } from '../../common/guards/read-only-client.guard';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { Role } from '../../common/enums/role.enum';
|
||||
import { ClientProfilesService } from './client-profiles.service';
|
||||
import { CreateClientProfileDto } from './dto/create-client-profile.dto';
|
||||
import { UpdateClientProfileDto } from './dto/update-client-profile.dto';
|
||||
import { ListClientProfilesQueryDto } from './dto/list-client-profiles-query.dto';
|
||||
|
||||
@Controller('clients/:clientId/profiles')
|
||||
export class ClientProfilesController {
|
||||
constructor(private readonly clientProfilesService: ClientProfilesService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT)
|
||||
async findAll(@Param('clientId') clientId: string, @Query() query: ListClientProfilesQueryDto) {
|
||||
return this.clientProfilesService.findByClientId(clientId, query);
|
||||
}
|
||||
|
||||
@Get('active')
|
||||
@UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT)
|
||||
async findActive(@Param('clientId') clientId: string) {
|
||||
return this.clientProfilesService.findActiveByClientId(clientId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.OPERATOR)
|
||||
async create(@Param('clientId') clientId: string, @Body() dto: CreateClientProfileDto) {
|
||||
return this.clientProfilesService.create(clientId, dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT)
|
||||
async findOne(@Param('clientId') clientId: string, @Param('id') id: string) {
|
||||
return this.clientProfilesService.findOne(clientId, id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.OPERATOR)
|
||||
async update(
|
||||
@Param('clientId') clientId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateClientProfileDto,
|
||||
) {
|
||||
return this.clientProfilesService.update(clientId, id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/toggle-status')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.OPERATOR)
|
||||
async toggleStatus(@Param('clientId') clientId: string, @Param('id') id: string) {
|
||||
return this.clientProfilesService.toggleStatus(clientId, id);
|
||||
}
|
||||
}
|
||||
11
src/modules/client-profiles/client-profiles.module.ts
Normal file
11
src/modules/client-profiles/client-profiles.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ClientProfilesController } from './client-profiles.controller';
|
||||
import { ClientProfilesService } from './client-profiles.service';
|
||||
import { ClientProfilesRepository } from './client-profiles.repository';
|
||||
|
||||
@Module({
|
||||
controllers: [ClientProfilesController],
|
||||
providers: [ClientProfilesService, ClientProfilesRepository],
|
||||
exports: [ClientProfilesService],
|
||||
})
|
||||
export class ClientProfilesModule {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user