Commit inicial - upload de todos os arquivos da pasta

This commit is contained in:
2026-06-13 16:36:29 -03:00
commit 807be1c5ee
275 changed files with 29408 additions and 0 deletions

15
.env Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

102
package.json Normal file
View 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
View 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());

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "deliverables" ADD COLUMN "num_weeks" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "timebox_manutencao" DECIMAL(65,30);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "deliverables" ALTER COLUMN "num_weeks" DROP DEFAULT;

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "TimelineEventType" ADD VALUE 'VALOR_RECALCULADO';

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "work_orders" ADD COLUMN "total_value" DECIMAL(65,30);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;

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

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

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

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

View File

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

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

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

View File

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

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

View File

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

View File

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

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

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

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

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

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

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

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

View File

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

View File

@@ -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}`);
});
}
}

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

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

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

View File

View File

@@ -0,0 +1,7 @@
import { ValidationPipe } from '@nestjs/common';
export const validationPipe = new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
});

View File

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

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

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

View File

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

View File

@@ -0,0 +1,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',
});
});
});

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

View File

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

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

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

61
src/config/env/env.module.spec.ts vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

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

View File

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

View File

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

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

View File

@@ -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[];
}

View File

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

View File

@@ -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[];
}

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

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

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

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

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

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

View File

View File

@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class ChangePasswordDto {
@IsString()
@IsNotEmpty()
currentPassword!: string;
@IsString()
@MinLength(8)
newPassword!: string;
}

View File

@@ -0,0 +1,6 @@
import { IsEmail } from 'class-validator';
export class ForgotPasswordDto {
@IsEmail({}, { message: 'Email inválido' })
email!: string;
}

View File

@@ -0,0 +1,10 @@
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@IsNotEmpty()
password!: string;
}

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

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

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

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

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

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

View 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