DuOrigin v2 - React + NestJS + Prisma + EUDR API Integration

This commit is contained in:
2026-02-09 09:10:15 -03:00
parent cb90bad239
commit 4122dc6f3b
170 changed files with 31333 additions and 200 deletions

33
backend/.env.example Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

85
backend/package.json Normal file
View 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"
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateAvaliacaoDto } from './create-avaliacao.dto';
export class UpdateAvaliacaoDto extends PartialType(CreateAvaliacaoDto) {}

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

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

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

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

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

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

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

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateEmpresaDto } from './create-empresa.dto';
export class UpdateEmpresaDto extends PartialType(CreateEmpresaDto) {}

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,4 @@
export * from './eudr-api.module';
export * from './eudr-api.service';
export * from './eudr-api.controller';
export * from './dto';

View File

@@ -0,0 +1,2 @@
export * from './xml-builder';
export * from './xml-parser';

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* 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>`;
}

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

View 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('&amp;');
expect(escaped).toContain('&lt;');
expect(escaped).toContain('&gt;');
expect(escaped).toContain('&quot;');
expect(escaped).toContain('&apos;');
});
});
});
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
View 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();

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

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

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

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreatePropriedadeDto } from './create-propriedade.dto';
export class UpdatePropriedadeDto extends PartialType(CreatePropriedadeDto) {}

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

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

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

@@ -0,0 +1,15 @@
import { Multer } from 'multer';
declare global {
namespace Express {
interface Request {
user?: {
sub: number;
email: string;
role: string;
};
}
}
}
export {};

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

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}

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

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

View 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
View 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/**"
]
}