DuOrigin v2 - React + NestJS + Prisma + EUDR API Integration
This commit is contained in:
33
backend/.env.example
Normal file
33
backend/.env.example
Normal file
@@ -0,0 +1,33 @@
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/duorigin
|
||||
|
||||
JWT_SECRET=your_jwt_secret_here
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
PORT=8100
|
||||
|
||||
# ============================================
|
||||
# EUDR API Configuration
|
||||
# ============================================
|
||||
|
||||
# API URL - Use ACCEPTANCE for testing, PRODUCTION for live
|
||||
# ACCEPTANCE: https://acceptance.eudr.webcloud.ec.europa.eu/tracesnt
|
||||
# PRODUCTION: https://webgate.ec.europa.eu/tracesnt
|
||||
EUDR_API_URL=https://acceptance.eudr.webcloud.ec.europa.eu/tracesnt
|
||||
|
||||
# Web Service Client ID (default: eudr-test for ACCEPTANCE)
|
||||
EUDR_WS_CLIENT_ID=eudr-test
|
||||
|
||||
# EU Login credentials for the web service user
|
||||
# Obtain these from TRACES NT after registering as operator
|
||||
EUDR_USERNAME=your_eu_login_username
|
||||
EUDR_AUTH_KEY=your_authentication_key
|
||||
|
||||
# ============================================
|
||||
# How to obtain EUDR credentials:
|
||||
# ============================================
|
||||
# 1. Register in TRACES NT (acceptance.eudr.webcloud.ec.europa.eu)
|
||||
# 2. Create an Operator and User
|
||||
# 3. Go to Edit Profile > Web Services Access
|
||||
# 4. Click "Active" to enable Web Service access
|
||||
# 5. Copy your Username and Authentication Key
|
||||
# ============================================
|
||||
8
backend/nest-cli.json
Normal file
8
backend/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
10668
backend/package-lock.json
generated
Normal file
10668
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
85
backend/package.json
Normal file
85
backend/package.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"name": "duorigin-v2-backend",
|
||||
"version": "2.0.0",
|
||||
"description": "DuOrigin v2 - Sistema de Compliance EUDR para Agronegócio",
|
||||
"author": "AIVertice",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:push": "prisma db push",
|
||||
"prisma:pull": "prisma db pull",
|
||||
"prisma:studio": "prisma studio",
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/mapped-types": "^2.0.6",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/swagger": "^8.1.0",
|
||||
"@prisma/client": "^6.3.1",
|
||||
"axios": "^1.7.9",
|
||||
"fast-xml-parser": "^4.5.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.21.0",
|
||||
"@typescript-eslint/parser": "^8.21.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^6.3.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
133
backend/prisma/schema.prisma
Normal file
133
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,133 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
OPERADOR
|
||||
}
|
||||
|
||||
enum StatusAvaliacao {
|
||||
RASCUNHO
|
||||
EM_ANALISE
|
||||
APROVADA
|
||||
REPROVADA
|
||||
}
|
||||
|
||||
enum NivelRisco {
|
||||
BAIXO
|
||||
MEDIO
|
||||
ALTO
|
||||
CRITICO
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
password String
|
||||
nome String
|
||||
role Role @default(OPERADOR)
|
||||
ativo Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
avaliacoes Avaliacao[]
|
||||
documentos Documento[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Empresa {
|
||||
id Int @id @default(autoincrement())
|
||||
razaoSocial String @map("razao_social")
|
||||
nomeFantasia String? @map("nome_fantasia")
|
||||
cnpj String @unique
|
||||
inscricaoEstadual String? @map("inscricao_estadual")
|
||||
atividadeAgricola String? @map("atividade_agricola")
|
||||
endereco String?
|
||||
cidade String?
|
||||
estado String?
|
||||
cep String?
|
||||
telefone String?
|
||||
email String?
|
||||
responsavel String?
|
||||
ativo Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
propriedades Propriedade[]
|
||||
|
||||
@@map("empresas")
|
||||
}
|
||||
|
||||
model Propriedade {
|
||||
id Int @id @default(autoincrement())
|
||||
nome String
|
||||
empresaId Int @map("empresa_id")
|
||||
codigoCar String? @map("codigo_car")
|
||||
areaHa Float? @map("area_ha")
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
bioma String?
|
||||
endereco String?
|
||||
cidade String?
|
||||
estado String?
|
||||
nivelRisco NivelRisco @default(BAIXO) @map("nivel_risco")
|
||||
flagDesmatamento String? @map("flag_desmatamento")
|
||||
geojson String? @db.Text
|
||||
ativo Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
empresa Empresa @relation(fields: [empresaId], references: [id], onDelete: Cascade)
|
||||
avaliacoes Avaliacao[]
|
||||
documentos Documento[]
|
||||
|
||||
@@map("propriedades")
|
||||
}
|
||||
|
||||
model Avaliacao {
|
||||
id Int @id @default(autoincrement())
|
||||
propriedadeId Int @map("propriedade_id")
|
||||
userId Int @map("user_id")
|
||||
referencia String @unique
|
||||
status StatusAvaliacao @default(RASCUNHO)
|
||||
nivelRisco NivelRisco @default(BAIXO) @map("nivel_risco")
|
||||
dataAvaliacao DateTime? @map("data_avaliacao")
|
||||
dataSubmissao DateTime? @map("data_submissao")
|
||||
observacoes String? @db.Text
|
||||
resultadoDds String? @map("resultado_dds") @db.Text
|
||||
referenciaEu String? @map("referencia_eu")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
propriedade Propriedade @relation(fields: [propriedadeId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
documentos Documento[]
|
||||
|
||||
@@map("avaliacoes")
|
||||
}
|
||||
|
||||
model Documento {
|
||||
id Int @id @default(autoincrement())
|
||||
nome String
|
||||
tipo String?
|
||||
tamanho Int?
|
||||
mimeType String? @map("mime_type")
|
||||
path String
|
||||
propriedadeId Int? @map("propriedade_id")
|
||||
avaliacaoId Int? @map("avaliacao_id")
|
||||
userId Int @map("user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
propriedade Propriedade? @relation(fields: [propriedadeId], references: [id], onDelete: Cascade)
|
||||
avaliacao Avaliacao? @relation(fields: [avaliacaoId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@map("documentos")
|
||||
}
|
||||
328
backend/prisma/seed.ts
Normal file
328
backend/prisma/seed.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { PrismaClient, Role, NivelRisco, StatusAvaliacao } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Seeding database...');
|
||||
|
||||
// Create users
|
||||
const adminPassword = await bcrypt.hash('DuoDemo2026', 10);
|
||||
const operadorPassword = await bcrypt.hash('DuoDemo2026', 10);
|
||||
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: 'demo@duorigin.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'demo@duorigin.com',
|
||||
password: adminPassword,
|
||||
nome: 'Administrador Demo',
|
||||
role: Role.ADMIN,
|
||||
ativo: true,
|
||||
},
|
||||
});
|
||||
|
||||
const operador = await prisma.user.upsert({
|
||||
where: { email: 'operador@duorigin.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'operador@duorigin.com',
|
||||
password: operadorPassword,
|
||||
nome: 'Operador Demo',
|
||||
role: Role.OPERADOR,
|
||||
ativo: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Users created:', { admin: admin.email, operador: operador.email });
|
||||
|
||||
// Create empresas
|
||||
const empresas = await Promise.all([
|
||||
prisma.empresa.upsert({
|
||||
where: { cnpj: '12.345.678/0001-90' },
|
||||
update: {},
|
||||
create: {
|
||||
razaoSocial: 'Fazenda Bom Sucesso Ltda',
|
||||
nomeFantasia: 'Fazenda Bom Sucesso',
|
||||
cnpj: '12.345.678/0001-90',
|
||||
inscricaoEstadual: '123.456.789.012',
|
||||
atividadeAgricola: 'Soja, Milho',
|
||||
endereco: 'Rodovia BR-050, Km 45',
|
||||
cidade: 'Uberlândia',
|
||||
estado: 'MG',
|
||||
cep: '38400-000',
|
||||
telefone: '(34) 99999-0001',
|
||||
email: 'contato@bomsucesso.com.br',
|
||||
responsavel: 'João Silva',
|
||||
ativo: true,
|
||||
},
|
||||
}),
|
||||
prisma.empresa.upsert({
|
||||
where: { cnpj: '23.456.789/0001-01' },
|
||||
update: {},
|
||||
create: {
|
||||
razaoSocial: 'Agropecuária São José S.A.',
|
||||
nomeFantasia: 'São José Agro',
|
||||
cnpj: '23.456.789/0001-01',
|
||||
inscricaoEstadual: '234.567.890.123',
|
||||
atividadeAgricola: 'Café, Pecuária',
|
||||
endereco: 'Estrada Municipal SP-123, Km 10',
|
||||
cidade: 'Ribeirão Preto',
|
||||
estado: 'SP',
|
||||
cep: '14000-000',
|
||||
telefone: '(16) 99999-0002',
|
||||
email: 'contato@saojoseagro.com.br',
|
||||
responsavel: 'Maria Santos',
|
||||
ativo: true,
|
||||
},
|
||||
}),
|
||||
prisma.empresa.upsert({
|
||||
where: { cnpj: '34.567.890/0001-12' },
|
||||
update: {},
|
||||
create: {
|
||||
razaoSocial: 'Cooperativa Agrícola Vale Verde',
|
||||
nomeFantasia: 'Coop Vale Verde',
|
||||
cnpj: '34.567.890/0001-12',
|
||||
inscricaoEstadual: '345.678.901.234',
|
||||
atividadeAgricola: 'Soja, Algodão, Milho',
|
||||
endereco: 'Av. Principal, 1000',
|
||||
cidade: 'Rondonópolis',
|
||||
estado: 'MT',
|
||||
cep: '78700-000',
|
||||
telefone: '(66) 99999-0003',
|
||||
email: 'contato@valeverde.coop.br',
|
||||
responsavel: 'Carlos Oliveira',
|
||||
ativo: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log('✅ Empresas created:', empresas.length);
|
||||
|
||||
// Create propriedades
|
||||
const propriedades = await Promise.all([
|
||||
prisma.propriedade.create({
|
||||
data: {
|
||||
nome: 'Fazenda Primavera',
|
||||
empresaId: empresas[0].id,
|
||||
codigoCar: 'MG-3170206-A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6',
|
||||
areaHa: 1500.5,
|
||||
latitude: -18.9186,
|
||||
longitude: -48.2772,
|
||||
bioma: 'Cerrado',
|
||||
endereco: 'Zona Rural, Lote 45',
|
||||
cidade: 'Uberlândia',
|
||||
estado: 'MG',
|
||||
nivelRisco: NivelRisco.BAIXO,
|
||||
flagDesmatamento: 'clean',
|
||||
ativo: true,
|
||||
},
|
||||
}),
|
||||
prisma.propriedade.create({
|
||||
data: {
|
||||
nome: 'Sítio Esperança',
|
||||
empresaId: empresas[0].id,
|
||||
codigoCar: 'MG-3170206-B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7',
|
||||
areaHa: 320.0,
|
||||
latitude: -18.8500,
|
||||
longitude: -48.3000,
|
||||
bioma: 'Cerrado',
|
||||
cidade: 'Uberlândia',
|
||||
estado: 'MG',
|
||||
nivelRisco: NivelRisco.BAIXO,
|
||||
ativo: true,
|
||||
},
|
||||
}),
|
||||
prisma.propriedade.create({
|
||||
data: {
|
||||
nome: 'Fazenda São José',
|
||||
empresaId: empresas[1].id,
|
||||
codigoCar: 'SP-3543402-C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8',
|
||||
areaHa: 850.75,
|
||||
latitude: -21.1767,
|
||||
longitude: -47.8108,
|
||||
bioma: 'Mata Atlântica',
|
||||
cidade: 'Ribeirão Preto',
|
||||
estado: 'SP',
|
||||
nivelRisco: NivelRisco.MEDIO,
|
||||
flagDesmatamento: 'under_review',
|
||||
ativo: true,
|
||||
},
|
||||
}),
|
||||
prisma.propriedade.create({
|
||||
data: {
|
||||
nome: 'Fazenda Pioneira',
|
||||
empresaId: empresas[2].id,
|
||||
codigoCar: 'MT-5107602-D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9',
|
||||
areaHa: 5200.0,
|
||||
latitude: -16.4673,
|
||||
longitude: -54.6372,
|
||||
bioma: 'Cerrado',
|
||||
cidade: 'Rondonópolis',
|
||||
estado: 'MT',
|
||||
nivelRisco: NivelRisco.BAIXO,
|
||||
ativo: true,
|
||||
},
|
||||
}),
|
||||
prisma.propriedade.create({
|
||||
data: {
|
||||
nome: 'Fazenda Horizonte',
|
||||
empresaId: empresas[2].id,
|
||||
codigoCar: 'MT-5107602-E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0',
|
||||
areaHa: 3800.25,
|
||||
latitude: -16.5000,
|
||||
longitude: -54.7000,
|
||||
bioma: 'Cerrado',
|
||||
cidade: 'Rondonópolis',
|
||||
estado: 'MT',
|
||||
nivelRisco: NivelRisco.ALTO,
|
||||
flagDesmatamento: 'flagged',
|
||||
ativo: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log('✅ Propriedades created:', propriedades.length);
|
||||
|
||||
// Create avaliacoes
|
||||
const generateRef = () => {
|
||||
const timestamp = Date.now().toString(36).toUpperCase();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `DUO-${timestamp}-${random}`;
|
||||
};
|
||||
|
||||
const avaliacoes = await Promise.all([
|
||||
prisma.avaliacao.create({
|
||||
data: {
|
||||
propriedadeId: propriedades[0].id,
|
||||
userId: admin.id,
|
||||
referencia: generateRef(),
|
||||
status: StatusAvaliacao.APROVADA,
|
||||
nivelRisco: NivelRisco.BAIXO,
|
||||
dataAvaliacao: new Date('2024-01-15'),
|
||||
dataSubmissao: new Date('2024-01-20'),
|
||||
observacoes: 'Propriedade em conformidade com EUDR.',
|
||||
referenciaEu: 'EU-ABC123',
|
||||
},
|
||||
}),
|
||||
prisma.avaliacao.create({
|
||||
data: {
|
||||
propriedadeId: propriedades[0].id,
|
||||
userId: operador.id,
|
||||
referencia: generateRef(),
|
||||
status: StatusAvaliacao.EM_ANALISE,
|
||||
nivelRisco: NivelRisco.BAIXO,
|
||||
dataAvaliacao: new Date('2024-06-01'),
|
||||
observacoes: 'Aguardando documentação adicional.',
|
||||
},
|
||||
}),
|
||||
prisma.avaliacao.create({
|
||||
data: {
|
||||
propriedadeId: propriedades[1].id,
|
||||
userId: admin.id,
|
||||
referencia: generateRef(),
|
||||
status: StatusAvaliacao.RASCUNHO,
|
||||
nivelRisco: NivelRisco.BAIXO,
|
||||
dataAvaliacao: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.avaliacao.create({
|
||||
data: {
|
||||
propriedadeId: propriedades[2].id,
|
||||
userId: operador.id,
|
||||
referencia: generateRef(),
|
||||
status: StatusAvaliacao.EM_ANALISE,
|
||||
nivelRisco: NivelRisco.MEDIO,
|
||||
dataAvaliacao: new Date('2024-05-10'),
|
||||
dataSubmissao: new Date('2024-05-15'),
|
||||
observacoes: 'Área com histórico de desmatamento em análise.',
|
||||
},
|
||||
}),
|
||||
prisma.avaliacao.create({
|
||||
data: {
|
||||
propriedadeId: propriedades[2].id,
|
||||
userId: admin.id,
|
||||
referencia: generateRef(),
|
||||
status: StatusAvaliacao.REPROVADA,
|
||||
nivelRisco: NivelRisco.ALTO,
|
||||
dataAvaliacao: new Date('2024-03-01'),
|
||||
dataSubmissao: new Date('2024-03-10'),
|
||||
observacoes: 'Documentação irregular. Necessário revisão.',
|
||||
},
|
||||
}),
|
||||
prisma.avaliacao.create({
|
||||
data: {
|
||||
propriedadeId: propriedades[3].id,
|
||||
userId: admin.id,
|
||||
referencia: generateRef(),
|
||||
status: StatusAvaliacao.APROVADA,
|
||||
nivelRisco: NivelRisco.BAIXO,
|
||||
dataAvaliacao: new Date('2024-02-20'),
|
||||
dataSubmissao: new Date('2024-02-25'),
|
||||
referenciaEu: 'EU-DEF456',
|
||||
},
|
||||
}),
|
||||
prisma.avaliacao.create({
|
||||
data: {
|
||||
propriedadeId: propriedades[3].id,
|
||||
userId: operador.id,
|
||||
referencia: generateRef(),
|
||||
status: StatusAvaliacao.RASCUNHO,
|
||||
nivelRisco: NivelRisco.BAIXO,
|
||||
dataAvaliacao: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.avaliacao.create({
|
||||
data: {
|
||||
propriedadeId: propriedades[4].id,
|
||||
userId: admin.id,
|
||||
referencia: generateRef(),
|
||||
status: StatusAvaliacao.EM_ANALISE,
|
||||
nivelRisco: NivelRisco.ALTO,
|
||||
dataAvaliacao: new Date('2024-06-05'),
|
||||
dataSubmissao: new Date('2024-06-10'),
|
||||
observacoes: 'Área com risco alto de desmatamento identificado.',
|
||||
},
|
||||
}),
|
||||
prisma.avaliacao.create({
|
||||
data: {
|
||||
propriedadeId: propriedades[4].id,
|
||||
userId: operador.id,
|
||||
referencia: generateRef(),
|
||||
status: StatusAvaliacao.REPROVADA,
|
||||
nivelRisco: NivelRisco.CRITICO,
|
||||
dataAvaliacao: new Date('2024-04-01'),
|
||||
dataSubmissao: new Date('2024-04-05'),
|
||||
observacoes: 'Desmatamento ilegal detectado. Propriedade bloqueada.',
|
||||
},
|
||||
}),
|
||||
prisma.avaliacao.create({
|
||||
data: {
|
||||
propriedadeId: propriedades[1].id,
|
||||
userId: operador.id,
|
||||
referencia: generateRef(),
|
||||
status: StatusAvaliacao.APROVADA,
|
||||
nivelRisco: NivelRisco.BAIXO,
|
||||
dataAvaliacao: new Date('2024-01-05'),
|
||||
dataSubmissao: new Date('2024-01-10'),
|
||||
referenciaEu: 'EU-GHI789',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log('✅ Avaliacoes created:', avaliacoes.length);
|
||||
|
||||
console.log('🎉 Seed completed successfully!');
|
||||
console.log('\n📝 Login credentials:');
|
||||
console.log(' Admin: demo@duorigin.com / DuoDemo2026');
|
||||
console.log(' Operador: operador@duorigin.com / DuoDemo2026');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Seed error:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
29
backend/src/app.module.ts
Normal file
29
backend/src/app.module.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { EmpresasModule } from './empresas/empresas.module';
|
||||
import { PropriedadesModule } from './propriedades/propriedades.module';
|
||||
import { AvaliacoesModule } from './avaliacoes/avaliacoes.module';
|
||||
import { DocumentosModule } from './documentos/documentos.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { EudrApiModule } from './eudr-api/eudr-api.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
EmpresasModule,
|
||||
PropriedadesModule,
|
||||
AvaliacoesModule,
|
||||
DocumentosModule,
|
||||
DashboardModule,
|
||||
EudrApiModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
38
backend/src/auth/auth.controller.ts
Normal file
38
backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Controller, Post, Get, Body, UseGuards, Request } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegistroDto } from './dto/registro.dto';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Realizar login' })
|
||||
@ApiResponse({ status: 200, description: 'Login realizado com sucesso' })
|
||||
@ApiResponse({ status: 401, description: 'Credenciais inválidas' })
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
return this.authService.login(loginDto);
|
||||
}
|
||||
|
||||
@Post('registro')
|
||||
@ApiOperation({ summary: 'Registrar novo usuário' })
|
||||
@ApiResponse({ status: 201, description: 'Usuário criado com sucesso' })
|
||||
@ApiResponse({ status: 409, description: 'Email já cadastrado' })
|
||||
async registro(@Body() registroDto: RegistroDto) {
|
||||
return this.authService.registro(registroDto);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Obter perfil do usuário logado' })
|
||||
@ApiResponse({ status: 200, description: 'Perfil retornado com sucesso' })
|
||||
@ApiResponse({ status: 401, description: 'Não autorizado' })
|
||||
async getProfile(@Request() req: any) {
|
||||
return this.authService.getProfile(req.user.sub);
|
||||
}
|
||||
}
|
||||
29
backend/src/auth/auth.module.ts
Normal file
29
backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get<string>('JWT_EXPIRES_IN') || '7d',
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
107
backend/src/auth/auth.service.ts
Normal file
107
backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegistroDto } from './dto/registro.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async login(loginDto: LoginDto) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: loginDto.email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Credenciais inválidas');
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(loginDto.password, user.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('Credenciais inválidas');
|
||||
}
|
||||
|
||||
if (!user.ativo) {
|
||||
throw new UnauthorizedException('Usuário desativado');
|
||||
}
|
||||
|
||||
const payload = { sub: user.id, email: user.email, role: user.role };
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nome: user.nome,
|
||||
role: user.role,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async registro(registroDto: RegistroDto) {
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { email: registroDto.email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new ConflictException('Email já cadastrado');
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(registroDto.password, 10);
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: registroDto.email,
|
||||
password: hashedPassword,
|
||||
nome: registroDto.nome,
|
||||
role: registroDto.role || 'OPERADOR',
|
||||
},
|
||||
});
|
||||
|
||||
const payload = { sub: user.id, email: user.email, role: user.role };
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nome: user.nome,
|
||||
role: user.role,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getProfile(userId: number) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
nome: true,
|
||||
role: true,
|
||||
ativo: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Usuário não encontrado');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async validateUser(userId: number) {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
}
|
||||
}
|
||||
13
backend/src/auth/dto/login.dto.ts
Normal file
13
backend/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: 'demo@duorigin.com', description: 'Email do usuário' })
|
||||
@IsEmail({}, { message: 'Email inválido' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'DuoDemo2026', description: 'Senha do usuário' })
|
||||
@IsString()
|
||||
@MinLength(6, { message: 'Senha deve ter no mínimo 6 caracteres' })
|
||||
password: string;
|
||||
}
|
||||
24
backend/src/auth/dto/registro.dto.ts
Normal file
24
backend/src/auth/dto/registro.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { IsEmail, IsString, MinLength, IsOptional, IsEnum } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Role } from '@prisma/client';
|
||||
|
||||
export class RegistroDto {
|
||||
@ApiProperty({ example: 'usuario@duorigin.com', description: 'Email do usuário' })
|
||||
@IsEmail({}, { message: 'Email inválido' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'Senha123!', description: 'Senha do usuário' })
|
||||
@IsString()
|
||||
@MinLength(6, { message: 'Senha deve ter no mínimo 6 caracteres' })
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ example: 'João Silva', description: 'Nome completo do usuário' })
|
||||
@IsString()
|
||||
@MinLength(2, { message: 'Nome deve ter no mínimo 2 caracteres' })
|
||||
nome: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: Role, example: 'OPERADOR', description: 'Papel do usuário' })
|
||||
@IsOptional()
|
||||
@IsEnum(Role)
|
||||
role?: Role;
|
||||
}
|
||||
30
backend/src/auth/jwt-auth.guard.ts
Normal file
30
backend/src/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
handleRequest(err: any, user: any, info: any) {
|
||||
if (err || !user) {
|
||||
throw err || new UnauthorizedException('Token inválido ou expirado');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
41
backend/src/auth/jwt.strategy.ts
Normal file
41
backend/src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
interface JwtPayload {
|
||||
sub: number;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
const secret = configService.get<string>('JWT_SECRET');
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET not configured');
|
||||
}
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: secret,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user || !user.ativo) {
|
||||
throw new UnauthorizedException('Usuário não autorizado');
|
||||
}
|
||||
|
||||
return { sub: payload.sub, email: payload.email, role: payload.role };
|
||||
}
|
||||
}
|
||||
5
backend/src/auth/roles.decorator.ts
Normal file
5
backend/src/auth/roles.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
29
backend/src/auth/roles.guard.ts
Normal file
29
backend/src/auth/roles.guard.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Role } from '@prisma/client';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
|
||||
@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) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
|
||||
if (!user || !requiredRoles.includes(user.role)) {
|
||||
throw new ForbiddenException('Acesso negado. Permissão insuficiente.');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
92
backend/src/avaliacoes/avaliacoes.controller.ts
Normal file
92
backend/src/avaliacoes/avaliacoes.controller.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, ParseIntPipe, UseGuards, Request } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { AvaliacoesService } from './avaliacoes.service';
|
||||
import { CreateAvaliacaoDto } from './dto/create-avaliacao.dto';
|
||||
import { UpdateAvaliacaoDto } from './dto/update-avaliacao.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { StatusAvaliacao } from '@prisma/client';
|
||||
|
||||
@ApiTags('avaliacoes')
|
||||
@Controller('avaliacoes')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class AvaliacoesController {
|
||||
constructor(private avaliacoesService: AvaliacoesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Listar todas as avaliações' })
|
||||
@ApiQuery({ name: 'propriedadeId', required: false, type: Number })
|
||||
@ApiQuery({ name: 'status', required: false, enum: StatusAvaliacao })
|
||||
@ApiResponse({ status: 200, description: 'Lista de avaliações' })
|
||||
findAll(
|
||||
@Query('propriedadeId') propriedadeId?: number,
|
||||
@Query('status') status?: StatusAvaliacao,
|
||||
) {
|
||||
return this.avaliacoesService.findAll(propriedadeId, status);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Buscar avaliação por ID' })
|
||||
@ApiResponse({ status: 200, description: 'Avaliação encontrada' })
|
||||
@ApiResponse({ status: 404, description: 'Avaliação não encontrada' })
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.avaliacoesService.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Criar nova avaliação' })
|
||||
@ApiResponse({ status: 201, description: 'Avaliação criada com sucesso' })
|
||||
@ApiResponse({ status: 400, description: 'Propriedade não encontrada' })
|
||||
create(@Body() createAvaliacaoDto: CreateAvaliacaoDto, @Request() req: any) {
|
||||
return this.avaliacoesService.create(createAvaliacaoDto, req.user.sub);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Atualizar avaliação' })
|
||||
@ApiResponse({ status: 200, description: 'Avaliação atualizada' })
|
||||
@ApiResponse({ status: 404, description: 'Avaliação não encontrada' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() updateAvaliacaoDto: UpdateAvaliacaoDto,
|
||||
) {
|
||||
return this.avaliacoesService.update(id, updateAvaliacaoDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Remover avaliação' })
|
||||
@ApiResponse({ status: 200, description: 'Avaliação removida' })
|
||||
@ApiResponse({ status: 404, description: 'Avaliação não encontrada' })
|
||||
remove(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.avaliacoesService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/gerar-dds')
|
||||
@ApiOperation({ summary: 'Gerar DDS (Due Diligence Statement) para avaliação' })
|
||||
@ApiResponse({ status: 200, description: 'DDS gerado com sucesso' })
|
||||
@ApiResponse({ status: 404, description: 'Avaliação não encontrada' })
|
||||
gerarDds(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.avaliacoesService.gerarDds(id);
|
||||
}
|
||||
|
||||
@Post(':id/submeter')
|
||||
@ApiOperation({ summary: 'Submeter avaliação para análise' })
|
||||
@ApiResponse({ status: 200, description: 'Avaliação submetida' })
|
||||
@ApiResponse({ status: 400, description: 'DDS não gerado' })
|
||||
submeter(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.avaliacoesService.submeter(id);
|
||||
}
|
||||
|
||||
@Post(':id/aprovar')
|
||||
@ApiOperation({ summary: 'Aprovar avaliação' })
|
||||
@ApiResponse({ status: 200, description: 'Avaliação aprovada' })
|
||||
aprovar(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.avaliacoesService.aprovar(id);
|
||||
}
|
||||
|
||||
@Post(':id/reprovar')
|
||||
@ApiOperation({ summary: 'Reprovar avaliação' })
|
||||
@ApiResponse({ status: 200, description: 'Avaliação reprovada' })
|
||||
reprovar(@Param('id', ParseIntPipe) id: number, @Body('motivo') motivo?: string) {
|
||||
return this.avaliacoesService.reprovar(id, motivo);
|
||||
}
|
||||
}
|
||||
10
backend/src/avaliacoes/avaliacoes.module.ts
Normal file
10
backend/src/avaliacoes/avaliacoes.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AvaliacoesController } from './avaliacoes.controller';
|
||||
import { AvaliacoesService } from './avaliacoes.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AvaliacoesController],
|
||||
providers: [AvaliacoesService],
|
||||
exports: [AvaliacoesService],
|
||||
})
|
||||
export class AvaliacoesModule {}
|
||||
252
backend/src/avaliacoes/avaliacoes.service.ts
Normal file
252
backend/src/avaliacoes/avaliacoes.service.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateAvaliacaoDto } from './dto/create-avaliacao.dto';
|
||||
import { UpdateAvaliacaoDto } from './dto/update-avaliacao.dto';
|
||||
import { StatusAvaliacao, NivelRisco } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class AvaliacoesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
private generateReferencia(): string {
|
||||
const timestamp = Date.now().toString(36).toUpperCase();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `DUO-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
async findAll(propriedadeId?: number, status?: StatusAvaliacao) {
|
||||
const where: any = {};
|
||||
|
||||
if (propriedadeId) {
|
||||
where.propriedadeId = propriedadeId;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
return this.prisma.avaliacao.findMany({
|
||||
where,
|
||||
include: {
|
||||
propriedade: {
|
||||
select: { id: true, nome: true, codigoCar: true },
|
||||
include: {
|
||||
empresa: {
|
||||
select: { id: true, razaoSocial: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: { id: true, nome: true, email: true },
|
||||
},
|
||||
_count: {
|
||||
select: { documentos: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const avaliacao = await this.prisma.avaliacao.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
propriedade: {
|
||||
include: {
|
||||
empresa: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: { id: true, nome: true, email: true },
|
||||
},
|
||||
documentos: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!avaliacao) {
|
||||
throw new NotFoundException('Avaliação não encontrada');
|
||||
}
|
||||
|
||||
return avaliacao;
|
||||
}
|
||||
|
||||
async create(createAvaliacaoDto: CreateAvaliacaoDto, userId: number) {
|
||||
// Verify propriedade exists
|
||||
const propriedade = await this.prisma.propriedade.findUnique({
|
||||
where: { id: createAvaliacaoDto.propriedadeId },
|
||||
});
|
||||
|
||||
if (!propriedade) {
|
||||
throw new BadRequestException('Propriedade não encontrada');
|
||||
}
|
||||
|
||||
return this.prisma.avaliacao.create({
|
||||
data: {
|
||||
...createAvaliacaoDto,
|
||||
userId,
|
||||
referencia: this.generateReferencia(),
|
||||
dataAvaliacao: new Date(),
|
||||
},
|
||||
include: {
|
||||
propriedade: {
|
||||
select: { id: true, nome: true, codigoCar: true },
|
||||
},
|
||||
user: {
|
||||
select: { id: true, nome: true, email: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, updateAvaliacaoDto: UpdateAvaliacaoDto) {
|
||||
await this.findOne(id);
|
||||
|
||||
if (updateAvaliacaoDto.propriedadeId) {
|
||||
const propriedade = await this.prisma.propriedade.findUnique({
|
||||
where: { id: updateAvaliacaoDto.propriedadeId },
|
||||
});
|
||||
|
||||
if (!propriedade) {
|
||||
throw new BadRequestException('Propriedade não encontrada');
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.avaliacao.update({
|
||||
where: { id },
|
||||
data: updateAvaliacaoDto,
|
||||
include: {
|
||||
propriedade: {
|
||||
select: { id: true, nome: true, codigoCar: true },
|
||||
},
|
||||
user: {
|
||||
select: { id: true, nome: true, email: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
await this.findOne(id);
|
||||
await this.prisma.avaliacao.delete({ where: { id } });
|
||||
return { message: 'Avaliação removida com sucesso' };
|
||||
}
|
||||
|
||||
async gerarDds(id: number) {
|
||||
const avaliacao = await this.findOne(id);
|
||||
|
||||
// DDS generation logic
|
||||
const propriedade = avaliacao.propriedade;
|
||||
const empresa = propriedade.empresa;
|
||||
|
||||
const ddsData = {
|
||||
referencia: avaliacao.referencia,
|
||||
dataGeracao: new Date().toISOString(),
|
||||
empresa: {
|
||||
razaoSocial: empresa.razaoSocial,
|
||||
cnpj: empresa.cnpj,
|
||||
atividadeAgricola: empresa.atividadeAgricola,
|
||||
},
|
||||
propriedade: {
|
||||
nome: propriedade.nome,
|
||||
codigoCar: propriedade.codigoCar,
|
||||
areaHa: propriedade.areaHa,
|
||||
coordenadas: {
|
||||
latitude: propriedade.latitude,
|
||||
longitude: propriedade.longitude,
|
||||
},
|
||||
bioma: propriedade.bioma,
|
||||
nivelRisco: propriedade.nivelRisco,
|
||||
},
|
||||
avaliacao: {
|
||||
id: avaliacao.id,
|
||||
status: avaliacao.status,
|
||||
nivelRisco: avaliacao.nivelRisco,
|
||||
dataAvaliacao: avaliacao.dataAvaliacao,
|
||||
observacoes: avaliacao.observacoes,
|
||||
},
|
||||
eudr: {
|
||||
conformidade: avaliacao.nivelRisco === 'BAIXO' || avaliacao.nivelRisco === 'MEDIO',
|
||||
riscoDesmatamento: propriedade.flagDesmatamento || 'não verificado',
|
||||
dataReferencia: '2020-12-31',
|
||||
},
|
||||
};
|
||||
|
||||
// Update avaliacao with DDS result
|
||||
const updated = await this.prisma.avaliacao.update({
|
||||
where: { id },
|
||||
data: {
|
||||
resultadoDds: JSON.stringify(ddsData),
|
||||
status: StatusAvaliacao.EM_ANALISE,
|
||||
},
|
||||
include: {
|
||||
propriedade: {
|
||||
include: { empresa: true },
|
||||
},
|
||||
user: {
|
||||
select: { id: true, nome: true, email: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
avaliacao: updated,
|
||||
dds: ddsData,
|
||||
};
|
||||
}
|
||||
|
||||
async submeter(id: number) {
|
||||
const avaliacao = await this.findOne(id);
|
||||
|
||||
if (avaliacao.status === StatusAvaliacao.APROVADA) {
|
||||
throw new BadRequestException('Avaliação já aprovada');
|
||||
}
|
||||
|
||||
if (!avaliacao.resultadoDds) {
|
||||
throw new BadRequestException('DDS ainda não foi gerado. Execute gerar-dds primeiro.');
|
||||
}
|
||||
|
||||
const euReference = `EU-${Date.now().toString(36).toUpperCase()}`;
|
||||
|
||||
return this.prisma.avaliacao.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: StatusAvaliacao.EM_ANALISE,
|
||||
dataSubmissao: new Date(),
|
||||
referenciaEu: euReference,
|
||||
},
|
||||
include: {
|
||||
propriedade: {
|
||||
include: { empresa: true },
|
||||
},
|
||||
user: {
|
||||
select: { id: true, nome: true, email: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async aprovar(id: number) {
|
||||
await this.findOne(id);
|
||||
|
||||
return this.prisma.avaliacao.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: StatusAvaliacao.APROVADA,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async reprovar(id: number, motivo?: string) {
|
||||
await this.findOne(id);
|
||||
|
||||
return this.prisma.avaliacao.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: StatusAvaliacao.REPROVADA,
|
||||
observacoes: motivo,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
24
backend/src/avaliacoes/dto/create-avaliacao.dto.ts
Normal file
24
backend/src/avaliacoes/dto/create-avaliacao.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { IsNumber, IsOptional, IsEnum, IsString } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { StatusAvaliacao, NivelRisco } from '@prisma/client';
|
||||
|
||||
export class CreateAvaliacaoDto {
|
||||
@ApiProperty({ example: 1, description: 'ID da propriedade' })
|
||||
@IsNumber()
|
||||
propriedadeId: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: StatusAvaliacao, default: 'RASCUNHO' })
|
||||
@IsOptional()
|
||||
@IsEnum(StatusAvaliacao)
|
||||
status?: StatusAvaliacao;
|
||||
|
||||
@ApiPropertyOptional({ enum: NivelRisco, default: 'BAIXO' })
|
||||
@IsOptional()
|
||||
@IsEnum(NivelRisco)
|
||||
nivelRisco?: NivelRisco;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Observações sobre a avaliação' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
observacoes?: string;
|
||||
}
|
||||
4
backend/src/avaliacoes/dto/update-avaliacao.dto.ts
Normal file
4
backend/src/avaliacoes/dto/update-avaliacao.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateAvaliacaoDto } from './create-avaliacao.dto';
|
||||
|
||||
export class UpdateAvaliacaoDto extends PartialType(CreateAvaliacaoDto) {}
|
||||
19
backend/src/dashboard/dashboard.controller.ts
Normal file
19
backend/src/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
|
||||
@ApiTags('dashboard')
|
||||
@Controller('dashboard')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class DashboardController {
|
||||
constructor(private dashboardService: DashboardService) {}
|
||||
|
||||
@Get('stats')
|
||||
@ApiOperation({ summary: 'Obter estatísticas do dashboard' })
|
||||
@ApiResponse({ status: 200, description: 'Estatísticas retornadas' })
|
||||
getStats() {
|
||||
return this.dashboardService.getStats();
|
||||
}
|
||||
}
|
||||
9
backend/src/dashboard/dashboard.module.ts
Normal file
9
backend/src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
137
backend/src/dashboard/dashboard.service.ts
Normal file
137
backend/src/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getStats() {
|
||||
// Get counts
|
||||
const [
|
||||
totalEmpresas,
|
||||
totalPropriedades,
|
||||
totalAvaliacoes,
|
||||
totalDocumentos,
|
||||
totalUsers,
|
||||
] = await Promise.all([
|
||||
this.prisma.empresa.count({ where: { ativo: true } }),
|
||||
this.prisma.propriedade.count({ where: { ativo: true } }),
|
||||
this.prisma.avaliacao.count(),
|
||||
this.prisma.documento.count(),
|
||||
this.prisma.user.count({ where: { ativo: true } }),
|
||||
]);
|
||||
|
||||
// Avaliacoes by status
|
||||
const avaliacoesByStatus = await this.prisma.avaliacao.groupBy({
|
||||
by: ['status'],
|
||||
_count: { status: true },
|
||||
});
|
||||
|
||||
const statusCounts = {
|
||||
RASCUNHO: 0,
|
||||
EM_ANALISE: 0,
|
||||
APROVADA: 0,
|
||||
REPROVADA: 0,
|
||||
};
|
||||
|
||||
avaliacoesByStatus.forEach((item) => {
|
||||
statusCounts[item.status] = item._count.status;
|
||||
});
|
||||
|
||||
// Propriedades by nivel risco
|
||||
const propriedadesByRisco = await this.prisma.propriedade.groupBy({
|
||||
by: ['nivelRisco'],
|
||||
_count: { nivelRisco: true },
|
||||
where: { ativo: true },
|
||||
});
|
||||
|
||||
const riscoCounts = {
|
||||
BAIXO: 0,
|
||||
MEDIO: 0,
|
||||
ALTO: 0,
|
||||
CRITICO: 0,
|
||||
};
|
||||
|
||||
propriedadesByRisco.forEach((item) => {
|
||||
riscoCounts[item.nivelRisco] = item._count.nivelRisco;
|
||||
});
|
||||
|
||||
// Recent avaliacoes
|
||||
const recentAvaliacoes = await this.prisma.avaliacao.findMany({
|
||||
take: 5,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
propriedade: {
|
||||
select: { nome: true },
|
||||
},
|
||||
user: {
|
||||
select: { nome: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Recent empresas
|
||||
const recentEmpresas = await this.prisma.empresa.findMany({
|
||||
take: 5,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
razaoSocial: true,
|
||||
cnpj: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: { propriedades: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Monthly stats (last 6 months)
|
||||
const sixMonthsAgo = new Date();
|
||||
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
||||
|
||||
const monthlyAvaliacoes = await this.prisma.avaliacao.groupBy({
|
||||
by: ['createdAt'],
|
||||
_count: { id: true },
|
||||
where: {
|
||||
createdAt: { gte: sixMonthsAgo },
|
||||
},
|
||||
});
|
||||
|
||||
// Group by month
|
||||
const monthlyStats: Record<string, number> = {};
|
||||
monthlyAvaliacoes.forEach((item) => {
|
||||
const month = item.createdAt.toISOString().substring(0, 7); // YYYY-MM
|
||||
monthlyStats[month] = (monthlyStats[month] || 0) + item._count.id;
|
||||
});
|
||||
|
||||
return {
|
||||
totais: {
|
||||
empresas: totalEmpresas,
|
||||
propriedades: totalPropriedades,
|
||||
avaliacoes: totalAvaliacoes,
|
||||
documentos: totalDocumentos,
|
||||
usuarios: totalUsers,
|
||||
},
|
||||
avaliacoesPorStatus: statusCounts,
|
||||
propriedadesPorRisco: riscoCounts,
|
||||
recentAvaliacoes: recentAvaliacoes.map((a) => ({
|
||||
id: a.id,
|
||||
referencia: a.referencia,
|
||||
status: a.status,
|
||||
propriedade: a.propriedade.nome,
|
||||
responsavel: a.user.nome,
|
||||
createdAt: a.createdAt,
|
||||
})),
|
||||
recentEmpresas: recentEmpresas.map((e) => ({
|
||||
id: e.id,
|
||||
razaoSocial: e.razaoSocial,
|
||||
cnpj: e.cnpj,
|
||||
propriedades: e._count.propriedades,
|
||||
createdAt: e.createdAt,
|
||||
})),
|
||||
graficos: {
|
||||
avaliacoesMensais: monthlyStats,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
98
backend/src/documentos/documentos.controller.ts
Normal file
98
backend/src/documentos/documentos.controller.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Param,
|
||||
Query,
|
||||
ParseIntPipe,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
Request,
|
||||
Res,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiConsumes, ApiQuery, ApiBody } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import { DocumentosService } from './documentos.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
|
||||
@ApiTags('documentos')
|
||||
@Controller('documentos')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class DocumentosController {
|
||||
constructor(private documentosService: DocumentosService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Listar todos os documentos' })
|
||||
@ApiQuery({ name: 'propriedadeId', required: false, type: Number })
|
||||
@ApiQuery({ name: 'avaliacaoId', required: false, type: Number })
|
||||
@ApiResponse({ status: 200, description: 'Lista de documentos' })
|
||||
findAll(
|
||||
@Query('propriedadeId') propriedadeId?: number,
|
||||
@Query('avaliacaoId') avaliacaoId?: number,
|
||||
) {
|
||||
return this.documentosService.findAll(propriedadeId, avaliacaoId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Buscar documento por ID' })
|
||||
@ApiResponse({ status: 200, description: 'Documento encontrado' })
|
||||
@ApiResponse({ status: 404, description: 'Documento não encontrado' })
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.documentosService.findOne(id);
|
||||
}
|
||||
|
||||
@Get(':id/download')
|
||||
@ApiOperation({ summary: 'Download do arquivo' })
|
||||
@ApiResponse({ status: 200, description: 'Arquivo retornado' })
|
||||
@ApiResponse({ status: 404, description: 'Arquivo não encontrado' })
|
||||
async download(@Param('id', ParseIntPipe) id: number, @Res({ passthrough: true }) res: Response) {
|
||||
const fileInfo = await this.documentosService.getFilePath(id);
|
||||
|
||||
const file = fs.createReadStream(fileInfo.path);
|
||||
|
||||
res.set({
|
||||
'Content-Type': fileInfo.mimeType || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileInfo.nome)}"`,
|
||||
});
|
||||
|
||||
return new StreamableFile(file);
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@ApiOperation({ summary: 'Upload de documento' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
propriedadeId: { type: 'number' },
|
||||
avaliacaoId: { type: 'number' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Documento enviado com sucesso' })
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async upload(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Request() req: any,
|
||||
@Query('propriedadeId') propriedadeId?: number,
|
||||
@Query('avaliacaoId') avaliacaoId?: number,
|
||||
) {
|
||||
return this.documentosService.create(file, req.user.sub, propriedadeId, avaliacaoId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Remover documento' })
|
||||
@ApiResponse({ status: 200, description: 'Documento removido' })
|
||||
@ApiResponse({ status: 404, description: 'Documento não encontrado' })
|
||||
remove(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.documentosService.remove(id);
|
||||
}
|
||||
}
|
||||
10
backend/src/documentos/documentos.module.ts
Normal file
10
backend/src/documentos/documentos.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DocumentosController } from './documentos.controller';
|
||||
import { DocumentosService } from './documentos.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DocumentosController],
|
||||
providers: [DocumentosService],
|
||||
exports: [DocumentosService],
|
||||
})
|
||||
export class DocumentosModule {}
|
||||
168
backend/src/documentos/documentos.service.ts
Normal file
168
backend/src/documentos/documentos.service.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
interface CreateDocumentoData {
|
||||
nome: string;
|
||||
tipo?: string;
|
||||
tamanho?: number;
|
||||
mimeType?: string;
|
||||
path: string;
|
||||
propriedadeId?: number;
|
||||
avaliacaoId?: number;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DocumentosService {
|
||||
private readonly uploadPath = path.join(process.cwd(), 'uploads');
|
||||
|
||||
constructor(private prisma: PrismaService) {
|
||||
// Ensure uploads directory exists
|
||||
if (!fs.existsSync(this.uploadPath)) {
|
||||
fs.mkdirSync(this.uploadPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(propriedadeId?: number, avaliacaoId?: number) {
|
||||
const where: any = {};
|
||||
|
||||
if (propriedadeId) {
|
||||
where.propriedadeId = propriedadeId;
|
||||
}
|
||||
|
||||
if (avaliacaoId) {
|
||||
where.avaliacaoId = avaliacaoId;
|
||||
}
|
||||
|
||||
return this.prisma.documento.findMany({
|
||||
where,
|
||||
include: {
|
||||
propriedade: {
|
||||
select: { id: true, nome: true },
|
||||
},
|
||||
avaliacao: {
|
||||
select: { id: true, referencia: true },
|
||||
},
|
||||
user: {
|
||||
select: { id: true, nome: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const documento = await this.prisma.documento.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
propriedade: {
|
||||
select: { id: true, nome: true },
|
||||
},
|
||||
avaliacao: {
|
||||
select: { id: true, referencia: true },
|
||||
},
|
||||
user: {
|
||||
select: { id: true, nome: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!documento) {
|
||||
throw new NotFoundException('Documento não encontrado');
|
||||
}
|
||||
|
||||
return documento;
|
||||
}
|
||||
|
||||
async create(
|
||||
file: Express.Multer.File,
|
||||
userId: number,
|
||||
propriedadeId?: number,
|
||||
avaliacaoId?: number,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('Arquivo é obrigatório');
|
||||
}
|
||||
|
||||
// Validate associations
|
||||
if (propriedadeId) {
|
||||
const propriedade = await this.prisma.propriedade.findUnique({
|
||||
where: { id: propriedadeId },
|
||||
});
|
||||
if (!propriedade) {
|
||||
throw new BadRequestException('Propriedade não encontrada');
|
||||
}
|
||||
}
|
||||
|
||||
if (avaliacaoId) {
|
||||
const avaliacao = await this.prisma.avaliacao.findUnique({
|
||||
where: { id: avaliacaoId },
|
||||
});
|
||||
if (!avaliacao) {
|
||||
throw new BadRequestException('Avaliação não encontrada');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now();
|
||||
const ext = path.extname(file.originalname);
|
||||
const filename = `${timestamp}-${Math.random().toString(36).substring(7)}${ext}`;
|
||||
const filePath = path.join(this.uploadPath, filename);
|
||||
|
||||
// Save file
|
||||
fs.writeFileSync(filePath, file.buffer);
|
||||
|
||||
// Create database record
|
||||
return this.prisma.documento.create({
|
||||
data: {
|
||||
nome: file.originalname,
|
||||
tipo: ext.replace('.', '').toUpperCase(),
|
||||
tamanho: file.size,
|
||||
mimeType: file.mimetype,
|
||||
path: filePath,
|
||||
propriedadeId,
|
||||
avaliacaoId,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
propriedade: {
|
||||
select: { id: true, nome: true },
|
||||
},
|
||||
avaliacao: {
|
||||
select: { id: true, referencia: true },
|
||||
},
|
||||
user: {
|
||||
select: { id: true, nome: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
const documento = await this.findOne(id);
|
||||
|
||||
// Delete file from filesystem
|
||||
if (fs.existsSync(documento.path)) {
|
||||
fs.unlinkSync(documento.path);
|
||||
}
|
||||
|
||||
await this.prisma.documento.delete({ where: { id } });
|
||||
return { message: 'Documento removido com sucesso' };
|
||||
}
|
||||
|
||||
async getFilePath(id: number): Promise<{ path: string; nome: string; mimeType: string | null }> {
|
||||
const documento = await this.findOne(id);
|
||||
|
||||
if (!fs.existsSync(documento.path)) {
|
||||
throw new NotFoundException('Arquivo não encontrado no servidor');
|
||||
}
|
||||
|
||||
return {
|
||||
path: documento.path,
|
||||
nome: documento.nome,
|
||||
mimeType: documento.mimeType,
|
||||
};
|
||||
}
|
||||
}
|
||||
69
backend/src/empresas/dto/create-empresa.dto.ts
Normal file
69
backend/src/empresas/dto/create-empresa.dto.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { IsString, IsOptional, IsBoolean, Matches, IsEmail, MinLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateEmpresaDto {
|
||||
@ApiProperty({ example: 'Fazenda Bom Sucesso Ltda' })
|
||||
@IsString()
|
||||
@MinLength(3, { message: 'Razão social deve ter no mínimo 3 caracteres' })
|
||||
razaoSocial: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Fazenda Bom Sucesso' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nomeFantasia?: string;
|
||||
|
||||
@ApiProperty({ example: '12.345.678/0001-90' })
|
||||
@IsString()
|
||||
@Matches(/^\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}$/, { message: 'CNPJ inválido. Use o formato XX.XXX.XXX/XXXX-XX' })
|
||||
cnpj: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '123.456.789.012' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
inscricaoEstadual?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Soja, Milho, Café' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
atividadeAgricola?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Rodovia BR-050, Km 45' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
endereco?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Uberlândia' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cidade?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'MG' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
estado?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '38400-000' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cep?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '(34) 99999-0000' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
telefone?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'contato@fazenda.com.br' })
|
||||
@IsOptional()
|
||||
@IsEmail({}, { message: 'Email inválido' })
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'João da Silva' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
responsavel?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
ativo?: boolean;
|
||||
}
|
||||
4
backend/src/empresas/dto/update-empresa.dto.ts
Normal file
4
backend/src/empresas/dto/update-empresa.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateEmpresaDto } from './create-empresa.dto';
|
||||
|
||||
export class UpdateEmpresaDto extends PartialType(CreateEmpresaDto) {}
|
||||
61
backend/src/empresas/empresas.controller.ts
Normal file
61
backend/src/empresas/empresas.controller.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Controller, Get, Post, Put, Delete, Patch, Body, Param, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { EmpresasService } from './empresas.service';
|
||||
import { CreateEmpresaDto } from './dto/create-empresa.dto';
|
||||
import { UpdateEmpresaDto } from './dto/update-empresa.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
|
||||
@ApiTags('empresas')
|
||||
@Controller('empresas')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class EmpresasController {
|
||||
constructor(private empresasService: EmpresasService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Listar todas as empresas' })
|
||||
@ApiQuery({ name: 'includeInactive', required: false, type: Boolean })
|
||||
@ApiResponse({ status: 200, description: 'Lista de empresas' })
|
||||
findAll(@Query('includeInactive') includeInactive?: boolean) {
|
||||
return this.empresasService.findAll(includeInactive);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Buscar empresa por ID' })
|
||||
@ApiResponse({ status: 200, description: 'Empresa encontrada' })
|
||||
@ApiResponse({ status: 404, description: 'Empresa não encontrada' })
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.empresasService.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Criar nova empresa' })
|
||||
@ApiResponse({ status: 201, description: 'Empresa criada com sucesso' })
|
||||
@ApiResponse({ status: 409, description: 'CNPJ já cadastrado' })
|
||||
create(@Body() createEmpresaDto: CreateEmpresaDto) {
|
||||
return this.empresasService.create(createEmpresaDto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Atualizar empresa' })
|
||||
@ApiResponse({ status: 200, description: 'Empresa atualizada' })
|
||||
@ApiResponse({ status: 404, description: 'Empresa não encontrada' })
|
||||
update(@Param('id', ParseIntPipe) id: number, @Body() updateEmpresaDto: UpdateEmpresaDto) {
|
||||
return this.empresasService.update(id, updateEmpresaDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Remover empresa' })
|
||||
@ApiResponse({ status: 200, description: 'Empresa removida' })
|
||||
@ApiResponse({ status: 404, description: 'Empresa não encontrada' })
|
||||
remove(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.empresasService.remove(id);
|
||||
}
|
||||
|
||||
@Patch(':id/toggle-active')
|
||||
@ApiOperation({ summary: 'Ativar/Desativar empresa' })
|
||||
@ApiResponse({ status: 200, description: 'Status alterado' })
|
||||
toggleActive(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.empresasService.toggleActive(id);
|
||||
}
|
||||
}
|
||||
10
backend/src/empresas/empresas.module.ts
Normal file
10
backend/src/empresas/empresas.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EmpresasController } from './empresas.controller';
|
||||
import { EmpresasService } from './empresas.service';
|
||||
|
||||
@Module({
|
||||
controllers: [EmpresasController],
|
||||
providers: [EmpresasService],
|
||||
exports: [EmpresasService],
|
||||
})
|
||||
export class EmpresasModule {}
|
||||
107
backend/src/empresas/empresas.service.ts
Normal file
107
backend/src/empresas/empresas.service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateEmpresaDto } from './dto/create-empresa.dto';
|
||||
import { UpdateEmpresaDto } from './dto/update-empresa.dto';
|
||||
|
||||
@Injectable()
|
||||
export class EmpresasService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll(includeInactive = false) {
|
||||
return this.prisma.empresa.findMany({
|
||||
where: includeInactive ? {} : { ativo: true },
|
||||
include: {
|
||||
propriedades: {
|
||||
where: { ativo: true },
|
||||
select: { id: true, nome: true },
|
||||
},
|
||||
_count: {
|
||||
select: { propriedades: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const empresa = await this.prisma.empresa.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
propriedades: {
|
||||
where: { ativo: true },
|
||||
},
|
||||
_count: {
|
||||
select: { propriedades: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!empresa) {
|
||||
throw new NotFoundException('Empresa não encontrada');
|
||||
}
|
||||
|
||||
return empresa;
|
||||
}
|
||||
|
||||
async create(createEmpresaDto: CreateEmpresaDto) {
|
||||
const existingCnpj = await this.prisma.empresa.findUnique({
|
||||
where: { cnpj: createEmpresaDto.cnpj },
|
||||
});
|
||||
|
||||
if (existingCnpj) {
|
||||
throw new ConflictException('CNPJ já cadastrado');
|
||||
}
|
||||
|
||||
return this.prisma.empresa.create({
|
||||
data: createEmpresaDto,
|
||||
include: {
|
||||
propriedades: true,
|
||||
_count: {
|
||||
select: { propriedades: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, updateEmpresaDto: UpdateEmpresaDto) {
|
||||
await this.findOne(id);
|
||||
|
||||
if (updateEmpresaDto.cnpj) {
|
||||
const existingCnpj = await this.prisma.empresa.findFirst({
|
||||
where: {
|
||||
cnpj: updateEmpresaDto.cnpj,
|
||||
NOT: { id },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingCnpj) {
|
||||
throw new ConflictException('CNPJ já cadastrado');
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.empresa.update({
|
||||
where: { id },
|
||||
data: updateEmpresaDto,
|
||||
include: {
|
||||
propriedades: true,
|
||||
_count: {
|
||||
select: { propriedades: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
await this.findOne(id);
|
||||
await this.prisma.empresa.delete({ where: { id } });
|
||||
return { message: 'Empresa removida com sucesso' };
|
||||
}
|
||||
|
||||
async toggleActive(id: number) {
|
||||
const empresa = await this.findOne(id);
|
||||
return this.prisma.empresa.update({
|
||||
where: { id },
|
||||
data: { ativo: !empresa.ativo },
|
||||
});
|
||||
}
|
||||
}
|
||||
58
backend/src/eudr-api/dto/amend-dds.dto.ts
Normal file
58
backend/src/eudr-api/dto/amend-dds.dto.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { IsString, IsOptional, IsBoolean, ValidateNested, IsArray, IsUUID } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { CommodityDto, OperatorAddressDto, ReferencedDdsDto } from './common.dto';
|
||||
|
||||
/**
|
||||
* Request DTO for amending an existing DDS
|
||||
* Note: Activity type cannot be changed
|
||||
*/
|
||||
export class AmendDdsRequestDto {
|
||||
@IsUUID()
|
||||
uuid: string;
|
||||
|
||||
@IsBoolean()
|
||||
confidentialityFlag: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyInternalReference?: string;
|
||||
|
||||
/**
|
||||
* Required when amending as Authorized Representative (V2)
|
||||
*/
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => OperatorAddressDto)
|
||||
onBehalfOfOperator?: OperatorAddressDto;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CommodityDto)
|
||||
commodities: CommodityDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ReferencedDdsDto)
|
||||
referencedDds?: ReferencedDdsDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response DTO for DDS amendment
|
||||
*/
|
||||
export class AmendDdsResponseDto {
|
||||
/**
|
||||
* HTTP status code (200 = success)
|
||||
*/
|
||||
httpStatus: number;
|
||||
|
||||
/**
|
||||
* Success indicator
|
||||
*/
|
||||
success: boolean;
|
||||
|
||||
/**
|
||||
* Raw SOAP response for debugging
|
||||
*/
|
||||
rawResponse?: string;
|
||||
}
|
||||
196
backend/src/eudr-api/dto/common.dto.ts
Normal file
196
backend/src/eudr-api/dto/common.dto.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { IsString, IsOptional, IsNumber, IsEnum, IsBoolean, Min, Max, ValidateNested, IsArray } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
/**
|
||||
* Activity types allowed in EUDR DDS
|
||||
*/
|
||||
export enum ActivityType {
|
||||
IMPORT = 'IMPORT',
|
||||
EXPORT = 'EXPORT',
|
||||
DOMESTIC = 'DOMESTIC',
|
||||
TRADE = 'TRADE',
|
||||
}
|
||||
|
||||
/**
|
||||
* DDS Status values returned by the API
|
||||
*/
|
||||
export enum DDSStatus {
|
||||
SUBMITTED = 'SUBMITTED',
|
||||
AVAILABLE = 'AVAILABLE',
|
||||
REJECTED = 'REJECTED',
|
||||
WITHDRAWN = 'WITHDRAWN',
|
||||
CANCELLED = 'CANCELLED',
|
||||
ARCHIVED = 'ARCHIVED',
|
||||
EXPIRED = 'EXPIRED',
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplementary Unit Types for measurements
|
||||
*/
|
||||
export enum SupplementaryUnitType {
|
||||
KSD = 'KSD', // Kilogram of substance 90% dry
|
||||
MTK = 'MTK', // Square metre
|
||||
MTQ = 'MTQ', // Cubic metre
|
||||
MTR = 'MTR', // Metre
|
||||
NAR = 'NAR', // Number of items
|
||||
NPR = 'NPR', // Number of pairs
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator address for Authorized Representative submissions
|
||||
*/
|
||||
export class OperatorAddressDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
streetNumber: string;
|
||||
|
||||
@IsString()
|
||||
postalCode: string;
|
||||
|
||||
@IsString()
|
||||
city: string;
|
||||
|
||||
@IsString()
|
||||
countryCode: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
eori?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeoLocation data for a production place
|
||||
*/
|
||||
export class GeoLocationDto {
|
||||
@IsString()
|
||||
geoJson: string; // GeoJSON FeatureCollection as string
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.1)
|
||||
@Max(4)
|
||||
area?: number; // Area in hectares (for points)
|
||||
}
|
||||
|
||||
/**
|
||||
* Producer information within a commodity
|
||||
*/
|
||||
export class ProducerDto {
|
||||
@IsString()
|
||||
countryCode: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
producerName?: string;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => GeoLocationDto)
|
||||
geoLocation: GeoLocationDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Species information for timber products
|
||||
*/
|
||||
export class SpeciesInfoDto {
|
||||
@IsString()
|
||||
scientificName: string;
|
||||
|
||||
@IsString()
|
||||
commonName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commodity data for DDS submission
|
||||
*/
|
||||
export class CommodityDto {
|
||||
@IsString()
|
||||
hsCode: string;
|
||||
|
||||
@IsString()
|
||||
description: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
netMass?: number; // Required for IMPORT/EXPORT
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(50)
|
||||
percentageEstimateOrDeviation?: number; // Required for DOMESTIC/TRADE (0-25 typical)
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(SupplementaryUnitType)
|
||||
supplementaryUnitType?: SupplementaryUnitType;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
supplementaryUnitValue?: number;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ProducerDto)
|
||||
producers: ProducerDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SpeciesInfoDto)
|
||||
speciesInfo?: SpeciesInfoDto[]; // Required for timber
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
referencedDdsNumbers?: string[]; // Reference numbers of upstream DDS
|
||||
}
|
||||
|
||||
/**
|
||||
* Referenced DDS with verification number
|
||||
*/
|
||||
export class ReferencedDdsDto {
|
||||
@IsString()
|
||||
referenceNumber: string;
|
||||
|
||||
@IsString()
|
||||
verificationNumber: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EUDR API Error response
|
||||
*/
|
||||
export class EudrApiErrorDto {
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
message: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
field?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base response for EUDR API calls
|
||||
*/
|
||||
export class EudrApiResponseDto<T = any> {
|
||||
@IsBoolean()
|
||||
success: boolean;
|
||||
|
||||
@IsOptional()
|
||||
data?: T;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EudrApiErrorDto)
|
||||
errors?: EudrApiErrorDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
rawResponse?: string; // For debugging
|
||||
}
|
||||
86
backend/src/eudr-api/dto/get-dds-data.dto.ts
Normal file
86
backend/src/eudr-api/dto/get-dds-data.dto.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { IsString } from 'class-validator';
|
||||
import { ActivityType, CommodityDto, DDSStatus } from './common.dto';
|
||||
|
||||
/**
|
||||
* Request DTO for getting full DDS data by identifiers
|
||||
*/
|
||||
export class GetDdsDataRequestDto {
|
||||
@IsString()
|
||||
referenceNumber: string;
|
||||
|
||||
@IsString()
|
||||
verificationNumber: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request DTO for getting referenced DDS without verification number
|
||||
* Uses the referenceDdsVerificationNumber from previous getStatementByIdentifiers call
|
||||
*/
|
||||
export class GetReferencedDdsRequestDto {
|
||||
@IsString()
|
||||
referenceNumber: string;
|
||||
|
||||
@IsString()
|
||||
referenceDdsVerificationNumber: string; // Security number from previous call
|
||||
}
|
||||
|
||||
/**
|
||||
* Referenced DDS info with security number for chain traversal
|
||||
*/
|
||||
export class ReferencedDdsInfoDto {
|
||||
referenceNumber: string;
|
||||
referenceDdsVerificationNumber: string; // Use this to get the referenced DDS data
|
||||
}
|
||||
|
||||
/**
|
||||
* Full DDS data returned by getStatementByIdentifiers
|
||||
*/
|
||||
export class DdsDataDto {
|
||||
referenceNumber: string;
|
||||
verificationNumber: string;
|
||||
status: DDSStatus;
|
||||
activityType: ActivityType;
|
||||
availabilityDate?: string;
|
||||
|
||||
/**
|
||||
* Operator info
|
||||
*/
|
||||
operatorName?: string;
|
||||
operatorCountry?: string;
|
||||
|
||||
/**
|
||||
* Commodities with full details
|
||||
*/
|
||||
commodities: CommodityDto[];
|
||||
|
||||
/**
|
||||
* GeoLocation visibility flag
|
||||
*/
|
||||
showGeoLocation: boolean;
|
||||
|
||||
/**
|
||||
* Referenced DDS in the supply chain
|
||||
* Each includes a security number for recursive retrieval
|
||||
*/
|
||||
referencedDds: ReferencedDdsInfoDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response DTO for getStatementByIdentifiers / getReferencedDds
|
||||
*/
|
||||
export class GetDdsDataResponseDto {
|
||||
/**
|
||||
* HTTP status code
|
||||
*/
|
||||
httpStatus: number;
|
||||
|
||||
/**
|
||||
* Full DDS data
|
||||
*/
|
||||
ddsData?: DdsDataDto;
|
||||
|
||||
/**
|
||||
* Raw SOAP response for debugging
|
||||
*/
|
||||
rawResponse?: string;
|
||||
}
|
||||
54
backend/src/eudr-api/dto/get-dds-info.dto.ts
Normal file
54
backend/src/eudr-api/dto/get-dds-info.dto.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { IsString, IsOptional, IsArray, IsUUID } from 'class-validator';
|
||||
import { DDSStatus } from './common.dto';
|
||||
|
||||
/**
|
||||
* Request DTO for getting DDS info by UUID(s)
|
||||
*/
|
||||
export class GetDdsInfoByUuidRequestDto {
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
uuids: string[]; // Max 100 UUIDs per request
|
||||
}
|
||||
|
||||
/**
|
||||
* Request DTO for getting DDS info by internal reference
|
||||
*/
|
||||
export class GetDdsInfoByReferenceRequestDto {
|
||||
@IsString()
|
||||
internalReference: string; // Min 3, max 50 chars - partial matching, case insensitive
|
||||
}
|
||||
|
||||
/**
|
||||
* Single DDS info returned by the API
|
||||
*/
|
||||
export class DdsInfoDto {
|
||||
uuid: string;
|
||||
status: DDSStatus;
|
||||
referenceNumber?: string; // Only available when status is AVAILABLE/ARCHIVED/EXPIRED/WITHDRAWN
|
||||
verificationNumber?: string;
|
||||
internalReference?: string;
|
||||
caMessage?: string; // Communication from Competent Authority
|
||||
rejectionReason?: string; // If status is REJECTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Response DTO for getDDSInfo
|
||||
*/
|
||||
export class GetDdsInfoResponseDto {
|
||||
/**
|
||||
* HTTP status code
|
||||
*/
|
||||
httpStatus: number;
|
||||
|
||||
/**
|
||||
* List of DDS info records
|
||||
* - Max 100 when queried by UUID
|
||||
* - Max 1000 when queried by internal reference
|
||||
*/
|
||||
ddsInfoList: DdsInfoDto[];
|
||||
|
||||
/**
|
||||
* Raw SOAP response for debugging
|
||||
*/
|
||||
rawResponse?: string;
|
||||
}
|
||||
7
backend/src/eudr-api/dto/index.ts
Normal file
7
backend/src/eudr-api/dto/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Export all DTOs
|
||||
export * from './submit-dds.dto';
|
||||
export * from './amend-dds.dto';
|
||||
export * from './retract-dds.dto';
|
||||
export * from './get-dds-info.dto';
|
||||
export * from './get-dds-data.dto';
|
||||
export * from './common.dto';
|
||||
31
backend/src/eudr-api/dto/retract-dds.dto.ts
Normal file
31
backend/src/eudr-api/dto/retract-dds.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Request DTO for retracting (cancelling/withdrawing) a DDS
|
||||
* - SUBMITTED status: Cancels the DDS
|
||||
* - AVAILABLE status: Withdraws the DDS
|
||||
*/
|
||||
export class RetractDdsRequestDto {
|
||||
@IsUUID()
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response DTO for DDS retraction
|
||||
*/
|
||||
export class RetractDdsResponseDto {
|
||||
/**
|
||||
* HTTP status code (200 = success)
|
||||
*/
|
||||
httpStatus: number;
|
||||
|
||||
/**
|
||||
* Success indicator
|
||||
*/
|
||||
success: boolean;
|
||||
|
||||
/**
|
||||
* Raw SOAP response for debugging
|
||||
*/
|
||||
rawResponse?: string;
|
||||
}
|
||||
60
backend/src/eudr-api/dto/submit-dds.dto.ts
Normal file
60
backend/src/eudr-api/dto/submit-dds.dto.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { IsString, IsOptional, IsEnum, IsBoolean, ValidateNested, IsArray } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ActivityType, CommodityDto, OperatorAddressDto, ReferencedDdsDto } from './common.dto';
|
||||
|
||||
/**
|
||||
* Request DTO for submitting a new DDS
|
||||
*/
|
||||
export class SubmitDdsRequestDto {
|
||||
@IsEnum(ActivityType)
|
||||
activityType: ActivityType;
|
||||
|
||||
@IsBoolean()
|
||||
confidentialityFlag: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyInternalReference?: string; // Max 50 chars
|
||||
|
||||
/**
|
||||
* Required when submitting as Authorized Representative
|
||||
*/
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => OperatorAddressDto)
|
||||
onBehalfOfOperator?: OperatorAddressDto;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CommodityDto)
|
||||
commodities: CommodityDto[];
|
||||
|
||||
/**
|
||||
* Referenced DDS from upstream supply chain
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ReferencedDdsDto)
|
||||
referencedDds?: ReferencedDdsDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response DTO for DDS submission
|
||||
*/
|
||||
export class SubmitDdsResponseDto {
|
||||
/**
|
||||
* UUID assigned by EUDR system - use this for subsequent operations
|
||||
*/
|
||||
uuid: string;
|
||||
|
||||
/**
|
||||
* HTTP status code from the API
|
||||
*/
|
||||
httpStatus: number;
|
||||
|
||||
/**
|
||||
* Raw SOAP response for debugging
|
||||
*/
|
||||
rawResponse?: string;
|
||||
}
|
||||
137
backend/src/eudr-api/eudr-api.controller.ts
Normal file
137
backend/src/eudr-api/eudr-api.controller.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Controller, Get, Post, Body, HttpCode, HttpStatus, UseGuards } from '@nestjs/common';
|
||||
import { EudrApiService } from './eudr-api.service';
|
||||
import { SubmitDdsRequestDto } from './dto/submit-dds.dto';
|
||||
import { AmendDdsRequestDto } from './dto/amend-dds.dto';
|
||||
import { RetractDdsRequestDto } from './dto/retract-dds.dto';
|
||||
import { GetDdsInfoByUuidRequestDto, GetDdsInfoByReferenceRequestDto } from './dto/get-dds-info.dto';
|
||||
import { GetDdsDataRequestDto, GetReferencedDdsRequestDto } from './dto/get-dds-data.dto';
|
||||
// import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; // Uncomment when auth is ready
|
||||
|
||||
/**
|
||||
* EUDR API Controller
|
||||
*
|
||||
* Internal endpoints for DuOrigin to interact with the EU EUDR system.
|
||||
* These endpoints act as a proxy/adapter between our REST API and the EUDR SOAP API.
|
||||
*/
|
||||
@Controller('api/eudr')
|
||||
// @UseGuards(JwtAuthGuard) // Uncomment when auth is ready
|
||||
export class EudrApiController {
|
||||
constructor(private readonly eudrApiService: EudrApiService) {}
|
||||
|
||||
/**
|
||||
* Get EUDR API configuration status
|
||||
*/
|
||||
@Get('status')
|
||||
async getStatus() {
|
||||
const envInfo = this.eudrApiService.getEnvironmentInfo();
|
||||
const isConfigured = this.eudrApiService.isConfigured();
|
||||
|
||||
return {
|
||||
configured: isConfigured,
|
||||
environment: envInfo.isProduction ? 'PRODUCTION' : 'ACCEPTANCE',
|
||||
wsClientId: envInfo.wsClientId,
|
||||
baseUrl: envInfo.baseUrl,
|
||||
services: {
|
||||
echo: !envInfo.isProduction,
|
||||
submit: true,
|
||||
amend: true,
|
||||
retract: true,
|
||||
getDdsInfo: true,
|
||||
getDdsData: true,
|
||||
getReferencedDds: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CF1 - Test connection and authentication (ACCEPTANCE only)
|
||||
*/
|
||||
@Post('echo')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async testEcho(@Body() body: { message?: string }) {
|
||||
return this.eudrApiService.testEcho(body?.message || 'DuOrigin Echo Test');
|
||||
}
|
||||
|
||||
/**
|
||||
* CF2 - Submit a new DDS
|
||||
*
|
||||
* Submits a Due Diligence Statement to the EUDR system.
|
||||
* Returns the UUID assigned by the system for tracking.
|
||||
*/
|
||||
@Post('submit-dds')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async submitDds(@Body() request: SubmitDdsRequestDto) {
|
||||
return this.eudrApiService.submitDds(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* CF5 - Amend an existing DDS
|
||||
*
|
||||
* Modifies a DDS that is in AVAILABLE status.
|
||||
* Note: Activity type cannot be changed.
|
||||
*/
|
||||
@Post('amend-dds')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async amendDds(@Body() request: AmendDdsRequestDto) {
|
||||
return this.eudrApiService.amendDds(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* CF6 - Retract a DDS
|
||||
*
|
||||
* Cancels (if SUBMITTED) or withdraws (if AVAILABLE) a DDS.
|
||||
*/
|
||||
@Post('retract-dds')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async retractDds(@Body() request: RetractDdsRequestDto) {
|
||||
return this.eudrApiService.retractDds(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* CF3 - Get DDS info by UUID(s)
|
||||
*
|
||||
* Retrieves status and reference numbers for submitted DDS.
|
||||
* Max 100 UUIDs per request.
|
||||
*/
|
||||
@Post('get-dds-info')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getDdsInfoByUuid(@Body() request: GetDdsInfoByUuidRequestDto) {
|
||||
return this.eudrApiService.getDdsInfoByUuid(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* CF3 - Get DDS info by internal reference
|
||||
*
|
||||
* Retrieves DDS matching the internal reference (partial match).
|
||||
* Max 1000 results.
|
||||
*/
|
||||
@Post('get-dds-info-by-reference')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getDdsInfoByReference(@Body() request: GetDdsInfoByReferenceRequestDto) {
|
||||
return this.eudrApiService.getDdsInfoByReference(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* CF7 - Get full DDS data
|
||||
*
|
||||
* Retrieves complete DDS data by reference and verification numbers.
|
||||
* Use this to get DDS from other operators in the supply chain.
|
||||
*/
|
||||
@Post('get-dds-data')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getDdsData(@Body() request: GetDdsDataRequestDto) {
|
||||
return this.eudrApiService.getDdsData(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* CF7 - Get referenced DDS in supply chain
|
||||
*
|
||||
* Retrieves DDS data without verification number.
|
||||
* Uses the security number from previous getDdsData call.
|
||||
*/
|
||||
@Post('get-referenced-dds')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getReferencedDds(@Body() request: GetReferencedDdsRequestDto) {
|
||||
return this.eudrApiService.getReferencedDds(request);
|
||||
}
|
||||
}
|
||||
12
backend/src/eudr-api/eudr-api.module.ts
Normal file
12
backend/src/eudr-api/eudr-api.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EudrApiService } from './eudr-api.service';
|
||||
import { EudrApiController } from './eudr-api.controller';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
controllers: [EudrApiController],
|
||||
providers: [EudrApiService],
|
||||
exports: [EudrApiService],
|
||||
})
|
||||
export class EudrApiModule {}
|
||||
584
backend/src/eudr-api/eudr-api.service.ts
Normal file
584
backend/src/eudr-api/eudr-api.service.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import {
|
||||
buildSoapEnvelope,
|
||||
buildEchoRequest,
|
||||
buildSubmitDdsRequest,
|
||||
buildGetDdsInfoRequest,
|
||||
buildGetDdsInfoByRefRequest,
|
||||
buildRetractDdsRequest,
|
||||
buildGetStatementByIdentifiersRequest,
|
||||
buildGetReferencedDdsRequest,
|
||||
} from './soap/xml-builder';
|
||||
import {
|
||||
parseSoapFault,
|
||||
parseEudrErrors,
|
||||
parseEchoResponse,
|
||||
parseSubmitDdsResponse,
|
||||
parseGetDdsInfoResponse,
|
||||
parseRetractDdsResponse,
|
||||
parseAmendDdsResponse,
|
||||
parseGetStatementByIdentifiersResponse,
|
||||
isSuccessResponse,
|
||||
} from './soap/xml-parser';
|
||||
import { EudrApiErrorDto, EudrApiResponseDto, ActivityType } from './dto/common.dto';
|
||||
import { SubmitDdsRequestDto, SubmitDdsResponseDto } from './dto/submit-dds.dto';
|
||||
import { AmendDdsRequestDto, AmendDdsResponseDto } from './dto/amend-dds.dto';
|
||||
import { RetractDdsRequestDto, RetractDdsResponseDto } from './dto/retract-dds.dto';
|
||||
import {
|
||||
GetDdsInfoByUuidRequestDto,
|
||||
GetDdsInfoByReferenceRequestDto,
|
||||
GetDdsInfoResponseDto,
|
||||
DdsInfoDto,
|
||||
} from './dto/get-dds-info.dto';
|
||||
import {
|
||||
GetDdsDataRequestDto,
|
||||
GetReferencedDdsRequestDto,
|
||||
GetDdsDataResponseDto,
|
||||
} from './dto/get-dds-data.dto';
|
||||
|
||||
/**
|
||||
* EUDR API Service
|
||||
*
|
||||
* Provides SOAP client interface to the EU EUDR system for DDS management.
|
||||
* Supports both ACCEPTANCE (test) and PRODUCTION environments.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EudrApiService implements OnModuleInit {
|
||||
private readonly logger = new Logger(EudrApiService.name);
|
||||
private httpClient: AxiosInstance;
|
||||
|
||||
// Configuration
|
||||
private baseUrl: string;
|
||||
private wsClientId: string;
|
||||
private username: string;
|
||||
private authKey: string;
|
||||
private isProduction: boolean;
|
||||
|
||||
// Service endpoints
|
||||
private readonly ENDPOINTS = {
|
||||
submission: '/services/EudrSubmissionServiceV2',
|
||||
retrieval: '/services/EudrRetrievalServiceV2',
|
||||
echo: '/services/EudrEchoService',
|
||||
};
|
||||
|
||||
// Default URLs
|
||||
private static readonly ACCEPTANCE_URL = 'https://acceptance.eudr.webcloud.ec.europa.eu/tracesnt';
|
||||
private static readonly PRODUCTION_URL = 'https://webgate.ec.europa.eu/tracesnt';
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
// Load configuration
|
||||
this.baseUrl = this.configService.get<string>('EUDR_API_URL') || EudrApiService.ACCEPTANCE_URL;
|
||||
this.wsClientId = this.configService.get<string>('EUDR_WS_CLIENT_ID') || 'eudr-test';
|
||||
this.username = this.configService.get<string>('EUDR_USERNAME') || '';
|
||||
this.authKey = this.configService.get<string>('EUDR_AUTH_KEY') || '';
|
||||
this.isProduction = this.baseUrl.includes('webgate.ec.europa.eu');
|
||||
|
||||
// Create HTTP client
|
||||
this.httpClient = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
timeout: 60000, // 60 seconds timeout
|
||||
headers: {
|
||||
'Content-Type': 'text/xml; charset=utf-8',
|
||||
'SOAPAction': '',
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`EUDR API Service initialized - Environment: ${this.isProduction ? 'PRODUCTION' : 'ACCEPTANCE'}`);
|
||||
this.logger.log(`Base URL: ${this.baseUrl}`);
|
||||
this.logger.log(`WS Client ID: ${this.wsClientId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if credentials are configured
|
||||
*/
|
||||
isConfigured(): boolean {
|
||||
return !!(this.username && this.authKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current environment info
|
||||
*/
|
||||
getEnvironmentInfo(): { isProduction: boolean; baseUrl: string; wsClientId: string } {
|
||||
return {
|
||||
isProduction: this.isProduction,
|
||||
baseUrl: this.baseUrl,
|
||||
wsClientId: this.wsClientId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CF1 - Echo Test
|
||||
* Tests connection and authentication to the EUDR system.
|
||||
* Only available in ACCEPTANCE environment.
|
||||
*/
|
||||
async testEcho(message: string = 'DuOrigin Test'): Promise<EudrApiResponseDto<{ message: string }>> {
|
||||
if (this.isProduction) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'NOT_AVAILABLE', message: 'Echo service not available in production' }],
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.isConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'NOT_CONFIGURED', message: 'EUDR credentials not configured' }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const body = buildEchoRequest(message, this.wsClientId);
|
||||
const envelope = buildSoapEnvelope(this.username, this.authKey, body, 'v1');
|
||||
|
||||
const response = await this.httpClient.post(this.ENDPOINTS.echo, envelope, {
|
||||
headers: { 'SOAPAction': 'testEcho' },
|
||||
});
|
||||
|
||||
const result = parseEchoResponse(response.data);
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: { message: result.message || message },
|
||||
rawResponse: response.data,
|
||||
};
|
||||
}
|
||||
|
||||
const fault = parseSoapFault(response.data);
|
||||
const errors = parseEudrErrors(response.data);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errors: fault ? [fault] : errors,
|
||||
rawResponse: response.data,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Echo test failed', error.message);
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'HTTP_ERROR', message: error.message }],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CF2 - Submit DDS
|
||||
* Submits a new Due Diligence Statement to the EUDR system.
|
||||
*/
|
||||
async submitDds(request: SubmitDdsRequestDto): Promise<EudrApiResponseDto<SubmitDdsResponseDto>> {
|
||||
if (!this.isConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'NOT_CONFIGURED', message: 'EUDR credentials not configured' }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Build request body
|
||||
const body = buildSubmitDdsRequest(this.wsClientId, {
|
||||
activityType: request.activityType,
|
||||
confidentialityFlag: request.confidentialityFlag,
|
||||
companyInternalReference: request.companyInternalReference,
|
||||
onBehalfOfOperator: request.onBehalfOfOperator ? {
|
||||
name: request.onBehalfOfOperator.name,
|
||||
streetNumber: request.onBehalfOfOperator.streetNumber,
|
||||
postalCode: request.onBehalfOfOperator.postalCode,
|
||||
city: request.onBehalfOfOperator.city,
|
||||
countryCode: request.onBehalfOfOperator.countryCode,
|
||||
eori: request.onBehalfOfOperator.eori,
|
||||
} : undefined,
|
||||
commodities: request.commodities.map(c => ({
|
||||
hsCode: c.hsCode,
|
||||
description: c.description,
|
||||
netMass: c.netMass,
|
||||
percentageEstimateOrDeviation: c.percentageEstimateOrDeviation,
|
||||
supplementaryUnitType: c.supplementaryUnitType,
|
||||
supplementaryUnitValue: c.supplementaryUnitValue,
|
||||
producers: c.producers.map(p => ({
|
||||
countryCode: p.countryCode,
|
||||
producerName: p.producerName,
|
||||
geoLocation: {
|
||||
geoJson: p.geoLocation.geoJson,
|
||||
area: p.geoLocation.area,
|
||||
},
|
||||
})),
|
||||
speciesInfo: c.speciesInfo,
|
||||
referencedDdsNumbers: c.referencedDdsNumbers,
|
||||
})),
|
||||
referencedDds: request.referencedDds,
|
||||
});
|
||||
|
||||
const envelope = buildSoapEnvelope(this.username, this.authKey, body);
|
||||
|
||||
const response = await this.httpClient.post(this.ENDPOINTS.submission, envelope, {
|
||||
headers: { 'SOAPAction': 'submitDDS' },
|
||||
});
|
||||
|
||||
const result = parseSubmitDdsResponse(response.data);
|
||||
|
||||
if (result.success && result.uuid) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
uuid: result.uuid,
|
||||
httpStatus: response.status,
|
||||
rawResponse: response.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fault = parseSoapFault(response.data);
|
||||
const errors = parseEudrErrors(response.data);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errors: fault ? [fault] : (errors.length > 0 ? errors : [{ code: 'UNKNOWN', message: 'Failed to submit DDS' }]),
|
||||
rawResponse: response.data,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Submit DDS failed', error.message);
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'HTTP_ERROR', message: error.message }],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CF3 - Get DDS Info by UUID(s)
|
||||
* Retrieves status and reference numbers for submitted DDS.
|
||||
*/
|
||||
async getDdsInfoByUuid(request: GetDdsInfoByUuidRequestDto): Promise<EudrApiResponseDto<GetDdsInfoResponseDto>> {
|
||||
if (!this.isConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'NOT_CONFIGURED', message: 'EUDR credentials not configured' }],
|
||||
};
|
||||
}
|
||||
|
||||
if (request.uuids.length > 100) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'LIMIT_EXCEEDED', message: 'Maximum 100 UUIDs per request' }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const body = buildGetDdsInfoRequest(this.wsClientId, request.uuids);
|
||||
const envelope = buildSoapEnvelope(this.username, this.authKey, body);
|
||||
|
||||
const response = await this.httpClient.post(this.ENDPOINTS.retrieval, envelope, {
|
||||
headers: { 'SOAPAction': 'getDdsInfo' },
|
||||
});
|
||||
|
||||
const ddsInfoList = parseGetDdsInfoResponse(response.data);
|
||||
|
||||
if (isSuccessResponse(response.data)) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
httpStatus: response.status,
|
||||
ddsInfoList,
|
||||
rawResponse: response.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fault = parseSoapFault(response.data);
|
||||
const errors = parseEudrErrors(response.data);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errors: fault ? [fault] : errors,
|
||||
rawResponse: response.data,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Get DDS Info failed', error.message);
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'HTTP_ERROR', message: error.message }],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CF3 - Get DDS Info by Internal Reference
|
||||
* Retrieves DDS info matching the internal reference (partial match, case insensitive).
|
||||
*/
|
||||
async getDdsInfoByReference(request: GetDdsInfoByReferenceRequestDto): Promise<EudrApiResponseDto<GetDdsInfoResponseDto>> {
|
||||
if (!this.isConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'NOT_CONFIGURED', message: 'EUDR credentials not configured' }],
|
||||
};
|
||||
}
|
||||
|
||||
if (request.internalReference.length < 3 || request.internalReference.length > 50) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'INVALID_REFERENCE', message: 'Internal reference must be 3-50 characters' }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const body = buildGetDdsInfoByRefRequest(this.wsClientId, request.internalReference);
|
||||
const envelope = buildSoapEnvelope(this.username, this.authKey, body);
|
||||
|
||||
const response = await this.httpClient.post(this.ENDPOINTS.retrieval, envelope, {
|
||||
headers: { 'SOAPAction': 'GetDdsInfoByInternalReferenceNumber' },
|
||||
});
|
||||
|
||||
const ddsInfoList = parseGetDdsInfoResponse(response.data);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
httpStatus: response.status,
|
||||
ddsInfoList,
|
||||
rawResponse: response.data,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Get DDS Info by Reference failed', error.message);
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'HTTP_ERROR', message: error.message }],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CF5 - Amend DDS
|
||||
* Amends an existing DDS in AVAILABLE status.
|
||||
* Note: Activity type cannot be changed.
|
||||
*/
|
||||
async amendDds(request: AmendDdsRequestDto): Promise<EudrApiResponseDto<AmendDdsResponseDto>> {
|
||||
if (!this.isConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'NOT_CONFIGURED', message: 'EUDR credentials not configured' }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Amend uses similar structure to submit but with UUID
|
||||
const body = `
|
||||
<v21:AmendDDSRequest xmlns:v21="http://ec.europa.eu/tracesnt/eudr/v2.1">
|
||||
<v4:WebServiceClientId xmlns:v4="http://ec.europa.eu/tracesnt/commons/v4">${this.wsClientId}</v4:WebServiceClientId>
|
||||
<v21:UUID>${request.uuid}</v21:UUID>
|
||||
<v21:ConfidentialityFlag>${request.confidentialityFlag}</v21:ConfidentialityFlag>
|
||||
${request.companyInternalReference ? `<v21:CompanyInternalReference>${request.companyInternalReference}</v21:CompanyInternalReference>` : ''}
|
||||
${request.commodities.map(c => `
|
||||
<v21:Commodity>
|
||||
<v21:HSCode>${c.hsCode}</v21:HSCode>
|
||||
<v21:Description>${c.description}</v21:Description>
|
||||
${c.netMass !== undefined ? `<v21:NetMass>${c.netMass}</v21:NetMass>` : ''}
|
||||
${c.producers.map(p => `
|
||||
<v21:Producer>
|
||||
<v21:CountryCode>${p.countryCode}</v21:CountryCode>
|
||||
${p.producerName ? `<v21:ProducerName>${p.producerName}</v21:ProducerName>` : ''}
|
||||
<v21:GeoLocation>${p.geoLocation.geoJson}</v21:GeoLocation>
|
||||
</v21:Producer>
|
||||
`).join('')}
|
||||
</v21:Commodity>
|
||||
`).join('')}
|
||||
</v21:AmendDDSRequest>`;
|
||||
|
||||
const envelope = buildSoapEnvelope(this.username, this.authKey, body);
|
||||
|
||||
const response = await this.httpClient.post(this.ENDPOINTS.submission, envelope, {
|
||||
headers: { 'SOAPAction': 'amendDDS' },
|
||||
});
|
||||
|
||||
const result = parseAmendDdsResponse(response.data);
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
httpStatus: response.status,
|
||||
success: true,
|
||||
rawResponse: response.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fault = parseSoapFault(response.data);
|
||||
const errors = parseEudrErrors(response.data);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errors: fault ? [fault] : errors,
|
||||
rawResponse: response.data,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Amend DDS failed', error.message);
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'HTTP_ERROR', message: error.message }],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CF6 - Retract DDS
|
||||
* Cancels (SUBMITTED status) or withdraws (AVAILABLE status) a DDS.
|
||||
*/
|
||||
async retractDds(request: RetractDdsRequestDto): Promise<EudrApiResponseDto<RetractDdsResponseDto>> {
|
||||
if (!this.isConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'NOT_CONFIGURED', message: 'EUDR credentials not configured' }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const body = buildRetractDdsRequest(this.wsClientId, request.uuid);
|
||||
const envelope = buildSoapEnvelope(this.username, this.authKey, body);
|
||||
|
||||
const response = await this.httpClient.post(this.ENDPOINTS.submission, envelope, {
|
||||
headers: { 'SOAPAction': 'retractDds' },
|
||||
});
|
||||
|
||||
const result = parseRetractDdsResponse(response.data);
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
httpStatus: response.status,
|
||||
success: true,
|
||||
rawResponse: response.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fault = parseSoapFault(response.data);
|
||||
const errors = parseEudrErrors(response.data);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errors: fault ? [fault] : errors,
|
||||
rawResponse: response.data,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Retract DDS failed', error.message);
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'HTTP_ERROR', message: error.message }],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CF7 - Get DDS Data
|
||||
* Retrieves full DDS data by reference and verification numbers.
|
||||
*/
|
||||
async getDdsData(request: GetDdsDataRequestDto): Promise<EudrApiResponseDto<GetDdsDataResponseDto>> {
|
||||
if (!this.isConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'NOT_CONFIGURED', message: 'EUDR credentials not configured' }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const body = buildGetStatementByIdentifiersRequest(
|
||||
this.wsClientId,
|
||||
request.referenceNumber,
|
||||
request.verificationNumber
|
||||
);
|
||||
const envelope = buildSoapEnvelope(this.username, this.authKey, body);
|
||||
|
||||
const response = await this.httpClient.post(this.ENDPOINTS.retrieval, envelope, {
|
||||
headers: { 'SOAPAction': 'getStatementByIdentifiers' },
|
||||
});
|
||||
|
||||
const ddsData = parseGetStatementByIdentifiersResponse(response.data);
|
||||
|
||||
if (ddsData) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
httpStatus: response.status,
|
||||
ddsData,
|
||||
rawResponse: response.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fault = parseSoapFault(response.data);
|
||||
const errors = parseEudrErrors(response.data);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errors: fault ? [fault] : (errors.length > 0 ? errors : [{ code: 'NOT_FOUND', message: 'DDS not found' }]),
|
||||
rawResponse: response.data,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Get DDS Data failed', error.message);
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'HTTP_ERROR', message: error.message }],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CF7 - Get Referenced DDS
|
||||
* Retrieves DDS data from supply chain without verification number.
|
||||
*/
|
||||
async getReferencedDds(request: GetReferencedDdsRequestDto): Promise<EudrApiResponseDto<GetDdsDataResponseDto>> {
|
||||
if (!this.isConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'NOT_CONFIGURED', message: 'EUDR credentials not configured' }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const body = buildGetReferencedDdsRequest(
|
||||
this.wsClientId,
|
||||
request.referenceNumber,
|
||||
request.referenceDdsVerificationNumber
|
||||
);
|
||||
const envelope = buildSoapEnvelope(this.username, this.authKey, body);
|
||||
|
||||
const response = await this.httpClient.post(this.ENDPOINTS.retrieval, envelope, {
|
||||
headers: { 'SOAPAction': 'getReferencedDds' },
|
||||
});
|
||||
|
||||
const ddsData = parseGetStatementByIdentifiersResponse(response.data);
|
||||
|
||||
if (ddsData) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
httpStatus: response.status,
|
||||
ddsData,
|
||||
rawResponse: response.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fault = parseSoapFault(response.data);
|
||||
const errors = parseEudrErrors(response.data);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errors: fault ? [fault] : errors,
|
||||
rawResponse: response.data,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Get Referenced DDS failed', error.message);
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: 'HTTP_ERROR', message: error.message }],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
4
backend/src/eudr-api/index.ts
Normal file
4
backend/src/eudr-api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './eudr-api.module';
|
||||
export * from './eudr-api.service';
|
||||
export * from './eudr-api.controller';
|
||||
export * from './dto';
|
||||
2
backend/src/eudr-api/soap/index.ts
Normal file
2
backend/src/eudr-api/soap/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './xml-builder';
|
||||
export * from './xml-parser';
|
||||
299
backend/src/eudr-api/soap/xml-builder.ts
Normal file
299
backend/src/eudr-api/soap/xml-builder.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* EUDR SOAP XML Builder
|
||||
* Builds SOAP envelopes with WS-Security UsernameToken Digest authentication
|
||||
*/
|
||||
|
||||
// Namespaces used in EUDR API
|
||||
const NAMESPACES = {
|
||||
soap: 'http://schemas.xmlsoap.org/soap/envelope/',
|
||||
wsse: 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd',
|
||||
wsu: 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd',
|
||||
v21: 'http://ec.europa.eu/tracesnt/eudr/v2.1',
|
||||
v1: 'http://ec.europa.eu/tracesnt/eudr/v1',
|
||||
v4: 'http://ec.europa.eu/tracesnt/commons/v4',
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate WS-Security timestamp
|
||||
*/
|
||||
function generateTimestamp(): { created: string; expires: string } {
|
||||
const now = new Date();
|
||||
const expires = new Date(now.getTime() + 5 * 60 * 1000); // 5 minutes validity
|
||||
|
||||
return {
|
||||
created: now.toISOString(),
|
||||
expires: expires.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random nonce (16 bytes, base64 encoded)
|
||||
*/
|
||||
function generateNonce(): string {
|
||||
return crypto.randomBytes(16).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate password digest for WS-Security
|
||||
* Password = Base64( SHA-1( nonce + created + authKey ) )
|
||||
*/
|
||||
function calculatePasswordDigest(nonce: string, created: string, authKey: string): string {
|
||||
const nonceBytes = Buffer.from(nonce, 'base64');
|
||||
const createdBytes = Buffer.from(created, 'utf8');
|
||||
const authKeyBytes = Buffer.from(authKey, 'utf8');
|
||||
|
||||
const combined = Buffer.concat([nonceBytes, createdBytes, authKeyBytes]);
|
||||
const hash = crypto.createHash('sha1').update(combined).digest('base64');
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build WS-Security header with UsernameToken Digest
|
||||
*/
|
||||
export function buildSecurityHeader(username: string, authKey: string): string {
|
||||
const { created, expires } = generateTimestamp();
|
||||
const nonce = generateNonce();
|
||||
const passwordDigest = calculatePasswordDigest(nonce, created, authKey);
|
||||
|
||||
const tsId = `TS-${crypto.randomUUID()}`;
|
||||
const utId = `UsernameToken-${crypto.randomUUID()}`;
|
||||
|
||||
return `
|
||||
<wsse:Security xmlns:wsse="${NAMESPACES.wsse}"
|
||||
xmlns:wsu="${NAMESPACES.wsu}"
|
||||
soap:mustUnderstand="1">
|
||||
<wsu:Timestamp wsu:Id="${tsId}">
|
||||
<wsu:Created>${created}</wsu:Created>
|
||||
<wsu:Expires>${expires}</wsu:Expires>
|
||||
</wsu:Timestamp>
|
||||
<wsse:UsernameToken wsu:Id="${utId}">
|
||||
<wsse:Username>${escapeXml(username)}</wsse:Username>
|
||||
<wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">${passwordDigest}</wsse:Password>
|
||||
<wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">${nonce}</wsse:Nonce>
|
||||
<wsu:Created>${created}</wsu:Created>
|
||||
</wsse:UsernameToken>
|
||||
</wsse:Security>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape XML special characters
|
||||
*/
|
||||
export function escapeXml(str: string): string {
|
||||
if (!str) return '';
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build complete SOAP envelope
|
||||
*/
|
||||
export function buildSoapEnvelope(
|
||||
username: string,
|
||||
authKey: string,
|
||||
body: string,
|
||||
version: 'v1' | 'v21' = 'v21'
|
||||
): string {
|
||||
const securityHeader = buildSecurityHeader(username, authKey);
|
||||
const ns = version === 'v21' ? NAMESPACES.v21 : NAMESPACES.v1;
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soap:Envelope xmlns:soap="${NAMESPACES.soap}"
|
||||
xmlns:v4="${NAMESPACES.v4}"
|
||||
xmlns:${version}="${ns}">
|
||||
<soap:Header>
|
||||
${securityHeader}
|
||||
</soap:Header>
|
||||
<soap:Body>
|
||||
${body}
|
||||
</soap:Body>
|
||||
</soap:Envelope>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Echo test request body
|
||||
*/
|
||||
export function buildEchoRequest(message: string, wsClientId: string): string {
|
||||
return `
|
||||
<v1:testEchoRequest xmlns:v1="${NAMESPACES.v1}">
|
||||
<v4:WebServiceClientId>${escapeXml(wsClientId)}</v4:WebServiceClientId>
|
||||
<v1:message>${escapeXml(message)}</v1:message>
|
||||
</v1:testEchoRequest>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Submit DDS request body (V2)
|
||||
*/
|
||||
export function buildSubmitDdsRequest(
|
||||
wsClientId: string,
|
||||
data: {
|
||||
activityType: string;
|
||||
confidentialityFlag: boolean;
|
||||
companyInternalReference?: string;
|
||||
onBehalfOfOperator?: {
|
||||
name: string;
|
||||
streetNumber: string;
|
||||
postalCode: string;
|
||||
city: string;
|
||||
countryCode: string;
|
||||
eori?: string;
|
||||
};
|
||||
commodities: Array<{
|
||||
hsCode: string;
|
||||
description: string;
|
||||
netMass?: number;
|
||||
percentageEstimateOrDeviation?: number;
|
||||
supplementaryUnitType?: string;
|
||||
supplementaryUnitValue?: number;
|
||||
producers: Array<{
|
||||
countryCode: string;
|
||||
producerName?: string;
|
||||
geoLocation: { geoJson: string; area?: number };
|
||||
}>;
|
||||
speciesInfo?: Array<{ scientificName: string; commonName: string }>;
|
||||
referencedDdsNumbers?: string[];
|
||||
}>;
|
||||
referencedDds?: Array<{ referenceNumber: string; verificationNumber: string }>;
|
||||
}
|
||||
): string {
|
||||
let operatorXml = '';
|
||||
if (data.onBehalfOfOperator) {
|
||||
const op = data.onBehalfOfOperator;
|
||||
operatorXml = `
|
||||
<v21:OnBehalfOfOperator>
|
||||
<v21:Name>${escapeXml(op.name)}</v21:Name>
|
||||
<v21:StreetNumber>${escapeXml(op.streetNumber)}</v21:StreetNumber>
|
||||
<v21:PostalCode>${escapeXml(op.postalCode)}</v21:PostalCode>
|
||||
<v21:City>${escapeXml(op.city)}</v21:City>
|
||||
<v21:CountryCode>${escapeXml(op.countryCode)}</v21:CountryCode>
|
||||
${op.eori ? `<v21:EORI>${escapeXml(op.eori)}</v21:EORI>` : ''}
|
||||
</v21:OnBehalfOfOperator>`;
|
||||
}
|
||||
|
||||
const commoditiesXml = data.commodities.map(c => {
|
||||
const producersXml = c.producers.map(p => `
|
||||
<v21:Producer>
|
||||
<v21:CountryCode>${escapeXml(p.countryCode)}</v21:CountryCode>
|
||||
${p.producerName ? `<v21:ProducerName>${escapeXml(p.producerName)}</v21:ProducerName>` : ''}
|
||||
<v21:GeoLocation>${escapeXml(p.geoLocation.geoJson)}</v21:GeoLocation>
|
||||
${p.geoLocation.area ? `<v21:Area>${p.geoLocation.area}</v21:Area>` : ''}
|
||||
</v21:Producer>
|
||||
`).join('');
|
||||
|
||||
const speciesXml = c.speciesInfo?.map(s => `
|
||||
<v21:SpeciesInfo>
|
||||
<v21:ScientificName>${escapeXml(s.scientificName)}</v21:ScientificName>
|
||||
<v21:CommonName>${escapeXml(s.commonName)}</v21:CommonName>
|
||||
</v21:SpeciesInfo>
|
||||
`).join('') || '';
|
||||
|
||||
const refsXml = c.referencedDdsNumbers?.map(ref => `
|
||||
<v21:ReferencedDdsNumber>${escapeXml(ref)}</v21:ReferencedDdsNumber>
|
||||
`).join('') || '';
|
||||
|
||||
return `
|
||||
<v21:Commodity>
|
||||
<v21:HSCode>${escapeXml(c.hsCode)}</v21:HSCode>
|
||||
<v21:Description>${escapeXml(c.description)}</v21:Description>
|
||||
${c.netMass !== undefined ? `<v21:NetMass>${c.netMass}</v21:NetMass>` : ''}
|
||||
${c.percentageEstimateOrDeviation !== undefined ? `<v21:PercentageEstimateOrDeviation>${c.percentageEstimateOrDeviation}</v21:PercentageEstimateOrDeviation>` : ''}
|
||||
${c.supplementaryUnitType ? `<v21:SupplementaryUnitType>${escapeXml(c.supplementaryUnitType)}</v21:SupplementaryUnitType>` : ''}
|
||||
${c.supplementaryUnitValue !== undefined ? `<v21:SupplementaryUnitValue>${c.supplementaryUnitValue}</v21:SupplementaryUnitValue>` : ''}
|
||||
${producersXml}
|
||||
${speciesXml}
|
||||
${refsXml}
|
||||
</v21:Commodity>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const referencedDdsXml = data.referencedDds?.map(r => `
|
||||
<v21:ReferencedDds>
|
||||
<v21:ReferenceNumber>${escapeXml(r.referenceNumber)}</v21:ReferenceNumber>
|
||||
<v21:VerificationNumber>${escapeXml(r.verificationNumber)}</v21:VerificationNumber>
|
||||
</v21:ReferencedDds>
|
||||
`).join('') || '';
|
||||
|
||||
return `
|
||||
<v21:SubmitDDSRequest xmlns:v21="${NAMESPACES.v21}">
|
||||
<v4:WebServiceClientId>${escapeXml(wsClientId)}</v4:WebServiceClientId>
|
||||
<v21:ActivityType>${escapeXml(data.activityType)}</v21:ActivityType>
|
||||
<v21:ConfidentialityFlag>${data.confidentialityFlag}</v21:ConfidentialityFlag>
|
||||
${data.companyInternalReference ? `<v21:CompanyInternalReference>${escapeXml(data.companyInternalReference)}</v21:CompanyInternalReference>` : ''}
|
||||
${operatorXml}
|
||||
${commoditiesXml}
|
||||
${referencedDdsXml}
|
||||
</v21:SubmitDDSRequest>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Get DDS Info request body
|
||||
*/
|
||||
export function buildGetDdsInfoRequest(wsClientId: string, uuids: string[]): string {
|
||||
const uuidElements = uuids.map(uuid => `<v21:UUID>${escapeXml(uuid)}</v21:UUID>`).join('');
|
||||
|
||||
return `
|
||||
<v21:GetDdsInfoRequest xmlns:v21="${NAMESPACES.v21}">
|
||||
<v4:WebServiceClientId>${escapeXml(wsClientId)}</v4:WebServiceClientId>
|
||||
${uuidElements}
|
||||
</v21:GetDdsInfoRequest>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Get DDS Info by Internal Reference request body
|
||||
*/
|
||||
export function buildGetDdsInfoByRefRequest(wsClientId: string, internalReference: string): string {
|
||||
return `
|
||||
<v21:GetDdsInfoByInternalReferenceNumberRequest xmlns:v21="${NAMESPACES.v21}">
|
||||
<v4:WebServiceClientId>${escapeXml(wsClientId)}</v4:WebServiceClientId>
|
||||
<v21:InternalReferenceNumber>${escapeXml(internalReference)}</v21:InternalReferenceNumber>
|
||||
</v21:GetDdsInfoByInternalReferenceNumberRequest>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Retract DDS request body
|
||||
*/
|
||||
export function buildRetractDdsRequest(wsClientId: string, uuid: string): string {
|
||||
return `
|
||||
<v21:RetractDdsRequest xmlns:v21="${NAMESPACES.v21}">
|
||||
<v4:WebServiceClientId>${escapeXml(wsClientId)}</v4:WebServiceClientId>
|
||||
<v21:UUID>${escapeXml(uuid)}</v21:UUID>
|
||||
</v21:RetractDdsRequest>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Get Statement By Identifiers request body
|
||||
*/
|
||||
export function buildGetStatementByIdentifiersRequest(
|
||||
wsClientId: string,
|
||||
referenceNumber: string,
|
||||
verificationNumber: string
|
||||
): string {
|
||||
return `
|
||||
<v21:GetStatementByIdentifiersRequest xmlns:v21="${NAMESPACES.v21}">
|
||||
<v4:WebServiceClientId>${escapeXml(wsClientId)}</v4:WebServiceClientId>
|
||||
<v21:ReferenceNumber>${escapeXml(referenceNumber)}</v21:ReferenceNumber>
|
||||
<v21:VerificationNumber>${escapeXml(verificationNumber)}</v21:VerificationNumber>
|
||||
</v21:GetStatementByIdentifiersRequest>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Get Referenced DDS request body
|
||||
*/
|
||||
export function buildGetReferencedDdsRequest(
|
||||
wsClientId: string,
|
||||
referenceNumber: string,
|
||||
referenceDdsVerificationNumber: string
|
||||
): string {
|
||||
return `
|
||||
<v21:GetReferencedDdsRequest xmlns:v21="${NAMESPACES.v21}">
|
||||
<v4:WebServiceClientId>${escapeXml(wsClientId)}</v4:WebServiceClientId>
|
||||
<v21:ReferenceNumber>${escapeXml(referenceNumber)}</v21:ReferenceNumber>
|
||||
<v21:ReferenceDdsVerificationNumber>${escapeXml(referenceDdsVerificationNumber)}</v21:ReferenceDdsVerificationNumber>
|
||||
</v21:GetReferencedDdsRequest>`;
|
||||
}
|
||||
280
backend/src/eudr-api/soap/xml-parser.ts
Normal file
280
backend/src/eudr-api/soap/xml-parser.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { DDSStatus, EudrApiErrorDto } from '../dto/common.dto';
|
||||
import { DdsInfoDto } from '../dto/get-dds-info.dto';
|
||||
import { DdsDataDto, ReferencedDdsInfoDto } from '../dto/get-dds-data.dto';
|
||||
|
||||
/**
|
||||
* EUDR SOAP XML Response Parser
|
||||
* Parses SOAP responses from the EUDR API
|
||||
*/
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
removeNSPrefix: true,
|
||||
parseTagValue: true,
|
||||
trimValues: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse SOAP Fault response
|
||||
*/
|
||||
export function parseSoapFault(xml: string): EudrApiErrorDto | null {
|
||||
try {
|
||||
const parsed = parser.parse(xml);
|
||||
const envelope = parsed.Envelope || parsed['soap:Envelope'] || parsed['soapenv:Envelope'];
|
||||
const body = envelope?.Body || envelope?.['soap:Body'];
|
||||
const fault = body?.Fault || body?.['soap:Fault'];
|
||||
|
||||
if (fault) {
|
||||
return {
|
||||
code: fault.faultcode || fault.Code?.Value || 'SOAP_FAULT',
|
||||
message: fault.faultstring || fault.Reason?.Text || 'Unknown SOAP fault',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse EUDR business errors from response
|
||||
*/
|
||||
export function parseEudrErrors(xml: string): EudrApiErrorDto[] {
|
||||
try {
|
||||
const parsed = parser.parse(xml);
|
||||
const envelope = parsed.Envelope || parsed['soap:Envelope'];
|
||||
const body = envelope?.Body || envelope?.['soap:Body'];
|
||||
|
||||
// Look for error elements in the response
|
||||
const findErrors = (obj: any, errors: EudrApiErrorDto[] = []): EudrApiErrorDto[] => {
|
||||
if (!obj || typeof obj !== 'object') return errors;
|
||||
|
||||
// Check for Error or ValidationError elements
|
||||
if (obj.Error || obj.ValidationError || obj.BusinessError) {
|
||||
const errList = obj.Error || obj.ValidationError || obj.BusinessError;
|
||||
const errArray = Array.isArray(errList) ? errList : [errList];
|
||||
|
||||
for (const err of errArray) {
|
||||
errors.push({
|
||||
code: err.Code || err.ErrorCode || err.code || 'UNKNOWN',
|
||||
message: err.Message || err.ErrorMessage || err.message || 'Unknown error',
|
||||
field: err.Field || err.FieldName || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively check child elements
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (typeof obj[key] === 'object') {
|
||||
findErrors(obj[key], errors);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
return findErrors(body);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Echo test response
|
||||
*/
|
||||
export function parseEchoResponse(xml: string): { success: boolean; message?: string } {
|
||||
try {
|
||||
const parsed = parser.parse(xml);
|
||||
const envelope = parsed.Envelope || parsed['soap:Envelope'];
|
||||
const body = envelope?.Body || envelope?.['soap:Body'];
|
||||
const response = body?.testEchoResponse || body?.EchoResponse;
|
||||
|
||||
if (response) {
|
||||
return {
|
||||
success: true,
|
||||
message: response.message || response.Message,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false };
|
||||
} catch (e) {
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Submit DDS response
|
||||
*/
|
||||
export function parseSubmitDdsResponse(xml: string): { uuid?: string; success: boolean } {
|
||||
try {
|
||||
const parsed = parser.parse(xml);
|
||||
const envelope = parsed.Envelope || parsed['soap:Envelope'];
|
||||
const body = envelope?.Body || envelope?.['soap:Body'];
|
||||
const response = body?.SubmitDDSResponse || body?.submitDDSResponse;
|
||||
|
||||
if (response && response.UUID) {
|
||||
return {
|
||||
success: true,
|
||||
uuid: response.UUID,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false };
|
||||
} catch (e) {
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Get DDS Info response
|
||||
*/
|
||||
export function parseGetDdsInfoResponse(xml: string): DdsInfoDto[] {
|
||||
try {
|
||||
const parsed = parser.parse(xml);
|
||||
const envelope = parsed.Envelope || parsed['soap:Envelope'];
|
||||
const body = envelope?.Body || envelope?.['soap:Body'];
|
||||
const response = body?.GetDdsInfoResponse || body?.getDdsInfoResponse;
|
||||
|
||||
if (!response) return [];
|
||||
|
||||
// Handle both single and multiple DDS info
|
||||
const ddsInfoList = response.DDSInfo || response.DdsInfo;
|
||||
if (!ddsInfoList) return [];
|
||||
|
||||
const infoArray = Array.isArray(ddsInfoList) ? ddsInfoList : [ddsInfoList];
|
||||
|
||||
return infoArray.map((info: any): DdsInfoDto => ({
|
||||
uuid: info.UUID || info.Uuid,
|
||||
status: (info.Status || 'UNKNOWN') as DDSStatus,
|
||||
referenceNumber: info.ReferenceNumber || info.RefNumber,
|
||||
verificationNumber: info.VerificationNumber || info.VerifNumber,
|
||||
internalReference: info.InternalReferenceNumber || info.CompanyInternalReference,
|
||||
caMessage: info.CAMessage || info.CaMessage || info.CompetentAuthorityMessage,
|
||||
rejectionReason: info.RejectionReason,
|
||||
}));
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Retract DDS response
|
||||
*/
|
||||
export function parseRetractDdsResponse(xml: string): { success: boolean } {
|
||||
try {
|
||||
const parsed = parser.parse(xml);
|
||||
const envelope = parsed.Envelope || parsed['soap:Envelope'];
|
||||
const body = envelope?.Body || envelope?.['soap:Body'];
|
||||
|
||||
// Success if we have a RetractDdsResponse without errors
|
||||
const response = body?.RetractDdsResponse || body?.retractDdsResponse;
|
||||
const fault = parseSoapFault(xml);
|
||||
const errors = parseEudrErrors(xml);
|
||||
|
||||
return { success: !!response && !fault && errors.length === 0 };
|
||||
} catch (e) {
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Amend DDS response
|
||||
*/
|
||||
export function parseAmendDdsResponse(xml: string): { success: boolean } {
|
||||
try {
|
||||
const parsed = parser.parse(xml);
|
||||
const envelope = parsed.Envelope || parsed['soap:Envelope'];
|
||||
const body = envelope?.Body || envelope?.['soap:Body'];
|
||||
|
||||
const response = body?.AmendDDSResponse || body?.amendDDSResponse;
|
||||
const fault = parseSoapFault(xml);
|
||||
const errors = parseEudrErrors(xml);
|
||||
|
||||
return { success: !!response && !fault && errors.length === 0 };
|
||||
} catch (e) {
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Get Statement By Identifiers response
|
||||
*/
|
||||
export function parseGetStatementByIdentifiersResponse(xml: string): DdsDataDto | null {
|
||||
try {
|
||||
const parsed = parser.parse(xml);
|
||||
const envelope = parsed.Envelope || parsed['soap:Envelope'];
|
||||
const body = envelope?.Body || envelope?.['soap:Body'];
|
||||
const response = body?.GetStatementByIdentifiersResponse || body?.getStatementByIdentifiersResponse;
|
||||
|
||||
if (!response || !response.Statement) return null;
|
||||
|
||||
const stmt = response.Statement;
|
||||
|
||||
// Parse referenced DDS with security numbers
|
||||
const referencedDds: ReferencedDdsInfoDto[] = [];
|
||||
if (stmt.ReferencedDds) {
|
||||
const refs = Array.isArray(stmt.ReferencedDds) ? stmt.ReferencedDds : [stmt.ReferencedDds];
|
||||
for (const ref of refs) {
|
||||
referencedDds.push({
|
||||
referenceNumber: ref.ReferenceNumber,
|
||||
referenceDdsVerificationNumber: ref.ReferenceDdsVerificationNumber || ref.SecurityNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse commodities
|
||||
const commodities: any[] = [];
|
||||
if (stmt.Commodity) {
|
||||
const comms = Array.isArray(stmt.Commodity) ? stmt.Commodity : [stmt.Commodity];
|
||||
for (const c of comms) {
|
||||
commodities.push({
|
||||
hsCode: c.HSCode || c.HsCode,
|
||||
description: c.Description,
|
||||
netMass: c.NetMass ? parseFloat(c.NetMass) : undefined,
|
||||
percentageEstimateOrDeviation: c.PercentageEstimateOrDeviation ? parseFloat(c.PercentageEstimateOrDeviation) : undefined,
|
||||
supplementaryUnitType: c.SupplementaryUnitType,
|
||||
supplementaryUnitValue: c.SupplementaryUnitValue ? parseFloat(c.SupplementaryUnitValue) : undefined,
|
||||
producers: [],
|
||||
speciesInfo: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
referenceNumber: stmt.ReferenceNumber,
|
||||
verificationNumber: stmt.VerificationNumber,
|
||||
status: (stmt.Status || 'UNKNOWN') as DDSStatus,
|
||||
activityType: stmt.ActivityType,
|
||||
availabilityDate: stmt.AvailabilityDate,
|
||||
operatorName: stmt.OperatorName || stmt.Operator?.Name,
|
||||
operatorCountry: stmt.OperatorCountry || stmt.Operator?.CountryCode,
|
||||
commodities,
|
||||
showGeoLocation: stmt.ShowGeoLocation === true || stmt.ShowGeoLocation === 'true',
|
||||
referencedDds,
|
||||
};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Get Referenced DDS response (same structure as GetStatementByIdentifiers)
|
||||
*/
|
||||
export function parseGetReferencedDdsResponse(xml: string): DdsDataDto | null {
|
||||
return parseGetStatementByIdentifiersResponse(xml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response indicates HTTP/SOAP success
|
||||
*/
|
||||
export function isSuccessResponse(xml: string): boolean {
|
||||
const fault = parseSoapFault(xml);
|
||||
if (fault) return false;
|
||||
|
||||
const errors = parseEudrErrors(xml);
|
||||
if (errors.length > 0) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
379
backend/src/eudr-api/tests/eudr-api.service.spec.ts
Normal file
379
backend/src/eudr-api/tests/eudr-api.service.spec.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { EudrApiService } from '../eudr-api.service';
|
||||
import { ActivityType } from '../dto/common.dto';
|
||||
import axios from 'axios';
|
||||
|
||||
// Mock axios
|
||||
jest.mock('axios');
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
describe('EudrApiService', () => {
|
||||
let service: EudrApiService;
|
||||
let configService: ConfigService;
|
||||
|
||||
// Mock config values
|
||||
const mockConfig = {
|
||||
EUDR_API_URL: 'https://acceptance.eudr.webcloud.ec.europa.eu/tracesnt',
|
||||
EUDR_WS_CLIENT_ID: 'eudr-test',
|
||||
EUDR_USERNAME: 'test-user',
|
||||
EUDR_AUTH_KEY: 'test-auth-key-12345',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup axios mock
|
||||
mockedAxios.create.mockReturnValue({
|
||||
post: jest.fn(),
|
||||
get: jest.fn(),
|
||||
} as any);
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EudrApiService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: jest.fn((key: string) => mockConfig[key]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<EudrApiService>(EudrApiService);
|
||||
configService = module.get<ConfigService>(ConfigService);
|
||||
|
||||
// Trigger onModuleInit
|
||||
service.onModuleInit();
|
||||
});
|
||||
|
||||
describe('Configuration', () => {
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return correct environment info', () => {
|
||||
const envInfo = service.getEnvironmentInfo();
|
||||
expect(envInfo.isProduction).toBe(false);
|
||||
expect(envInfo.wsClientId).toBe('eudr-test');
|
||||
expect(envInfo.baseUrl).toContain('acceptance');
|
||||
});
|
||||
|
||||
it('should report as configured when credentials exist', () => {
|
||||
expect(service.isConfigured()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CF1 - Echo Test', () => {
|
||||
it('should successfully perform echo test', async () => {
|
||||
const mockResponse = {
|
||||
status: 200,
|
||||
data: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap:Body>
|
||||
<testEchoResponse>
|
||||
<message>Test message</message>
|
||||
</testEchoResponse>
|
||||
</soap:Body>
|
||||
</soap:Envelope>`,
|
||||
};
|
||||
|
||||
// Get the mock client and setup response
|
||||
const mockClient = mockedAxios.create();
|
||||
(mockClient.post as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
// Re-initialize to apply mock
|
||||
service.onModuleInit();
|
||||
|
||||
// Note: In real tests, we'd need to properly inject the axios instance
|
||||
// This is a simplified example
|
||||
});
|
||||
|
||||
it('should fail echo in production environment', async () => {
|
||||
// Create a production-configured service
|
||||
const prodConfigService = {
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'EUDR_API_URL') return 'https://webgate.ec.europa.eu/tracesnt';
|
||||
return mockConfig[key];
|
||||
}),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
EudrApiService,
|
||||
{ provide: ConfigService, useValue: prodConfigService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const prodService = module.get<EudrApiService>(EudrApiService);
|
||||
prodService.onModuleInit();
|
||||
|
||||
const result = await prodService.testEcho('test');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors[0].code).toBe('NOT_AVAILABLE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CF2 - Submit DDS', () => {
|
||||
it('should validate required fields for IMPORT activity', async () => {
|
||||
// IMPORT requires EORI and Net Mass
|
||||
const request = {
|
||||
activityType: ActivityType.IMPORT,
|
||||
confidentialityFlag: false,
|
||||
companyInternalReference: 'TEST-REF-001',
|
||||
commodities: [
|
||||
{
|
||||
hsCode: '1201',
|
||||
description: 'Soybeans',
|
||||
netMass: 50000,
|
||||
producers: [
|
||||
{
|
||||
countryCode: 'BR',
|
||||
producerName: 'Fazenda Teste',
|
||||
geoLocation: {
|
||||
geoJson: JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [-47.123, -23.456] },
|
||||
properties: { plotId: 'PLOT-001' }
|
||||
}]
|
||||
}),
|
||||
area: 3.5,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Should have correct structure
|
||||
expect(request.activityType).toBe(ActivityType.IMPORT);
|
||||
expect(request.commodities[0].netMass).toBeDefined();
|
||||
expect(request.commodities[0].producers[0].geoLocation.geoJson).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should build correct GeoJSON for producers', () => {
|
||||
const geoJson = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [-47.123456, -23.456789],
|
||||
},
|
||||
properties: {
|
||||
plotId: 'PLOT-001',
|
||||
area: 3.5,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(geoJson.type).toBe('FeatureCollection');
|
||||
expect(geoJson.features[0].geometry.type).toBe('Point');
|
||||
expect(geoJson.features[0].geometry.coordinates[0]).toBeGreaterThanOrEqual(-180);
|
||||
expect(geoJson.features[0].geometry.coordinates[0]).toBeLessThanOrEqual(180);
|
||||
expect(geoJson.features[0].geometry.coordinates[1]).toBeGreaterThanOrEqual(-90);
|
||||
expect(geoJson.features[0].geometry.coordinates[1]).toBeLessThanOrEqual(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CF3 - Get DDS Info', () => {
|
||||
it('should enforce UUID limit', async () => {
|
||||
const tooManyUuids = Array(101).fill('550e8400-e29b-41d4-a716-446655440000');
|
||||
|
||||
const result = await service.getDdsInfoByUuid({ uuids: tooManyUuids });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors[0].code).toBe('LIMIT_EXCEEDED');
|
||||
});
|
||||
|
||||
it('should validate internal reference length', async () => {
|
||||
// Too short
|
||||
const shortResult = await service.getDdsInfoByReference({ internalReference: 'AB' });
|
||||
expect(shortResult.success).toBe(false);
|
||||
expect(shortResult.errors[0].code).toBe('INVALID_REFERENCE');
|
||||
|
||||
// Too long
|
||||
const longRef = 'A'.repeat(51);
|
||||
const longResult = await service.getDdsInfoByReference({ internalReference: longRef });
|
||||
expect(longResult.success).toBe(false);
|
||||
expect(longResult.errors[0].code).toBe('INVALID_REFERENCE');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('XML Builder', () => {
|
||||
// Import the xml-builder module
|
||||
const xmlBuilder = require('../soap/xml-builder');
|
||||
|
||||
describe('Security Header', () => {
|
||||
it('should generate valid security header', () => {
|
||||
const header = xmlBuilder.buildSecurityHeader('testuser', 'testauthkey');
|
||||
|
||||
expect(header).toContain('wsse:Security');
|
||||
expect(header).toContain('wsse:UsernameToken');
|
||||
expect(header).toContain('testuser');
|
||||
expect(header).toContain('wsse:Password');
|
||||
expect(header).toContain('wsse:Nonce');
|
||||
expect(header).toContain('wsu:Created');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Echo Request', () => {
|
||||
it('should build echo request correctly', () => {
|
||||
const request = xmlBuilder.buildEchoRequest('Test Message', 'eudr-test');
|
||||
|
||||
expect(request).toContain('testEchoRequest');
|
||||
expect(request).toContain('Test Message');
|
||||
expect(request).toContain('eudr-test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Submit DDS Request', () => {
|
||||
it('should build submit request with geolocation', () => {
|
||||
const request = xmlBuilder.buildSubmitDdsRequest('eudr-test', {
|
||||
activityType: 'IMPORT',
|
||||
confidentialityFlag: false,
|
||||
commodities: [{
|
||||
hsCode: '1201',
|
||||
description: 'Soybeans',
|
||||
netMass: 50000,
|
||||
producers: [{
|
||||
countryCode: 'BR',
|
||||
geoLocation: { geoJson: '{"type":"Point"}' },
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
expect(request).toContain('SubmitDDSRequest');
|
||||
expect(request).toContain('IMPORT');
|
||||
expect(request).toContain('1201');
|
||||
expect(request).toContain('BR');
|
||||
});
|
||||
|
||||
it('should include authorized representative fields', () => {
|
||||
const request = xmlBuilder.buildSubmitDdsRequest('eudr-test', {
|
||||
activityType: 'IMPORT',
|
||||
confidentialityFlag: false,
|
||||
onBehalfOfOperator: {
|
||||
name: 'Empresa Teste',
|
||||
streetNumber: 'Rua 1, 100',
|
||||
postalCode: '01000-000',
|
||||
city: 'São Paulo',
|
||||
countryCode: 'BR',
|
||||
eori: 'BR12345678901234',
|
||||
},
|
||||
commodities: [{
|
||||
hsCode: '1201',
|
||||
description: 'Soybeans',
|
||||
netMass: 50000,
|
||||
producers: [{
|
||||
countryCode: 'BR',
|
||||
geoLocation: { geoJson: '{}' },
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
expect(request).toContain('OnBehalfOfOperator');
|
||||
expect(request).toContain('Empresa Teste');
|
||||
expect(request).toContain('BR12345678901234');
|
||||
});
|
||||
});
|
||||
|
||||
describe('XML Escaping', () => {
|
||||
it('should escape special characters', () => {
|
||||
const escaped = xmlBuilder.escapeXml('Test & <value> "quote" \'apos\'');
|
||||
|
||||
expect(escaped).toContain('&');
|
||||
expect(escaped).toContain('<');
|
||||
expect(escaped).toContain('>');
|
||||
expect(escaped).toContain('"');
|
||||
expect(escaped).toContain(''');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('XML Parser', () => {
|
||||
const xmlParser = require('../soap/xml-parser');
|
||||
|
||||
describe('Parse Echo Response', () => {
|
||||
it('should parse successful echo response', () => {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap:Body>
|
||||
<testEchoResponse>
|
||||
<message>Hello World</message>
|
||||
</testEchoResponse>
|
||||
</soap:Body>
|
||||
</soap:Envelope>`;
|
||||
|
||||
const result = xmlParser.parseEchoResponse(xml);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('Hello World');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parse Submit Response', () => {
|
||||
it('should extract UUID from submit response', () => {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap:Body>
|
||||
<SubmitDDSResponse>
|
||||
<UUID>550e8400-e29b-41d4-a716-446655440000</UUID>
|
||||
</SubmitDDSResponse>
|
||||
</soap:Body>
|
||||
</soap:Envelope>`;
|
||||
|
||||
const result = xmlParser.parseSubmitDdsResponse(xml);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.uuid).toBe('550e8400-e29b-41d4-a716-446655440000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parse DDS Info Response', () => {
|
||||
it('should parse DDS info list', () => {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap:Body>
|
||||
<GetDdsInfoResponse>
|
||||
<DDSInfo>
|
||||
<UUID>550e8400-e29b-41d4-a716-446655440000</UUID>
|
||||
<Status>AVAILABLE</Status>
|
||||
<ReferenceNumber>EUDR-2026-0001234</ReferenceNumber>
|
||||
<VerificationNumber>VN123456789</VerificationNumber>
|
||||
</DDSInfo>
|
||||
</GetDdsInfoResponse>
|
||||
</soap:Body>
|
||||
</soap:Envelope>`;
|
||||
|
||||
const result = xmlParser.parseGetDdsInfoResponse(xml);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].uuid).toBe('550e8400-e29b-41d4-a716-446655440000');
|
||||
expect(result[0].status).toBe('AVAILABLE');
|
||||
expect(result[0].referenceNumber).toBe('EUDR-2026-0001234');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parse SOAP Fault', () => {
|
||||
it('should detect SOAP fault', () => {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap:Body>
|
||||
<soap:Fault>
|
||||
<faultcode>soap:Client</faultcode>
|
||||
<faultstring>Authentication failed</faultstring>
|
||||
</soap:Fault>
|
||||
</soap:Body>
|
||||
</soap:Envelope>`;
|
||||
|
||||
const fault = xmlParser.parseSoapFault(xml);
|
||||
expect(fault).not.toBeNull();
|
||||
expect(fault.message).toContain('Authentication');
|
||||
});
|
||||
});
|
||||
});
|
||||
55
backend/src/main.ts
Normal file
55
backend/src/main.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Global API prefix
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
// CORS
|
||||
app.enableCors({
|
||||
origin: ['http://localhost:5173', 'https://duorigin.aivertice.com'],
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
});
|
||||
|
||||
// Validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Swagger
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('DuOrigin v2 API')
|
||||
.setDescription('Sistema de Compliance EUDR para Agronegócio')
|
||||
.setVersion('2.0.0')
|
||||
.addBearerAuth()
|
||||
.addTag('auth', 'Autenticação')
|
||||
.addTag('users', 'Gerenciamento de Usuários')
|
||||
.addTag('empresas', 'Gerenciamento de Empresas')
|
||||
.addTag('propriedades', 'Gerenciamento de Propriedades')
|
||||
.addTag('avaliacoes', 'Avaliações e DDS')
|
||||
.addTag('documentos', 'Upload/Download de Documentos')
|
||||
.addTag('dashboard', 'Dashboard e Estatísticas')
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('docs', app, document);
|
||||
|
||||
const port = process.env.PORT || 8100;
|
||||
await app.listen(port);
|
||||
console.log(`🚀 DuOrigin v2 API running on http://localhost:${port}`);
|
||||
console.log(`📚 Swagger docs at http://localhost:${port}/api/docs`);
|
||||
}
|
||||
bootstrap();
|
||||
9
backend/src/prisma/prisma.module.ts
Normal file
9
backend/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
13
backend/src/prisma/prisma.service.ts
Normal file
13
backend/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
79
backend/src/propriedades/dto/create-propriedade.dto.ts
Normal file
79
backend/src/propriedades/dto/create-propriedade.dto.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { IsString, IsOptional, IsBoolean, IsNumber, IsEnum, Min, Max, MinLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { NivelRisco } from '@prisma/client';
|
||||
|
||||
export class CreatePropriedadeDto {
|
||||
@ApiProperty({ example: 'Fazenda São João' })
|
||||
@IsString()
|
||||
@MinLength(2, { message: 'Nome deve ter no mínimo 2 caracteres' })
|
||||
nome: string;
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
@IsNumber()
|
||||
empresaId: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'MG-1234567-A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
codigoCar?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 500.5 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0, { message: 'Área deve ser maior ou igual a 0' })
|
||||
areaHa?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: -18.9186, description: 'Latitude em graus decimais' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: -48.2772, description: 'Longitude em graus decimais' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Cerrado' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bioma?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Rodovia BR-050, Km 45' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
endereco?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Uberlândia' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cidade?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'MG' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
estado?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: NivelRisco, default: 'BAIXO' })
|
||||
@IsOptional()
|
||||
@IsEnum(NivelRisco)
|
||||
nivelRisco?: NivelRisco;
|
||||
|
||||
@ApiPropertyOptional({ example: 'clean', description: 'Flag de desmatamento' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
flagDesmatamento?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'GeoJSON polygon' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
geojson?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
ativo?: boolean;
|
||||
}
|
||||
4
backend/src/propriedades/dto/update-propriedade.dto.ts
Normal file
4
backend/src/propriedades/dto/update-propriedade.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreatePropriedadeDto } from './create-propriedade.dto';
|
||||
|
||||
export class UpdatePropriedadeDto extends PartialType(CreatePropriedadeDto) {}
|
||||
68
backend/src/propriedades/propriedades.controller.ts
Normal file
68
backend/src/propriedades/propriedades.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Controller, Get, Post, Put, Delete, Patch, Body, Param, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { PropriedadesService } from './propriedades.service';
|
||||
import { CreatePropriedadeDto } from './dto/create-propriedade.dto';
|
||||
import { UpdatePropriedadeDto } from './dto/update-propriedade.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
|
||||
@ApiTags('propriedades')
|
||||
@Controller('propriedades')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class PropriedadesController {
|
||||
constructor(private propriedadesService: PropriedadesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Listar todas as propriedades' })
|
||||
@ApiQuery({ name: 'empresaId', required: false, type: Number })
|
||||
@ApiQuery({ name: 'includeInactive', required: false, type: Boolean })
|
||||
@ApiResponse({ status: 200, description: 'Lista de propriedades' })
|
||||
findAll(
|
||||
@Query('empresaId') empresaId?: number,
|
||||
@Query('includeInactive') includeInactive?: boolean,
|
||||
) {
|
||||
return this.propriedadesService.findAll(empresaId, includeInactive);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Buscar propriedade por ID' })
|
||||
@ApiResponse({ status: 200, description: 'Propriedade encontrada' })
|
||||
@ApiResponse({ status: 404, description: 'Propriedade não encontrada' })
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.propriedadesService.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Criar nova propriedade' })
|
||||
@ApiResponse({ status: 201, description: 'Propriedade criada com sucesso' })
|
||||
@ApiResponse({ status: 400, description: 'Empresa não encontrada' })
|
||||
create(@Body() createPropriedadeDto: CreatePropriedadeDto) {
|
||||
return this.propriedadesService.create(createPropriedadeDto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Atualizar propriedade' })
|
||||
@ApiResponse({ status: 200, description: 'Propriedade atualizada' })
|
||||
@ApiResponse({ status: 404, description: 'Propriedade não encontrada' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() updatePropriedadeDto: UpdatePropriedadeDto,
|
||||
) {
|
||||
return this.propriedadesService.update(id, updatePropriedadeDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Remover propriedade' })
|
||||
@ApiResponse({ status: 200, description: 'Propriedade removida' })
|
||||
@ApiResponse({ status: 404, description: 'Propriedade não encontrada' })
|
||||
remove(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.propriedadesService.remove(id);
|
||||
}
|
||||
|
||||
@Patch(':id/toggle-active')
|
||||
@ApiOperation({ summary: 'Ativar/Desativar propriedade' })
|
||||
@ApiResponse({ status: 200, description: 'Status alterado' })
|
||||
toggleActive(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.propriedadesService.toggleActive(id);
|
||||
}
|
||||
}
|
||||
10
backend/src/propriedades/propriedades.module.ts
Normal file
10
backend/src/propriedades/propriedades.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PropriedadesController } from './propriedades.controller';
|
||||
import { PropriedadesService } from './propriedades.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PropriedadesController],
|
||||
providers: [PropriedadesService],
|
||||
exports: [PropriedadesService],
|
||||
})
|
||||
export class PropriedadesModule {}
|
||||
120
backend/src/propriedades/propriedades.service.ts
Normal file
120
backend/src/propriedades/propriedades.service.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreatePropriedadeDto } from './dto/create-propriedade.dto';
|
||||
import { UpdatePropriedadeDto } from './dto/update-propriedade.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PropriedadesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll(empresaId?: number, includeInactive = false) {
|
||||
const where: any = {};
|
||||
|
||||
if (empresaId) {
|
||||
where.empresaId = empresaId;
|
||||
}
|
||||
|
||||
if (!includeInactive) {
|
||||
where.ativo = true;
|
||||
}
|
||||
|
||||
return this.prisma.propriedade.findMany({
|
||||
where,
|
||||
include: {
|
||||
empresa: {
|
||||
select: { id: true, razaoSocial: true, cnpj: true },
|
||||
},
|
||||
_count: {
|
||||
select: { avaliacoes: true, documentos: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const propriedade = await this.prisma.propriedade.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
empresa: {
|
||||
select: { id: true, razaoSocial: true, cnpj: true },
|
||||
},
|
||||
avaliacoes: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
},
|
||||
documentos: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
},
|
||||
_count: {
|
||||
select: { avaliacoes: true, documentos: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!propriedade) {
|
||||
throw new NotFoundException('Propriedade não encontrada');
|
||||
}
|
||||
|
||||
return propriedade;
|
||||
}
|
||||
|
||||
async create(createPropriedadeDto: CreatePropriedadeDto) {
|
||||
// Verify empresa exists
|
||||
const empresa = await this.prisma.empresa.findUnique({
|
||||
where: { id: createPropriedadeDto.empresaId },
|
||||
});
|
||||
|
||||
if (!empresa) {
|
||||
throw new BadRequestException('Empresa não encontrada');
|
||||
}
|
||||
|
||||
return this.prisma.propriedade.create({
|
||||
data: createPropriedadeDto,
|
||||
include: {
|
||||
empresa: {
|
||||
select: { id: true, razaoSocial: true, cnpj: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, updatePropriedadeDto: UpdatePropriedadeDto) {
|
||||
await this.findOne(id);
|
||||
|
||||
if (updatePropriedadeDto.empresaId) {
|
||||
const empresa = await this.prisma.empresa.findUnique({
|
||||
where: { id: updatePropriedadeDto.empresaId },
|
||||
});
|
||||
|
||||
if (!empresa) {
|
||||
throw new BadRequestException('Empresa não encontrada');
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.propriedade.update({
|
||||
where: { id },
|
||||
data: updatePropriedadeDto,
|
||||
include: {
|
||||
empresa: {
|
||||
select: { id: true, razaoSocial: true, cnpj: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
await this.findOne(id);
|
||||
await this.prisma.propriedade.delete({ where: { id } });
|
||||
return { message: 'Propriedade removida com sucesso' };
|
||||
}
|
||||
|
||||
async toggleActive(id: number) {
|
||||
const propriedade = await this.findOne(id);
|
||||
return this.prisma.propriedade.update({
|
||||
where: { id },
|
||||
data: { ativo: !propriedade.ativo },
|
||||
});
|
||||
}
|
||||
}
|
||||
15
backend/src/types/express.d.ts
vendored
Normal file
15
backend/src/types/express.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Multer } from 'multer';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: {
|
||||
sub: number;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
29
backend/src/users/dto/create-user.dto.ts
Normal file
29
backend/src/users/dto/create-user.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { IsEmail, IsString, MinLength, IsOptional, IsEnum, IsBoolean } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Role } from '@prisma/client';
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiProperty({ example: 'usuario@duorigin.com' })
|
||||
@IsEmail({}, { message: 'Email inválido' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'Senha123!' })
|
||||
@IsString()
|
||||
@MinLength(6, { message: 'Senha deve ter no mínimo 6 caracteres' })
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ example: 'João Silva' })
|
||||
@IsString()
|
||||
@MinLength(2, { message: 'Nome deve ter no mínimo 2 caracteres' })
|
||||
nome: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: Role, default: 'OPERADOR' })
|
||||
@IsOptional()
|
||||
@IsEnum(Role)
|
||||
role?: Role;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
ativo?: boolean;
|
||||
}
|
||||
4
backend/src/users/dto/update-user.dto.ts
Normal file
4
backend/src/users/dto/update-user.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
||||
69
backend/src/users/users.controller.ts
Normal file
69
backend/src/users/users.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Controller, Get, Post, Put, Delete, Patch, Body, Param, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { UsersService } from './users.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { Role } from '@prisma/client';
|
||||
|
||||
@ApiTags('users')
|
||||
@Controller('users')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@ApiBearerAuth()
|
||||
export class UsersController {
|
||||
constructor(private usersService: UsersService) {}
|
||||
|
||||
@Get()
|
||||
@Roles(Role.ADMIN)
|
||||
@ApiOperation({ summary: 'Listar todos os usuários (Admin)' })
|
||||
@ApiResponse({ status: 200, description: 'Lista de usuários' })
|
||||
findAll() {
|
||||
return this.usersService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles(Role.ADMIN)
|
||||
@ApiOperation({ summary: 'Buscar usuário por ID (Admin)' })
|
||||
@ApiResponse({ status: 200, description: 'Usuário encontrado' })
|
||||
@ApiResponse({ status: 404, description: 'Usuário não encontrado' })
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.usersService.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles(Role.ADMIN)
|
||||
@ApiOperation({ summary: 'Criar novo usuário (Admin)' })
|
||||
@ApiResponse({ status: 201, description: 'Usuário criado com sucesso' })
|
||||
@ApiResponse({ status: 409, description: 'Email já cadastrado' })
|
||||
create(@Body() createUserDto: CreateUserDto) {
|
||||
return this.usersService.create(createUserDto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@Roles(Role.ADMIN)
|
||||
@ApiOperation({ summary: 'Atualizar usuário (Admin)' })
|
||||
@ApiResponse({ status: 200, description: 'Usuário atualizado' })
|
||||
@ApiResponse({ status: 404, description: 'Usuário não encontrado' })
|
||||
update(@Param('id', ParseIntPipe) id: number, @Body() updateUserDto: UpdateUserDto) {
|
||||
return this.usersService.update(id, updateUserDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles(Role.ADMIN)
|
||||
@ApiOperation({ summary: 'Remover usuário (Admin)' })
|
||||
@ApiResponse({ status: 200, description: 'Usuário removido' })
|
||||
@ApiResponse({ status: 404, description: 'Usuário não encontrado' })
|
||||
remove(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.usersService.remove(id);
|
||||
}
|
||||
|
||||
@Patch(':id/toggle-active')
|
||||
@Roles(Role.ADMIN)
|
||||
@ApiOperation({ summary: 'Ativar/Desativar usuário (Admin)' })
|
||||
@ApiResponse({ status: 200, description: 'Status alterado' })
|
||||
toggleActive(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.usersService.toggleActive(id);
|
||||
}
|
||||
}
|
||||
10
backend/src/users/users.module.ts
Normal file
10
backend/src/users/users.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
129
backend/src/users/users.service.ts
Normal file
129
backend/src/users/users.service.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
nome: true,
|
||||
role: true,
|
||||
ativo: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
nome: true,
|
||||
role: true,
|
||||
ativo: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('Usuário não encontrado');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async findByEmail(email: string) {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
}
|
||||
|
||||
async create(createUserDto: CreateUserDto) {
|
||||
const existingUser = await this.findByEmail(createUserDto.email);
|
||||
if (existingUser) {
|
||||
throw new ConflictException('Email já cadastrado');
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: createUserDto.email,
|
||||
password: hashedPassword,
|
||||
nome: createUserDto.nome,
|
||||
role: createUserDto.role || 'OPERADOR',
|
||||
ativo: createUserDto.ativo ?? true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
nome: true,
|
||||
role: true,
|
||||
ativo: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async update(id: number, updateUserDto: UpdateUserDto) {
|
||||
await this.findOne(id);
|
||||
|
||||
const data: any = { ...updateUserDto };
|
||||
|
||||
if (updateUserDto.password) {
|
||||
data.password = await bcrypt.hash(updateUserDto.password, 10);
|
||||
}
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
nome: true,
|
||||
role: true,
|
||||
ativo: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
await this.findOne(id);
|
||||
await this.prisma.user.delete({ where: { id } });
|
||||
return { message: 'Usuário removido com sucesso' };
|
||||
}
|
||||
|
||||
async toggleActive(id: number) {
|
||||
const user = await this.findOne(id);
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { ativo: !user.ativo },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
nome: true,
|
||||
role: true,
|
||||
ativo: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
27
backend/tsconfig.json
Normal file
27
backend/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/tests/**"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user