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