Compare commits

..
Author SHA1 Message Date
DH V2 Deploy Bot 2ffaab15a4 deploy-status: success · phase complete 2026-09-14 11:03:15 -03:00
766 changed files with 512 additions and 85065 deletions
-33
View File
@@ -1,33 +0,0 @@
COMPOSE_PROJECT_NAME=dhv2
DB_NAME=dhv2
DB_OWNER_USER=dhv2_owner
DB_OWNER_PASSWORD=CHANGE_ME
DB_APP_USER=dhv2_app
DB_APP_PASSWORD=CHANGE_ME
API_PORT=3000
WEB_HOST_PORT=8182
API_HOST_PORT=3101
WEB_ORIGIN=https://dhv2.korexlabs.com
JWT_ACCESS_SECRET=CHANGE_ME_WITH_AT_LEAST_64_RANDOM_CHARACTERS
REFRESH_TOKEN_PEPPER=CHANGE_ME_WITH_ANOTHER_64_RANDOM_CHARACTERS
ACCESS_TOKEN_TTL_SECONDS=900
REFRESH_TOKEN_TTL_SECONDS=604800
AUTH_MAX_LOGIN_ATTEMPTS=5
AUTH_LOCKOUT_SECONDS=900
ACCESS_COOKIE_NAME=dhv2_access
REFRESH_COOKIE_NAME=dhv2_refresh
CSRF_COOKIE_NAME=dhv2_csrf
INSPECTION_REPORT_WORD_ROOT=/app/storage/asset-media/inspection-reports-word
INSPECTION_REPORT_REVISION_ROOT=/app/storage/asset-media/inspection-report-revisions
INSPECTION_ACT_PDF_ROOT=/app/storage/asset-media/inspection-acts-pdf
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
MAIL_FROM=
-66
View File
@@ -1,66 +0,0 @@
name: DH V2 CI
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
contents: read
concurrency:
group: dhv2-ci-${{ github.ref }}
cancel-in-progress: true
jobs:
api:
name: API · typecheck, tests, build
runs-on: ubuntu-latest
defaults:
run:
working-directory: api-v3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm test
- run: npm run build
web:
name: WEB · typecheck, build
runs-on: ubuntu-latest
defaults:
run:
working-directory: web-v2
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: web-v2/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm run build
contract:
name: Docker / scripts contract
runs-on: ubuntu-latest
needs: [api, web]
steps:
- uses: actions/checkout@v4
- name: Validate shell scripts
run: |
while IFS= read -r -d '' script; do
bash -n "$script"
done < <(find scripts -type f -name '*.sh' -print0)
- name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null
- name: Build production images
run: docker compose --env-file .env.example build api migrate web
-51
View File
@@ -1,51 +0,0 @@
# Secrets / environment
.env
.env.*
!.env.example
# Dependencies / builds
**/node_modules/
**/dist/
**/.next/
**/.cache/
**/.vite/
**/coverage/
# Backups / exports
*.zip
*.tar.gz
*.tgz
*.dump
*.sql
*.bak
*.bak-*
*.backup
# Runtime / logs
*.log
*.pid
*.tmp
*.swp
# OS / editors
.DS_Store
Thumbs.db
.vscode/
.idea/
# Private keys / certificates
*.pem
*.key
*.p12
*.pfx
id_rsa
id_ed25519
*_github
*_github.pub
# TypeScript generated metadata
*.tsbuildinfo
# Generated from web-v2/vite.config.ts
web-v2/vite.config.js
web-v2/vite.config.d.ts
-35
View File
@@ -1,35 +0,0 @@
# DH Inspección V2 · D5.6.2
Hotfix de mantenimiento de Fase D para accesos Android y contrato de regresión de cierre.
# DH Inspección V2
Versión: **0.19.5-1**
Fase: **D5.5.1 · Corrección de staging de planificación**
D5.5.1 conserva íntegramente D5.5 y corrige únicamente el contrato de pruebas de staging. D5.5 consolida la preparación de visitas desde oficina antes del trabajo de campo. La visita queda anclada explícitamente a Área y Organización operadora, y el sistema genera un checklist auditable con antecedentes, respuestas vencidas, controles vencidos y próximos controles.
## Alcance D5.5
- selección web Área → Organización operadora vigente;
- fecha y equipo inspector dentro del plan;
- checklist automático versionado y append-only;
- respuestas de empresa vencidas y controles de verificación vencidos;
- próximos controles dentro de 30 días de la fecha planificada;
- antecedentes históricos del mismo contexto;
- incorporación automática de registros accionables;
- alta manual de registros preventivos del mismo Área/Operadora;
- exclusiones sólo mediante motivo auditable;
- reincorporación sin borrar la exclusión histórica;
- visitas de verificación integradas al mismo contrato de planificación;
- cambio de contexto o fecha invalida el checklist hasta regeneración explícita;
- inicio de visita exclusivamente desde APK mediante la política móvil ya existente.
## Migración
`1789063200000-phase-d5-5-web-planning-checklist.ts`
`PhaseD55WebPlanningChecklist1789063200000`
## Despliegue
El despliegue debe partir exactamente de API/WEB **0.19.4-1** y ejecutar `scripts/deploy-phase-d5-5.sh`. Antes de modificar producción, el script compila API y web en staging y exige **252 tests** aprobados.
-5
View File
@@ -1,5 +0,0 @@
node_modules
dist
.env
.git
*.log
-19
View File
@@ -1,19 +0,0 @@
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY nest-cli.json tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:24-alpine AS runner
RUN apk add --no-cache unzip
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
RUN mkdir -p /app/storage/asset-media/imports && chown -R node:node /app/storage
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
-6267
View File
File diff suppressed because it is too large Load Diff
-46
View File
@@ -1,46 +0,0 @@
{
"name": "dhv2-api",
"version": "0.20.0-1",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"start": "node dist/main.js",
"start:dev": "nest start --watch",
"typecheck": "tsc --noEmit",
"test": "tsc -p tsconfig.test.json --noEmit && node --import tsx --test test/**/*.test.ts",
"migration:run": "node dist/database/migration-cli.js run",
"migration:show": "node dist/database/migration-cli.js show",
"migration:revert": "node dist/database/migration-cli.js revert",
"bootstrap:admin": "node dist/cli/bootstrap-admin.js",
"dev:seed:mendoza-demo": "node dist/cli/dev-seed-mendoza-demo.js"
},
"dependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.0",
"argon2": "^0.45.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"cookie-parser": "^1.4.7",
"helmet": "^8.0.0",
"pg": "^8.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"typeorm": "^0.3.0"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.3",
"@types/node": "^24.0.0",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.9.0"
}
}
@@ -1,15 +0,0 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { PhaseADataModule } from '../core-data/phase-a-data.module';
import { RolesController } from './roles/roles.controller';
import { RolesService } from './roles/roles.service';
import { UsersController } from './users/users.controller';
import { UsersService } from './users/users.service';
@Module({
imports: [PhaseADataModule, AuditModule, AuthModule],
controllers: [UsersController, RolesController],
providers: [UsersService, RolesService],
})
export class AdministrationModule {}
@@ -1,72 +0,0 @@
import { isIP } from 'node:net';
import { ConflictException } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import type {
AuthPrincipal,
RequestWithContext,
} from '../../common/http/request-context';
import { AuditSource } from '../../database/entities';
const REQUIRED_RECOVERY_PERMISSIONS = [
'roles.manage',
'users.assign_roles',
] as const;
export function administrationAuditContext(
principal: AuthPrincipal,
request: RequestWithContext,
) {
const candidateIp = request.ip || request.socket.remoteAddress || '';
const rawUserAgent = request.header('user-agent')?.trim();
return {
actorUserId: principal.userId,
actorUsername: principal.username,
requestId: request.requestId,
source: AuditSource.WEB,
ip: isIP(candidateIp) ? candidateIp : null,
userAgent: rawUserAgent ? rawUserAgent.slice(0, 2048) : null,
};
}
export async function assertAdministrativeRecoveryRemains(
manager: EntityManager,
): Promise<void> {
const [row] = (await manager.query(
`
SELECT COUNT(*)::integer AS count
FROM (
SELECT user_account.id
FROM users user_account
INNER JOIN user_roles user_role
ON user_role.user_id = user_account.id
INNER JOIN role_permissions role_permission
ON role_permission.role_id = user_role.role_id
INNER JOIN permissions permission
ON permission.id = role_permission.permission_id
WHERE user_account.status = 'ACTIVE'
AND permission.code = ANY($1::varchar[])
GROUP BY user_account.id
HAVING COUNT(DISTINCT permission.code) = $2
) administrators
`,
[REQUIRED_RECOVERY_PERMISSIONS, REQUIRED_RECOVERY_PERMISSIONS.length],
)) as Array<{ count: number }>;
if (!row || Number(row.count) < 1) {
throw new ConflictException({
code: 'LAST_ADMINISTRATOR_PROTECTED',
message:
'Debe permanecer al menos un usuario activo capaz de administrar roles y asignaciones',
});
}
}
export function isUniqueViolation(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: unknown }).code === '23505'
);
}
@@ -1,38 +0,0 @@
import { Transform } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsString,
IsUUID,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateRoleDto {
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
@IsString()
@MinLength(3)
@MaxLength(80)
@Matches(/^[a-z][a-z0-9_-]+$/)
code!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(120)
name!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(1000)
description!: string;
@IsArray()
@ArrayUnique()
@IsUUID('4', { each: true })
permissionIds!: string[];
}
@@ -1,8 +0,0 @@
import { ArrayUnique, IsArray, IsUUID } from 'class-validator';
export class ReplaceRolePermissionsDto {
@IsArray()
@ArrayUnique()
@IsUUID('4', { each: true })
permissionIds!: string[];
}
@@ -1,18 +0,0 @@
import { Transform } from 'class-transformer';
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class UpdateRoleDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(120)
name?: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(1000)
description?: string;
}
@@ -1,76 +0,0 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Req,
} from '@nestjs/common';
import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../../auth/decorators/current-auth.decorator';
import type {
AuthPrincipal,
RequestWithContext,
} from '../../common/http/request-context';
import { CreateRoleDto } from './dto/create-role.dto';
import { ReplaceRolePermissionsDto } from './dto/replace-role-permissions.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
import { RolesService } from './roles.service';
@Controller('roles')
export class RolesController {
constructor(private readonly roles: RolesService) {}
@Get()
@RequirePermissions('roles.read')
list() {
return this.roles.list();
}
@Get('permissions')
@RequirePermissions('roles.read')
listPermissions() {
return this.roles.listPermissions();
}
@Post()
@RequirePermissions('roles.manage')
create(
@Body() dto: CreateRoleDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.roles.create(dto, principal, request);
}
@Get(':id')
@RequirePermissions('roles.read')
getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.roles.getById(id);
}
@Patch(':id')
@RequirePermissions('roles.manage')
update(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateRoleDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.roles.update(id, dto, principal, request);
}
@Put(':id/permissions')
@RequirePermissions('roles.manage')
replacePermissions(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ReplaceRolePermissionsDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.roles.replacePermissions(id, dto, principal, request);
}
}
@@ -1,309 +0,0 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, In } from 'typeorm';
import { AuditService } from '../../audit/audit.service';
import type {
AuthPrincipal,
RequestWithContext,
} from '../../common/http/request-context';
import {
AuditAction,
Permission,
Role,
RolePermission,
} from '../../database/entities';
import {
administrationAuditContext,
assertAdministrativeRecoveryRemains,
isUniqueViolation,
} from '../common/administration-audit';
import type { CreateRoleDto } from './dto/create-role.dto';
import type { ReplaceRolePermissionsDto } from './dto/replace-role-permissions.dto';
import type { UpdateRoleDto } from './dto/update-role.dto';
export interface PermissionView {
id: string;
code: string;
description: string;
}
export interface AdministrativeRoleView {
id: string;
code: string;
name: string;
description: string;
isSystem: boolean;
createdAt: Date;
updatedAt: Date;
userCount: number;
permissions: PermissionView[];
}
function roleNotFound(): NotFoundException {
return new NotFoundException({
code: 'ROLE_NOT_FOUND',
message: 'Rol no encontrado',
});
}
function permissionSelectionInvalid(): BadRequestException {
return new BadRequestException({
code: 'PERMISSION_NOT_FOUND',
message: 'Uno o más permisos no existen',
});
}
@Injectable()
export class RolesService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async list(): Promise<{ data: AdministrativeRoleView[] }> {
const rows = (await this.dataSource.query(this.roleViewQuery(''), [])) as
AdministrativeRoleView[];
return { data: rows };
}
async listPermissions(): Promise<{ data: PermissionView[] }> {
const rows = (await this.dataSource.query(
`
SELECT id, code, description
FROM permissions
ORDER BY code ASC
`,
)) as PermissionView[];
return { data: rows };
}
async getById(id: string): Promise<AdministrativeRoleView> {
return this.dataSource.transaction((manager) =>
this.loadRoleView(manager, id),
);
}
async create(
dto: CreateRoleDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AdministrativeRoleView> {
try {
return await this.dataSource.transaction(async (manager) => {
const permissions = await this.resolvePermissions(
manager,
dto.permissionIds,
);
const role = manager.getRepository(Role).create({
code: dto.code.trim().toLowerCase(),
name: dto.name.trim(),
description: dto.description.trim(),
isSystem: false,
});
await manager.getRepository(Role).save(role);
await this.insertPermissions(manager, role.id, permissions);
const created = await this.loadRoleView(manager, role.id);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.ROLE_CREATED,
entityType: 'role',
entityId: role.id,
afterData: { ...created },
},
manager,
);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) throw this.roleConflict();
throw error;
}
}
async update(
id: string,
dto: UpdateRoleDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AdministrativeRoleView> {
if (dto.name === undefined && dto.description === undefined) {
throw new BadRequestException({
code: 'NO_CHANGES',
message: 'No se recibieron cambios',
});
}
return this.dataSource.transaction(async (manager) => {
const role = await this.lockRole(manager, id);
const before = await this.loadRoleView(manager, id);
if (dto.name !== undefined) role.name = dto.name.trim();
if (dto.description !== undefined) {
role.description = dto.description.trim();
}
await manager.getRepository(Role).save(role);
const updated = await this.loadRoleView(manager, id);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.ROLE_UPDATED,
entityType: 'role',
entityId: id,
beforeData: {
name: before.name,
description: before.description,
},
afterData: {
name: updated.name,
description: updated.description,
},
},
manager,
);
return updated;
});
}
async replacePermissions(
id: string,
dto: ReplaceRolePermissionsDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AdministrativeRoleView> {
return this.dataSource.transaction(async (manager) => {
await this.lockRole(manager, id);
const permissions = await this.resolvePermissions(
manager,
dto.permissionIds,
);
const before = await this.loadRoleView(manager, id);
const beforeIds = before.permissions
.map((permission) => permission.id)
.sort();
const afterIds = permissions.map((permission) => permission.id).sort();
if (beforeIds.join(',') === afterIds.join(',')) return before;
await manager.getRepository(RolePermission).delete({ roleId: id });
await this.insertPermissions(manager, id, permissions);
await assertAdministrativeRecoveryRemains(manager);
const updated = await this.loadRoleView(manager, id);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.ROLE_PERMISSIONS_CHANGED,
entityType: 'role',
entityId: id,
beforeData: { permissions: before.permissions },
afterData: { permissions: updated.permissions },
},
manager,
);
return updated;
});
}
private async lockRole(manager: EntityManager, id: string): Promise<Role> {
const role = await manager
.getRepository(Role)
.createQueryBuilder('role')
.where('role.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!role) throw roleNotFound();
return role;
}
private async resolvePermissions(
manager: EntityManager,
permissionIds: string[],
): Promise<Permission[]> {
const uniqueIds = [...new Set(permissionIds)];
if (uniqueIds.length === 0) return [];
const permissions = await manager.getRepository(Permission).find({
where: { id: In(uniqueIds) },
order: { code: 'ASC' },
});
if (permissions.length !== uniqueIds.length) {
throw permissionSelectionInvalid();
}
return permissions;
}
private async insertPermissions(
manager: EntityManager,
roleId: string,
permissions: Permission[],
): Promise<void> {
if (permissions.length === 0) return;
const assignments = permissions.map((permission) =>
manager.getRepository(RolePermission).create({
roleId,
permissionId: permission.id,
}),
);
await manager.getRepository(RolePermission).save(assignments);
}
private async loadRoleView(
manager: EntityManager,
id: string,
): Promise<AdministrativeRoleView> {
const rows = (await manager.query(
this.roleViewQuery('WHERE role.id = $1'),
[id],
)) as AdministrativeRoleView[];
if (!rows[0]) throw roleNotFound();
return rows[0];
}
private roleViewQuery(where: string): string {
return `
SELECT
role.id,
role.code,
role.name,
role.description,
role.is_system AS "isSystem",
role.created_at AS "createdAt",
role.updated_at AS "updatedAt",
(
SELECT COUNT(*)::integer
FROM user_roles user_role
WHERE user_role.role_id = role.id
) AS "userCount",
COALESCE(
(
SELECT JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', permission.id,
'code', permission.code,
'description', permission.description
) ORDER BY permission.code
)
FROM role_permissions role_permission
INNER JOIN permissions permission
ON permission.id = role_permission.permission_id
WHERE role_permission.role_id = role.id
),
'[]'::jsonb
) AS permissions
FROM roles role
${where}
ORDER BY role.is_system DESC, role.code ASC
`;
}
private roleConflict(): ConflictException {
return new ConflictException({
code: 'ROLE_ALREADY_EXISTS',
message: 'Ya existe un rol con ese código',
});
}
}
@@ -1,7 +0,0 @@
import { IsEnum } from 'class-validator';
import { UserStatus } from '../../../database/entities';
export class ChangeUserStatusDto {
@IsEnum(UserStatus)
status!: UserStatus;
}
@@ -1,58 +0,0 @@
import { Transform } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsEmail,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateUserDto {
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
@IsString()
@MinLength(3)
@MaxLength(80)
@Matches(/^[a-zA-Z0-9._-]+$/)
username!: string;
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
@IsOptional()
@IsEmail()
@MaxLength(320)
email?: string | null;
@IsString()
@MinLength(12)
@MaxLength(128)
password!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(120)
firstName!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(120)
lastName!: string;
@IsOptional()
@IsBoolean()
mustChangePassword = true;
@IsArray()
@ArrayUnique()
@IsUUID('4', { each: true })
roleIds!: string[];
}
@@ -1,35 +0,0 @@
import { Type } from 'class-transformer';
import {
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
import { UserStatus } from '../../../database/entities';
export class ListUsersQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 25;
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
@IsOptional()
@IsEnum(UserStatus)
status?: UserStatus;
}
@@ -1,8 +0,0 @@
import { ArrayUnique, IsArray, IsUUID } from 'class-validator';
export class ReplaceUserRolesDto {
@IsArray()
@ArrayUnique()
@IsUUID('4', { each: true })
roleIds!: string[];
}
@@ -1,12 +0,0 @@
import { IsBoolean, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class ResetUserPasswordDto {
@IsString()
@MinLength(12)
@MaxLength(128)
password!: string;
@IsOptional()
@IsBoolean()
mustChangePassword = true;
}
@@ -1,43 +0,0 @@
import { Transform } from 'class-transformer';
import {
IsEmail,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
export class UpdateUserDto {
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(80)
@Matches(/^[a-zA-Z0-9._-]+$/)
username?: string;
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
@IsOptional()
@IsEmail()
@MaxLength(320)
email?: string | null;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(120)
firstName?: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(120)
lastName?: string;
}
@@ -1,96 +0,0 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Query,
Req,
} from '@nestjs/common';
import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../../auth/decorators/current-auth.decorator';
import type {
AuthPrincipal,
RequestWithContext,
} from '../../common/http/request-context';
import { ChangeUserStatusDto } from './dto/change-user-status.dto';
import { CreateUserDto } from './dto/create-user.dto';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
import { ResetUserPasswordDto } from './dto/reset-user-password.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get()
@RequirePermissions('users.read')
list(@Query() query: ListUsersQueryDto) {
return this.users.list(query);
}
@Post()
@RequirePermissions('users.create')
create(
@Body() dto: CreateUserDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.create(dto, principal, request);
}
@Get(':id')
@RequirePermissions('users.read')
getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.users.getById(id);
}
@Patch(':id')
@RequirePermissions('users.update')
update(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateUserDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.update(id, dto, principal, request);
}
@Patch(':id/status')
@RequirePermissions('users.change_status')
changeStatus(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ChangeUserStatusDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.changeStatus(id, dto, principal, request);
}
@Post(':id/reset-password')
@RequirePermissions('users.update')
resetPassword(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ResetUserPasswordDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.resetPassword(id, dto, principal, request);
}
@Put(':id/roles')
@RequirePermissions('users.assign_roles')
replaceRoles(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ReplaceUserRolesDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.replaceRoles(id, dto, principal, request);
}
}
@@ -1,497 +0,0 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, In } from 'typeorm';
import { AuditService } from '../../audit/audit.service';
import { PasswordService } from '../../auth/services/password.service';
import type {
AuthPrincipal,
RequestWithContext,
} from '../../common/http/request-context';
import { AuthSessionsRepository } from '../../core-data/repositories/auth-sessions.repository';
import {
AuditAction,
Role,
User,
UserRole,
UserStatus,
} from '../../database/entities';
import {
administrationAuditContext,
assertAdministrativeRecoveryRemains,
isUniqueViolation,
} from '../common/administration-audit';
import type { ChangeUserStatusDto } from './dto/change-user-status.dto';
import type { CreateUserDto } from './dto/create-user.dto';
import type { ListUsersQueryDto } from './dto/list-users-query.dto';
import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
import type { ResetUserPasswordDto } from './dto/reset-user-password.dto';
import type { UpdateUserDto } from './dto/update-user.dto';
export interface UserRoleView {
id: string;
code: string;
name: string;
}
export interface AdministrativeUserView {
id: string;
username: string;
email: string | null;
firstName: string;
lastName: string;
status: UserStatus;
mustChangePassword: boolean;
failedLoginAttempts: number;
lockedUntil: Date | null;
lastLoginAt: Date | null;
passwordChangedAt: Date | null;
createdAt: Date;
updatedAt: Date;
roles: UserRoleView[];
}
interface UserViewRow extends AdministrativeUserView {
total?: string | number;
}
function userNotFound(): NotFoundException {
return new NotFoundException({
code: 'USER_NOT_FOUND',
message: 'Usuario no encontrado',
});
}
function roleSelectionInvalid(): BadRequestException {
return new BadRequestException({
code: 'ROLE_NOT_FOUND',
message: 'Uno o más roles no existen',
});
}
@Injectable()
export class UsersService {
constructor(
private readonly dataSource: DataSource,
private readonly passwords: PasswordService,
private readonly sessions: AuthSessionsRepository,
private readonly audit: AuditService,
) {}
async list(query: ListUsersQueryDto) {
const page = query.page;
const pageSize = query.pageSize;
const filters: string[] = [];
const parameters: unknown[] = [];
const search = query.search?.trim();
if (search) {
parameters.push(`%${search}%`);
filters.push(`
(
user_account.username ILIKE $${parameters.length}
OR user_account.email ILIKE $${parameters.length}
OR user_account.first_name ILIKE $${parameters.length}
OR user_account.last_name ILIKE $${parameters.length}
)
`);
}
if (query.status) {
parameters.push(query.status);
filters.push(`user_account.status = $${parameters.length}`);
}
parameters.push(pageSize, (page - 1) * pageSize);
const limitParameter = parameters.length - 1;
const offsetParameter = parameters.length;
const where = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
const rows = (await this.dataSource.query(
`
SELECT
user_account.id,
user_account.username,
user_account.email,
user_account.first_name AS "firstName",
user_account.last_name AS "lastName",
user_account.status,
user_account.must_change_password AS "mustChangePassword",
user_account.failed_login_attempts AS "failedLoginAttempts",
user_account.locked_until AS "lockedUntil",
user_account.last_login_at AS "lastLoginAt",
user_account.password_changed_at AS "passwordChangedAt",
user_account.created_at AS "createdAt",
user_account.updated_at AS "updatedAt",
COALESCE(
JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', role.id,
'code', role.code,
'name', role.name
) ORDER BY role.code
) FILTER (WHERE role.id IS NOT NULL),
'[]'::jsonb
) AS roles,
COUNT(*) OVER() AS total
FROM users user_account
LEFT JOIN user_roles user_role
ON user_role.user_id = user_account.id
LEFT JOIN roles role ON role.id = user_role.role_id
${where}
GROUP BY user_account.id
ORDER BY user_account.created_at DESC, user_account.username ASC
LIMIT $${limitParameter} OFFSET $${offsetParameter}
`,
parameters,
)) as UserViewRow[];
const total = rows.length > 0 ? Number(rows[0].total ?? 0) : 0;
return {
data: rows.map(({ total: _total, ...row }) => row),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async getById(id: string): Promise<AdministrativeUserView> {
return this.dataSource.transaction(async (manager) =>
this.loadUserView(manager, id),
);
}
async create(
dto: CreateUserDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AdministrativeUserView> {
const passwordHash = await this.passwords.hash(dto.password);
try {
return await this.dataSource.transaction(async (manager) => {
const roles = await this.resolveRoles(manager, dto.roleIds);
const user = manager.getRepository(User).create({
username: dto.username.trim().toLowerCase(),
email: dto.email?.trim().toLowerCase() || null,
passwordHash,
firstName: dto.firstName.trim(),
lastName: dto.lastName.trim(),
status: UserStatus.ACTIVE,
mustChangePassword: dto.mustChangePassword,
failedLoginAttempts: 0,
lockedUntil: null,
lastLoginAt: null,
passwordChangedAt: null,
createdBy: principal.userId,
updatedBy: principal.userId,
});
await manager.getRepository(User).save(user);
await this.insertUserRoles(
manager,
user.id,
roles,
principal.userId,
);
const created = await this.loadUserView(manager, user.id);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.USER_CREATED,
entityType: 'user',
entityId: user.id,
afterData: { ...created },
},
manager,
);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) throw this.userConflict();
throw error;
}
}
async update(
id: string,
dto: UpdateUserDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AdministrativeUserView> {
if (
dto.username === undefined &&
dto.email === undefined &&
dto.firstName === undefined &&
dto.lastName === undefined
) {
throw new BadRequestException({
code: 'NO_CHANGES',
message: 'No se recibieron cambios',
});
}
try {
return await this.dataSource.transaction(async (manager) => {
const user = await this.lockUser(manager, id);
const before = await this.loadUserView(manager, id);
if (dto.username !== undefined) {
user.username = dto.username.trim().toLowerCase();
}
if (dto.email !== undefined) {
user.email = dto.email?.trim().toLowerCase() || null;
}
if (dto.firstName !== undefined) user.firstName = dto.firstName.trim();
if (dto.lastName !== undefined) user.lastName = dto.lastName.trim();
user.updatedBy = principal.userId;
await manager.getRepository(User).save(user);
const updated = await this.loadUserView(manager, id);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.USER_UPDATED,
entityType: 'user',
entityId: id,
beforeData: { ...before },
afterData: { ...updated },
},
manager,
);
return updated;
});
} catch (error) {
if (isUniqueViolation(error)) throw this.userConflict();
throw error;
}
}
async changeStatus(
id: string,
dto: ChangeUserStatusDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AdministrativeUserView> {
if (id === principal.userId && dto.status === UserStatus.INACTIVE) {
throw new ConflictException({
code: 'SELF_DEACTIVATION_FORBIDDEN',
message: 'No puede desactivar su propio usuario',
});
}
return this.dataSource.transaction(async (manager) => {
const user = await this.lockUser(manager, id);
const before = await this.loadUserView(manager, id);
if (user.status === dto.status) return before;
user.status = dto.status;
user.updatedBy = principal.userId;
if (dto.status === UserStatus.ACTIVE) {
user.failedLoginAttempts = 0;
user.lockedUntil = null;
}
await manager.getRepository(User).save(user);
if (dto.status === UserStatus.INACTIVE) {
await this.sessions.revokeUserSessions(id, undefined, manager);
}
await assertAdministrativeRecoveryRemains(manager);
const updated = await this.loadUserView(manager, id);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.USER_STATUS_CHANGED,
entityType: 'user',
entityId: id,
beforeData: { status: before.status },
afterData: { status: updated.status },
metadata: {
sessionsRevoked: dto.status === UserStatus.INACTIVE,
},
},
manager,
);
return updated;
});
}
async resetPassword(
id: string,
dto: ResetUserPasswordDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AdministrativeUserView> {
const passwordHash = await this.passwords.hash(dto.password);
return this.dataSource.transaction(async (manager) => {
const user = await this.lockUser(manager, id);
const before = await this.loadUserView(manager, id);
const now = new Date();
user.passwordHash = passwordHash;
user.passwordChangedAt = now;
user.mustChangePassword = dto.mustChangePassword;
user.failedLoginAttempts = 0;
user.lockedUntil = null;
user.updatedBy = principal.userId;
await manager.getRepository(User).save(user);
await this.sessions.revokeUserSessions(id, undefined, manager);
const updated = await this.loadUserView(manager, id);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.USER_PASSWORD_RESET,
entityType: 'user',
entityId: id,
beforeData: {
mustChangePassword: before.mustChangePassword,
failedLoginAttempts: before.failedLoginAttempts,
lockedUntil: before.lockedUntil,
},
afterData: {
mustChangePassword: updated.mustChangePassword,
failedLoginAttempts: updated.failedLoginAttempts,
lockedUntil: updated.lockedUntil,
passwordChangedAt: updated.passwordChangedAt,
},
metadata: { sessionsRevoked: true, passwordValueRecorded: false },
},
manager,
);
return updated;
});
}
async replaceRoles(
id: string,
dto: ReplaceUserRolesDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AdministrativeUserView> {
return this.dataSource.transaction(async (manager) => {
await this.lockUser(manager, id);
const roles = await this.resolveRoles(manager, dto.roleIds);
const before = await this.loadUserView(manager, id);
const beforeIds = before.roles.map((role) => role.id).sort();
const afterIds = roles.map((role) => role.id).sort();
if (beforeIds.join(',') === afterIds.join(',')) return before;
await manager.getRepository(UserRole).delete({ userId: id });
await this.insertUserRoles(manager, id, roles, principal.userId);
await assertAdministrativeRecoveryRemains(manager);
const updated = await this.loadUserView(manager, id);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.USER_ROLES_CHANGED,
entityType: 'user',
entityId: id,
beforeData: { roles: before.roles },
afterData: { roles: updated.roles },
},
manager,
);
return updated;
});
}
private async resolveRoles(
manager: EntityManager,
roleIds: string[],
): Promise<Role[]> {
const uniqueIds = [...new Set(roleIds)];
if (uniqueIds.length === 0) return [];
const roles = await manager.getRepository(Role).find({
where: { id: In(uniqueIds) },
order: { code: 'ASC' },
});
if (roles.length !== uniqueIds.length) throw roleSelectionInvalid();
return roles;
}
private async insertUserRoles(
manager: EntityManager,
userId: string,
roles: Role[],
assignedBy: string,
): Promise<void> {
if (roles.length === 0) return;
const assignments = roles.map((role) =>
manager.getRepository(UserRole).create({
userId,
roleId: role.id,
assignedBy,
}),
);
await manager.getRepository(UserRole).save(assignments);
}
private async lockUser(manager: EntityManager, id: string): Promise<User> {
const user = await manager
.getRepository(User)
.createQueryBuilder('user')
.where('user.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!user) throw userNotFound();
return user;
}
private async loadUserView(
manager: EntityManager,
id: string,
): Promise<AdministrativeUserView> {
const [row] = (await manager.query(
`
SELECT
user_account.id,
user_account.username,
user_account.email,
user_account.first_name AS "firstName",
user_account.last_name AS "lastName",
user_account.status,
user_account.must_change_password AS "mustChangePassword",
user_account.failed_login_attempts AS "failedLoginAttempts",
user_account.locked_until AS "lockedUntil",
user_account.last_login_at AS "lastLoginAt",
user_account.password_changed_at AS "passwordChangedAt",
user_account.created_at AS "createdAt",
user_account.updated_at AS "updatedAt",
COALESCE(
JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', role.id,
'code', role.code,
'name', role.name
) ORDER BY role.code
) FILTER (WHERE role.id IS NOT NULL),
'[]'::jsonb
) AS roles
FROM users user_account
LEFT JOIN user_roles user_role
ON user_role.user_id = user_account.id
LEFT JOIN roles role ON role.id = user_role.role_id
WHERE user_account.id = $1
GROUP BY user_account.id
`,
[id],
)) as AdministrativeUserView[];
if (!row) throw userNotFound();
return row;
}
private userConflict(): ConflictException {
return new ConflictException({
code: 'USER_ALREADY_EXISTS',
message: 'El usuario o email ya está registrado',
});
}
}
-90
View File
@@ -1,90 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from './audit/audit.module';
import { AdministrationModule } from './administration/administration.module';
import { AuthorizationModule } from './authorization/authorization.module';
import { PermissionsGuard } from './authorization/guards/permissions.guard';
import { AuthModule } from './auth/auth.module';
import { AccessTokenGuard } from './auth/guards/access-token.guard';
import { CsrfGuard } from './auth/guards/csrf.guard';
import { PhaseADataModule } from './core-data/phase-a-data.module';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
import { DashboardModule } from './dashboard/dashboard.module';
import { AssetMasterModule } from './asset-master/asset-master.module';
import { SurveyPlanningModule } from './survey-planning/survey-planning.module';
import { SurveyExecutionModule } from './survey-execution/survey-execution.module';
import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module';
import { InspectionActsModule } from './inspection-acts/inspection-acts.module';
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
import { InspectionClosingModule } from './inspection-closing/inspection-closing.module';
import { AssetImportsModule } from './asset-imports/asset-imports.module';
import { InspectionReportsModule } from './inspection-reports/inspection-reports.module';
import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module';
function required(config: ConfigService, key: string): string {
const value = config.get<string>(key);
if (!value) throw new Error(`Missing required environment variable: ${key}`);
return value;
}
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
ignoreEnvFile: true,
}),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: required(config, 'DB_HOST'),
port: Number(config.get<string>('DB_PORT') ?? 5432),
database: required(config, 'DB_NAME'),
username: required(config, 'DB_APP_USER'),
password: required(config, 'DB_APP_PASSWORD'),
autoLoadEntities: true,
synchronize: false,
migrationsRun: false,
logging: false,
applicationName: 'dhv2-api',
connectTimeoutMS: 5000,
}),
}),
ThrottlerModule.forRoot([
{
name: 'default',
ttl: 60_000,
limit: 120,
},
]),
PhaseADataModule,
AuditModule,
AuthorizationModule,
AuthModule,
AdministrationModule,
DashboardModule,
AssetMasterModule,
SurveyPlanningModule,
SurveyExecutionModule,
InspectionVisitsModule,
InspectionActsModule,
InspectionFindingsModule,
InspectionClosingModule,
InspectionReportsModule,
InspectionVerificationsModule,
AssetImportsModule,
],
controllers: [HealthController],
providers: [
HealthService,
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: AccessTokenGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_GUARD, useClass: CsrfGuard },
],
})
export class AppModule {}
@@ -1,250 +0,0 @@
import { execFile as execFileCallback } from 'node:child_process';
import { promisify } from 'node:util';
import { BadRequestException } from '@nestjs/common';
const execFile = promisify(execFileCallback);
export const MAX_ASSET_IMPORT_BYTES = 25 * 1024 * 1024;
const MAX_XLSX_UNCOMPRESSED_BYTES = 120 * 1024 * 1024;
const MAX_XLSX_ENTRIES = 2500;
const MAX_ROWS_PER_SHEET = 50_000;
const MAX_COLUMNS = 120;
export interface UploadedImportFile {
originalname: string;
mimetype: string;
size: number;
buffer: Buffer;
}
export interface ParsedSheet {
name: string;
rows: string[][];
}
export interface ParsedWorkbook {
kind: 'XLSX' | 'CSV';
sheets: ParsedSheet[];
}
function importFileError(code: string, message: string): BadRequestException {
return new BadRequestException({ code, message });
}
export function inspectImportFile(file: UploadedImportFile | undefined): { extension: '.xlsx' | '.csv'; mimeType: string } {
if (!file?.buffer?.length) throw importFileError('IMPORT_FILE_REQUIRED', 'Seleccioná un archivo XLSX o CSV');
if (file.buffer.length > MAX_ASSET_IMPORT_BYTES) throw importFileError('IMPORT_FILE_TOO_LARGE', 'El archivo supera el límite de 25 MB');
const lower = file.originalname.toLowerCase();
if (lower.endsWith('.xlsx')) {
if (!(file.buffer[0] === 0x50 && file.buffer[1] === 0x4b)) {
throw importFileError('INVALID_XLSX_FILE', 'El contenido no corresponde a un archivo XLSX válido');
}
return { extension: '.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' };
}
if (lower.endsWith('.csv')) {
if (file.buffer.includes(0)) throw importFileError('INVALID_CSV_FILE', 'El CSV contiene datos binarios no admitidos');
return { extension: '.csv', mimeType: 'text/csv' };
}
throw importFileError('UNSUPPORTED_IMPORT_FILE', 'Sólo se admiten archivos .xlsx y .csv');
}
function decodeXml(value: string): string {
return value
.replace(/&#x([0-9a-f]+);/gi, (_match, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16)))
.replace(/&#([0-9]+);/g, (_match, decimal: string) => String.fromCodePoint(Number.parseInt(decimal, 10)))
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
function stripXmlText(xml: string): string {
const parts: string[] = [];
for (const match of xml.matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g)) parts.push(decodeXml(match[1] ?? ''));
return parts.join('');
}
function columnIndex(reference: string): number {
const match = /^([A-Z]+)\d+$/i.exec(reference);
if (!match) return -1;
let result = 0;
for (const char of match[1]!.toUpperCase()) result = result * 26 + (char.charCodeAt(0) - 64);
return result - 1;
}
async function zipList(filePath: string): Promise<string[]> {
let stdout: string;
try {
({ stdout } = await execFile('unzip', ['-Z1', filePath], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }));
} catch {
throw importFileError('XLSX_UNZIP_UNAVAILABLE', 'No se pudo inspeccionar el XLSX. Verificá que el archivo no esté dañado');
}
const entries = stdout.split(/\r?\n/).map((item) => item.trim()).filter(Boolean);
if (entries.length > MAX_XLSX_ENTRIES) throw importFileError('XLSX_TOO_COMPLEX', 'El XLSX contiene demasiados archivos internos');
if (entries.some((entry) => entry.startsWith('/') || entry.split('/').includes('..'))) {
throw importFileError('INVALID_XLSX_PATH', 'El XLSX contiene rutas internas inválidas');
}
return entries;
}
async function assertZipSize(filePath: string): Promise<void> {
try {
const { stdout } = await execFile('unzip', ['-l', filePath], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 });
const summary = stdout.split(/\r?\n/).reverse().find((line) => /\bfiles?\b/.test(line));
const bytes = summary ? Number(/^\s*(\d+)/.exec(summary)?.[1] ?? 0) : 0;
if (Number.isFinite(bytes) && bytes > MAX_XLSX_UNCOMPRESSED_BYTES) {
throw importFileError('XLSX_UNCOMPRESSED_TOO_LARGE', 'El contenido descomprimido del XLSX supera el límite de seguridad');
}
} catch (error) {
if (error instanceof BadRequestException) throw error;
throw importFileError('INVALID_XLSX_FILE', 'No se pudo leer la estructura interna del XLSX');
}
}
async function zipEntry(filePath: string, entry: string): Promise<string> {
try {
const { stdout } = await execFile('unzip', ['-p', filePath, entry], {
encoding: 'utf8',
maxBuffer: MAX_XLSX_UNCOMPRESSED_BYTES,
});
return stdout;
} catch {
throw importFileError('INVALID_XLSX_FILE', `No se pudo leer ${entry} dentro del XLSX`);
}
}
function workbookSheets(workbookXml: string, relationshipsXml: string): Array<{ name: string; path: string }> {
const relationTargets = new Map<string, string>();
for (const relation of relationshipsXml.matchAll(/<Relationship\b([^>]*)\/?\s*>/g)) {
const attributes = relation[1] ?? '';
const id = /\bId="([^"]+)"/.exec(attributes)?.[1];
const target = /\bTarget="([^"]+)"/.exec(attributes)?.[1];
if (id && target) relationTargets.set(id, target);
}
const result: Array<{ name: string; path: string }> = [];
for (const sheet of workbookXml.matchAll(/<sheet\b([^>]*)\/?\s*>/g)) {
const attributes = sheet[1] ?? '';
const name = decodeXml(/\bname="([^"]+)"/.exec(attributes)?.[1] ?? 'Hoja');
const relationId = /\br:id="([^"]+)"/.exec(attributes)?.[1];
if (!relationId) continue;
const target = relationTargets.get(relationId);
if (!target) continue;
const clean = target.replace(/^\//, '');
const path = clean.startsWith('xl/') ? clean : `xl/${clean.replace(/^\.\//, '')}`;
result.push({ name, path });
}
return result;
}
function parseSharedStrings(xml: string): string[] {
const result: string[] = [];
for (const match of xml.matchAll(/<si(?:\s[^>]*)?>([\s\S]*?)<\/si>/g)) result.push(stripXmlText(match[1] ?? ''));
return result;
}
function cellValue(cellXml: string, cellType: string | undefined, sharedStrings: string[]): string {
if (cellType === 'inlineStr') return stripXmlText(cellXml).trim();
const raw = /<v(?:\s[^>]*)?>([\s\S]*?)<\/v>/.exec(cellXml)?.[1] ?? '';
const value = decodeXml(raw);
if (cellType === 's') {
const index = Number.parseInt(value, 10);
return Number.isInteger(index) ? (sharedStrings[index] ?? '') : '';
}
if (cellType === 'b') return value === '1' ? 'TRUE' : 'FALSE';
if (cellType === 'str') return value;
return value;
}
function parseSheetXml(xml: string, sharedStrings: string[]): string[][] {
const rows: string[][] = [];
let count = 0;
for (const rowMatch of xml.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/g)) {
if (++count > MAX_ROWS_PER_SHEET) throw importFileError('IMPORT_TOO_MANY_ROWS', `La hoja supera ${MAX_ROWS_PER_SHEET.toLocaleString('es-AR')} filas`);
const values: string[] = [];
const rowXml = rowMatch[1] ?? '';
for (const cellMatch of rowXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
const attributes = cellMatch[1] ?? '';
const reference = /\br="([A-Z]+\d+)"/i.exec(attributes)?.[1];
if (!reference) continue;
const index = columnIndex(reference);
if (index < 0 || index >= MAX_COLUMNS) continue;
const type = /\bt="([^"]+)"/.exec(attributes)?.[1];
values[index] = cellValue(cellMatch[2] ?? '', type, sharedStrings).trim();
}
while (values.length && !values[values.length - 1]) values.pop();
rows.push(values.map((value) => value ?? ''));
}
return rows;
}
function detectDelimiter(firstLines: string[]): ',' | ';' | '\t' {
const candidates: Array<',' | ';' | '\t'> = [',', ';', '\t'];
let best: ',' | ';' | '\t' = ',';
let score = -1;
for (const candidate of candidates) {
const current = firstLines.reduce((sum, line) => sum + line.split(candidate).length - 1, 0);
if (current > score) { score = current; best = candidate; }
}
return best;
}
export function parseCsv(text: string): string[][] {
const normalized = text.replace(/^\uFEFF/, '');
const sample = normalized.split(/\r?\n/).slice(0, 8);
const delimiter = detectDelimiter(sample);
const rows: string[][] = [];
let row: string[] = [];
let value = '';
let quoted = false;
for (let i = 0; i < normalized.length; i += 1) {
const char = normalized[i]!;
if (char === '"') {
if (quoted && normalized[i + 1] === '"') { value += '"'; i += 1; }
else quoted = !quoted;
continue;
}
if (!quoted && char === delimiter) { row.push(value.trim()); value = ''; continue; }
if (!quoted && (char === '\n' || char === '\r')) {
if (char === '\r' && normalized[i + 1] === '\n') i += 1;
row.push(value.trim()); value = '';
if (row.some(Boolean)) rows.push(row);
row = [];
if (rows.length > MAX_ROWS_PER_SHEET) throw importFileError('IMPORT_TOO_MANY_ROWS', `El archivo supera ${MAX_ROWS_PER_SHEET.toLocaleString('es-AR')} filas`);
continue;
}
value += char;
}
if (value.length || row.length) { row.push(value.trim()); if (row.some(Boolean)) rows.push(row); }
return rows;
}
export async function parseImportWorkbook(filePath: string, extension: '.xlsx' | '.csv'): Promise<ParsedWorkbook> {
if (extension === '.csv') {
const { readFile } = await import('node:fs/promises');
const text = await readFile(filePath, 'utf8');
return { kind: 'CSV', sheets: [{ name: 'CSV', rows: parseCsv(text) }] };
}
await assertZipSize(filePath);
const entries = await zipList(filePath);
if (!entries.includes('xl/workbook.xml') || !entries.includes('xl/_rels/workbook.xml.rels')) {
throw importFileError('INVALID_XLSX_FILE', 'El archivo no contiene una estructura XLSX compatible');
}
const [workbookXml, relationshipsXml] = await Promise.all([
zipEntry(filePath, 'xl/workbook.xml'),
zipEntry(filePath, 'xl/_rels/workbook.xml.rels'),
]);
const sharedStrings = entries.includes('xl/sharedStrings.xml')
? parseSharedStrings(await zipEntry(filePath, 'xl/sharedStrings.xml'))
: [];
const sheets = workbookSheets(workbookXml, relationshipsXml);
if (!sheets.length) throw importFileError('XLSX_WITHOUT_SHEETS', 'El XLSX no contiene hojas legibles');
const parsed: ParsedSheet[] = [];
for (const sheet of sheets.slice(0, 30)) {
if (!entries.includes(sheet.path)) continue;
const xml = await zipEntry(filePath, sheet.path);
parsed.push({ name: sheet.name, rows: parseSheetXml(xml, sharedStrings) });
}
if (!parsed.length) throw importFileError('XLSX_WITHOUT_SHEETS', 'No se pudo leer ninguna hoja del XLSX');
return { kind: 'XLSX', sheets: parsed };
}
@@ -1,328 +0,0 @@
import { createHash } from 'node:crypto';
export type AssetImportPlanEntityKind = 'DEPARTMENT' | 'ORGANIZATION' | 'AREA' | 'AREA_DEPARTMENT_RELATION' | 'FIELD' | 'OPERATOR_RELATION' | 'LEGAL_RIGHT' | 'LEGAL_RIGHT_ORGANIZATION' | 'INSTALLATION' | 'LOCAL_STRUCTURE' | 'TECHNICAL_ASSET';
export type AssetImportPlanAction = 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE';
export type AssetImportPlanItemStatus = 'PLANNED' | 'MATCHED' | 'REVIEW' | 'IGNORED' | 'APPLIED' | 'ROLLED_BACK' | 'FAILED';
export type AssetImportPlanStatus = 'REVIEW_REQUIRED' | 'READY' | 'APPLIED' | 'ROLLED_BACK' | 'SUPERSEDED' | 'FAILED';
export interface AssetImportPlanDraftItem {
entityKey: string;
entityKind: AssetImportPlanEntityKind;
action: AssetImportPlanAction;
status: AssetImportPlanItemStatus;
assetTypeCode: string | null;
displayName: string;
generatedCode: string | null;
parentEntityKey: string | null;
matchedAssetId: string | null;
payload: Record<string, unknown>;
sourceRowNumbers: number[];
reviewCodes: string[];
}
export function plainImportKey(value: unknown): string {
return String(value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
export function organizationImportKey(value: unknown): string {
const tokens = plainImportKey(value).split(' ').filter(Boolean);
const output: string[] = [];
for (let index = 0; index < tokens.length;) {
if (tokens[index]!.length !== 1) { output.push(tokens[index]!); index += 1; continue; }
const letters: string[] = [];
let cursor = index;
while (cursor < tokens.length && tokens[cursor]!.length === 1) { letters.push(tokens[cursor]!); cursor += 1; }
output.push(letters.length >= 2 ? letters.join('') : letters[0]!);
index = cursor;
}
return output.join(' ');
}
export function isExplicitlyUnassignedOperator(value: unknown): boolean {
return plainImportKey(value) === 'sin empresa operadora';
}
export type NormalizedLegalRightType = 'EXPLOITATION_CONCESSION' | 'EXPLORATION_PERMIT' | 'TRANSPORT_CONCESSION' | 'OTHER';
export function normalizedLegalRightType(value: unknown): NormalizedLegalRightType | null {
const normalized = plainImportKey(value);
if (normalized === 'explotacion') return 'EXPLOITATION_CONCESSION';
if (normalized === 'exploracion') return 'EXPLORATION_PERMIT';
if (normalized === 'transporte' || normalized === 'concesion de transporte') return 'TRANSPORT_CONCESSION';
if (!normalized) return null;
return 'OTHER';
}
export function legalRightTypeLabel(value: NormalizedLegalRightType): string {
if (value === 'EXPLOITATION_CONCESSION') return 'Concesión de explotación';
if (value === 'EXPLORATION_PERMIT') return 'Permiso de exploración';
if (value === 'TRANSPORT_CONCESSION') return 'Concesión de transporte';
return 'Otro derecho';
}
export function departmentCode(value: unknown): string {
const normalized = String(value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase().replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 70);
return normalized || 'SIN-DEPARTAMENTO';
}
export function externalIdNamespace(input: string | null | undefined, originalName: string): string {
const seed = (input?.trim() || originalName.replace(/\.[^.]+$/, '').split(/[-_]/)[0] || 'IMPORT').normalize('NFD').replace(/[\u0300-\u036f]/g, '');
const normalized = seed.toUpperCase().replace(/[^A-Z0-9._/-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
if (normalized.length >= 2 && /^[A-Z0-9]/.test(normalized)) return normalized;
return 'IMPORT';
}
export function generatedImportCode(planId: string, typeCode: string, entityKey: string): string {
const prefix = typeCode.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 10) || 'ACT';
const digest = createHash('sha256').update(`${planId}\u001f${entityKey}`).digest('hex').slice(0, 12).toUpperCase();
return `IMP-${prefix}-${digest}`;
}
export function technicalFamilyTypeCode(family: unknown, subtype: unknown): string {
const normalizedFamily = plainImportKey(family).replace(/ /g, '_');
const normalizedSubtype = plainImportKey(subtype).replace(/ /g, '_');
const direct = new Set(['tanque','separador','bomba','caldera','antorcha','colector','filtro','calentador','ducto','pozo']);
if (direct.has(normalizedFamily)) return normalizedFamily;
if (normalizedFamily === 'pileta') return 'pileta_api';
if (normalizedFamily === 'defensa_incendios') return 'sistema_defensa_incendios';
if (normalizedFamily === 'instalacion' && normalizedSubtype === 'planta') return 'planta';
if (normalizedFamily === 'instalacion' && normalizedSubtype === 'bateria') return 'bateria';
return 'equipo';
}
export interface SourceLocalStructureSuggestion {
displayName: string;
sourcePath: string[];
sourceInstallation: string | null;
sourceSubInstallation: string | null;
concreteFromLocation: boolean;
sourceGroupOnly: boolean;
}
export function sourceLocalStructureSuggestion(
areaOrField: unknown,
installation: unknown,
subInstallation: unknown,
location: unknown,
): SourceLocalStructureSuggestion | null {
const areaText = String(areaOrField ?? '').trim();
const installationText = String(installation ?? '').trim();
const subInstallationText = String(subInstallation ?? '').trim();
const locationText = String(location ?? '').trim();
const unusable = (value: string) => !value || ['-', 'n/a', 'na', 's/d', 'sd', 'sin dato', 'sin datos'].includes(plainImportKey(value));
const sourcePath = locationText.split('/').map((segment) => segment.trim()).filter((segment) => !unusable(segment));
const areaKey = plainImportKey(areaText);
const installationKey = plainImportKey(installationText);
const subInstallationKey = plainImportKey(subInstallationText);
const genericProvinceKeys = new Set(['mendoza', 'provincia de mendoza']);
const genericLocationKeys = new Set([
'yacimiento','planta','energia','edilicio','transporte','repositorio','repositorios','op digitales',
'bateria','set','pta','ptc','em','pcg','et','oficina','estacion de servicio','taller','almacen',
]);
const contextualSegments = sourcePath.filter((segment, index) => {
const key = plainImportKey(segment);
if (!key) return false;
if (index === 0 && genericProvinceKeys.has(key)) return false;
if (areaKey && key === areaKey) return false;
return true;
});
const concrete = [...contextualSegments].reverse().find((segment) => {
const key = plainImportKey(segment);
if (!key) return false;
if (installationKey && key === installationKey) return false;
if (subInstallationKey && key === subInstallationKey) return false;
if (/^pozo\b/.test(key)) return false;
return !genericLocationKeys.has(key);
}) ?? null;
if (concrete) {
return {
displayName: concrete.slice(0, 200),
sourcePath,
sourceInstallation: unusable(installationText) ? null : installationText,
sourceSubInstallation: unusable(subInstallationText) ? null : subInstallationText,
concreteFromLocation: true,
sourceGroupOnly: false,
};
}
const categoryParts = [installationText, subInstallationText]
.filter((value) => !unusable(value))
.filter((value, index, values) => values.findIndex((candidate) => plainImportKey(candidate) === plainImportKey(value)) === index);
if (!categoryParts.length) return null;
return {
displayName: categoryParts.join(' / ').slice(0, 200),
sourcePath,
sourceInstallation: unusable(installationText) ? null : installationText,
sourceSubInstallation: unusable(subInstallationText) ? null : subInstallationText,
concreteFromLocation: false,
sourceGroupOnly: true,
};
}
export function sourceContainerTypeCode(installation: unknown, subInstallation?: unknown): string {
const parent = plainImportKey(installation);
const sub = plainImportKey(subInstallation);
if (sub === 'bateria') return 'bateria';
if (['pta', 'ptc', 'pcg'].includes(sub)) return 'planta';
if (sub === 'estacion de servicio') return 'estacion';
if (/\bplanta\b/.test(parent)) return 'planta';
if (/\bbateria\b/.test(parent)) return 'bateria';
if (/\bsubestacion\b/.test(parent)) return 'subestacion';
if (/\bestacion\b/.test(parent)) return 'estacion';
if (/\blocacion\b/.test(parent)) return 'locacion';
return 'instalacion';
}
export function sourceContainerName(installation: unknown, subInstallation: unknown, location?: unknown): string | null {
const sub = String(subInstallation ?? '').trim();
const parent = String(installation ?? '').trim();
const locationText = String(location ?? '').trim();
const unusable = (value: string) => !value || ['-', 'n/a', 'na', 's/d', 'sd', 'sin dato', 'sin datos'].includes(plainImportKey(value));
const genericSub = new Set(['bateria','set','pta','ptc','em','pcg','et','transporte','oficina','repositorio','repositorios','estacion de servicio','taller','op digitales','almacen']);
const subKey = plainImportKey(sub);
if (!unusable(sub) && !genericSub.has(subKey)) return sub;
const segments = locationText.split('/').map((segment) => segment.trim()).filter(Boolean);
const prefixes: Record<string, RegExp> = {
bateria: /^BAT[A-Z0-9-]/i,
pta: /^PTA[A-Z0-9-]/i,
ptc: /^(PTC[A-Z0-9-]|ESTACION DE BOMBEO)/i,
pcg: /^PCG[A-Z0-9-]/i,
};
const prefix = prefixes[subKey];
if (prefix) {
const candidate = segments.find((segment) => plainImportKey(segment) !== subKey && prefix.test(segment));
if (candidate) return candidate;
}
if (!unusable(parent) && !['planta','bateria','estacion','subestacion','yacimiento','locacion','energia','edilicio'].includes(plainImportKey(parent))) return parent;
return null;
}
export function planItemHash(items: Array<Pick<AssetImportPlanDraftItem, 'entityKey' | 'entityKind' | 'action' | 'status' | 'assetTypeCode' | 'displayName' | 'generatedCode' | 'parentEntityKey' | 'matchedAssetId' | 'payload' | 'sourceRowNumbers' | 'reviewCodes'>>): string {
const canonical = items
.map((item) => ({
entityKey: item.entityKey,
entityKind: item.entityKind,
action: item.action,
status: item.status,
assetTypeCode: item.assetTypeCode,
displayName: item.displayName,
generatedCode: item.generatedCode,
parentEntityKey: item.parentEntityKey,
matchedAssetId: item.matchedAssetId,
payload: item.payload,
sourceRowNumbers: [...item.sourceRowNumbers].sort((a, b) => a - b),
reviewCodes: [...item.reviewCodes].sort(),
}))
.sort((a, b) => a.entityKey.localeCompare(b.entityKey));
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
}
export const PLAN_DEPENDENCY_REVIEW_CODES = new Set([
'PLAN_DEPARTMENT_REVIEW_REQUIRED',
'PLAN_LEGAL_RIGHT_REVIEW_REQUIRED',
'PLAN_AREA_REVIEW_REQUIRED',
'PLAN_ORGANIZATION_REVIEW_REQUIRED',
'PLAN_TERRITORY_CONTEXT_REQUIRED',
'PLAN_CONTEXT_DECISION_REQUIRED',
'PLAN_CONTAINER_REVIEW_REQUIRED',
'PLAN_PARENT_NOT_RESOLVED',
]);
export function isPlanDependencyReview(item: Pick<AssetImportPlanDraftItem, 'action' | 'reviewCodes'>): boolean {
return item.action === 'REVIEW'
&& item.reviewCodes.length > 0
&& item.reviewCodes.every((code) => PLAN_DEPENDENCY_REVIEW_CODES.has(code));
}
export function planDependencyKeys(item: Pick<AssetImportPlanDraftItem, 'entityKey' | 'parentEntityKey' | 'payload'>): string[] {
const keys = new Set<string>();
if (item.parentEntityKey) keys.add(item.parentEntityKey);
for (const key of ['areaEntityKey','organizationEntityKey','departmentEntityKey','legalRightEntityKey','operationalAreaEntityKey','operatorEntityKey','localStructureEntityKey']) {
const value = item.payload[key];
if (typeof value === 'string' && value.trim()) keys.add(value.trim());
}
keys.delete(item.entityKey);
return [...keys];
}
export function safePlanItems<T extends Pick<AssetImportPlanDraftItem, 'entityKey' | 'parentEntityKey' | 'payload' | 'action' | 'status'>>(items: T[]): T[] {
const byKey = new Map(items.map((item) => [item.entityKey, item]));
const memo = new Map<string, boolean>();
const visiting = new Set<string>();
const isSafe = (item: T): boolean => {
if (item.status === 'APPLIED') return true;
if (item.action === 'REVIEW') return false;
const cached = memo.get(item.entityKey);
if (cached !== undefined) return cached;
if (visiting.has(item.entityKey)) return false;
visiting.add(item.entityKey);
const safe = planDependencyKeys(item).every((key) => {
const dependency = byKey.get(key);
return !dependency || isSafe(dependency);
});
visiting.delete(item.entityKey);
memo.set(item.entityKey, safe);
return safe;
};
return items.filter((item) => isSafe(item));
}
export function planStatusForItems(items: Array<Pick<AssetImportPlanDraftItem, 'action'>>): AssetImportPlanStatus {
return items.some((item) => item.action === 'REVIEW') ? 'REVIEW_REQUIRED' : 'READY';
}
export function summarizePlanItems(items: AssetImportPlanDraftItem[]): Record<string, unknown> {
const actionCounts = { create: 0, match: 0, review: 0, ignore: 0 };
let directReviewItems = 0;
let dependencyReviewItems = 0;
let appliedCreateItems = 0;
let pendingCreateItems = 0;
const byKind: Record<string, { create: number; match: number; review: number; ignore: number; total: number }> = {};
for (const item of items) {
const action = item.action.toLowerCase() as keyof typeof actionCounts;
actionCounts[action] += 1;
if (item.action === 'CREATE') {
if (item.status === 'APPLIED') appliedCreateItems += 1;
else pendingCreateItems += 1;
}
if (item.action === 'REVIEW') {
if (isPlanDependencyReview(item)) dependencyReviewItems += 1;
else directReviewItems += 1;
}
const current = byKind[item.entityKind] ?? { create: 0, match: 0, review: 0, ignore: 0, total: 0 };
current[action] += 1;
current.total += 1;
byKind[item.entityKind] = current;
}
const safeCreateItems = safePlanItems(items).filter((item) => item.action === 'CREATE' && item.status !== 'APPLIED').length;
return {
totalItems: items.length,
createItems: actionCounts.create,
matchItems: actionCounts.match,
reviewItems: actionCounts.review,
directReviewItems,
dependencyReviewItems,
ignoreItems: actionCounts.ignore,
appliedCreateItems,
pendingCreateItems,
safeCreateItems,
blockedCreateItems: Math.max(0, pendingCreateItems - safeCreateItems),
partialApplied: appliedCreateItems > 0 && actionCounts.review > 0,
byKind,
blocked: actionCounts.review > 0,
};
}
@@ -1,447 +0,0 @@
import { createHash } from 'node:crypto';
import type { ParsedSheet, ParsedWorkbook } from './asset-import-parser';
export type AssetImportProfileCode = 'MENDOZA_INVENTORY_V1' | 'MENDOZA_YACIMIENTOS_V1' | 'UNKNOWN';
export type AssetImportRowStatus = 'READY' | 'WARNING' | 'CONFLICT' | 'IGNORED';
export type AssetImportSuggestedAction = 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE';
export interface ImportProfileDetection {
profileCode: AssetImportProfileCode;
confidence: number;
sheetName: string;
headerRow: number;
columnMap: Record<string, number>;
detectedHeaders: string[];
}
export interface ImportNormalizedRow {
profileCode: AssetImportProfileCode;
rowNumber: number;
sheetName: string;
raw: Record<string, string>;
normalized: Record<string, unknown>;
status: AssetImportRowStatus;
suggestedAction: AssetImportSuggestedAction;
issues: string[];
fingerprint: string;
}
export interface ImportAnalysis {
detection: ImportProfileDetection;
rows: ImportNormalizedRow[];
summary: {
totalRows: number;
readyRows: number;
warningRows: number;
conflictRows: number;
ignoredRows: number;
issueCounts: Record<string, number>;
};
}
function plain(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
function compact(value: string): string {
return plain(value).replace(/\s+/g, '');
}
const inventoryAliases: Record<string, string[]> = {
item: ['item', 'n item', 'numero item'],
areaOrField: ['area yacimiento', 'area/yacimiento', 'area y yacimiento', 'area yacimiento '],
installation: ['instalacion', 'intalacion'],
subInstallation: ['sub instalacion', 'subinstalacion'],
equipment: ['equipo'],
equipmentDenomination: ['denominacion del equipo', 'denominacion equipo'],
location: ['ubicacion del equipo o instalacion', 'ubicacion equipo o instalacion', 'ubicacion'],
inventoryId: ['n id inventario', 'n° id inventario', 'nº id inventario', 'id inventario', 'numero id inventario'],
quantity: ['cantidad'],
technicalSpecs: ['especificaciones tecnicas', 'especificacion tecnica'],
sourceStatus: ['estado en servicio fuera de servicio', 'estado', 'estado servicio'],
};
const fieldAliases: Record<string, string[]> = {
field: ['yacimiento', 'nombre yacimiento'],
area: ['area', 'area hidrocarburifera'],
department: ['departamento'],
rightType: ['tipo concesion', 'tipo de concesion', 'tipo permiso', 'tipo'],
operator: ['operadora', 'operador', 'empresa operadora'],
};
function aliasScore(header: string, aliases: string[]): number {
const normalized = plain(header);
const normalizedCompact = compact(header);
let best = 0;
for (const alias of aliases) {
const a = plain(alias);
const ac = compact(alias);
if (normalized === a || normalizedCompact === ac) best = Math.max(best, 10 + a.length / 100);
else if (normalized.includes(a) || a.includes(normalized)) best = Math.max(best, 6 + Math.min(a.length, normalized.length) / 100);
}
return best;
}
function mapHeaders(headers: string[], aliases: Record<string, string[]>): { map: Record<string, number>; score: number } {
const candidates: Array<{ field: string; index: number; score: number }> = [];
headers.forEach((header, index) => {
Object.entries(aliases).forEach(([field, values]) => {
const score = aliasScore(header, values);
if (score > 0) candidates.push({ field, index, score });
});
});
candidates.sort((a, b) => b.score - a.score);
const usedFields = new Set<string>();
const usedIndexes = new Set<number>();
const map: Record<string, number> = {};
let score = 0;
for (const candidate of candidates) {
if (usedFields.has(candidate.field) || usedIndexes.has(candidate.index)) continue;
usedFields.add(candidate.field);
usedIndexes.add(candidate.index);
map[candidate.field] = candidate.index;
score += candidate.score;
}
return { map, score };
}
function bestHeader(sheet: ParsedSheet, aliases: Record<string, string[]>): { row: number; map: Record<string, number>; score: number; headers: string[] } {
let best = { row: 0, map: {} as Record<string, number>, score: -1, headers: [] as string[] };
for (let index = 0; index < Math.min(sheet.rows.length, 30); index += 1) {
const headers = sheet.rows[index] ?? [];
const mapped = mapHeaders(headers, aliases);
if (mapped.score > best.score) best = { row: index + 1, map: mapped.map, score: mapped.score, headers };
}
return best;
}
export function detectImportProfile(workbook: ParsedWorkbook): ImportProfileDetection {
let best: ImportProfileDetection = { profileCode: 'UNKNOWN', confidence: 0, sheetName: workbook.sheets[0]?.name ?? 'Hoja', headerRow: 1, columnMap: {}, detectedHeaders: [] };
for (const sheet of workbook.sheets) {
const inventory = bestHeader(sheet, inventoryAliases);
const inventoryRequired = ['areaOrField', 'installation', 'equipment', 'inventoryId'];
const inventoryHits = inventoryRequired.filter((field) => inventory.map[field] !== undefined).length;
const inventoryConfidence = Math.min(100, Math.round((inventory.score / 95) * 100));
if (inventoryHits >= 3 && inventoryConfidence > best.confidence) {
best = { profileCode: 'MENDOZA_INVENTORY_V1', confidence: inventoryConfidence, sheetName: sheet.name, headerRow: inventory.row, columnMap: inventory.map, detectedHeaders: inventory.headers };
}
const fields = bestHeader(sheet, fieldAliases);
const fieldRequired = ['field', 'area', 'operator'];
const fieldHits = fieldRequired.filter((field) => fields.map[field] !== undefined).length;
const fieldConfidence = Math.min(100, Math.round((fields.score / 55) * 100));
if (fieldHits >= 2 && fieldConfidence > best.confidence) {
best = { profileCode: 'MENDOZA_YACIMIENTOS_V1', confidence: fieldConfidence, sheetName: sheet.name, headerRow: fields.row, columnMap: fields.map, detectedHeaders: fields.headers };
}
}
return best;
}
function text(row: string[], index: number | undefined): string {
return index === undefined ? '' : (row[index] ?? '').trim();
}
function asNumber(value: string): number | null {
if (!value.trim()) return null;
const normalized = value.trim().replace(/\s+/g, '').replace(',', '.');
const number = Number(normalized);
return Number.isFinite(number) ? number : null;
}
function inventoryStatusSuggestion(value: string): { operationalStatus?: string; conditionStatus?: string; ambiguous: boolean } {
const normalized = plain(value);
if (!normalized) return { ambiguous: false };
if (['en servicio', 'servicio', 'operativo', 'operativa'].includes(normalized)) return { operationalStatus: 'IN_SERVICE', ambiguous: false };
if (['fuera de servicio', 'f servicio', 'f serv', 'fs', 'f s'].includes(normalized)) return { operationalStatus: 'OUT_OF_SERVICE', ambiguous: false };
if (['bueno', 'buena'].includes(normalized)) return { conditionStatus: 'GOOD', ambiguous: true };
if (['regular'].includes(normalized)) return { conditionStatus: 'FAIR', ambiguous: true };
if (['malo', 'mala'].includes(normalized)) return { conditionStatus: 'POOR', ambiguous: true };
if (['si', 'no', 's', 'n'].includes(normalized)) return { ambiguous: true };
return { ambiguous: true };
}
function splitClassType(value: string): { sourceClass: string | null; sourceSubtype: string | null } {
const classMatch = /clase\s*:\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null;
const typeMatch = /tipo\s*:\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null;
return { sourceClass: classMatch, sourceSubtype: typeMatch };
}
function splitManufacturerModel(value: string): { manufacturer: string | null; model: string | null } {
const manufacturer = /fabricante\s*[;:]\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null;
const model = /modelo\s*[;:]\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null;
if (manufacturer || model) return { manufacturer, model };
const parts = value.split('|').map((part) => part.trim()).filter(Boolean);
if (parts.length >= 3) return { manufacturer: parts[1] ?? null, model: parts[2] ?? null };
return { manufacturer: null, model: null };
}
export interface TechnicalNormalizationSuggestion {
family: string | null;
subtype: string | null;
}
function technicalNormalizationSuggestion(
equipment: string,
denomination: string,
sourceClass: string | null,
sourceSubtype: string | null,
): TechnicalNormalizationSuggestion {
const source = plain(`${sourceClass ?? ''} ${sourceSubtype ?? ''} ${equipment} ${denomination}`);
const classCode = plain(sourceClass ?? '');
const subtypeCode = plain(sourceSubtype ?? '');
if (/rectificador/.test(plain(equipment))) return { family: 'equipo_electrico', subtype: 'rectificador' };
const equipmentOnly = plain(equipment);
if (/^psv$/.test(equipmentOnly)) return { family: 'valvula', subtype: 'seguridad' };
if (/^vpsv$|^vpv$/.test(equipmentOnly)) return { family: 'valvula', subtype: 'presion_vacio' };
if (/^aib$/.test(equipmentOnly)) return { family: 'sistema_extraccion', subtype: 'aib' };
if (/seccionador/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'seccionador' };
if (/interruptor/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'interruptor' };
if (/reconectador/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'reconectador' };
if (/^colector$/.test(equipmentOnly)) return { family: 'colector', subtype: null };
if (/punto de medicion/.test(equipmentOnly)) return { family: 'instrumentacion', subtype: 'punto_medicion' };
if (/sistema rci|^rci$/.test(equipmentOnly)) return { family: 'defensa_incendios', subtype: null };
if (/aeroenfriador/.test(equipmentOnly)) return { family: 'aeroenfriador', subtype: null };
if (/^bateria$/.test(equipmentOnly)) return { family: 'instalacion', subtype: 'bateria' };
if (/^planta$/.test(equipmentOnly)) return { family: 'instalacion', subtype: 'planta' };
if (classCode === 'bba' || /\bbomba\b/.test(source)) {
if (/\bcen\b|centrifug/.test(source)) return { family: 'bomba', subtype: 'centrifuga' };
if (/\btx\b|triplex|quintuplex|alternativa/.test(source)) return { family: 'bomba', subtype: 'alternativa' };
if (/tornillo/.test(source)) return { family: 'bomba', subtype: 'tornillo' };
if (/diafragma/.test(source)) return { family: 'bomba', subtype: 'diafragma' };
return { family: 'bomba', subtype: null };
}
if (classCode === 'tk' || /\btanque\b|\btk\b/.test(source)) return { family: 'tanque', subtype: null };
if (classCode === 'sep' || /separador/.test(source)) {
if (/sep b|bifasic/.test(source)) return { family: 'separador', subtype: 'bifasico' };
if (/sep g|gas/.test(source)) return { family: 'separador', subtype: 'gas' };
return { family: 'separador', subtype: null };
}
if (classCode === 'cald' || /\bcaldera\b/.test(source)) return { family: 'caldera', subtype: null };
if (classCode === 'cal' || /calentador|hot oil/.test(source)) return { family: 'calentador', subtype: null };
if (classCode === 'ant' || /antorcha|flare/.test(source)) return { family: 'antorcha', subtype: /frio/.test(source) ? 'venteo_frio' : null };
if (classCode === 'fil' || /\bfiltro\b/.test(source)) return { family: 'filtro', subtype: /arena/.test(source) ? 'arena' : null };
if (classCode === 'tra' || /transformador/.test(source)) return { family: 'equipo_electrico', subtype: 'transformador' };
if (classCode === 'moe' || /motor electr/.test(source)) return { family: 'motor', subtype: 'electrico' };
if (classCode === 'moex' || /motor de combustion|motor a explosion/.test(source)) return { family: 'motor', subtype: 'combustion' };
if (classCode === 'com' || /compresor|soplador/.test(source)) return { family: 'compresor', subtype: null };
if (classCode === 'va' || /valvula/.test(source)) {
if (/vpv|presion y vacio/.test(source)) return { family: 'valvula', subtype: 'presion_vacio' };
if (/\bvs\b|seguridad/.test(source)) return { family: 'valvula', subtype: 'seguridad' };
if (/\bvr\b|reguladora/.test(source)) return { family: 'valvula', subtype: 'reguladora' };
return { family: 'valvula', subtype: null };
}
if (classCode === 'caud' || /caudalimetro/.test(source)) return { family: 'instrumentacion', subtype: 'caudalimetro' };
if (classCode === 'eg' || /generador|motogenerador/.test(source)) return { family: 'generador', subtype: null };
if (classCode === 'cel' || /\bcelda\b/.test(source)) return { family: 'equipo_electrico', subtype: 'celda' };
if (classCode === 'pil' || /pileta/.test(source)) return { family: 'pileta', subtype: null };
if (classCode === 'aib' || /aparato individual de bombeo|rotaflex/.test(source)) {
const subtype = /rotaflex/.test(source) ? 'rotaflex' : /mark ii/.test(source) ? 'mark_ii' : /convencional/.test(source) ? 'convencional' : null;
return { family: 'sistema_extraccion', subtype };
}
if (classCode === 'pcp' || /\bpcp\b/.test(source)) return { family: 'sistema_extraccion', subtype: 'pcp' };
if (/\bbes\b|electrosumerg/.test(source)) return { family: 'sistema_extraccion', subtype: 'bes' };
if (/\bpozo\b/.test(source)) return { family: 'pozo', subtype: null };
if (/oleoducto/.test(source)) return { family: 'ducto', subtype: 'oleoducto' };
if (/gasoducto/.test(source)) return { family: 'ducto', subtype: 'gasoducto' };
if (/acueducto/.test(source)) return { family: 'ducto', subtype: 'acueducto' };
if (/caneria/.test(source)) return { family: 'ducto', subtype: 'caneria' };
return { family: null, subtype: null };
}
function rawObject(headers: string[], row: string[]): Record<string, string> {
const result: Record<string, string> = {};
headers.forEach((header, index) => {
if (!header.trim() && !row[index]?.trim()) return;
result[header.trim() || `Columna ${index + 1}`] = row[index]?.trim() ?? '';
});
return result;
}
function fingerprint(value: Record<string, unknown>): string {
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
}
function normalizeInventory(sheet: ParsedSheet, detection: ImportProfileDetection): ImportNormalizedRow[] {
const headers = sheet.rows[detection.headerRow - 1] ?? [];
const result: ImportNormalizedRow[] = [];
const map = detection.columnMap;
for (let index = detection.headerRow; index < sheet.rows.length; index += 1) {
const row = sheet.rows[index] ?? [];
if (!row.some((value) => value.trim())) continue;
const areaOrField = text(row, map.areaOrField);
const installation = text(row, map.installation);
const subInstallation = text(row, map.subInstallation);
const equipment = text(row, map.equipment);
const denomination = text(row, map.equipmentDenomination);
const location = text(row, map.location);
const inventoryId = text(row, map.inventoryId);
const quantityRaw = text(row, map.quantity);
const technicalSpecs = text(row, map.technicalSpecs);
const sourceStatus = text(row, map.sourceStatus);
const item = text(row, map.item);
const isHeaderRepeat = compact(areaOrField) === compact(headers[map.areaOrField ?? -1] ?? '') && compact(equipment) === compact(headers[map.equipment ?? -1] ?? '');
if (isHeaderRepeat) continue;
const issues: string[] = [];
if (!areaOrField) issues.push('MISSING_AREA_OR_YACIMIENTO');
if (!equipment && !denomination) issues.push('MISSING_EQUIPMENT_DESCRIPTION');
if (!inventoryId) issues.push('MISSING_INVENTORY_ID');
const quantity = asNumber(quantityRaw);
if (quantity !== null && (!Number.isInteger(quantity) || quantity <= 0)) issues.push('INVALID_QUANTITY');
if (quantity !== null && quantity > 1) issues.push('GROUPED_QUANTITY');
const sourceState = inventoryStatusSuggestion(sourceStatus);
if (sourceState.ambiguous && sourceStatus) issues.push('SOURCE_STATUS_REQUIRES_MAPPING');
const classType = splitClassType(denomination);
const manufacturerModel = splitManufacturerModel(technicalSpecs);
const technical = technicalNormalizationSuggestion(equipment, denomination, classType.sourceClass, classType.sourceSubtype);
const normalized: Record<string, unknown> = {
item: item || null,
areaOrField: areaOrField || null,
installation: installation || null,
subInstallation: subInstallation || null,
equipment: equipment || null,
sourceClassification: denomination || null,
location: location || null,
inventoryId: inventoryId || null,
quantity: quantity ?? (quantityRaw || null),
technicalSpecs: technicalSpecs || null,
sourceStatus: sourceStatus || null,
operationalStatusSuggestion: sourceState.operationalStatus ?? null,
conditionStatusSuggestion: sourceState.conditionStatus ?? null,
sourceClass: classType.sourceClass,
sourceSubtype: classType.sourceSubtype,
manufacturer: manufacturerModel.manufacturer,
model: manufacturerModel.model,
familySuggestion: technical.family,
normalizedFamily: technical.family,
normalizedSubtype: technical.subtype,
};
const conflict = issues.includes('MISSING_EQUIPMENT_DESCRIPTION') || issues.includes('INVALID_QUANTITY');
const status: AssetImportRowStatus = conflict ? 'CONFLICT' : issues.length ? 'WARNING' : 'READY';
result.push({
profileCode: 'MENDOZA_INVENTORY_V1',
rowNumber: index + 1,
sheetName: sheet.name,
raw: rawObject(headers, row),
normalized,
status,
suggestedAction: conflict ? 'REVIEW' : 'CREATE',
issues,
fingerprint: fingerprint(normalized),
});
}
return applyBatchDuplicateRules(result);
}
function applyBatchDuplicateRules(rows: ImportNormalizedRow[]): ImportNormalizedRow[] {
const groups = new Map<string, ImportNormalizedRow[]>();
for (const row of rows) {
const id = String(row.normalized.inventoryId ?? '').trim().toLowerCase();
if (!id) continue;
const area = plain(String(row.normalized.areaOrField ?? ''));
const key = `${area}|${id}`;
const current = groups.get(key) ?? [];
current.push(row);
groups.set(key, current);
}
for (const group of groups.values()) {
if (group.length < 2) continue;
const locations = new Set(group.map((row) => plain(`${row.normalized.installation ?? ''}|${row.normalized.subInstallation ?? ''}|${row.normalized.location ?? ''}`)));
const issue = locations.size > 1 ? 'INVENTORY_ID_MULTIPLE_LOCATIONS' : 'DUPLICATE_INVENTORY_ID_IN_BATCH';
for (const row of group) {
if (!row.issues.includes(issue)) row.issues.push(issue);
if (issue === 'INVENTORY_ID_MULTIPLE_LOCATIONS') {
row.status = 'CONFLICT';
row.suggestedAction = 'REVIEW';
} else if (row.status === 'READY') {
row.status = 'WARNING';
}
}
}
return rows;
}
function normalizeFields(sheet: ParsedSheet, detection: ImportProfileDetection): ImportNormalizedRow[] {
const headers = sheet.rows[detection.headerRow - 1] ?? [];
const result: ImportNormalizedRow[] = [];
const map = detection.columnMap;
for (let index = detection.headerRow; index < sheet.rows.length; index += 1) {
const row = sheet.rows[index] ?? [];
if (!row.some((value) => value.trim())) continue;
const field = text(row, map.field);
const area = text(row, map.area);
const department = text(row, map.department);
const rightType = text(row, map.rightType);
const operator = text(row, map.operator);
const issues: string[] = [];
if (!field) issues.push('MISSING_FIELD');
if (!area) issues.push('MISSING_AREA');
if (!operator) issues.push('MISSING_OPERATOR');
const normalized: Record<string, unknown> = {
field: field || null,
area: area || null,
department: department || null,
rightType: rightType || null,
operator: operator || null,
};
const conflict = !field || !area;
result.push({
profileCode: 'MENDOZA_YACIMIENTOS_V1',
rowNumber: index + 1,
sheetName: sheet.name,
raw: rawObject(headers, row),
normalized,
status: conflict ? 'CONFLICT' : issues.length ? 'WARNING' : 'READY',
suggestedAction: conflict ? 'REVIEW' : 'CREATE',
issues,
fingerprint: fingerprint(normalized),
});
}
return result;
}
function summarize(rows: ImportNormalizedRow[]): ImportAnalysis['summary'] {
const issueCounts: Record<string, number> = {};
rows.forEach((row) => row.issues.forEach((issue) => { issueCounts[issue] = (issueCounts[issue] ?? 0) + 1; }));
return {
totalRows: rows.length,
readyRows: rows.filter((row) => row.status === 'READY').length,
warningRows: rows.filter((row) => row.status === 'WARNING').length,
conflictRows: rows.filter((row) => row.status === 'CONFLICT').length,
ignoredRows: rows.filter((row) => row.status === 'IGNORED').length,
issueCounts,
};
}
export function analyzeImportWorkbook(workbook: ParsedWorkbook, forcedProfile?: AssetImportProfileCode): ImportAnalysis {
const detected = detectImportProfile(workbook);
const profileCode = forcedProfile && forcedProfile !== 'UNKNOWN' ? forcedProfile : detected.profileCode;
if (profileCode === 'UNKNOWN') {
return { detection: detected, rows: [], summary: { totalRows: 0, readyRows: 0, warningRows: 0, conflictRows: 0, ignoredRows: 0, issueCounts: { PROFILE_NOT_RECOGNIZED: 1 } } };
}
const aliases = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? fieldAliases : inventoryAliases;
let selected: { sheet: ParsedSheet; header: ReturnType<typeof bestHeader> } | null = null;
for (const candidate of workbook.sheets) {
const header = bestHeader(candidate, aliases);
if (!selected || header.score > selected.header.score) selected = { sheet: candidate, header };
}
if (!selected) {
return { detection: detected, rows: [], summary: { totalRows: 0, readyRows: 0, warningRows: 0, conflictRows: 0, ignoredRows: 0, issueCounts: { PROFILE_NOT_RECOGNIZED: 1 } } };
}
const denominator = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? 55 : 95;
const confidence = Math.min(100, Math.max(0, Math.round((selected.header.score / denominator) * 100)));
const detection: ImportProfileDetection = { profileCode, confidence, sheetName: selected.sheet.name, headerRow: selected.header.row, columnMap: selected.header.map, detectedHeaders: selected.header.headers };
const rows = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? normalizeFields(selected.sheet, detection) : normalizeInventory(selected.sheet, detection);
return { detection, rows, summary: summarize(rows) };
}
export function profileLabel(code: AssetImportProfileCode): string {
if (code === 'MENDOZA_INVENTORY_V1') return 'Inventario de instalaciones · Mendoza';
if (code === 'MENDOZA_YACIMIENTOS_V1') return 'Tabla Área / Yacimiento · Mendoza';
return 'Formato no reconocido';
}
@@ -1,158 +0,0 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query, Req, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AssetImportsService, MAX_ASSET_IMPORT_BYTES, type UploadedImportFile } from './asset-imports.service';
import { ApplyAssetImportPlanDto } from './dto/apply-asset-import-plan.dto';
import { CancelAssetImportDto } from './dto/cancel-asset-import.dto';
import { CreateAssetImportPlanDto } from './dto/create-asset-import-plan.dto';
import { ResolveAssetImportPlanItemDto } from './dto/resolve-asset-import-plan-item.dto';
import { RollbackAssetImportPlanDto } from './dto/rollback-asset-import-plan.dto';
import { ListAssetImportPlanItemsQueryDto } from './dto/list-asset-import-plan-items-query.dto';
import { ListAssetImportReviewsQueryDto } from './dto/list-asset-import-reviews-query.dto';
import { ListAssetImportRowsQueryDto } from './dto/list-asset-import-rows-query.dto';
import { ListAssetImportsQueryDto } from './dto/list-asset-imports-query.dto';
import { UploadAssetImportDto } from './dto/upload-asset-import.dto';
@Controller('asset-imports')
export class AssetImportsController {
constructor(private readonly imports: AssetImportsService) {}
@Get()
@RequirePermissions('asset_imports.read')
list(@Query() query: ListAssetImportsQueryDto) {
return this.imports.list(query);
}
@Post()
@RequirePermissions('asset_imports.manage')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_ASSET_IMPORT_BYTES, files: 1 } }))
upload(
@Body() dto: UploadAssetImportDto,
@UploadedFile() file: UploadedImportFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.imports.upload(dto, file, principal, request);
}
@Get('context/organizations')
@RequirePermissions('asset_imports.read')
organizations(@Query('search') search?: string) {
return this.imports.organizationOptions(search);
}
@Get('reviews')
@RequirePermissions('asset_imports.read')
reviews(@Query() query: ListAssetImportReviewsQueryDto) {
return this.imports.reviews(query);
}
@Get(':id')
@RequirePermissions('asset_imports.read')
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.imports.get(id);
}
@Get(':id/plan')
@RequirePermissions('asset_imports.read')
plan(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.imports.plan(id);
}
@Get(':id/plan/items')
@RequirePermissions('asset_imports.read')
planItems(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Query() query: ListAssetImportPlanItemsQueryDto,
) {
return this.imports.planItems(id, query);
}
@Post(':id/plan')
@RequirePermissions('asset_imports.manage')
generatePlan(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: CreateAssetImportPlanDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.imports.generatePlan(id, dto, principal, request);
}
@Post(':id/plan/items/:itemId/resolve')
@RequirePermissions('asset_imports.manage')
resolvePlanItem(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Param('itemId', new ParseUUIDPipe({ version: '4' })) itemId: string,
@Body() dto: ResolveAssetImportPlanItemDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.imports.resolvePlanItem(id, itemId, dto, principal, request);
}
@Post(':id/apply-safe')
@RequirePermissions('asset_imports.apply')
applySafePlan(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ApplyAssetImportPlanDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.imports.applySafePlan(id, dto, principal, request);
}
@Post(':id/apply')
@RequirePermissions('asset_imports.apply')
applyPlan(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ApplyAssetImportPlanDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.imports.applyPlan(id, dto, principal, request);
}
@Post(':id/rollback')
@RequirePermissions('asset_imports.apply')
rollbackPlan(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: RollbackAssetImportPlanDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.imports.rollbackPlan(id, dto, principal, request);
}
@Get(':id/rows')
@RequirePermissions('asset_imports.read')
rows(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Query() query: ListAssetImportRowsQueryDto,
) {
return this.imports.rows(id, query);
}
@Post(':id/reconcile')
@RequirePermissions('asset_imports.manage')
reconcile(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.imports.reconcile(id, principal, request);
}
@Post(':id/cancel')
@RequirePermissions('asset_imports.manage')
cancel(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: CancelAssetImportDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.imports.cancel(id, dto, principal, request);
}
}
@@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { AssetImportsController } from './asset-imports.controller';
import { AssetImportsService } from './asset-imports.service';
@Module({
imports: [AuditModule],
controllers: [AssetImportsController],
providers: [AssetImportsService],
})
export class AssetImportsModule {}
File diff suppressed because it is too large Load Diff
@@ -1,7 +0,0 @@
import { IsString, Matches } from 'class-validator';
export class ApplyAssetImportPlanDto {
@IsString()
@Matches(/^[0-9a-f]{64}$/)
planHash!: string;
}
@@ -1,5 +0,0 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class CancelAssetImportDto {
@IsString() @MinLength(5) @MaxLength(500) reason!: string;
}
@@ -1,13 +0,0 @@
import { IsOptional, IsString, IsUUID, Matches, MaxLength } from 'class-validator';
export class CreateAssetImportPlanDto {
@IsOptional()
@IsUUID('4')
operatorAssetId?: string;
@IsOptional()
@IsString()
@MaxLength(80)
@Matches(/^[A-Z0-9][A-Z0-9._/-]{1,79}$/i)
externalIdNamespace?: string;
}
@@ -1,14 +0,0 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
const entityKinds = [
'DEPARTMENT','ORGANIZATION','AREA','AREA_DEPARTMENT_RELATION','FIELD','OPERATOR_RELATION',
'LEGAL_RIGHT','LEGAL_RIGHT_ORGANIZATION','INSTALLATION','LOCAL_STRUCTURE','TECHNICAL_ASSET',
] as const;
export class ListAssetImportPlanItemsQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize = 50;
@IsOptional() @IsIn(entityKinds) entityKind?: typeof entityKinds[number];
@IsOptional() @IsIn(['CREATE','MATCH','REVIEW','IGNORE']) action?: 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE';
}
@@ -1,8 +0,0 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
export class ListAssetImportReviewsQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) pageSize = 30;
@IsOptional() @IsIn(['ALL','DIRECT','DEPENDENCY']) kind: 'ALL' | 'DIRECT' | 'DEPENDENCY' = 'ALL';
}
@@ -1,9 +0,0 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class ListAssetImportRowsQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize = 50;
@IsOptional() @IsIn(['READY','WARNING','CONFLICT','IGNORED']) status?: string;
@IsOptional() @IsString() @MaxLength(160) search?: string;
}
@@ -1,8 +0,0 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
export class ListAssetImportsQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) pageSize = 20;
@IsOptional() @IsIn(['ANALYZED','REVIEW_REQUIRED','CANCELLED','FAILED']) status?: string;
}
@@ -1,15 +0,0 @@
import { IsIn, IsOptional, IsString, IsUUID, MaxLength, MinLength, ValidateIf } from 'class-validator';
export class ResolveAssetImportPlanItemDto {
@IsIn(['CREATE', 'MATCH', 'IGNORE'])
action!: 'CREATE' | 'MATCH' | 'IGNORE';
@ValidateIf((value: ResolveAssetImportPlanItemDto) => value.action === 'MATCH')
@IsUUID('4')
matchedAssetId?: string;
@IsString()
@MinLength(5)
@MaxLength(1000)
reason!: string;
}
@@ -1,8 +0,0 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class RollbackAssetImportPlanDto {
@IsString()
@MinLength(8)
@MaxLength(1000)
reason!: string;
}
@@ -1,18 +0,0 @@
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import type { AssetImportProfileCode } from '../asset-import-profiles';
export class UploadAssetImportDto {
@IsOptional()
@IsString()
@MaxLength(240)
sourceLabel?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@IsOptional()
@IsIn(['MENDOZA_INVENTORY_V1', 'MENDOZA_YACIMIENTOS_V1'])
profileCode?: Exclude<AssetImportProfileCode, 'UNKNOWN'>;
}
@@ -1,131 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import {
AssetAttributeDataType,
AssetAttributeDefinition,
} from '../database/entities';
export interface NormalizedAttributeValue {
definitionId: string;
value: unknown;
}
function invalidAttribute(message: string, definitionId?: string): never {
throw new BadRequestException({
code: 'INVALID_ASSET_ATTRIBUTE',
message,
...(definitionId ? { definitionId } : {}),
});
}
function isMissing(value: unknown): boolean {
return value === undefined || value === null || value === '';
}
function isValidCalendarDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const [year, month, day] = value.split('-').map(Number);
const parsed = new Date(Date.UTC(year!, month! - 1, day));
return (
parsed.getUTCFullYear() === year &&
parsed.getUTCMonth() === month! - 1 &&
parsed.getUTCDate() === day
);
}
function normalizeValue(
definition: AssetAttributeDefinition,
value: unknown,
): unknown {
switch (definition.dataType) {
case AssetAttributeDataType.TEXT:
if (typeof value !== 'string' || value.length > 4000) {
return invalidAttribute(
`El atributo ${definition.name} debe ser un texto de hasta 4000 caracteres`,
definition.id,
);
}
return value.trim();
case AssetAttributeDataType.NUMBER:
if (typeof value !== 'number' || !Number.isFinite(value)) {
return invalidAttribute(
`El atributo ${definition.name} debe ser numérico`,
definition.id,
);
}
return value;
case AssetAttributeDataType.BOOLEAN:
if (typeof value !== 'boolean') {
return invalidAttribute(
`El atributo ${definition.name} debe ser verdadero o falso`,
definition.id,
);
}
return value;
case AssetAttributeDataType.DATE:
if (typeof value !== 'string' || !isValidCalendarDate(value)) {
return invalidAttribute(
`El atributo ${definition.name} debe ser una fecha válida`,
definition.id,
);
}
return value;
case AssetAttributeDataType.DATETIME:
if (
typeof value !== 'string' ||
!value.trim() ||
!Number.isFinite(Date.parse(value))
) {
return invalidAttribute(
`El atributo ${definition.name} debe ser una fecha y hora válida`,
definition.id,
);
}
return new Date(value).toISOString();
case AssetAttributeDataType.SELECT: {
const options = definition.options ?? [];
if (typeof value !== 'string' || !options.includes(value)) {
return invalidAttribute(
`El atributo ${definition.name} no contiene una opción válida`,
definition.id,
);
}
return value;
}
}
}
export function validateAssetAttributeValues(
definitions: AssetAttributeDefinition[],
values: Record<string, unknown>,
): NormalizedAttributeValue[] {
const activeDefinitions = definitions.filter((definition) => definition.isActive);
const byId = new Map(activeDefinitions.map((definition) => [definition.id, definition]));
for (const definitionId of Object.keys(values)) {
if (!byId.has(definitionId)) {
invalidAttribute('Se recibió un atributo que no pertenece al tipo de activo', definitionId);
}
}
const normalized: NormalizedAttributeValue[] = [];
for (const definition of activeDefinitions) {
const value = values[definition.id];
if (isMissing(value)) {
if (definition.isRequired) {
invalidAttribute(`El atributo ${definition.name} es obligatorio`, definition.id);
}
continue;
}
normalized.push({
definitionId: definition.id,
value: normalizeValue(definition, value),
});
}
return normalized;
}
@@ -1,65 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Put,
Query,
Req,
} from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import type {
AuthPrincipal,
RequestWithContext,
} from '../common/http/request-context';
import { AssetGeometriesService } from './asset-geometries.service';
import { MapAssetsQueryDto } from './dto/map-assets-query.dto';
import { UpsertAssetGeometryDto } from './dto/upsert-asset-geometry.dto';
@Controller('assets')
export class AssetGeometriesController {
constructor(private readonly geometries: AssetGeometriesService) {}
@Get(':id/geometry')
@RequirePermissions('assets.read')
get(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
) {
return this.geometries.get(id);
}
@Put(':id/geometry')
@RequirePermissions('assets.update_geometry')
upsert(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpsertAssetGeometryDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.geometries.upsert(id, dto, principal, request);
}
@Delete(':id/geometry')
@RequirePermissions('assets.update_geometry')
remove(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.geometries.remove(id, principal, request);
}
}
@Controller('map/assets')
export class MapAssetsController {
constructor(private readonly geometries: AssetGeometriesService) {}
@Get()
@RequirePermissions('assets.read')
map(@Query() query: MapAssetsQueryDto) {
return this.geometries.map(query);
}
}
@@ -1,301 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type {
AuthPrincipal,
RequestWithContext,
} from '../common/http/request-context';
import {
Asset,
AssetGeometrySource,
AssetGeometryType,
AssetVersionChangeType,
AuditAction,
} from '../database/entities';
import {
parseBoundingBox,
validateGeoJsonGeometry,
type GeoJsonGeometry,
} from './asset-geometry-validator';
import type { MapAssetsQueryDto } from './dto/map-assets-query.dto';
import type { UpsertAssetGeometryDto } from './dto/upsert-asset-geometry.dto';
import { AssetHistoryService } from './asset-history.service';
export interface AssetGeometryView {
assetId: string;
geometry: GeoJsonGeometry;
geometryType: AssetGeometryType;
source: AssetGeometrySource;
accuracyM: number | null;
capturedAt: Date | null;
deviceLabel: string | null;
createdAt: Date;
updatedAt: Date;
updatedBy: string | null;
}
interface MapAssetRow {
id: string;
geometry: GeoJsonGeometry;
code: string;
name: string;
typeId: string;
typeCode: string;
typeName: string;
parentId: string | null;
parentName: string | null;
informationStatus: string;
geometryType: AssetGeometryType;
accuracyM: number | string | null;
capturedAt: Date | null;
updatedAt: Date;
}
function assetNotFound(): NotFoundException {
return new NotFoundException({
code: 'ASSET_NOT_FOUND',
message: 'Activo no encontrado',
});
}
@Injectable()
export class AssetGeometriesService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly history: AssetHistoryService,
) {}
async get(assetId: string): Promise<{ data: AssetGeometryView | null }> {
return this.dataSource.transaction(async (manager) => {
await this.requireAsset(manager, assetId);
return { data: await this.loadGeometry(manager, assetId) };
});
}
async upsert(
assetId: string,
dto: UpsertAssetGeometryDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AssetGeometryView> {
const geometry = validateGeoJsonGeometry(dto.geometry);
return this.dataSource.transaction(async (manager) => {
await this.requireAsset(manager, assetId, true);
const before = await this.loadGeometry(manager, assetId);
const source = principal.transport === 'bearer'
? AssetGeometrySource.ANDROID
: AssetGeometrySource.WEB;
await manager.query(
`INSERT INTO asset_geometries (
asset_id, geometry, geometry_type, source, accuracy_m,
captured_at, device_label, updated_by
) VALUES (
$1,
ST_SetSRID(ST_GeomFromGeoJSON($2::text), 4326),
$3, $4, $5, $6, $7, $8
)
ON CONFLICT (asset_id) DO UPDATE SET
geometry = EXCLUDED.geometry,
geometry_type = EXCLUDED.geometry_type,
source = EXCLUDED.source,
accuracy_m = EXCLUDED.accuracy_m,
captured_at = EXCLUDED.captured_at,
device_label = EXCLUDED.device_label,
updated_at = CURRENT_TIMESTAMP,
updated_by = EXCLUDED.updated_by`,
[
assetId,
JSON.stringify(geometry),
geometry.type,
source,
dto.accuracyM ?? null,
dto.capturedAt ? new Date(dto.capturedAt) : null,
dto.deviceLabel?.trim() || null,
principal.userId,
],
);
const updated = await this.loadGeometry(manager, assetId);
if (!updated) throw new Error('Asset geometry was not persisted');
const versionNumber = await this.history.capture(
manager,
assetId,
AssetVersionChangeType.GEOMETRY_UPDATED,
principal,
request,
);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_GEOMETRY_UPDATED,
entityType: 'asset',
entityId: assetId,
beforeData: before ? this.auditGeometry(before) : null,
afterData: this.auditGeometry(updated),
metadata: { versionNumber },
},
manager,
);
return updated;
});
}
async remove(
assetId: string,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<{ status: 'removed' | 'absent' }> {
return this.dataSource.transaction(async (manager) => {
await this.requireAsset(manager, assetId, true);
const before = await this.loadGeometry(manager, assetId);
if (!before) return { status: 'absent' };
await manager.query('DELETE FROM asset_geometries WHERE asset_id = $1', [assetId]);
const versionNumber = await this.history.capture(
manager,
assetId,
AssetVersionChangeType.GEOMETRY_REMOVED,
principal,
request,
);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_GEOMETRY_REMOVED,
entityType: 'asset',
entityId: assetId,
beforeData: this.auditGeometry(before),
afterData: { geometry: null },
metadata: { versionNumber },
},
manager,
);
return { status: 'removed' };
});
}
async map(query: MapAssetsQueryDto) {
const conditions: string[] = [];
const parameters: unknown[] = [];
const add = (value: unknown): string => {
parameters.push(value);
return `$${parameters.length}`;
};
const bbox = parseBoundingBox(query.bbox);
if (bbox) {
const placeholders = bbox.map((value) => add(value));
conditions.push(
`ST_Intersects(geometry.geometry, ST_MakeEnvelope(${placeholders.join(', ')}, 4326))`,
);
}
if (query.typeId) conditions.push(`asset.asset_type_id = ${add(query.typeId)}`);
if (query.status) conditions.push(`asset.information_status = ${add(query.status)}`);
if (query.geometryType) conditions.push(`geometry.geometry_type = ${add(query.geometryType)}`);
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const rows = (await this.dataSource.query(
`SELECT
asset.id,
ST_AsGeoJSON(geometry.geometry)::jsonb AS geometry,
asset.code,
asset.name,
asset_type.id AS "typeId",
asset_type.code AS "typeCode",
asset_type.name AS "typeName",
parent.id AS "parentId",
parent.name AS "parentName",
asset.information_status AS "informationStatus",
geometry.geometry_type AS "geometryType",
geometry.accuracy_m AS "accuracyM",
geometry.captured_at AS "capturedAt",
geometry.updated_at AS "updatedAt"
FROM asset_geometries geometry
INNER JOIN assets asset ON asset.id = geometry.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets parent ON parent.id = asset.parent_id
${where}
ORDER BY asset.name, asset.code
LIMIT 5001`,
parameters,
)) as MapAssetRow[];
const truncated = rows.length > 5000;
const visible = truncated ? rows.slice(0, 5000) : rows;
return {
type: 'FeatureCollection' as const,
features: visible.map((row) => ({
type: 'Feature' as const,
id: row.id,
geometry: row.geometry,
properties: {
id: row.id,
code: row.code,
name: row.name,
typeId: row.typeId,
typeCode: row.typeCode,
typeName: row.typeName,
parentId: row.parentId,
parentName: row.parentName,
informationStatus: row.informationStatus,
geometryType: row.geometryType,
accuracyM: row.accuracyM == null ? null : Number(row.accuracyM),
capturedAt: row.capturedAt,
updatedAt: row.updatedAt,
},
})),
meta: { count: visible.length, truncated },
};
}
private async requireAsset(
manager: EntityManager,
id: string,
lock = false,
): Promise<void> {
if (lock) {
const [row] = (await manager.query(
'SELECT 1 FROM assets WHERE id = $1 FOR UPDATE',
[id],
)) as unknown[];
if (!row) throw assetNotFound();
return;
}
const exists = await manager.getRepository(Asset).exist({ where: { id } });
if (!exists) throw assetNotFound();
}
private async loadGeometry(
manager: EntityManager,
assetId: string,
): Promise<AssetGeometryView | null> {
const [row] = (await manager.query(
`SELECT
asset_id AS "assetId",
ST_AsGeoJSON(geometry)::jsonb AS geometry,
geometry_type AS "geometryType",
source,
accuracy_m::double precision AS "accuracyM",
captured_at AS "capturedAt",
device_label AS "deviceLabel",
created_at AS "createdAt",
updated_at AS "updatedAt",
updated_by AS "updatedBy"
FROM asset_geometries
WHERE asset_id = $1`,
[assetId],
)) as AssetGeometryView[];
return row ?? null;
}
private auditGeometry(view: AssetGeometryView): Record<string, unknown> {
return {
geometry: view.geometry,
geometryType: view.geometryType,
source: view.source,
accuracyM: view.accuracyM,
capturedAt: view.capturedAt,
deviceLabel: view.deviceLabel,
};
}
}
@@ -1,93 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import { AssetGeometryType } from '../database/entities';
export interface GeoJsonGeometry {
type: AssetGeometryType;
coordinates: unknown[];
}
type Position = [number, number];
function invalid(message: string): never {
throw new BadRequestException({
code: 'INVALID_ASSET_GEOMETRY',
message,
});
}
function position(value: unknown, label: string): Position {
if (!Array.isArray(value) || value.length < 2) {
return invalid(`${label} debe contener longitud y latitud`);
}
const longitude = Number(value[0]);
const latitude = Number(value[1]);
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
return invalid(`${label} contiene coordenadas no numéricas`);
}
if (longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) {
return invalid(`${label} está fuera del rango geográfico válido`);
}
return [longitude, latitude];
}
function positions(value: unknown, minimum: number, label: string): Position[] {
if (!Array.isArray(value) || value.length < minimum) {
return invalid(`${label} necesita al menos ${minimum} vértices`);
}
if (value.length > 10_000) return invalid(`${label} supera el máximo de 10000 vértices`);
return value.map((item, index) => position(item, `${label} · vértice ${index + 1}`));
}
function samePosition(first: Position, last: Position): boolean {
return first[0] === last[0] && first[1] === last[1];
}
export function validateGeoJsonGeometry(input: GeoJsonGeometry): GeoJsonGeometry {
if (!Object.values(AssetGeometryType).includes(input.type)) {
return invalid('El tipo de geometría no está permitido');
}
if (input.type === AssetGeometryType.POINT) {
return { type: input.type, coordinates: position(input.coordinates, 'El punto') };
}
if (input.type === AssetGeometryType.LINESTRING) {
const line = positions(input.coordinates, 2, 'La línea');
if (new Set(line.map((item) => item.join(','))).size < 2) {
return invalid('La línea necesita al menos dos posiciones diferentes');
}
return { type: input.type, coordinates: line };
}
if (!Array.isArray(input.coordinates) || input.coordinates.length < 1) {
return invalid('El polígono necesita al menos un anillo');
}
if (input.coordinates.length > 20) return invalid('El polígono supera el máximo de 20 anillos');
const rings = input.coordinates.map((ring, ringIndex) => {
const normalized = positions(ring, 4, `Anillo ${ringIndex + 1}`);
if (!samePosition(normalized[0]!, normalized[normalized.length - 1]!)) {
return invalid(`El anillo ${ringIndex + 1} debe estar cerrado`);
}
if (new Set(normalized.slice(0, -1).map((item) => item.join(','))).size < 3) {
return invalid(`El anillo ${ringIndex + 1} necesita tres posiciones diferentes`);
}
return normalized;
});
return { type: input.type, coordinates: rings };
}
export function parseBoundingBox(value?: string): [number, number, number, number] | null {
if (!value) return null;
const numbers = value.split(',').map(Number);
if (numbers.length !== 4 || numbers.some((item) => !Number.isFinite(item))) {
return invalid('El área visible del mapa no es válida');
}
const [west, south, east, north] = numbers as [number, number, number, number];
if (
west < -180 || east > 180 || south < -90 || north > 90 ||
west >= east || south >= north
) {
return invalid('El área visible del mapa está fuera de rango');
}
return [west, south, east, north];
}
@@ -1,43 +0,0 @@
import {
Controller,
Get,
Param,
ParseIntPipe,
ParseUUIDPipe,
Query,
} from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { AssetHistoryService } from './asset-history.service';
import {
AssetVersionPageQueryDto,
ListAssetVersionsQueryDto,
} from './dto/list-asset-versions-query.dto';
@Controller()
export class AssetHistoryController {
constructor(private readonly history: AssetHistoryService) {}
@Get('asset-versions')
@RequirePermissions('assets.read_history')
list(@Query() query: ListAssetVersionsQueryDto) {
return this.history.list(query);
}
@Get('assets/:id/versions')
@RequirePermissions('assets.read_history')
listForAsset(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Query() query: AssetVersionPageQueryDto,
) {
return this.history.listForAsset(id, query);
}
@Get('assets/:id/versions/:versionNumber')
@RequirePermissions('assets.read_history')
getVersion(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Param('versionNumber', new ParseIntPipe()) versionNumber: number,
) {
return this.history.getVersion(id, versionNumber);
}
}
@@ -1,369 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import type {
AuthPrincipal,
RequestWithContext,
} from '../common/http/request-context';
import {
AssetVersionChangeType,
AuditSource,
} from '../database/entities';
import { changedSnapshotFields } from './asset-version-diff';
import type {
AssetVersionPageQueryDto,
ListAssetVersionsQueryDto,
} from './dto/list-asset-versions-query.dto';
export interface AssetVersionSummary {
id: string;
assetId: string;
assetCode: string;
assetName: string;
typeId: string;
typeName: string;
informationStatus: string;
operationalStatus: string;
versionNumber: number;
changeType: AssetVersionChangeType;
changedFields: string[];
occurredAt: Date;
actorUserId: string | null;
actorUsername: string | null;
source: AuditSource;
requestId: string | null;
isCurrent: boolean;
}
export interface AssetVersionDetail extends AssetVersionSummary {
snapshot: Record<string, unknown>;
}
function assetNotFound(): NotFoundException {
return new NotFoundException({
code: 'ASSET_NOT_FOUND',
message: 'Activo no encontrado',
});
}
function versionNotFound(): NotFoundException {
return new NotFoundException({
code: 'ASSET_VERSION_NOT_FOUND',
message: 'Versión de activo no encontrada',
});
}
@Injectable()
export class AssetHistoryService {
constructor(private readonly dataSource: DataSource) {}
async capture(
manager: EntityManager,
assetId: string,
changeType: AssetVersionChangeType,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<number> {
await manager.query(
`UPDATE assets
SET current_version = COALESCE(current_version, 0) + 1
WHERE id = $1`,
[assetId],
);
const [versionRow] = (await manager.query(
`SELECT current_version
FROM assets
WHERE id = $1`,
[assetId],
)) as Array<{ current_version: number | string | null }>;
if (!versionRow) throw assetNotFound();
const versionNumber = Number(versionRow.current_version);
if (!Number.isInteger(versionNumber) || versionNumber < 1) {
throw new Error(`Versión de activo inválida después de incrementar: ${versionRow.current_version}`);
}
const snapshot = await this.loadCurrentSnapshot(manager, assetId);
const [previousRow] = (await manager.query(
`SELECT snapshot
FROM asset_versions
WHERE asset_id = $1 AND version_number < $2
ORDER BY version_number DESC
LIMIT 1`,
[assetId, versionNumber],
)) as Array<{ snapshot: Record<string, unknown> }>;
const changedFields = changedSnapshotFields(previousRow?.snapshot ?? null, snapshot);
const source = principal.transport === 'bearer'
? AuditSource.ANDROID
: AuditSource.WEB;
await manager.query(
`INSERT INTO asset_versions (
asset_id, version_number, change_type, changed_fields, snapshot,
actor_user_id, actor_username, source, request_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
assetId,
versionNumber,
changeType,
changedFields,
snapshot,
principal.userId,
principal.username,
source,
request.requestId,
],
);
return versionNumber;
}
async list(query: ListAssetVersionsQueryDto) {
const conditions: string[] = [];
const parameters: unknown[] = [];
const add = (value: unknown): string => {
parameters.push(value);
return `$${parameters.length}`;
};
if (query.search?.trim()) {
const search = add(`%${query.search.trim()}%`);
conditions.push(`(
version.snapshot->>'code' ILIKE ${search}
OR version.snapshot->>'name' ILIKE ${search}
OR version.actor_username ILIKE ${search}
)`);
}
if (query.typeId) {
conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`);
}
if (query.status) {
conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`);
}
if (query.changeType) {
conditions.push(`version.change_type = ${add(query.changeType)}`);
}
if (query.from) conditions.push(`version.occurred_at >= ${add(new Date(query.from))}`);
if (query.to) conditions.push(`version.occurred_at <= ${add(new Date(query.to))}`);
return this.listWithConditions(query.page, query.pageSize, conditions, parameters);
}
async listForAsset(assetId: string, query: AssetVersionPageQueryDto) {
await this.requireAsset(assetId);
return this.listWithConditions(
query.page,
query.pageSize,
['version.asset_id = $1'],
[assetId],
);
}
async getVersion(assetId: string, versionNumber: number): Promise<AssetVersionDetail> {
await this.requireAsset(assetId);
const [row] = (await this.dataSource.query(
`${this.selectSummary()}, version.snapshot
FROM asset_versions version
INNER JOIN assets current_asset ON current_asset.id = version.asset_id
WHERE version.asset_id = $1 AND version.version_number = $2`,
[assetId, versionNumber],
)) as AssetVersionDetail[];
if (!row) throw versionNotFound();
return row;
}
private async listWithConditions(
page: number,
pageSize: number,
conditions: string[],
parameters: unknown[],
) {
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const [countRow] = (await this.dataSource.query(
`SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`,
parameters,
)) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0);
const paginated = [...parameters, pageSize, (page - 1) * pageSize];
const limit = `$${parameters.length + 1}`;
const offset = `$${parameters.length + 2}`;
const data = (await this.dataSource.query(
`${this.selectSummary()}
FROM asset_versions version
INNER JOIN assets current_asset ON current_asset.id = version.asset_id
${where}
ORDER BY version.occurred_at DESC, version.version_number DESC
LIMIT ${limit} OFFSET ${offset}`,
paginated,
)) as AssetVersionSummary[];
return {
data,
meta: {
page,
pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / pageSize),
},
};
}
private selectSummary(): string {
return `SELECT
version.id,
version.asset_id AS "assetId",
version.snapshot->>'code' AS "assetCode",
version.snapshot->>'name' AS "assetName",
version.snapshot #>> '{type,id}' AS "typeId",
version.snapshot #>> '{type,name}' AS "typeName",
version.snapshot->>'informationStatus' AS "informationStatus",
version.snapshot->>'operationalStatus' AS "operationalStatus",
version.version_number AS "versionNumber",
version.change_type AS "changeType",
version.changed_fields AS "changedFields",
version.occurred_at AS "occurredAt",
version.actor_user_id AS "actorUserId",
version.actor_username AS "actorUsername",
version.source,
version.request_id AS "requestId",
(version.version_number = current_asset.current_version) AS "isCurrent"`;
}
private async requireAsset(assetId: string): Promise<void> {
const [row] = (await this.dataSource.query(
'SELECT 1 FROM assets WHERE id = $1',
[assetId],
)) as unknown[];
if (!row) throw assetNotFound();
}
private async loadCurrentSnapshot(
manager: EntityManager,
assetId: string,
): Promise<Record<string, unknown>> {
const [row] = (await manager.query(
`SELECT JSONB_BUILD_OBJECT(
'id', asset.id,
'code', asset.code,
'name', asset.name,
'commonName', asset.common_name,
'description', asset.description,
'type', JSONB_BUILD_OBJECT(
'id', asset_type.id,
'code', asset_type.code,
'name', asset_type.name,
'operationalRole', asset_type.operational_role
),
'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', parent.id,
'code', parent.code,
'name', parent.name
) END,
'operationalArea', CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', operational_area.id,
'code', operational_area.code,
'name', operational_area.name
) END,
'operatorCompany', CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', operator_company.id,
'code', operator_company.code,
'name', operator_company.name
) END,
'informationStatus', asset.information_status,
'operationalStatus', asset.operational_status,
'attributes', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'definitionId', definition.id,
'code', definition.code,
'name', definition.name,
'dataType', definition.data_type,
'isRequired', definition.is_required,
'unit', definition.unit,
'options', definition.options,
'sortOrder', definition.sort_order,
'value', value.value
) ORDER BY definition.sort_order, definition.name)
FROM asset_attribute_definitions definition
LEFT JOIN asset_attribute_values value
ON value.definition_id = definition.id
AND value.asset_id = asset.id
WHERE definition.asset_type_id = asset.asset_type_id
AND definition.is_active = true
), '[]'::jsonb),
'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'assetId', geometry.asset_id,
'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb,
'geometryType', geometry.geometry_type,
'source', geometry.source,
'accuracyM', geometry.accuracy_m::double precision,
'capturedAt', geometry.captured_at,
'deviceLabel', geometry.device_label,
'createdAt', geometry.created_at,
'updatedAt', geometry.updated_at,
'updatedBy', geometry.updated_by
) END,
'media', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'id', media.id,
'kind', media.kind,
'originalName', media.original_name,
'mimeType', media.mime_type,
'sizeBytes', media.size_bytes,
'sha256', media.sha256,
'title', media.title,
'description', media.description,
'capturedAt', media.captured_at,
'latitude', media.latitude,
'longitude', media.longitude,
'accuracyM', media.accuracy_m,
'source', media.source,
'uploadedBy', media.uploaded_by,
'createdAt', media.created_at,
'updatedAt', media.updated_at
) ORDER BY media.created_at, media.id)
FROM asset_media media
WHERE media.asset_id = asset.id
AND media.deleted_at IS NULL
), '[]'::jsonb),
'organizationProfile', (SELECT TO_JSONB(profile) - 'created_at' - 'updated_at' FROM organization_profiles profile WHERE profile.asset_id=asset.id),
'organizationMemberships', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',m.id,'parentOrganizationId',m.parent_organization_id,'memberOrganizationId',m.member_organization_id,'role',m.role,'participationPercent',m.participation_percent::double precision,'validFrom',m.valid_from,'validUntil',m.valid_until,'sourceDocumentId',m.source_document_id,'notes',m.notes,'endReason',m.end_reason) ORDER BY m.valid_until NULLS FIRST,m.valid_from DESC)
FROM organization_memberships m WHERE m.parent_organization_id=asset.id OR m.member_organization_id=asset.id
),'[]'::jsonb),
'externalIdentifiers', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',i.id,'namespace',i.namespace,'value',i.value,'validFrom',i.valid_from,'validUntil',i.valid_until,'sourceDocumentId',i.source_document_id,'notes',i.notes,'endReason',i.end_reason) ORDER BY i.valid_until NULLS FIRST,i.namespace,i.valid_from DESC) FROM asset_external_identifiers i WHERE i.asset_id=asset.id
),'[]'::jsonb),
'sourceDocuments', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('linkId',l.id,'relationType',l.relation_type,'documentId',d.id,'documentType',d.document_type,'documentNumber',d.document_number,'title',d.title,'issuer',d.issuer,'documentDate',d.document_date,'externalReference',d.external_reference) ORDER BY d.document_date DESC NULLS LAST,d.created_at DESC) FROM asset_source_documents l JOIN source_documents d ON d.id=l.document_id WHERE l.asset_id=asset.id
),'[]'::jsonb),
'legalRights', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',r.id,'rightType',r.right_type,'name',r.name,'instrumentNumber',r.instrument_number,'validFrom',r.valid_from,'validUntil',r.valid_until,'status',r.status,'sourceDocumentId',r.source_document_id,'notes',r.notes,'organizations',COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',o.id,'organizationId',o.organization_id,'role',o.role,'participationPercent',o.participation_percent::double precision,'validFrom',o.valid_from,'validUntil',o.valid_until,'notes',o.notes,'endReason',o.end_reason) ORDER BY o.valid_until NULLS FIRST,o.valid_from DESC) FROM area_legal_right_organizations o WHERE o.right_id=r.id),'[]'::jsonb)) ORDER BY r.valid_until DESC NULLS FIRST,r.valid_from DESC NULLS LAST) FROM area_legal_rights r WHERE r.area_id=asset.id
),'[]'::jsonb),
'provenance', JSONB_BUILD_OBJECT(
'origin', asset.data_origin,
'sourceName', asset.source_name,
'sourceReference', asset.source_reference,
'observedAt', asset.source_observed_at,
'notes', asset.source_notes,
'verifiedAt', asset.provenance_verified_at,
'verifiedBy', asset.provenance_verified_by,
'updatedAt', asset.provenance_updated_at,
'updatedBy', asset.provenance_updated_by
),
'createdAt', asset.created_at,
'updatedAt', asset.updated_at,
'createdBy', asset.created_by,
'updatedBy', asset.updated_by,
'currentVersion', asset.current_version
) AS snapshot
FROM assets asset
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets parent ON parent.id = asset.parent_id
LEFT JOIN assets operational_area ON operational_area.id = asset.operational_area_id
LEFT JOIN assets operator_company ON operator_company.id = asset.operator_company_id
LEFT JOIN asset_geometries geometry ON geometry.asset_id = asset.id
WHERE asset.id = $1`,
[assetId],
)) as Array<{ snapshot: Record<string, unknown> }>;
if (!row) throw assetNotFound();
return row.snapshot;
}
}
@@ -1,405 +0,0 @@
import {
AssetAttributeDataType,
AssetTypeOperationalRole,
} from '../database/entities';
export interface MasterBootstrapAttributePreset {
code: string;
name: string;
dataType: AssetAttributeDataType;
isRequired: boolean;
unit: string | null;
options: string[] | null;
sortOrder: number;
}
export interface MasterBootstrapTypePreset {
code: string;
name: string;
description: string;
canBeRoot: boolean;
operationalRole: AssetTypeOperationalRole;
allowedParentCodes: string[];
attributes: MasterBootstrapAttributePreset[];
}
const text = (code: string, name: string, sortOrder: number, unit: string | null = null): MasterBootstrapAttributePreset => ({
code,
name,
dataType: AssetAttributeDataType.TEXT,
isRequired: false,
unit,
options: null,
sortOrder,
});
const number = (code: string, name: string, sortOrder: number, unit: string | null = null): MasterBootstrapAttributePreset => ({
code,
name,
dataType: AssetAttributeDataType.NUMBER,
isRequired: false,
unit,
options: null,
sortOrder,
});
const date = (code: string, name: string, sortOrder: number): MasterBootstrapAttributePreset => ({
code,
name,
dataType: AssetAttributeDataType.DATE,
isRequired: false,
unit: null,
options: null,
sortOrder,
});
const select = (code: string, name: string, options: string[], sortOrder: number): MasterBootstrapAttributePreset => ({
code,
name,
dataType: AssetAttributeDataType.SELECT,
isRequired: false,
unit: null,
options,
sortOrder,
});
export const MASTER_BOOTSTRAP_PRESET_CODE = 'mendoza-hidrocarburos-v2';
export const MASTER_BOOTSTRAP_PRESET_NAME = 'Hidrocarburos · Mendoza';
export const MASTER_BOOTSTRAP_CORE_CODES = [
'area',
'empresa',
'yacimiento',
'instalacion',
'estacion',
'subestacion',
'pozo',
'equipo',
'ducto',
] as const;
export const MASTER_BOOTSTRAP_TYPES: MasterBootstrapTypePreset[] = [
{
code: 'area',
name: 'Área',
description: 'Área hidrocarburífera administrada como ancla territorial. Concesiones, permisos y titulares se registran por separado en la capa legal.',
canBeRoot: true,
operationalRole: AssetTypeOperationalRole.AREA,
allowedParentCodes: [],
attributes: [
text('identificacion_oficial', 'Identificación oficial', 10),
text('cuenca', 'Cuenca', 20),
text('departamento', 'Departamento', 30),
],
},
{
code: 'empresa',
name: 'Organización',
description: 'Entidad jurídica u organización administrada (empresa, UTE u otra figura). Su rol como operadora, titular o participante se registra mediante relaciones históricas.',
canBeRoot: true,
operationalRole: AssetTypeOperationalRole.COMPANY,
allowedParentCodes: [],
attributes: [
text('cuit', 'CUIT', 10),
text('razon_social', 'Razón social', 20),
],
},
{
code: 'yacimiento',
name: 'Yacimiento',
description: 'Unidad territorial u operativa dentro de un Área. Es opcional porque la documentación también utiliza el campo combinado Área/Yacimiento.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['area'],
attributes: [text('identificacion_oficial', 'Identificación oficial', 10)],
},
{
code: 'estructura_local',
name: 'Estructura local (fuente)',
description: 'Nodo estructural provisional que conserva la nomenclatura propia de cada operadora o Área/Yacimiento. No implica una clasificación física normalizada por DH hasta su revisión.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['area', 'yacimiento', 'locacion'],
attributes: [
text('nivel_fuente', 'Nivel informado por la fuente', 10),
text('clasificacion_fuente', 'Clasificación local informada', 20),
text('ruta_fuente', 'Ruta / nomenclatura de origen', 30),
],
},
{
code: 'locacion',
name: 'Locación',
description: 'Sitio físico dentro de un área o yacimiento que puede agrupar pozos, instalaciones o equipos.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['area', 'yacimiento'],
attributes: [],
},
{
code: 'instalacion',
name: 'Instalación de superficie',
description: 'Tipo genérico de instalación de proceso, tratamiento, almacenamiento o apoyo. Se conserva como alternativa cuando no exista un tipo técnico más específico.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['area', 'yacimiento', 'locacion'],
attributes: [text('tipo_instalacion', 'Tipo de instalación', 10)],
},
{
code: 'planta',
name: 'Planta',
description: 'Instalación de superficie administrada como unidad de proceso, tratamiento, entrega o almacenamiento.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['area', 'yacimiento', 'locacion'],
attributes: [text('funcion_planta', 'Función / denominación operativa', 10)],
},
{
code: 'bateria',
name: 'Batería',
description: 'Batería hidrocarburífera administrada como instalación y contenedor de sistemas/equipos.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['area', 'yacimiento', 'locacion'],
attributes: [],
},
{
code: 'estacion',
name: 'Estación',
description: 'Estación operativa perteneciente a un área, yacimiento, locación o instalación.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria'],
attributes: [],
},
{
code: 'subestacion',
name: 'Subestación',
description: 'Subestación o unidad subordinada dentro de una instalación o estación.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion'],
attributes: [],
},
{
code: 'zona_bombas',
name: 'Zona de bombas',
description: 'Sector o conjunto físico donde se agrupan bombas. Se distingue de cada equipo Bomba individual.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
attributes: [],
},
{
code: 'sistema_drenaje',
name: 'Sistema de drenaje',
description: 'Sistema de drenaje de una instalación. Puede contener piletas u otros elementos que requieran identidad propia.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
attributes: [],
},
{
code: 'sistema_electrico_iluminacion',
name: 'Sistema eléctrico / iluminación',
description: 'Sistema eléctrico y de iluminación administrable cuando requiere historial e inspección propios.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
attributes: [],
},
{
code: 'sistema_defensa_incendios',
name: 'Defensa contra incendios',
description: 'Sistema de defensa contra incendios, incluyendo red y equipos asociados cuando corresponda.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
attributes: [],
},
{
code: 'cargadero_descargadero',
name: 'Cargadero / descargadero de camiones',
description: 'Instalación utilizada para carga o descarga de camiones, inspeccionable como unidad.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion'],
attributes: [],
},
{
code: 'pileta_api',
name: 'Pileta API',
description: 'Pileta API identificable dentro de una instalación o sistema de drenaje.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['sistema_drenaje', 'instalacion', 'planta', 'bateria', 'estacion'],
attributes: [],
},
{
code: 'pozo',
name: 'Pozo',
description: 'Pozo hidrocarburífero. El método o función se administra como atributo para no duplicar el Maestro en varios tipos de pozo.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['area', 'yacimiento', 'locacion'],
attributes: [
text('identificacion_oficial', 'Identificación oficial', 10),
text('tipo_pozo', 'Tipo de pozo informado', 20),
select('metodo_extraccion', 'Método / función operativa', [
'Bombeo mecánico',
'Bombeo electrosumergible',
'Bombeo de cavidad progresiva (PCP)',
'Surgente / productor de gas',
'Inyector de agua',
'Otro / a validar',
], 30),
number('profundidad', 'Profundidad', 40, 'm'),
text('estado_operativo', 'Estado operativo informado', 50),
date('ultima_intervencion', 'Última intervención informada', 60),
],
},
{
code: 'equipo',
name: 'Equipo',
description: 'Equipo físico genérico. Se conserva como alternativa cuando el inventario no permita clasificarlo todavía en una familia técnica específica.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion', 'subestacion', 'pozo', 'zona_bombas', 'sistema_defensa_incendios', 'cargadero_descargadero'],
attributes: [
text('fabricante', 'Fabricante', 10),
text('modelo', 'Modelo', 20),
text('numero_serie', 'Número de serie', 30),
],
},
{
code: 'tanque',
name: 'Tanque',
description: 'Tanque de almacenamiento o proceso administrado como equipo individual.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
attributes: [
number('capacidad_nominal', 'Capacidad nominal', 10, 'm³'),
text('producto_servicio', 'Producto / servicio', 20),
text('fabricante', 'Fabricante', 30),
text('modelo', 'Modelo', 40),
text('numero_serie', 'Número de serie', 50),
],
},
{
code: 'separador',
name: 'Separador',
description: 'Separador de proceso administrado como equipo individual.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
attributes: [],
},
{
code: 'bomba',
name: 'Bomba',
description: 'Bomba individual. Puede pertenecer a una zona de bombas, sistema contra incendios, pozo u otra instalación.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion', 'zona_bombas', 'sistema_defensa_incendios', 'cargadero_descargadero', 'pozo'],
attributes: [
text('fabricante', 'Fabricante', 10),
text('modelo', 'Modelo', 20),
text('numero_serie', 'Número de serie', 30),
],
},
{
code: 'caldera',
name: 'Caldera',
description: 'Caldera administrada como equipo individual con controles documentales y operativos propios.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
attributes: [],
},
{
code: 'antorcha',
name: 'Antorcha',
description: 'Antorcha administrada como equipo o instalación individual.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
attributes: [],
},
{
code: 'colector',
name: 'Colector',
description: 'Colector administrado como activo individual cuando requiere trazabilidad propia.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion'],
attributes: [],
},
{
code: 'filtro',
name: 'Filtro',
description: 'Filtro administrado como equipo individual.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
attributes: [],
},
{
code: 'equipo_flotacion',
name: 'Equipo de flotación',
description: 'Equipo de flotación administrado como activo individual.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
attributes: [],
},
{
code: 'fwko',
name: 'FWKO',
description: 'Free Water Knock Out administrado como equipo individual.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
attributes: [],
},
{
code: 'tratador',
name: 'Tratador',
description: 'Tratador administrado como equipo individual.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
attributes: [],
},
{
code: 'calentador',
name: 'Calentador',
description: 'Calentador administrado como equipo individual.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
attributes: [],
},
{
code: 'ducto',
name: 'Ducto / Cañería',
description: 'Tramo de ducto o cañería administrable e inspeccionable dentro de un área, yacimiento, locación o instalación.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion'],
attributes: [
text('servicio', 'Servicio / fluido', 10),
text('diametro_nominal', 'Diámetro nominal', 20),
],
},
];
for (const preset of MASTER_BOOTSTRAP_TYPES) {
if (
preset.operationalRole === AssetTypeOperationalRole.GENERIC
&& !['yacimiento', 'locacion', 'estructura_local', 'pozo', 'ducto', 'colector'].includes(preset.code)
&& !preset.allowedParentCodes.includes('estructura_local')
) {
preset.allowedParentCodes.push('estructura_local');
}
}
@@ -1,54 +0,0 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { AssetTypesController } from './asset-types.controller';
import { AssetTypesService } from './asset-types.service';
import { AssetsController } from './assets.controller';
import { AssetsService } from './assets.service';
import {
AssetGeometriesController,
MapAssetsController,
} from './asset-geometries.controller';
import { AssetGeometriesService } from './asset-geometries.service';
import { AssetHistoryController } from './asset-history.controller';
import { AssetHistoryService } from './asset-history.service';
import { AssetMediaController } from './asset-media.controller';
import { AssetMediaService } from './asset-media.service';
import { AssetProvenanceController } from './asset-provenance.controller';
import { AssetProvenanceService } from './asset-provenance.service';
import { AssetTemporalController } from './asset-temporal.controller';
import { AssetTemporalService } from './asset-temporal.service';
import { AssetOperationalRelationsController } from './asset-operational-relations.controller';
import { AssetOperationalRelationsService } from './asset-operational-relations.service';
import { AssetRegistryController } from './asset-registry.controller';
import { AssetRegistryService } from './asset-registry.service';
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
@Module({
imports: [AuditModule],
controllers: [
AssetTypesController,
AssetsController,
AssetGeometriesController,
MapAssetsController,
AssetHistoryController,
AssetMediaController,
AssetProvenanceController,
AssetTemporalController,
AssetOperationalRelationsController,
AssetRegistryController,
],
providers: [
AssetTypesService,
AssetsService,
AssetGeometriesService,
AssetHistoryService,
AssetMediaService,
AssetProvenanceService,
AssetTemporalService,
AssetOperationalRelationsService,
AssetRegistryService,
FieldDiscoveryInspectionLinkService,
],
exports: [AssetHistoryService],
})
export class AssetMasterModule {}
@@ -1,72 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import { AssetMediaKind } from '../database/entities';
export const MAX_ASSET_MEDIA_BYTES = 15 * 1024 * 1024;
export interface UploadedAssetFile {
buffer: Buffer;
originalname: string;
mimetype?: string;
size: number;
}
export interface InspectedAssetFile {
originalName: string;
mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'application/pdf';
extension: '.jpg' | '.png' | '.webp' | '.pdf';
}
function invalidFile(message: string): BadRequestException {
return new BadRequestException({ code: 'INVALID_ASSET_FILE', message });
}
function detectedType(buffer: Buffer): Omit<InspectedAssetFile, 'originalName'> | null {
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return { mimeType: 'image/jpeg', extension: '.jpg' };
}
if (
buffer.length >= 8
&& buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
) {
return { mimeType: 'image/png', extension: '.png' };
}
if (
buffer.length >= 12
&& buffer.subarray(0, 4).toString('ascii') === 'RIFF'
&& buffer.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return { mimeType: 'image/webp', extension: '.webp' };
}
if (buffer.length >= 5 && buffer.subarray(0, 5).toString('ascii') === '%PDF-') {
return { mimeType: 'application/pdf', extension: '.pdf' };
}
return null;
}
export function inspectAssetFile(
file: UploadedAssetFile | undefined,
kind: AssetMediaKind,
): InspectedAssetFile {
if (!file?.buffer || file.size <= 0 || file.buffer.length <= 0) {
throw invalidFile('Debe seleccionar un archivo no vacío');
}
if (file.size > MAX_ASSET_MEDIA_BYTES || file.buffer.length > MAX_ASSET_MEDIA_BYTES) {
throw invalidFile('El archivo supera el máximo permitido de 15 MB');
}
const detected = detectedType(file.buffer);
if (!detected) {
throw invalidFile('Sólo se permiten JPG, PNG, WebP y PDF válidos');
}
if (kind === AssetMediaKind.PHOTO && !detected.mimeType.startsWith('image/')) {
throw invalidFile('Una fotografía debe ser JPG, PNG o WebP');
}
if (kind === AssetMediaKind.DOCUMENT && detected.mimeType !== 'application/pdf') {
throw invalidFile('Un documento debe ser un archivo PDF');
}
const originalName = file.originalname
.replace(/[\u0000-\u001f\u007f]/g, '')
.trim()
.slice(0, 255);
if (!originalName) throw invalidFile('El nombre original del archivo no es válido');
return { ...detected, originalName };
}
@@ -1,106 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
Req,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import type {
AuthPrincipal,
RequestWithContext,
} from '../common/http/request-context';
import { AssetMediaKind } from '../database/entities';
import {
MAX_ASSET_MEDIA_BYTES,
type UploadedAssetFile,
} from './asset-media-file';
import { AssetMediaService } from './asset-media.service';
import { CreateAssetMediaDto } from './dto/create-asset-media.dto';
import { UpdateAssetMediaDto } from './dto/update-asset-media.dto';
@Controller()
export class AssetMediaController {
constructor(private readonly media: AssetMediaService) {}
@Get('assets/:assetId/media')
@RequirePermissions('assets.read_media')
list(
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
) {
return this.media.list(assetId);
}
@Post('assets/:assetId/media')
@RequirePermissions('assets.manage_media')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_ASSET_MEDIA_BYTES, files: 1 },
}))
upload(
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
@Body() dto: CreateAssetMediaDto,
@UploadedFile() file: UploadedAssetFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.media.upload(assetId, dto, file, principal, request);
}
@Patch('asset-media/:mediaId')
@RequirePermissions('assets.manage_media')
update(
@Param('mediaId', new ParseUUIDPipe({ version: '4' })) mediaId: string,
@Body() dto: UpdateAssetMediaDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.media.update(mediaId, dto, principal, request);
}
@Delete('asset-media/:mediaId')
@RequirePermissions('assets.manage_media')
remove(
@Param('mediaId', new ParseUUIDPipe({ version: '4' })) mediaId: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.media.remove(mediaId, principal, request);
}
@Get('asset-media/:mediaId/content')
@RequirePermissions('assets.read_media')
async content(
@Param('mediaId', new ParseUUIDPipe({ version: '4' })) mediaId: string,
@Query('download') download: string | undefined,
@Res() response: Response,
): Promise<void> {
const { filePath, media } = await this.media.content(mediaId);
const forceDownload = download === '1' || media.kind === AssetMediaKind.DOCUMENT;
const disposition = forceDownload ? 'attachment' : 'inline';
const fallbackName = media.originalName
.replace(/[^\x20-\x7e]/g, '_')
.replace(/["\\]/g, '_');
response.setHeader('Content-Type', media.mimeType);
response.setHeader('Content-Length', String(media.sizeBytes));
response.setHeader('Content-Disposition', `${disposition}; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(media.originalName)}`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
await new Promise<void>((resolveSend, rejectSend) => {
response.sendFile(filePath, (error) => {
if (error) rejectSend(error);
else resolveSend();
});
});
}
}
@@ -1,402 +0,0 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, stat, unlink, writeFile } from 'node:fs/promises';
import { isAbsolute, parse, resolve } from 'node:path';
import {
BadRequestException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type {
AuthPrincipal,
RequestWithContext,
} from '../common/http/request-context';
import {
AssetMediaKind,
AssetMediaSource,
AssetVersionChangeType,
AuditAction,
} from '../database/entities';
import { AssetHistoryService } from './asset-history.service';
import {
inspectAssetFile,
type UploadedAssetFile,
} from './asset-media-file';
import type { CreateAssetMediaDto } from './dto/create-asset-media.dto';
import type { UpdateAssetMediaDto } from './dto/update-asset-media.dto';
export interface AssetMediaView {
id: string;
assetId: string;
kind: AssetMediaKind;
originalName: string;
mimeType: string;
sizeBytes: number;
sha256: string;
title: string | null;
description: string | null;
capturedAt: Date | null;
latitude: number | null;
longitude: number | null;
accuracyM: number | null;
source: AssetMediaSource;
uploadedBy: string | null;
uploadedByUsername: string | null;
createdAt: Date;
updatedAt: Date;
}
interface StoredAssetMedia extends AssetMediaView {
storedName: string;
}
function assetNotFound(): NotFoundException {
return new NotFoundException({
code: 'ASSET_NOT_FOUND',
message: 'Activo no encontrado',
});
}
function mediaNotFound(): NotFoundException {
return new NotFoundException({
code: 'ASSET_MEDIA_NOT_FOUND',
message: 'Archivo de activo no encontrado',
});
}
function coordinateError(): BadRequestException {
return new BadRequestException({
code: 'INVALID_MEDIA_COORDINATES',
message: 'Latitud y longitud deben informarse juntas',
});
}
@Injectable()
export class AssetMediaService {
private readonly storageRoot: string;
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly history: AssetHistoryService,
config: ConfigService,
) {
const configured = config.get<string>('ASSET_MEDIA_ROOT') ?? '/app/storage/asset-media';
if (!isAbsolute(configured)) {
throw new Error('ASSET_MEDIA_ROOT must be an absolute path');
}
this.storageRoot = resolve(configured);
if (this.storageRoot === parse(this.storageRoot).root) {
throw new Error('ASSET_MEDIA_ROOT cannot be the filesystem root');
}
}
async list(assetId: string): Promise<{ data: AssetMediaView[] }> {
await this.requireAsset(this.dataSource.manager, assetId, false);
const rows = (await this.dataSource.query(
`${this.mediaSelect()}
WHERE media.asset_id = $1 AND media.deleted_at IS NULL
ORDER BY media.created_at DESC`,
[assetId],
)) as StoredAssetMedia[];
return { data: rows.map(({ storedName: _storedName, ...media }) => media) };
}
async upload(
assetId: string,
dto: CreateAssetMediaDto,
file: UploadedAssetFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AssetMediaView> {
this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM);
const inspected = inspectAssetFile(file, dto.kind);
const id = randomUUID();
const storedName = `${id}${inspected.extension}`;
const filePath = resolve(this.storageRoot, storedName);
const source = principal.transport === 'bearer'
? AssetMediaSource.ANDROID
: AssetMediaSource.WEB;
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
await mkdir(this.storageRoot, { recursive: true, mode: 0o700 });
await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 });
try {
return await this.dataSource.transaction(async (manager) => {
await this.requireAsset(manager, assetId, true);
await manager.query(
`INSERT INTO asset_media (
id, asset_id, kind, original_name, stored_name, mime_type,
size_bytes, sha256, title, description, captured_at,
latitude, longitude, accuracy_m, source, uploaded_by
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16
)`,
[
id,
assetId,
dto.kind,
inspected.originalName,
storedName,
inspected.mimeType,
file!.buffer.length,
sha256,
dto.title?.trim() || null,
dto.description?.trim() || null,
dto.capturedAt ? new Date(dto.capturedAt) : null,
dto.latitude ?? null,
dto.longitude ?? null,
dto.accuracyM ?? null,
source,
principal.userId,
],
);
const created = await this.loadActive(manager, id);
const versionNumber = await this.history.capture(
manager,
assetId,
AssetVersionChangeType.MEDIA_UPLOADED,
principal,
request,
);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_MEDIA_UPLOADED,
entityType: 'asset_media',
entityId: id,
afterData: this.auditView(created),
metadata: { assetId, versionNumber },
},
manager,
);
const { storedName: _storedName, ...view } = created;
return view;
});
} catch (error) {
await unlink(filePath).catch(() => undefined);
throw error;
}
}
async update(
mediaId: string,
dto: UpdateAssetMediaDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AssetMediaView> {
if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
}
return this.dataSource.transaction(async (manager) => {
const before = await this.loadActive(manager, mediaId, true);
const latitude = dto.latitude === undefined ? before.latitude : dto.latitude;
const longitude = dto.longitude === undefined ? before.longitude : dto.longitude;
const accuracyM = dto.accuracyM === undefined ? before.accuracyM : dto.accuracyM;
this.validateCoordinates(latitude, longitude, accuracyM);
await manager.query(
`UPDATE asset_media SET
title = $2,
description = $3,
captured_at = $4,
latitude = $5,
longitude = $6,
accuracy_m = $7,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND deleted_at IS NULL`,
[
mediaId,
dto.title === undefined ? before.title : dto.title?.trim() || null,
dto.description === undefined
? before.description
: dto.description?.trim() || null,
dto.capturedAt === undefined
? before.capturedAt
: dto.capturedAt ? new Date(dto.capturedAt) : null,
latitude,
longitude,
accuracyM,
],
);
const updated = await this.loadActive(manager, mediaId);
const versionNumber = await this.history.capture(
manager,
updated.assetId,
AssetVersionChangeType.MEDIA_UPDATED,
principal,
request,
);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_MEDIA_UPDATED,
entityType: 'asset_media',
entityId: mediaId,
beforeData: this.auditView(before),
afterData: this.auditView(updated),
metadata: { assetId: updated.assetId, versionNumber },
},
manager,
);
const { storedName: _storedName, ...view } = updated;
return view;
});
}
async remove(
mediaId: string,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<{ status: 'removed' }> {
return this.dataSource.transaction(async (manager) => {
const before = await this.loadActive(manager, mediaId, true);
await manager.query(
`UPDATE asset_media
SET deleted_at = CURRENT_TIMESTAMP,
deleted_by = $2,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND deleted_at IS NULL`,
[mediaId, principal.userId],
);
const versionNumber = await this.history.capture(
manager,
before.assetId,
AssetVersionChangeType.MEDIA_REMOVED,
principal,
request,
);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_MEDIA_REMOVED,
entityType: 'asset_media',
entityId: mediaId,
beforeData: this.auditView(before),
afterData: { active: false },
metadata: { assetId: before.assetId, versionNumber, physicalFileRetained: true },
},
manager,
);
return { status: 'removed' };
});
}
async content(mediaId: string): Promise<{
filePath: string;
media: StoredAssetMedia;
}> {
const media = await this.loadActive(this.dataSource.manager, mediaId);
const filePath = resolve(this.storageRoot, media.storedName);
if (!filePath.startsWith(`${this.storageRoot}/`)) {
throw new InternalServerErrorException({
code: 'INVALID_MEDIA_STORAGE_PATH',
message: 'Ruta de almacenamiento inválida',
});
}
try {
const fileStat = await stat(filePath);
if (!fileStat.isFile() || fileStat.size !== media.sizeBytes) throw new Error('size mismatch');
} catch {
throw new InternalServerErrorException({
code: 'ASSET_MEDIA_FILE_MISSING',
message: 'El archivo físico no está disponible',
});
}
return { filePath, media };
}
private validateCoordinates(
latitude: number | null | undefined,
longitude: number | null | undefined,
accuracyM: number | null | undefined,
): void {
const hasLatitude = latitude !== null && latitude !== undefined;
const hasLongitude = longitude !== null && longitude !== undefined;
if (hasLatitude !== hasLongitude) throw coordinateError();
if (accuracyM !== null && accuracyM !== undefined && !hasLatitude) {
throw new BadRequestException({
code: 'MEDIA_ACCURACY_WITHOUT_COORDINATES',
message: 'La precisión GPS requiere latitud y longitud',
});
}
}
private async requireAsset(
manager: EntityManager,
assetId: string,
lock: boolean,
): Promise<void> {
const suffix = lock ? ' FOR UPDATE' : '';
const [row] = (await manager.query(
`SELECT 1 FROM assets WHERE id = $1${suffix}`,
[assetId],
)) as unknown[];
if (!row) throw assetNotFound();
}
private async loadActive(
manager: EntityManager,
mediaId: string,
lock = false,
): Promise<StoredAssetMedia> {
const [row] = (await manager.query(
`${this.mediaSelect()}
WHERE media.id = $1 AND media.deleted_at IS NULL
${lock ? 'FOR UPDATE OF media' : ''}`,
[mediaId],
)) as StoredAssetMedia[];
if (!row) throw mediaNotFound();
return row;
}
private mediaSelect(): string {
return `SELECT
media.id,
media.asset_id AS "assetId",
media.kind,
media.original_name AS "originalName",
media.stored_name AS "storedName",
media.mime_type AS "mimeType",
media.size_bytes::double precision AS "sizeBytes",
media.sha256,
media.title,
media.description,
media.captured_at AS "capturedAt",
media.latitude::double precision AS latitude,
media.longitude::double precision AS longitude,
media.accuracy_m::double precision AS "accuracyM",
media.source,
media.uploaded_by AS "uploadedBy",
uploader.username AS "uploadedByUsername",
media.created_at AS "createdAt",
media.updated_at AS "updatedAt"
FROM asset_media media
LEFT JOIN users uploader ON uploader.id = media.uploaded_by`;
}
private auditView(media: StoredAssetMedia): Record<string, unknown> {
return {
id: media.id,
assetId: media.assetId,
kind: media.kind,
originalName: media.originalName,
mimeType: media.mimeType,
sizeBytes: media.sizeBytes,
sha256: media.sha256,
title: media.title,
description: media.description,
capturedAt: media.capturedAt,
latitude: media.latitude,
longitude: media.longitude,
accuracyM: media.accuracyM,
source: media.source,
};
}
}
@@ -1,92 +0,0 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
Req,
} from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AssetOperationalRelationsService } from './asset-operational-relations.service';
import { CreateAreaCompanyRelationDto } from './dto/create-area-company-relation.dto';
import { EndAreaCompanyRelationDto } from './dto/end-area-company-relation.dto';
import { ListAreaCompanyRelationsQueryDto } from './dto/list-area-company-relations-query.dto';
import { ListOperationalAreasQueryDto } from './dto/list-operational-areas-query.dto';
@Controller('asset-operational-relations')
export class AssetOperationalRelationsController {
constructor(private readonly relations: AssetOperationalRelationsService) {}
@Get('areas')
@RequirePermissions('asset_relations.read')
areas(@Query() query: ListOperationalAreasQueryDto) {
return this.relations.listAreas(query.parentId);
}
@Get('companies')
@RequirePermissions('asset_relations.read')
companies() {
return this.relations.listCompanies();
}
@Get('organizations')
@RequirePermissions('asset_relations.read')
organizations() {
return this.relations.listCompanies();
}
@Get('areas/:areaId/companies')
@RequirePermissions('asset_relations.read')
companiesForArea(@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string) {
return this.relations.listCompaniesForArea(areaId);
}
@Get('areas/:areaId/organizations')
@RequirePermissions('asset_relations.read')
organizationsForArea(@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string) {
return this.relations.listCompaniesForArea(areaId);
}
@Get('companies/:companyId/areas')
@RequirePermissions('asset_relations.read')
areasForCompany(@Param('companyId', new ParseUUIDPipe({ version: '4' })) companyId: string) {
return this.relations.listAreasForCompany(companyId);
}
@Get('organizations/:organizationId/areas')
@RequirePermissions('asset_relations.read')
areasForOrganization(@Param('organizationId', new ParseUUIDPipe({ version: '4' })) organizationId: string) {
return this.relations.listAreasForCompany(organizationId);
}
@Get()
@RequirePermissions('asset_relations.read')
list(@Query() query: ListAreaCompanyRelationsQueryDto) {
return this.relations.list(query);
}
@Post()
@RequirePermissions('asset_relations.manage')
create(
@Body() dto: CreateAreaCompanyRelationDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.relations.create(dto, principal, request);
}
@Post(':id/end')
@RequirePermissions('asset_relations.manage')
end(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: EndAreaCompanyRelationDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.relations.end(id, dto, principal, request);
}
}
@@ -1,369 +0,0 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AreaOrganizationRole, AssetTypeOperationalRole, AuditAction } from '../database/entities';
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
import type { CreateAreaCompanyRelationDto } from './dto/create-area-company-relation.dto';
import type { EndAreaCompanyRelationDto } from './dto/end-area-company-relation.dto';
import type { ListAreaCompanyRelationsQueryDto } from './dto/list-area-company-relations-query.dto';
export interface OperationalAssetSummary {
id: string;
code: string;
name: string;
commonName: string | null;
typeName: string;
}
export interface AreaCompanyRelationView {
id: string;
area: OperationalAssetSummary;
company: OperationalAssetSummary;
relationRole: AreaOrganizationRole;
participationPercent: number | null;
legalInstrument: string | null;
sourceDocumentId: string | null;
validFrom: Date;
validUntil: Date | null;
startReason: string;
endReason: string | null;
createdBy: { id: string; username: string } | null;
endedBy: { id: string; username: string } | null;
createdAt: Date;
updatedAt: Date;
active: boolean;
assignedAssetCount: number;
}
@Injectable()
export class AssetOperationalRelationsService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async listAreas(parentId?: string): Promise<{ data: OperationalAssetSummary[] }> {
if (!parentId) {
return { data: await this.listAssetsByRole(AssetTypeOperationalRole.AREA) };
}
const data = (await this.dataSource.query(`
WITH RECURSIVE ancestors AS (
SELECT id, parent_id FROM assets WHERE id = $1
UNION ALL
SELECT parent.id, parent.parent_id
FROM assets parent
INNER JOIN ancestors current ON parent.id = current.parent_id
)
SELECT asset.id, asset.code, asset.name, asset.common_name AS "commonName", asset_type.name AS "typeName"
FROM ancestors
INNER JOIN assets asset ON asset.id = ancestors.id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
WHERE asset_type.operational_role = $2
AND asset_type.is_active = true
AND asset.information_status <> 'INACTIVE'
ORDER BY asset.name, asset.code
`, [parentId, AssetTypeOperationalRole.AREA])) as OperationalAssetSummary[];
return { data };
}
async listCompanies(): Promise<{ data: OperationalAssetSummary[] }> {
return { data: await this.listAssetsByRole(AssetTypeOperationalRole.COMPANY) };
}
async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> {
await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA);
const data = (await this.dataSource.query(`
SELECT DISTINCT company.id, company.code, company.name, company.common_name AS "commonName", company_type.name AS "typeName"
FROM (
SELECT relation.company_id
FROM area_company_relations relation
WHERE relation.area_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
UNION
SELECT asset.operator_company_id AS company_id
FROM assets asset
WHERE asset.operational_area_id = $1
AND asset.operator_company_id IS NOT NULL
AND asset.information_status <> 'INACTIVE'
) linked
INNER JOIN assets company ON company.id = linked.company_id
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
WHERE company.information_status <> 'INACTIVE'
ORDER BY company.name, company.code
`, [areaId])) as OperationalAssetSummary[];
return { data };
}
async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> {
await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY);
const data = (await this.dataSource.query(`
SELECT DISTINCT area.id, area.code, area.name, area.common_name AS "commonName", area_type.name AS "typeName"
FROM (
SELECT relation.area_id
FROM area_company_relations relation
WHERE relation.company_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
UNION
SELECT asset.operational_area_id AS area_id
FROM assets asset
WHERE asset.operator_company_id = $1
AND asset.operational_area_id IS NOT NULL
AND asset.information_status <> 'INACTIVE'
) linked
INNER JOIN assets area ON area.id = linked.area_id
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
WHERE area.information_status <> 'INACTIVE'
ORDER BY area.name, area.code
`, [companyId])) as OperationalAssetSummary[];
return { data };
}
async list(query: ListAreaCompanyRelationsQueryDto): Promise<{ data: AreaCompanyRelationView[] }> {
const conditions: string[] = [];
const parameters: unknown[] = [];
const add = (value: unknown): string => {
parameters.push(value);
return `$${parameters.length}`;
};
if (query.areaId) conditions.push(`relation.area_id = ${add(query.areaId)}`);
if (query.companyId) conditions.push(`relation.company_id = ${add(query.companyId)}`);
if (!query.includeHistory) conditions.push('relation.valid_until IS NULL');
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const data = (await this.dataSource.query(
`${this.relationSelect(where)} ORDER BY relation.valid_until NULLS FIRST, relation.valid_from DESC`,
parameters,
)) as AreaCompanyRelationView[];
return { data };
}
async create(
dto: CreateAreaCompanyRelationDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AreaCompanyRelationView> {
try {
return await this.dataSource.transaction(async (manager) => {
await this.requireAssetRole(manager, dto.areaId, AssetTypeOperationalRole.AREA);
await this.requireAssetRole(manager, dto.companyId, AssetTypeOperationalRole.COMPANY);
if (dto.sourceDocumentId) {
const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]);
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
}
const [row] = (await manager.query(`
INSERT INTO area_company_relations (
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
const created = await this.loadRelation(manager, row.id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_AREA_COMPANY_RELATION_CREATED,
entityType: 'area_company_relation',
entityId: row.id,
afterData: this.auditView(created),
}, manager);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException({
code: 'AREA_COMPANY_RELATION_EXISTS',
message: 'La organización ya tiene ese rol activo en el área',
});
}
throw error;
}
}
async end(
id: string,
dto: EndAreaCompanyRelationDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AreaCompanyRelationView> {
return this.dataSource.transaction(async (manager) => {
const before = await this.loadRelation(manager, id, true);
if (!before.active) {
throw new ConflictException({
code: 'AREA_COMPANY_RELATION_ALREADY_ENDED',
message: 'La relación ya se encuentra finalizada',
});
}
if (before.assignedAssetCount > 0) {
throw new ConflictException({
code: 'AREA_COMPANY_RELATION_IN_USE',
message: `No se puede finalizar la relación: ${before.assignedAssetCount} activo(s) todavía dependen de esta combinación`,
});
}
await manager.query(`
UPDATE area_company_relations
SET valid_until = CURRENT_TIMESTAMP,
end_reason = $2,
ended_by = $3,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND valid_until IS NULL
`, [id, dto.reason, principal.userId]);
const updated = await this.loadRelation(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_AREA_COMPANY_RELATION_ENDED,
entityType: 'area_company_relation',
entityId: id,
beforeData: this.auditView(before),
afterData: this.auditView(updated),
}, manager);
return updated;
});
}
private async listAssetsByRole(role: AssetTypeOperationalRole): Promise<OperationalAssetSummary[]> {
return (await this.dataSource.query(`
SELECT asset.id, asset.code, asset.name, asset.common_name AS "commonName", asset_type.name AS "typeName"
FROM assets asset
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
WHERE asset_type.operational_role = $1
AND asset_type.is_active = true
AND asset.information_status <> 'INACTIVE'
ORDER BY asset.name, asset.code
`, [role])) as OperationalAssetSummary[];
}
private async requireAssetRole(
manager: EntityManager,
assetId: string,
role: AssetTypeOperationalRole,
): Promise<void> {
const [row] = (await manager.query(`
SELECT
asset.id,
asset.information_status AS "informationStatus",
asset_type.operational_role AS role,
asset_type.is_active AS "typeActive"
FROM assets asset
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
WHERE asset.id = $1
`, [assetId])) as Array<{
id: string;
informationStatus: string;
role: AssetTypeOperationalRole;
typeActive: boolean;
}>;
if (!row) {
throw new BadRequestException({
code: 'OPERATIONAL_ASSET_NOT_FOUND',
message: role === AssetTypeOperationalRole.AREA ? 'El área seleccionada no existe' : 'La empresa seleccionada no existe',
});
}
if (row.role !== role) {
throw new BadRequestException({
code: 'OPERATIONAL_ASSET_ROLE_INVALID',
message: role === AssetTypeOperationalRole.AREA
? 'El activo seleccionado no está configurado como Área'
: 'El activo seleccionado no está configurado como Empresa',
});
}
if (!row.typeActive || row.informationStatus === 'INACTIVE') {
throw new ConflictException({
code: 'OPERATIONAL_ASSET_INACTIVE',
message: role === AssetTypeOperationalRole.AREA
? 'El área o su tipo se encuentra inactivo'
: 'La empresa o su tipo se encuentra inactivo',
});
}
}
private async loadRelation(
manager: EntityManager,
id: string,
lock = false,
): Promise<AreaCompanyRelationView> {
if (lock) {
const rows = await manager.query(
'SELECT id FROM area_company_relations WHERE id = $1 FOR UPDATE',
[id],
) as unknown[];
if (rows.length === 0) throw this.relationNotFound();
}
const [row] = (await manager.query(
`${this.relationSelect('WHERE relation.id = $1')}`,
[id],
)) as AreaCompanyRelationView[];
if (!row) throw this.relationNotFound();
return row;
}
private relationSelect(where: string): string {
return `
SELECT
relation.id,
JSONB_BUILD_OBJECT(
'id', area.id, 'code', area.code, 'name', area.name, 'commonName', area.common_name, 'typeName', area_type.name
) AS area,
JSONB_BUILD_OBJECT(
'id', company.id, 'code', company.code, 'name', company.name, 'commonName', company.common_name, 'typeName', company_type.name
) AS company,
relation.relation_role AS "relationRole",
relation.participation_percent::double precision AS "participationPercent",
relation.legal_instrument AS "legalInstrument",
relation.source_document_id AS "sourceDocumentId",
relation.valid_from AS "validFrom",
relation.valid_until AS "validUntil",
relation.start_reason AS "startReason",
relation.end_reason AS "endReason",
CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', creator.id, 'username', creator.username
) END AS "createdBy",
CASE WHEN ender.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', ender.id, 'username', ender.username
) END AS "endedBy",
relation.created_at AS "createdAt",
relation.updated_at AS "updatedAt",
(relation.valid_until IS NULL) AS active,
CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset
WHERE asset.operational_area_id = relation.area_id
AND asset.operator_company_id = relation.company_id) ELSE 0 END AS "assignedAssetCount"
FROM area_company_relations relation
INNER JOIN assets area ON area.id = relation.area_id
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
INNER JOIN assets company ON company.id = relation.company_id
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
LEFT JOIN users creator ON creator.id = relation.created_by
LEFT JOIN users ender ON ender.id = relation.ended_by
${where}
`;
}
private relationNotFound(): NotFoundException {
return new NotFoundException({
code: 'AREA_COMPANY_RELATION_NOT_FOUND',
message: 'Relación entre área y empresa no encontrada',
});
}
private auditView(relation: AreaCompanyRelationView): Record<string, unknown> {
return {
id: relation.id,
areaId: relation.area.id,
companyId: relation.company.id,
relationRole: relation.relationRole,
participationPercent: relation.participationPercent,
legalInstrument: relation.legalInstrument,
sourceDocumentId: relation.sourceDocumentId,
validFrom: relation.validFrom,
validUntil: relation.validUntil,
startReason: relation.startReason,
endReason: relation.endReason,
active: relation.active,
assignedAssetCount: relation.assignedAssetCount,
};
}
}
@@ -1,52 +0,0 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Put,
Req,
} from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import type {
AuthPrincipal,
RequestWithContext,
} from '../common/http/request-context';
import { AssetProvenanceService } from './asset-provenance.service';
import { UpdateAssetProvenanceDto } from './dto/update-asset-provenance.dto';
@Controller('assets/:assetId/provenance')
export class AssetProvenanceController {
constructor(private readonly provenance: AssetProvenanceService) {}
@Get()
@RequirePermissions('assets.read_provenance')
get(
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
) {
return this.provenance.get(assetId);
}
@Put()
@RequirePermissions('assets.manage_provenance')
update(
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
@Body() dto: UpdateAssetProvenanceDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.provenance.update(assetId, dto, principal, request);
}
@Post('verify')
@RequirePermissions('assets.verify_provenance')
verify(
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.provenance.verify(assetId, principal, request);
}
}
@@ -1,195 +0,0 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type {
AuthPrincipal,
RequestWithContext,
} from '../common/http/request-context';
import {
AssetDataOrigin,
AssetVersionChangeType,
AuditAction,
} from '../database/entities';
import { AssetHistoryService } from './asset-history.service';
import type { UpdateAssetProvenanceDto } from './dto/update-asset-provenance.dto';
export interface AssetProvenanceView {
assetId: string;
origin: AssetDataOrigin;
sourceName: string | null;
sourceReference: string | null;
observedAt: Date | null;
notes: string | null;
verifiedAt: Date | null;
verifiedBy: string | null;
verifiedByUsername: string | null;
updatedAt: Date;
updatedBy: string | null;
updatedByUsername: string | null;
}
function assetNotFound(): NotFoundException {
return new NotFoundException({
code: 'ASSET_NOT_FOUND',
message: 'Activo no encontrado',
});
}
@Injectable()
export class AssetProvenanceService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly history: AssetHistoryService,
) {}
get(assetId: string): Promise<AssetProvenanceView> {
return this.load(this.dataSource.manager, assetId);
}
async update(
assetId: string,
dto: UpdateAssetProvenanceDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AssetProvenanceView> {
this.validateSource(dto);
return this.dataSource.transaction(async (manager) => {
const before = await this.load(manager, assetId, true);
await manager.query(
`UPDATE assets SET
data_origin = $2,
source_name = $3,
source_reference = $4,
source_observed_at = $5,
source_notes = $6,
provenance_verified_at = NULL,
provenance_verified_by = NULL,
provenance_updated_at = CURRENT_TIMESTAMP,
provenance_updated_by = $7,
updated_at = CURRENT_TIMESTAMP,
updated_by = $7
WHERE id = $1`,
[
assetId,
dto.origin,
dto.sourceName?.trim() || null,
dto.sourceReference?.trim() || null,
dto.observedAt ? new Date(dto.observedAt) : null,
dto.notes?.trim() || null,
principal.userId,
],
);
const versionNumber = await this.history.capture(
manager,
assetId,
AssetVersionChangeType.PROVENANCE_UPDATED,
principal,
request,
);
const updated = await this.load(manager, assetId);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_PROVENANCE_UPDATED,
entityType: 'asset_provenance',
entityId: assetId,
beforeData: { ...before },
afterData: { ...updated },
metadata: { versionNumber, verificationCleared: before.verifiedAt !== null },
},
manager,
);
return updated;
});
}
async verify(
assetId: string,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AssetProvenanceView> {
return this.dataSource.transaction(async (manager) => {
const before = await this.load(manager, assetId, true);
if (before.verifiedAt) return before;
await manager.query(
`UPDATE assets SET
provenance_verified_at = CURRENT_TIMESTAMP,
provenance_verified_by = $2,
provenance_updated_at = CURRENT_TIMESTAMP,
provenance_updated_by = $2,
updated_at = CURRENT_TIMESTAMP,
updated_by = $2
WHERE id = $1`,
[assetId, principal.userId],
);
const versionNumber = await this.history.capture(
manager,
assetId,
AssetVersionChangeType.PROVENANCE_VERIFIED,
principal,
request,
);
const verified = await this.load(manager, assetId);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_PROVENANCE_VERIFIED,
entityType: 'asset_provenance',
entityId: assetId,
beforeData: { ...before },
afterData: { ...verified },
metadata: { versionNumber },
},
manager,
);
return verified;
});
}
private validateSource(dto: UpdateAssetProvenanceDto): void {
const requiresNamedSource = dto.origin === AssetDataOrigin.PROVIDED_DOCUMENT
|| dto.origin === AssetDataOrigin.IMPORT;
if (requiresNamedSource && !dto.sourceName?.trim()) {
throw new BadRequestException({
code: 'PROVENANCE_SOURCE_REQUIRED',
message: 'La documentación recibida y las importaciones requieren identificar la fuente',
});
}
}
private async load(
manager: EntityManager,
assetId: string,
lock = false,
): Promise<AssetProvenanceView> {
const [row] = (await manager.query(
`SELECT
asset.id AS "assetId",
asset.data_origin AS origin,
asset.source_name AS "sourceName",
asset.source_reference AS "sourceReference",
asset.source_observed_at AS "observedAt",
asset.source_notes AS notes,
asset.provenance_verified_at AS "verifiedAt",
asset.provenance_verified_by AS "verifiedBy",
verifier.username AS "verifiedByUsername",
asset.provenance_updated_at AS "updatedAt",
asset.provenance_updated_by AS "updatedBy",
updater.username AS "updatedByUsername"
FROM assets asset
LEFT JOIN users verifier ON verifier.id = asset.provenance_verified_by
LEFT JOIN users updater ON updater.id = asset.provenance_updated_by
WHERE asset.id = $1
${lock ? 'FOR UPDATE OF asset' : ''}`,
[assetId],
)) as AssetProvenanceView[];
if (!row) throw assetNotFound();
return row;
}
}
@@ -1,60 +0,0 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req } from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AssetRegistryService } from './asset-registry.service';
import { AddAreaLegalRightOrganizationDto } from './dto/add-area-legal-right-organization.dto';
import { AddOrganizationMembershipDto } from './dto/add-organization-membership.dto';
import { CreateAreaLegalRightDto } from './dto/create-area-legal-right.dto';
import { CreateExternalIdentifierDto } from './dto/create-external-identifier.dto';
import { CreateSourceDocumentDto } from './dto/create-source-document.dto';
import { EndAreaLegalRightOrganizationDto } from './dto/end-area-legal-right-organization.dto';
import { EndExternalIdentifierDto } from './dto/end-external-identifier.dto';
import { EndOrganizationMembershipDto } from './dto/end-organization-membership.dto';
import { LinkAssetSourceDocumentDto } from './dto/link-asset-source-document.dto';
import { UpdateAreaLegalRightDto } from './dto/update-area-legal-right.dto';
import { UpsertOrganizationProfileDto } from './dto/upsert-organization-profile.dto';
@Controller()
export class AssetRegistryController {
constructor(private readonly registry: AssetRegistryService) {}
@Get('assets/:assetId/registry') @RequirePermissions('asset_registry.read')
get(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string){ return this.registry.get(assetId); }
@Get('source-documents') @RequirePermissions('asset_registry.read')
documents(@Query('search') search?:string){ return this.registry.listSourceDocuments(search); }
@Post('source-documents') @RequirePermissions('asset_registry.manage')
createDocument(@Body() dto:CreateSourceDocumentDto,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){ return this.registry.createSourceDocument(dto,principal,request); }
@Patch('assets/:assetId/organization-profile') @RequirePermissions('asset_registry.manage')
profile(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,@Body() dto:UpsertOrganizationProfileDto,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){ return this.registry.upsertOrganizationProfile(assetId,dto,principal,request); }
@Post('organizations/:organizationId/memberships') @RequirePermissions('asset_registry.manage')
addMembership(@Param('organizationId',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:AddOrganizationMembershipDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.addOrganizationMembership(id,dto,p,r); }
@Post('organization-memberships/:id/end') @RequirePermissions('asset_registry.manage')
endMembership(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:EndOrganizationMembershipDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.endOrganizationMembership(id,dto,p,r); }
@Post('assets/:assetId/source-documents/:documentId') @RequirePermissions('asset_registry.manage')
link(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,@Param('documentId',new ParseUUIDPipe({version:'4'})) documentId:string,@Body() dto:LinkAssetSourceDocumentDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.linkDocument(assetId,documentId,dto,p,r); }
@Post('assets/:assetId/external-identifiers') @RequirePermissions('asset_registry.manage')
identifier(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,@Body() dto:CreateExternalIdentifierDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.addExternalIdentifier(assetId,dto,p,r); }
@Post('asset-external-identifiers/:id/end') @RequirePermissions('asset_registry.manage')
endIdentifier(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:EndExternalIdentifierDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.endExternalIdentifier(id,dto,p,r); }
@Post('areas/:areaId/legal-rights') @RequirePermissions('asset_registry.manage')
legalRight(@Param('areaId',new ParseUUIDPipe({version:'4'})) areaId:string,@Body() dto:CreateAreaLegalRightDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.createLegalRight(areaId,dto,p,r); }
@Patch('area-legal-rights/:rightId') @RequirePermissions('asset_registry.manage')
updateRight(@Param('rightId',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:UpdateAreaLegalRightDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.updateLegalRight(id,dto,p,r); }
@Post('area-legal-rights/:rightId/organizations') @RequirePermissions('asset_registry.manage')
rightOrg(@Param('rightId',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:AddAreaLegalRightOrganizationDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.addLegalRightOrganization(id,dto,p,r); }
@Post('area-legal-right-organizations/:id/end') @RequirePermissions('asset_registry.manage')
endRightOrg(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:EndAreaLegalRightOrganizationDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.endLegalRightOrganization(id,dto,p,r); }
}
@@ -1,252 +0,0 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AreaLegalRight,
AreaLegalRightOrganization,
Asset,
AssetExternalIdentifier,
AssetSourceDocument,
AssetTypeOperationalRole,
AssetVersionChangeType,
AuditAction,
OrganizationKind,
OrganizationMembership,
OrganizationProfile,
SourceDocument,
} from '../database/entities';
import { AssetHistoryService } from './asset-history.service';
import type { AddAreaLegalRightOrganizationDto } from './dto/add-area-legal-right-organization.dto';
import type { AddOrganizationMembershipDto } from './dto/add-organization-membership.dto';
import type { CreateAreaLegalRightDto } from './dto/create-area-legal-right.dto';
import type { CreateExternalIdentifierDto } from './dto/create-external-identifier.dto';
import type { CreateSourceDocumentDto } from './dto/create-source-document.dto';
import type { EndAreaLegalRightOrganizationDto } from './dto/end-area-legal-right-organization.dto';
import type { EndExternalIdentifierDto } from './dto/end-external-identifier.dto';
import type { EndOrganizationMembershipDto } from './dto/end-organization-membership.dto';
import type { LinkAssetSourceDocumentDto } from './dto/link-asset-source-document.dto';
import type { UpdateAreaLegalRightDto } from './dto/update-area-legal-right.dto';
import type { UpsertOrganizationProfileDto } from './dto/upsert-organization-profile.dto';
function registryNotFound(entity: string): NotFoundException {
return new NotFoundException({ code: 'ASSET_REGISTRY_NOT_FOUND', message: `${entity} no encontrado` });
}
@Injectable()
export class AssetRegistryService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly history: AssetHistoryService,
) {}
async get(assetId: string) {
await this.requireAsset(this.dataSource.manager, assetId);
const [organizationProfile] = await this.dataSource.query(`
SELECT asset_id AS "assetId", organization_kind AS "organizationKind", legal_name AS "legalName",
tax_id AS "taxId", notification_email AS "notificationEmail", notes, created_at AS "createdAt", updated_at AS "updatedAt", updated_by AS "updatedBy"
FROM organization_profiles WHERE asset_id=$1
`, [assetId]);
const organizationMemberships = await this.dataSource.query(`
SELECT m.id,
JSONB_BUILD_OBJECT('id',p.id,'code',p.code,'name',p.name) AS parent,
JSONB_BUILD_OBJECT('id',member.id,'code',member.code,'name',member.name) AS member,
m.role, m.participation_percent::double precision AS "participationPercent",
m.valid_from AS "validFrom", m.valid_until AS "validUntil", m.source_document_id AS "sourceDocumentId",
m.notes, m.end_reason AS "endReason", m.created_at AS "createdAt", m.updated_at AS "updatedAt"
FROM organization_memberships m
JOIN assets p ON p.id=m.parent_organization_id
JOIN assets member ON member.id=m.member_organization_id
WHERE m.parent_organization_id=$1 OR m.member_organization_id=$1
ORDER BY m.valid_until NULLS FIRST, m.valid_from DESC
`, [assetId]);
const externalIdentifiers = await this.dataSource.query(`
SELECT id, namespace, value, valid_from AS "validFrom", valid_until AS "validUntil",
source_document_id AS "sourceDocumentId", notes, end_reason AS "endReason", created_at AS "createdAt"
FROM asset_external_identifiers WHERE asset_id=$1 ORDER BY valid_until NULLS FIRST, namespace, valid_from DESC
`, [assetId]);
const sourceDocuments = await this.dataSource.query(`
SELECT link.id AS "linkId", link.relation_type AS "relationType", link.notes AS "linkNotes",
doc.id, doc.document_type AS "documentType", doc.document_number AS "documentNumber", doc.title,
doc.issuer, doc.document_date AS "documentDate", doc.external_reference AS "externalReference", doc.notes
FROM asset_source_documents link JOIN source_documents doc ON doc.id=link.document_id
WHERE link.asset_id=$1 ORDER BY doc.document_date DESC NULLS LAST, doc.created_at DESC
`, [assetId]);
const legalRights = await this.dataSource.query(`
SELECT r.id, r.right_type AS "rightType", r.name, r.instrument_number AS "instrumentNumber",
r.valid_from AS "validFrom", r.valid_until AS "validUntil", r.status,
r.source_document_id AS "sourceDocumentId", r.notes,
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'id',o.id,'organizationId',o.organization_id,'organizationName',a.name,'role',o.role,
'participationPercent',o.participation_percent::double precision,'validFrom',o.valid_from,
'validUntil',o.valid_until,'notes',o.notes,'endReason',o.end_reason
) ORDER BY o.valid_until NULLS FIRST,o.valid_from DESC)
FROM area_legal_right_organizations o JOIN assets a ON a.id=o.organization_id WHERE o.right_id=r.id),'[]'::jsonb) AS organizations
FROM area_legal_rights r WHERE r.area_id=$1 ORDER BY r.valid_until DESC NULLS FIRST, r.valid_from DESC NULLS LAST
`, [assetId]);
return { organizationProfile: organizationProfile ?? null, organizationMemberships, externalIdentifiers, sourceDocuments, legalRights };
}
async listSourceDocuments(search?: string) {
const q = search?.trim();
const params: unknown[] = [];
const where = q ? `WHERE title ILIKE $1 OR document_number ILIKE $1 OR issuer ILIKE $1` : '';
if (q) params.push(`%${q}%`);
const data = await this.dataSource.query(`
SELECT id, document_type AS "documentType", document_number AS "documentNumber", title, issuer,
document_date AS "documentDate", external_reference AS "externalReference", notes,
created_at AS "createdAt", updated_at AS "updatedAt"
FROM source_documents ${where}
ORDER BY document_date DESC NULLS LAST, created_at DESC LIMIT 100
`, params);
return { data };
}
async createSourceDocument(dto: CreateSourceDocumentDto, principal: AuthPrincipal, request: RequestWithContext) {
try {
return await this.dataSource.transaction(async manager => {
const doc = manager.getRepository(SourceDocument).create({
documentType: dto.documentType,
documentNumber: dto.documentNumber ?? null,
title: dto.title,
issuer: dto.issuer ?? null,
documentDate: dto.documentDate ?? null,
externalReference: dto.externalReference ?? null,
notes: dto.notes ?? null,
createdBy: principal.userId,
updatedBy: principal.userId,
});
await manager.getRepository(SourceDocument).save(doc);
await this.audit.record({
...administrationAuditContext(principal, request), action: AuditAction.SOURCE_DOCUMENT_CREATED,
entityType: 'source_document', entityId: doc.id, afterData: { ...doc },
}, manager);
return doc;
});
} catch (error) {
if (isUniqueViolation(error)) throw new ConflictException({ code:'SOURCE_DOCUMENT_EXISTS', message:'Ya existe un documento con ese número y emisor' });
throw error;
}
}
async upsertOrganizationProfile(assetId: string, dto: UpsertOrganizationProfileDto, principal: AuthPrincipal, request: RequestWithContext) {
try {
return await this.dataSource.transaction(async manager => {
const asset = await this.requireRole(manager, assetId, AssetTypeOperationalRole.COMPANY);
const repo = manager.getRepository(OrganizationProfile);
const before = await repo.findOne({ where: { assetId } });
if (before && before.organizationKind !== dto.organizationKind) {
const [usage] = await manager.query(`SELECT 1 FROM organization_memberships WHERE (parent_organization_id=$1 OR member_organization_id=$1) AND valid_until IS NULL LIMIT 1`, [assetId]);
if (usage) throw new ConflictException({ code:'ORGANIZATION_KIND_IN_USE', message:'No se puede cambiar el tipo de organización mientras tenga una composición UTE activa' });
}
const profile = repo.create({ ...(before ?? {}), assetId, organizationKind:dto.organizationKind, legalName:dto.legalName ?? asset.name, taxId:dto.taxId ?? null, notificationEmail:dto.notificationEmail ?? null, notes:dto.notes ?? null, updatedBy:principal.userId });
await repo.save(profile);
const versionNumber = await this.history.capture(manager, assetId, AssetVersionChangeType.REGISTRY_UPDATED, principal, request);
await this.audit.record({ ...administrationAuditContext(principal,request), action:AuditAction.ASSET_REGISTRY_UPDATED, entityType:'organization_profile', entityId:assetId, beforeData:before ? { ...before }:null, afterData:{ ...profile }, metadata:{versionNumber} }, manager);
return profile;
});
} catch (error) {
if (isUniqueViolation(error)) throw new ConflictException({ code:'ORGANIZATION_TAX_ID_EXISTS', message:'El identificador fiscal ya pertenece a otra organización' });
throw error;
}
}
async addOrganizationMembership(parentId:string, dto:AddOrganizationMembershipDto, principal:AuthPrincipal, request:RequestWithContext) {
try {
return await this.dataSource.transaction(async manager => {
await this.requireRole(manager,parentId,AssetTypeOperationalRole.COMPANY);
await manager.query('SELECT id FROM assets WHERE id=$1 FOR UPDATE',[parentId]);
await this.requireRole(manager,dto.memberOrganizationId,AssetTypeOperationalRole.COMPANY);
const parentProfile = await manager.getRepository(OrganizationProfile).findOne({where:{assetId:parentId}});
const memberProfile = await manager.getRepository(OrganizationProfile).findOne({where:{assetId:dto.memberOrganizationId}});
if (parentProfile?.organizationKind !== OrganizationKind.UTE) throw new BadRequestException({code:'UTE_REQUIRED',message:'La organización contenedora debe estar configurada como UTE'});
if (memberProfile?.organizationKind === OrganizationKind.UTE) throw new BadRequestException({code:'UTE_MEMBER_INVALID',message:'Una UTE no puede ser miembro directo de otra UTE'});
if (dto.sourceDocumentId) await this.requireDocument(manager,dto.sourceDocumentId);
const validFrom = dto.validFrom ?? this.today();
const [overlap] = await manager.query(`
SELECT 1 FROM organization_memberships
WHERE parent_organization_id=$1 AND member_organization_id=$2 AND role=$3
AND daterange(valid_from,COALESCE(valid_until,'infinity'::date),'[]') && daterange($4::date,'infinity'::date,'[]')
LIMIT 1
`,[parentId,dto.memberOrganizationId,dto.role,validFrom]);
if (overlap) throw new ConflictException({code:'UTE_MEMBERSHIP_OVERLAP',message:'La participación se superpone con una vigencia histórica existente'});
if (dto.participationPercent != null) {
const [sum] = await manager.query(`SELECT COALESCE(SUM(participation_percent),0)::double precision AS total FROM organization_memberships WHERE parent_organization_id=$1 AND valid_until IS NULL`,[parentId]);
if (Number(sum?.total ?? 0) + dto.participationPercent > 100.0001) throw new BadRequestException({code:'UTE_PARTICIPATION_EXCEEDS_100',message:'La participación activa de la UTE no puede superar el 100%'});
}
const repo=manager.getRepository(OrganizationMembership);
const membership=repo.create({parentOrganizationId:parentId,memberOrganizationId:dto.memberOrganizationId,role:dto.role,participationPercent:dto.participationPercent == null ? null:String(dto.participationPercent),validFrom,validUntil:null,sourceDocumentId:dto.sourceDocumentId ?? null,notes:dto.notes ?? null,endReason:null,createdBy:principal.userId,endedBy:null});
await repo.save(membership);
await this.captureAssets(manager,[parentId,dto.memberOrganizationId],principal,request);
await this.audit.record({ ...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'organization_membership',entityId:membership.id,afterData:{...membership}},manager);
return membership;
});
} catch(error){ if(isUniqueViolation(error)) throw new ConflictException({code:'UTE_MEMBERSHIP_EXISTS',message:'La participación ya se encuentra activa'}); throw error; }
}
async endOrganizationMembership(id:string,dto:EndOrganizationMembershipDto,principal:AuthPrincipal,request:RequestWithContext){
return this.dataSource.transaction(async manager=>{
const repo=manager.getRepository(OrganizationMembership);
const membership=await repo.createQueryBuilder('m').where('m.id=:id',{id}).setLock('pessimistic_write').getOne();
if(!membership) throw registryNotFound('Participación de organización');
if(membership.validUntil) throw new ConflictException({code:'UTE_MEMBERSHIP_ENDED',message:'La participación ya está finalizada'});
const end=dto.validUntil ?? this.today(); this.validateEndDate(end,membership.validFrom);
membership.validUntil=end; membership.endReason=dto.reason; membership.endedBy=principal.userId; await repo.save(membership);
await this.captureAssets(manager,[membership.parentOrganizationId,membership.memberOrganizationId],principal,request);
await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'organization_membership',entityId:id,afterData:{...membership}},manager);
return membership;
});
}
async linkDocument(assetId:string,documentId:string,dto:LinkAssetSourceDocumentDto,principal:AuthPrincipal,request:RequestWithContext){
try { return await this.dataSource.transaction(async manager=>{
await this.requireAsset(manager,assetId); await this.requireDocument(manager,documentId);
const repo=manager.getRepository(AssetSourceDocument); const link=repo.create({assetId,documentId,relationType:dto.relationType,notes:dto.notes??null,createdBy:principal.userId}); await repo.save(link);
const versionNumber=await this.history.capture(manager,assetId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request);
await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'asset_source_document',entityId:link.id,afterData:{...link},metadata:{versionNumber}},manager); return link;
}); } catch(error){ if(isUniqueViolation(error)) throw new ConflictException({code:'ASSET_DOCUMENT_LINK_EXISTS',message:'El documento ya está vinculado al activo con ese tipo de relación'}); throw error; }
}
async addExternalIdentifier(assetId:string,dto:CreateExternalIdentifierDto,principal:AuthPrincipal,request:RequestWithContext){
try { return await this.dataSource.transaction(async manager=>{
await this.requireAsset(manager,assetId); if(dto.sourceDocumentId) await this.requireDocument(manager,dto.sourceDocumentId);
const repo=manager.getRepository(AssetExternalIdentifier); const identifier=repo.create({assetId,namespace:dto.namespace,value:dto.value,validFrom:dto.validFrom ? new Date(dto.validFrom):new Date(),validUntil:null,sourceDocumentId:dto.sourceDocumentId??null,notes:dto.notes??null,endReason:null,createdBy:principal.userId,endedBy:null}); await repo.save(identifier);
const versionNumber=await this.history.capture(manager,assetId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'asset_external_identifier',entityId:identifier.id,afterData:{...identifier},metadata:{versionNumber}},manager); return identifier;
}); } catch(error){ if(isUniqueViolation(error)) throw new ConflictException({code:'EXTERNAL_IDENTIFIER_EXISTS',message:'Ese identificador externo ya está activo'}); throw error; }
}
async endExternalIdentifier(id:string,dto:EndExternalIdentifierDto,principal:AuthPrincipal,request:RequestWithContext){
return this.dataSource.transaction(async manager=>{ const repo=manager.getRepository(AssetExternalIdentifier); const item=await repo.createQueryBuilder('i').where('i.id=:id',{id}).setLock('pessimistic_write').getOne(); if(!item) throw registryNotFound('Identificador'); if(item.validUntil) throw new ConflictException({code:'EXTERNAL_IDENTIFIER_ENDED',message:'El identificador ya está finalizado'}); item.validUntil=new Date(); item.endReason=dto.reason; item.endedBy=principal.userId; await repo.save(item); const versionNumber=await this.history.capture(manager,item.assetId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'asset_external_identifier',entityId:id,afterData:{...item},metadata:{versionNumber}},manager); return item; });
}
async createLegalRight(areaId:string,dto:CreateAreaLegalRightDto,principal:AuthPrincipal,request:RequestWithContext){
return this.dataSource.transaction(async manager=>{ await this.requireRole(manager,areaId,AssetTypeOperationalRole.AREA); if(dto.sourceDocumentId) await this.requireDocument(manager,dto.sourceDocumentId); this.validateDateRange(dto.validFrom??null,dto.validUntil??null); const repo=manager.getRepository(AreaLegalRight); const right=repo.create({areaId,rightType:dto.rightType,name:dto.name,instrumentNumber:dto.instrumentNumber??null,validFrom:dto.validFrom??null,validUntil:dto.validUntil??null,status:dto.status,sourceDocumentId:dto.sourceDocumentId??null,notes:dto.notes??null,createdBy:principal.userId,updatedBy:principal.userId}); await repo.save(right); const versionNumber=await this.history.capture(manager,areaId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.AREA_LEGAL_RIGHT_CREATED,entityType:'area_legal_right',entityId:right.id,afterData:{...right},metadata:{versionNumber}},manager); return right; });
}
async updateLegalRight(id:string,dto:UpdateAreaLegalRightDto,principal:AuthPrincipal,request:RequestWithContext){
return this.dataSource.transaction(async manager=>{ const repo=manager.getRepository(AreaLegalRight); const right=await repo.createQueryBuilder('r').where('r.id=:id',{id}).setLock('pessimistic_write').getOne(); if(!right) throw registryNotFound('Derecho hidrocarburífero'); const before={...right}; if(dto.sourceDocumentId) await this.requireDocument(manager,dto.sourceDocumentId); const validFrom=dto.validFrom===undefined?right.validFrom:dto.validFrom; const validUntil=dto.validUntil===undefined?right.validUntil:dto.validUntil; this.validateDateRange(validFrom,validUntil); if(dto.name!==undefined)right.name=dto.name;if(dto.instrumentNumber!==undefined)right.instrumentNumber=dto.instrumentNumber;if(dto.validFrom!==undefined)right.validFrom=dto.validFrom;if(dto.validUntil!==undefined)right.validUntil=dto.validUntil;if(dto.status!==undefined)right.status=dto.status;if(dto.sourceDocumentId!==undefined)right.sourceDocumentId=dto.sourceDocumentId;if(dto.notes!==undefined)right.notes=dto.notes;right.updatedBy=principal.userId;await repo.save(right);const versionNumber=await this.history.capture(manager,right.areaId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request);await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.AREA_LEGAL_RIGHT_UPDATED,entityType:'area_legal_right',entityId:id,beforeData:before,afterData:{...right},metadata:{reason:dto.reason,versionNumber}},manager);return right; });
}
async addLegalRightOrganization(rightId:string,dto:AddAreaLegalRightOrganizationDto,principal:AuthPrincipal,request:RequestWithContext){
try { return await this.dataSource.transaction(async manager=>{ const right=await manager.getRepository(AreaLegalRight).createQueryBuilder('r').where('r.id=:rightId',{rightId}).setLock('pessimistic_write').getOne(); if(!right) throw registryNotFound('Derecho hidrocarburífero'); await this.requireRole(manager,dto.organizationId,AssetTypeOperationalRole.COMPANY); const validFrom=dto.validFrom??this.today(); const [overlap]=await manager.query(`SELECT 1 FROM area_legal_right_organizations WHERE right_id=$1 AND organization_id=$2 AND role=$3 AND daterange(valid_from,COALESCE(valid_until,'infinity'::date),'[]') && daterange($4::date,'infinity'::date,'[]') LIMIT 1`,[rightId,dto.organizationId,dto.role,validFrom]); if(overlap) throw new ConflictException({code:'LEGAL_RIGHT_ORGANIZATION_OVERLAP',message:'La participación se superpone con una vigencia histórica existente'}); const repo=manager.getRepository(AreaLegalRightOrganization); const item=repo.create({rightId,organizationId:dto.organizationId,role:dto.role,participationPercent:dto.participationPercent==null?null:String(dto.participationPercent),validFrom,validUntil:null,notes:dto.notes??null,endReason:null,createdBy:principal.userId,endedBy:null}); await repo.save(item); await this.captureAssets(manager,[right.areaId,dto.organizationId],principal,request); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'area_legal_right_organization',entityId:item.id,afterData:{...item}},manager); return item; }); } catch(error){ if(isUniqueViolation(error)) throw new ConflictException({code:'LEGAL_RIGHT_ORGANIZATION_EXISTS',message:'La organización ya tiene ese rol activo en el derecho'}); throw error; }
}
async endLegalRightOrganization(id:string,dto:EndAreaLegalRightOrganizationDto,principal:AuthPrincipal,request:RequestWithContext){
return this.dataSource.transaction(async manager=>{ const repo=manager.getRepository(AreaLegalRightOrganization); const item=await repo.createQueryBuilder('o').where('o.id=:id',{id}).setLock('pessimistic_write').getOne(); if(!item) throw registryNotFound('Participación legal'); if(item.validUntil) throw new ConflictException({code:'LEGAL_RIGHT_ORGANIZATION_ENDED',message:'La participación ya está finalizada'}); const end=dto.validUntil??this.today();this.validateEndDate(end,item.validFrom);item.validUntil=end;item.endReason=dto.reason;item.endedBy=principal.userId;await repo.save(item);const right=await manager.getRepository(AreaLegalRight).findOne({where:{id:item.rightId}});if(right)await this.captureAssets(manager,[right.areaId,item.organizationId],principal,request);await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.AREA_LEGAL_RIGHT_ORGANIZATION_ENDED,entityType:'area_legal_right_organization',entityId:id,afterData:{...item}},manager);return item; });
}
private async captureAssets(manager:EntityManager,ids:string[],principal:AuthPrincipal,request:RequestWithContext){ for(const id of [...new Set(ids)]) await this.history.capture(manager,id,AssetVersionChangeType.REGISTRY_UPDATED,principal,request); }
private async requireAsset(manager:EntityManager,id:string):Promise<Asset>{ const asset=await manager.getRepository(Asset).findOne({where:{id}}); if(!asset) throw registryNotFound('Activo'); return asset; }
private async requireRole(manager:EntityManager,id:string,role:AssetTypeOperationalRole):Promise<Asset>{ const [row]=await manager.query(`SELECT a.id FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=$1 AND t.operational_role=$2`,[id,role]); if(!row) throw new BadRequestException({code:'ASSET_ROLE_INVALID',message:role===AssetTypeOperationalRole.AREA?'El activo debe ser un Área':'El activo debe ser una Organización'}); return this.requireAsset(manager,id); }
private async requireDocument(manager:EntityManager,id:string){ const doc=await manager.getRepository(SourceDocument).findOne({where:{id}}); if(!doc) throw registryNotFound('Documento fuente'); return doc; }
private today(){ return new Date().toISOString().slice(0,10); }
private validateEndDate(end:string,start:string){ const today=this.today(); if(end<start) throw new BadRequestException({code:'INVALID_VALIDITY_RANGE',message:'La fecha de finalización no puede ser anterior al inicio'}); if(end>today) throw new BadRequestException({code:'FUTURE_END_DATE',message:'La fecha de finalización no puede estar en el futuro'}); }
private validateDateRange(start:string|null,end:string|null){ if(start&&end&&end<start) throw new BadRequestException({code:'INVALID_VALIDITY_RANGE',message:'La vigencia hasta no puede ser anterior a la vigencia desde'}); }
}
@@ -1,33 +0,0 @@
import {
Controller,
Get,
Param,
ParseUUIDPipe,
Query,
} from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { AssetTemporalService } from './asset-temporal.service';
import {
ListTemporalAssetsQueryDto,
TemporalAtQueryDto,
} from './dto/list-temporal-assets-query.dto';
@Controller('temporal-assets')
export class AssetTemporalController {
constructor(private readonly temporal: AssetTemporalService) {}
@Get()
@RequirePermissions('assets.read_temporal')
list(@Query() query: ListTemporalAssetsQueryDto) {
return this.temporal.list(query);
}
@Get(':assetId')
@RequirePermissions('assets.read_temporal')
get(
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
@Query() query: TemporalAtQueryDto,
) {
return this.temporal.get(assetId, query);
}
}
@@ -1,194 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import {
AssetVersionChangeType,
AuditSource,
} from '../database/entities';
import type {
ListTemporalAssetsQueryDto,
TemporalAtQueryDto,
} from './dto/list-temporal-assets-query.dto';
export interface TemporalAssetSummary {
id: string;
assetId: string;
assetCode: string;
assetName: string;
typeId: string;
typeName: string;
informationStatus: string;
operationalStatus: string;
versionNumber: number;
changeType: AssetVersionChangeType;
changedFields: string[];
occurredAt: Date;
effectiveUntil: Date | null;
actorUserId: string | null;
actorUsername: string | null;
source: AuditSource;
requestId: string | null;
isCurrent: boolean;
}
export interface TemporalAssetDetail extends TemporalAssetSummary {
snapshot: Record<string, unknown>;
asOf: Date;
}
function temporalAssetNotFound(): NotFoundException {
return new NotFoundException({
code: 'ASSET_NOT_EXISTING_AT_DATE',
message: 'El activo no tenía una versión registrada en la fecha solicitada',
});
}
@Injectable()
export class AssetTemporalService {
constructor(private readonly dataSource: DataSource) {}
async list(query: ListTemporalAssetsQueryDto) {
const parameters: unknown[] = [new Date(query.at)];
const conditions: string[] = [];
const add = (value: unknown): string => {
parameters.push(value);
return `$${parameters.length}`;
};
if (query.search?.trim()) {
const search = add(`%${query.search.trim()}%`);
conditions.push(`(
version.snapshot->>'code' ILIKE ${search}
OR version.snapshot->>'name' ILIKE ${search}
)`);
}
if (query.typeId) {
conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`);
}
if (query.status) {
conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const selected = this.selectedAt('$1');
const [countRow] = (await this.dataSource.query(
`WITH selected_version AS (${selected})
SELECT COUNT(*)::integer AS total
FROM selected_version version
${where}`,
parameters,
)) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0);
const paginated = [
...parameters,
query.pageSize,
(query.page - 1) * query.pageSize,
];
const limit = `$${parameters.length + 1}`;
const offset = `$${parameters.length + 2}`;
const data = (await this.dataSource.query(
`WITH selected_version AS (${selected})
${this.selectSummary()}
FROM selected_version version
INNER JOIN assets current_asset ON current_asset.id = version.asset_id
LEFT JOIN LATERAL (
SELECT candidate.occurred_at
FROM asset_versions candidate
WHERE candidate.asset_id = version.asset_id
AND candidate.version_number > version.version_number
ORDER BY candidate.version_number ASC
LIMIT 1
) next_version ON true
${where}
ORDER BY "assetName" ASC, "assetCode" ASC
LIMIT ${limit} OFFSET ${offset}`,
paginated,
)) as TemporalAssetSummary[];
return {
data,
asOf: new Date(query.at),
meta: {
page: query.page,
pageSize: query.pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize),
},
};
}
async get(assetId: string, query: TemporalAtQueryDto): Promise<TemporalAssetDetail> {
const [row] = (await this.dataSource.query(
`${this.selectSummary()}, version.snapshot
FROM asset_versions version
INNER JOIN assets current_asset ON current_asset.id = version.asset_id
LEFT JOIN LATERAL (
SELECT candidate.occurred_at
FROM asset_versions candidate
WHERE candidate.asset_id = version.asset_id
AND candidate.version_number > version.version_number
ORDER BY candidate.version_number ASC
LIMIT 1
) next_version ON true
WHERE version.asset_id = $1
AND version.occurred_at <= $2
ORDER BY version.occurred_at DESC, version.version_number DESC
LIMIT 1`,
[assetId, new Date(query.at)],
)) as TemporalAssetDetail[];
if (!row) throw temporalAssetNotFound();
const asOf = new Date(query.at);
row.asOf = asOf;
const [context] = (await this.dataSource.query(`
SELECT
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS "operatorCompany"
FROM asset_context_history history
LEFT JOIN assets parent ON parent.id=history.parent_id
LEFT JOIN assets area ON area.id=history.operational_area_id
LEFT JOIN assets company ON company.id=history.operator_company_id
WHERE history.asset_id=$1
AND history.valid_from <= $2
AND (history.valid_until IS NULL OR history.valid_until > $2)
ORDER BY history.valid_from DESC
LIMIT 1
`, [assetId, asOf])) as Array<{ parent: Record<string, unknown> | null; operationalArea: Record<string, unknown> | null; operatorCompany: Record<string, unknown> | null }>;
if (context && row.snapshot) {
row.snapshot = {
...row.snapshot,
parent: context.parent,
operationalArea: context.operationalArea,
operatorCompany: context.operatorCompany,
};
}
return row;
}
private selectedAt(atParameter: string): string {
return `SELECT DISTINCT ON (candidate.asset_id) candidate.*
FROM asset_versions candidate
WHERE candidate.occurred_at <= ${atParameter}
ORDER BY candidate.asset_id, candidate.occurred_at DESC, candidate.version_number DESC`;
}
private selectSummary(): string {
return `SELECT
version.id,
version.asset_id AS "assetId",
version.snapshot->>'code' AS "assetCode",
version.snapshot->>'name' AS "assetName",
version.snapshot #>> '{type,id}' AS "typeId",
version.snapshot #>> '{type,name}' AS "typeName",
version.snapshot->>'informationStatus' AS "informationStatus",
version.snapshot->>'operationalStatus' AS "operationalStatus",
version.version_number AS "versionNumber",
version.change_type AS "changeType",
version.changed_fields AS "changedFields",
version.occurred_at AS "occurredAt",
next_version.occurred_at AS "effectiveUntil",
version.actor_user_id AS "actorUserId",
version.actor_username AS "actorUsername",
version.source,
version.request_id AS "requestId",
(version.version_number = current_asset.current_version) AS "isCurrent"`;
}
}
@@ -1,118 +0,0 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Req,
} from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import type {
AuthPrincipal,
RequestWithContext,
} from '../common/http/request-context';
import { AssetTypesService } from './asset-types.service';
import { CreateAssetTypeDto } from './dto/create-asset-type.dto';
import { UpdateAssetTypeDto } from './dto/update-asset-type.dto';
import { CreateAttributeDefinitionDto } from './dto/create-attribute-definition.dto';
import { UpdateAttributeDefinitionDto } from './dto/update-attribute-definition.dto';
@Controller('asset-types')
export class AssetTypesController {
constructor(private readonly assetTypes: AssetTypesService) {}
@Get()
@RequirePermissions('asset_types.read')
list() {
return this.assetTypes.list();
}
@Get('bootstrap-status')
@RequirePermissions('asset_types.read')
bootstrapStatus() {
return this.assetTypes.bootstrapStatus();
}
@Post('bootstrap-defaults')
@RequirePermissions('asset_types.manage')
bootstrapDefaults(
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.assetTypes.bootstrapDefaults(principal, request);
}
@Get('enrichment-status')
@RequirePermissions('asset_types.read')
enrichmentStatus() {
return this.assetTypes.enrichmentStatus();
}
@Post('enrich-defaults')
@RequirePermissions('asset_types.manage')
enrichDefaults(
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.assetTypes.enrichDefaults(principal, request);
}
@Post()
@RequirePermissions('asset_types.manage')
create(
@Body() dto: CreateAssetTypeDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.assetTypes.create(dto, principal, request);
}
@Get(':id')
@RequirePermissions('asset_types.read')
getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.assetTypes.getById(id);
}
@Patch(':id')
@RequirePermissions('asset_types.manage')
update(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateAssetTypeDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.assetTypes.update(id, dto, principal, request);
}
@Post(':id/attributes')
@RequirePermissions('asset_types.manage')
createAttribute(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: CreateAttributeDefinitionDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.assetTypes.createAttribute(id, dto, principal, request);
}
@Patch(':id/attributes/:attributeId')
@RequirePermissions('asset_types.manage')
updateAttribute(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Param('attributeId', new ParseUUIDPipe({ version: '4' })) attributeId: string,
@Body() dto: UpdateAttributeDefinitionDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.assetTypes.updateAttribute(
id,
attributeId,
dto,
principal,
request,
);
}
}
@@ -1,905 +0,0 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, In } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import type {
AuthPrincipal,
RequestWithContext,
} from '../common/http/request-context';
import {
AssetAttributeDataType,
AssetAttributeDefinition,
AssetType,
AssetTypeOperationalRole,
AssetTypeParentRule,
AuditAction,
} from '../database/entities';
import {
administrationAuditContext,
isUniqueViolation,
} from '../administration/common/administration-audit';
import type { CreateAssetTypeDto } from './dto/create-asset-type.dto';
import type { UpdateAssetTypeDto } from './dto/update-asset-type.dto';
import type { CreateAttributeDefinitionDto } from './dto/create-attribute-definition.dto';
import type { UpdateAttributeDefinitionDto } from './dto/update-attribute-definition.dto';
import {
MASTER_BOOTSTRAP_PRESET_CODE,
MASTER_BOOTSTRAP_PRESET_NAME,
MASTER_BOOTSTRAP_TYPES,
MASTER_BOOTSTRAP_CORE_CODES,
} from './asset-master-bootstrap';
export interface AssetAttributeDefinitionView {
id: string;
code: string;
name: string;
dataType: AssetAttributeDataType;
isRequired: boolean;
isActive: boolean;
unit: string | null;
options: string[] | null;
sortOrder: number;
}
export interface AssetTypeSummary {
id: string;
code: string;
name: string;
isActive: boolean;
operationalRole: AssetTypeOperationalRole;
}
export interface AssetTypeView extends AssetTypeSummary {
description: string;
canBeRoot: boolean;
createdAt: Date;
updatedAt: Date;
assetCount: number;
allowedParentTypes: AssetTypeSummary[];
attributes: AssetAttributeDefinitionView[];
}
export interface MasterBootstrapStatus {
presetCode: string;
presetName: string;
typeCount: number;
canApply: boolean;
reason: string | null;
types: Array<{
code: string;
name: string;
operationalRole: AssetTypeOperationalRole;
parentCodes: string[];
attributeCount: number;
}>;
}
export interface MasterBootstrapResult {
presetCode: string;
presetName: string;
createdTypeCount: number;
createdAttributeCount: number;
createdParentRuleCount: number;
data: AssetTypeView[];
}
export interface MasterEnrichmentStatus {
presetCode: string;
presetName: string;
typeCount: number;
canApply: boolean;
complete: boolean;
reason: string | null;
missingTypeCodes: string[];
missingAttributeCount: number;
missingParentRuleCount: number;
}
function typeNotFound(): NotFoundException {
return new NotFoundException({
code: 'ASSET_TYPE_NOT_FOUND',
message: 'Tipo de activo no encontrado',
});
}
function attributeNotFound(): NotFoundException {
return new NotFoundException({
code: 'ASSET_ATTRIBUTE_NOT_FOUND',
message: 'Atributo configurable no encontrado',
});
}
@Injectable()
export class AssetTypesService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async bootstrapStatus(): Promise<MasterBootstrapStatus> {
const [row] = (await this.dataSource.query(
'SELECT COUNT(*)::integer AS count FROM asset_types',
)) as Array<{ count: number }>;
const typeCount = Number(row?.count ?? 0);
return {
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
typeCount,
canApply: typeCount === 0,
reason: typeCount === 0
? null
: 'La configuración inicial sólo puede aplicarse cuando el Maestro no tiene tipos de activo.',
types: MASTER_BOOTSTRAP_TYPES.map((type) => ({
code: type.code,
name: type.name,
operationalRole: type.operationalRole,
parentCodes: [...type.allowedParentCodes],
attributeCount: type.attributes.length,
})),
};
}
async bootstrapDefaults(
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<MasterBootstrapResult> {
return this.dataSource.transaction(async (manager) => {
await manager.query('LOCK TABLE asset_types IN SHARE ROW EXCLUSIVE MODE');
const [existing] = (await manager.query(
'SELECT COUNT(*)::integer AS count FROM asset_types',
)) as Array<{ count: number }>;
if (Number(existing?.count ?? 0) !== 0) {
throw new ConflictException({
code: 'MASTER_BOOTSTRAP_REQUIRES_EMPTY_MASTER',
message: 'La configuración inicial sólo puede aplicarse cuando el Maestro está vacío',
});
}
const typeRepository = manager.getRepository(AssetType);
const ruleRepository = manager.getRepository(AssetTypeParentRule);
const attributeRepository = manager.getRepository(AssetAttributeDefinition);
const idsByCode = new Map<string, string>();
for (const preset of MASTER_BOOTSTRAP_TYPES) {
const type = typeRepository.create({
code: preset.code,
name: preset.name,
description: preset.description,
canBeRoot: preset.canBeRoot,
isActive: true,
operationalRole: preset.operationalRole,
createdBy: principal.userId,
updatedBy: principal.userId,
});
const saved = await typeRepository.save(type);
idsByCode.set(preset.code, saved.id);
}
let createdParentRuleCount = 0;
let createdAttributeCount = 0;
for (const preset of MASTER_BOOTSTRAP_TYPES) {
const childTypeId = idsByCode.get(preset.code)!;
const rules = preset.allowedParentCodes.map((parentCode) =>
ruleRepository.create({
childTypeId,
parentTypeId: idsByCode.get(parentCode)!,
}),
);
if (rules.length) {
await ruleRepository.save(rules);
createdParentRuleCount += rules.length;
}
const attributes = preset.attributes.map((attribute) =>
attributeRepository.create({
assetTypeId: childTypeId,
code: attribute.code,
name: attribute.name,
dataType: attribute.dataType,
isRequired: attribute.isRequired,
isActive: true,
unit: attribute.unit,
options: attribute.options,
sortOrder: attribute.sortOrder,
}),
);
if (attributes.length) {
await attributeRepository.save(attributes);
createdAttributeCount += attributes.length;
}
}
const data = (await manager.query(this.viewQuery(''), [])) as AssetTypeView[];
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_MASTER_BOOTSTRAPPED,
entityType: 'asset_master',
entityId: MASTER_BOOTSTRAP_PRESET_CODE,
afterData: {
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
mode: 'INITIAL_BOOTSTRAP',
createdTypeCount: MASTER_BOOTSTRAP_TYPES.length,
createdAttributeCount,
createdParentRuleCount,
},
},
manager,
);
return {
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
createdTypeCount: MASTER_BOOTSTRAP_TYPES.length,
createdAttributeCount,
createdParentRuleCount,
data,
};
});
}
async enrichmentStatus(): Promise<MasterEnrichmentStatus> {
return this.dataSource.transaction((manager) => this.computeEnrichmentStatus(manager));
}
async enrichDefaults(
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<MasterBootstrapResult> {
return this.dataSource.transaction(async (manager) => {
await manager.query('LOCK TABLE asset_types IN SHARE ROW EXCLUSIVE MODE');
const before = await this.computeEnrichmentStatus(manager);
if (!before.canApply) {
throw new ConflictException({
code: 'MASTER_ENRICHMENT_INCOMPATIBLE',
message: before.reason ?? 'El Maestro actual no es compatible con el catálogo técnico',
});
}
const typeRepository = manager.getRepository(AssetType);
const ruleRepository = manager.getRepository(AssetTypeParentRule);
const attributeRepository = manager.getRepository(AssetAttributeDefinition);
const existingTypes = await typeRepository.find();
const typesByCode = new Map<string, AssetType>(existingTypes.map((type) => [type.code, type]));
let createdTypeCount = 0;
let createdAttributeCount = 0;
let createdParentRuleCount = 0;
for (const preset of MASTER_BOOTSTRAP_TYPES) {
if (typesByCode.has(preset.code)) continue;
const saved = await typeRepository.save(typeRepository.create({
code: preset.code,
name: preset.name,
description: preset.description,
canBeRoot: preset.canBeRoot,
isActive: true,
operationalRole: preset.operationalRole,
createdBy: principal.userId,
updatedBy: principal.userId,
}));
typesByCode.set(preset.code, saved);
createdTypeCount += 1;
}
for (const preset of MASTER_BOOTSTRAP_TYPES) {
const childType = typesByCode.get(preset.code)!;
const existingAttributes = await attributeRepository.find({
where: { assetTypeId: childType.id },
});
const attributeCodes = new Set(existingAttributes.map((attribute) => attribute.code));
for (const attribute of preset.attributes) {
if (attributeCodes.has(attribute.code)) continue;
await attributeRepository.save(attributeRepository.create({
assetTypeId: childType.id,
code: attribute.code,
name: attribute.name,
dataType: attribute.dataType,
isRequired: attribute.isRequired,
isActive: true,
unit: attribute.unit,
options: attribute.options,
sortOrder: attribute.sortOrder,
}));
createdAttributeCount += 1;
}
for (const parentCode of preset.allowedParentCodes) {
const parentType = typesByCode.get(parentCode)!;
const existing = await ruleRepository.findOne({
where: { childTypeId: childType.id, parentTypeId: parentType.id },
});
if (existing) continue;
await ruleRepository.save(ruleRepository.create({
childTypeId: childType.id,
parentTypeId: parentType.id,
}));
createdParentRuleCount += 1;
}
}
const data = (await manager.query(this.viewQuery(''), [])) as AssetTypeView[];
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_MASTER_BOOTSTRAPPED,
entityType: 'asset_master',
entityId: MASTER_BOOTSTRAP_PRESET_CODE,
afterData: {
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
mode: 'TECHNICAL_ENRICHMENT',
createdTypeCount,
createdAttributeCount,
createdParentRuleCount,
},
},
manager,
);
return {
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
createdTypeCount,
createdAttributeCount,
createdParentRuleCount,
data,
};
});
}
private async computeEnrichmentStatus(manager: EntityManager): Promise<MasterEnrichmentStatus> {
const existingTypes = await manager.getRepository(AssetType).find();
const typesByCode = new Map<string, AssetType>(existingTypes.map((type) => [type.code, type]));
const missingCore = MASTER_BOOTSTRAP_CORE_CODES.filter((code) => !typesByCode.has(code));
const area = typesByCode.get('area');
const company = typesByCode.get('empresa');
const incompatible = [
...(missingCore.length ? [`Faltan tipos base: ${missingCore.join(', ')}`] : []),
...(area && area.operationalRole !== AssetTypeOperationalRole.AREA ? ['El tipo area no tiene rol AREA'] : []),
...(company && company.operationalRole !== AssetTypeOperationalRole.COMPANY ? ['El tipo empresa no tiene rol COMPANY'] : []),
];
const canApply = incompatible.length === 0;
const missingTypeCodes = MASTER_BOOTSTRAP_TYPES
.filter((preset) => !typesByCode.has(preset.code))
.map((preset) => preset.code);
let missingAttributeCount = 0;
let missingParentRuleCount = 0;
if (canApply) {
for (const preset of MASTER_BOOTSTRAP_TYPES) {
const childType = typesByCode.get(preset.code);
if (!childType) {
missingAttributeCount += preset.attributes.length;
missingParentRuleCount += preset.allowedParentCodes.length;
continue;
}
const attributeRows = (await manager.query(
'SELECT code FROM asset_attribute_definitions WHERE asset_type_id = $1',
[childType.id],
)) as Array<{ code: string }>;
const attributeCodes = new Set(attributeRows.map((row) => row.code));
missingAttributeCount += preset.attributes.filter((attribute) => !attributeCodes.has(attribute.code)).length;
for (const parentCode of preset.allowedParentCodes) {
const parentType = typesByCode.get(parentCode);
if (!parentType) {
missingParentRuleCount += 1;
continue;
}
const [rule] = (await manager.query(
'SELECT 1 FROM asset_type_parent_rules WHERE child_type_id = $1 AND parent_type_id = $2 LIMIT 1',
[childType.id, parentType.id],
)) as unknown[];
if (!rule) missingParentRuleCount += 1;
}
}
}
const complete = canApply
&& missingTypeCodes.length === 0
&& missingAttributeCount === 0
&& missingParentRuleCount === 0;
return {
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
typeCount: existingTypes.length,
canApply,
complete,
reason: canApply ? null : incompatible.join('. '),
missingTypeCodes,
missingAttributeCount,
missingParentRuleCount,
};
}
async list(): Promise<{ data: AssetTypeView[] }> {
const rows = (await this.dataSource.query(this.viewQuery(''), [])) as AssetTypeView[];
return { data: rows };
}
async getById(id: string): Promise<AssetTypeView> {
return this.dataSource.transaction((manager) => this.loadView(manager, id));
}
async create(
dto: CreateAssetTypeDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AssetTypeView> {
try {
return await this.dataSource.transaction(async (manager) => {
await this.validateParentTypes(manager, null, dto.allowedParentTypeIds);
const type = manager.getRepository(AssetType).create({
code: dto.code,
name: dto.name,
description: dto.description,
canBeRoot: dto.canBeRoot,
isActive: true,
operationalRole: dto.operationalRole,
createdBy: principal.userId,
updatedBy: principal.userId,
});
await manager.getRepository(AssetType).save(type);
await this.replaceParentRules(manager, type.id, dto.allowedParentTypeIds);
const created = await this.loadView(manager, type.id);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_TYPE_CREATED,
entityType: 'asset_type',
entityId: type.id,
afterData: { ...created },
},
manager,
);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException({
code: 'ASSET_TYPE_ALREADY_EXISTS',
message: 'Ya existe un tipo de activo con ese código',
});
}
throw error;
}
}
async update(
id: string,
dto: UpdateAssetTypeDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AssetTypeView> {
if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
}
return this.dataSource.transaction(async (manager) => {
const type = await manager.getRepository(AssetType).findOne({ where: { id } });
if (!type) throw typeNotFound();
const before = await this.loadView(manager, id);
const nextCanBeRoot = dto.canBeRoot ?? type.canBeRoot;
const nextOperationalRole = dto.operationalRole ?? type.operationalRole;
const nextParentIds = dto.allowedParentTypeIds ?? before.allowedParentTypes.map((item) => item.id);
await this.validateParentTypes(manager, id, nextParentIds);
await this.assertExistingAssetsRemainValid(manager, id, nextCanBeRoot, nextParentIds);
await this.assertOperationalRoleChangeAllowed(manager, id, type.operationalRole, nextOperationalRole);
if (dto.isActive === false && type.isActive) {
await this.assertOperationalTypeCanBeDeactivated(manager, id, type.operationalRole);
}
if (dto.name !== undefined) type.name = dto.name;
if (dto.description !== undefined) type.description = dto.description;
if (dto.canBeRoot !== undefined) type.canBeRoot = dto.canBeRoot;
if (dto.isActive !== undefined) type.isActive = dto.isActive;
if (dto.operationalRole !== undefined) type.operationalRole = dto.operationalRole;
type.updatedBy = principal.userId;
await manager.getRepository(AssetType).save(type);
if (dto.allowedParentTypeIds !== undefined) {
await this.replaceParentRules(manager, id, nextParentIds);
}
const updated = await this.loadView(manager, id);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_TYPE_UPDATED,
entityType: 'asset_type',
entityId: id,
beforeData: { ...before },
afterData: { ...updated },
},
manager,
);
return updated;
});
}
async createAttribute(
typeId: string,
dto: CreateAttributeDefinitionDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AssetTypeView> {
try {
return await this.dataSource.transaction(async (manager) => {
await this.requireType(manager, typeId);
this.validateOptions(dto.dataType, dto.options);
if (dto.isRequired) {
const [{ count }] = (await manager.query(
'SELECT COUNT(*)::integer AS count FROM assets WHERE asset_type_id = $1',
[typeId],
)) as Array<{ count: number }>;
if (Number(count) > 0) {
throw new ConflictException({
code: 'ATTRIBUTE_REQUIRED_VALUES_MISSING',
message: 'No se puede agregar un atributo obligatorio a activos existentes sin completar sus valores',
});
}
}
const normalizedOptions = dto.options?.map((item) => item.trim());
const definition = manager.getRepository(AssetAttributeDefinition).create({
assetTypeId: typeId,
code: dto.code,
name: dto.name,
dataType: dto.dataType,
isRequired: dto.isRequired,
isActive: true,
unit: dto.unit ?? null,
options: dto.dataType === AssetAttributeDataType.SELECT ? normalizedOptions! : null,
sortOrder: dto.sortOrder,
});
await manager.getRepository(AssetAttributeDefinition).save(definition);
const updated = await this.loadView(manager, typeId);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_ATTRIBUTE_CREATED,
entityType: 'asset_attribute_definition',
entityId: definition.id,
afterData: { ...definition },
metadata: { assetTypeId: typeId },
},
manager,
);
return updated;
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException({
code: 'ASSET_ATTRIBUTE_ALREADY_EXISTS',
message: 'Ya existe un atributo con ese código para el tipo seleccionado',
});
}
throw error;
}
}
async updateAttribute(
typeId: string,
attributeId: string,
dto: UpdateAttributeDefinitionDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AssetTypeView> {
if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
}
return this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(AssetAttributeDefinition);
const definition = await repository.findOne({
where: { id: attributeId, assetTypeId: typeId },
});
if (!definition) throw attributeNotFound();
const before = { ...definition };
const nextType = dto.dataType ?? definition.dataType;
const nextOptions = dto.options === undefined
? definition.options
: dto.options?.map((item) => item.trim()) ?? null;
this.validateOptions(nextType, nextOptions ?? undefined);
if (dto.dataType !== undefined && dto.dataType !== definition.dataType) {
const [{ count }] = (await manager.query(
'SELECT COUNT(*)::integer AS count FROM asset_attribute_values WHERE definition_id = $1',
[attributeId],
)) as Array<{ count: number }>;
if (Number(count) > 0) {
throw new ConflictException({
code: 'ATTRIBUTE_TYPE_IN_USE',
message: 'No se puede cambiar el tipo de un atributo que ya tiene valores',
});
}
}
if (dto.isRequired === true && !definition.isRequired) {
const [{ count }] = (await manager.query(
`SELECT COUNT(*)::integer AS count FROM assets asset
WHERE asset.asset_type_id = $1
AND NOT EXISTS (
SELECT 1 FROM asset_attribute_values value
WHERE value.asset_id = asset.id AND value.definition_id = $2
)`,
[typeId, attributeId],
)) as Array<{ count: number }>;
if (Number(count) > 0) {
throw new ConflictException({
code: 'ATTRIBUTE_REQUIRED_VALUES_MISSING',
message: 'Hay activos existentes sin valor para este atributo',
});
}
}
if (dto.name !== undefined) definition.name = dto.name;
if (dto.dataType !== undefined) definition.dataType = dto.dataType;
if (dto.isRequired !== undefined) definition.isRequired = dto.isRequired;
if (dto.isActive !== undefined) definition.isActive = dto.isActive;
if (dto.unit !== undefined) definition.unit = dto.unit;
if (dto.options !== undefined || dto.dataType !== undefined) {
definition.options = nextType === AssetAttributeDataType.SELECT ? nextOptions : null;
}
if (dto.sortOrder !== undefined) definition.sortOrder = dto.sortOrder;
await repository.save(definition);
const updated = await this.loadView(manager, typeId);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_ATTRIBUTE_UPDATED,
entityType: 'asset_attribute_definition',
entityId: attributeId,
beforeData: before,
afterData: { ...definition },
metadata: { assetTypeId: typeId },
},
manager,
);
return updated;
});
}
private validateOptions(
dataType: AssetAttributeDataType,
options?: string[] | null,
): void {
if (dataType === AssetAttributeDataType.SELECT) {
const cleaned = (options ?? []).map((item) => item.trim()).filter(Boolean);
if (cleaned.length < 1 || new Set(cleaned).size !== cleaned.length) {
throw new BadRequestException({
code: 'INVALID_ATTRIBUTE_OPTIONS',
message: 'Los atributos de selección necesitan opciones únicas',
});
}
} else if (options != null) {
throw new BadRequestException({
code: 'INVALID_ATTRIBUTE_OPTIONS',
message: 'Sólo los atributos de selección pueden tener opciones',
});
}
}
private async requireType(manager: EntityManager, id: string): Promise<AssetType> {
const type = await manager.getRepository(AssetType).findOne({ where: { id } });
if (!type) throw typeNotFound();
return type;
}
private async validateParentTypes(
manager: EntityManager,
childTypeId: string | null,
parentTypeIds: string[],
): Promise<void> {
if (childTypeId && parentTypeIds.includes(childTypeId)) {
throw new BadRequestException({
code: 'INVALID_PARENT_TYPE_RULE',
message: 'Un tipo no puede ser padre de sí mismo',
});
}
if (parentTypeIds.length === 0) return;
const parents = await manager.getRepository(AssetType).find({
where: { id: In(parentTypeIds) },
});
if (parents.length !== parentTypeIds.length) {
throw new BadRequestException({
code: 'ASSET_PARENT_TYPE_NOT_FOUND',
message: 'Uno o más tipos padre no existen',
});
}
}
private async replaceParentRules(
manager: EntityManager,
childTypeId: string,
parentTypeIds: string[],
): Promise<void> {
const repository = manager.getRepository(AssetTypeParentRule);
await repository.delete({ childTypeId });
if (parentTypeIds.length) {
await repository.save(
parentTypeIds.map((parentTypeId) =>
repository.create({ childTypeId, parentTypeId }),
),
);
}
}
private async assertExistingAssetsRemainValid(
manager: EntityManager,
typeId: string,
canBeRoot: boolean,
parentTypeIds: string[],
): Promise<void> {
const [row] = (await manager.query(
`SELECT COUNT(*)::integer AS count
FROM assets asset
LEFT JOIN assets parent ON parent.id = asset.parent_id
WHERE asset.asset_type_id = $1
AND (
(asset.parent_id IS NULL AND $2::boolean = false)
OR (asset.parent_id IS NOT NULL AND NOT(parent.asset_type_id = ANY($3::uuid[])))
)`,
[typeId, canBeRoot, parentTypeIds],
)) as Array<{ count: number }>;
if (Number(row?.count ?? 0) > 0) {
throw new ConflictException({
code: 'ASSET_TYPE_RULES_IN_USE',
message: 'Las reglas dejarían activos existentes fuera de una jerarquía válida',
});
}
}
private async assertOperationalTypeCanBeDeactivated(
manager: EntityManager,
typeId: string,
role: AssetTypeOperationalRole,
): Promise<void> {
if (role === AssetTypeOperationalRole.GENERIC) return;
const relationColumn = role === AssetTypeOperationalRole.AREA ? 'area_id' : 'company_id';
const assignmentColumn = role === AssetTypeOperationalRole.AREA
? 'operational_area_id'
: 'operator_company_id';
const [usage] = (await manager.query(`
SELECT (
(SELECT COUNT(*) FROM area_company_relations relation
INNER JOIN assets anchor ON anchor.id = relation.${relationColumn}
WHERE anchor.asset_type_id = $1 AND relation.valid_until IS NULL)
+
(SELECT COUNT(*) FROM assets assigned
INNER JOIN assets anchor ON anchor.id = assigned.${assignmentColumn}
WHERE anchor.asset_type_id = $1)
)::integer AS count
`, [typeId])) as Array<{ count: number }>;
if (Number(usage?.count ?? 0) > 0) {
throw new ConflictException({
code: 'ASSET_TYPE_OPERATIONAL_ROLE_IN_USE',
message: role === AssetTypeOperationalRole.AREA
? 'No se puede inactivar el tipo mientras sus áreas tengan relaciones o activos operativos asociados'
: 'No se puede inactivar el tipo mientras sus empresas tengan relaciones o activos operativos asociados',
});
}
}
private async assertOperationalRoleChangeAllowed(
manager: EntityManager,
typeId: string,
currentRole: AssetTypeOperationalRole,
nextRole: AssetTypeOperationalRole,
): Promise<void> {
if (currentRole === nextRole) return;
if (nextRole !== AssetTypeOperationalRole.GENERIC) {
const [assigned] = (await manager.query(
`SELECT COUNT(*)::integer AS count
FROM assets
WHERE asset_type_id = $1
AND (operational_area_id IS NOT NULL OR operator_company_id IS NOT NULL)`,
[typeId],
)) as Array<{ count: number }>;
if (Number(assigned?.count ?? 0) > 0) {
throw new ConflictException({
code: 'ASSET_TYPE_OPERATIONAL_ROLE_IN_USE',
message: 'No se puede convertir el tipo en Área o Empresa mientras sus activos tengan asignaciones operativas',
});
}
}
if (currentRole === AssetTypeOperationalRole.AREA) {
const [used] = (await manager.query(
`SELECT (
(SELECT COUNT(*) FROM area_company_relations relation
INNER JOIN assets area ON area.id = relation.area_id
WHERE area.asset_type_id = $1)
+
(SELECT COUNT(*) FROM assets asset
INNER JOIN assets area ON area.id = asset.operational_area_id
WHERE area.asset_type_id = $1)
)::integer AS count`,
[typeId],
)) as Array<{ count: number }>;
if (Number(used?.count ?? 0) > 0) {
throw new ConflictException({
code: 'ASSET_TYPE_OPERATIONAL_ROLE_IN_USE',
message: 'El tipo todavía está utilizado como Área en relaciones o asignaciones operativas',
});
}
}
if (currentRole === AssetTypeOperationalRole.COMPANY) {
const [used] = (await manager.query(
`SELECT (
(SELECT COUNT(*) FROM area_company_relations relation
INNER JOIN assets company ON company.id = relation.company_id
WHERE company.asset_type_id = $1)
+
(SELECT COUNT(*) FROM assets asset
INNER JOIN assets company ON company.id = asset.operator_company_id
WHERE company.asset_type_id = $1)
)::integer AS count`,
[typeId],
)) as Array<{ count: number }>;
if (Number(used?.count ?? 0) > 0) {
throw new ConflictException({
code: 'ASSET_TYPE_OPERATIONAL_ROLE_IN_USE',
message: 'El tipo todavía está utilizado como Empresa en relaciones o asignaciones operativas',
});
}
}
}
private async loadView(manager: EntityManager, id: string): Promise<AssetTypeView> {
const rows = (await manager.query(this.viewQuery('WHERE asset_type.id = $1'), [id])) as AssetTypeView[];
if (!rows[0]) throw typeNotFound();
return rows[0];
}
private viewQuery(where: string): string {
return `
SELECT
asset_type.id,
asset_type.code,
asset_type.name,
asset_type.description,
asset_type.can_be_root AS "canBeRoot",
asset_type.is_active AS "isActive",
asset_type.operational_role AS "operationalRole",
asset_type.created_at AS "createdAt",
asset_type.updated_at AS "updatedAt",
(SELECT COUNT(*)::integer FROM assets WHERE asset_type_id = asset_type.id) AS "assetCount",
COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'id', parent_type.id,
'code', parent_type.code,
'name', parent_type.name,
'isActive', parent_type.is_active,
'operationalRole', parent_type.operational_role
) ORDER BY parent_type.name)
FROM asset_type_parent_rules rule
INNER JOIN asset_types parent_type ON parent_type.id = rule.parent_type_id
WHERE rule.child_type_id = asset_type.id
), '[]'::jsonb) AS "allowedParentTypes",
COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'id', definition.id,
'code', definition.code,
'name', definition.name,
'dataType', definition.data_type,
'isRequired', definition.is_required,
'isActive', definition.is_active,
'unit', definition.unit,
'options', definition.options,
'sortOrder', definition.sort_order
) ORDER BY definition.sort_order, definition.name)
FROM asset_attribute_definitions definition
WHERE definition.asset_type_id = asset_type.id
), '[]'::jsonb) AS attributes
FROM asset_types asset_type
${where}
ORDER BY asset_type.is_active DESC, asset_type.name ASC
`;
}
}
@@ -1,41 +0,0 @@
import { isDeepStrictEqual } from 'node:util';
const TRACKED_FIELDS = [
'code',
'name',
'commonName',
'description',
'type',
'parent',
'operationalArea',
'operatorCompany',
'informationStatus',
'operationalStatus',
'organizationProfile',
'organizationMemberships',
'externalIdentifiers',
'sourceDocuments',
'legalRights',
'attributes',
'geometry',
'media',
'provenance',
] as const;
export function changedSnapshotFields(
previous: Record<string, unknown> | null,
current: Record<string, unknown>,
): string[] {
if (!previous) {
return TRACKED_FIELDS.filter((field) => {
const value = current[field];
return value !== null && value !== undefined && !(
Array.isArray(value) && value.length === 0
);
});
}
return TRACKED_FIELDS.filter(
(field) => !isDeepStrictEqual(previous[field], current[field]),
);
}
@@ -1,193 +0,0 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
Req,
} from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import type {
AuthPrincipal,
RequestWithContext,
} from '../common/http/request-context';
import { AssetsService } from './assets.service';
import { ChangeAssetStatusDto } from './dto/change-asset-status.dto';
import { ChangeAssetOperationalStatusDto } from './dto/change-asset-operational-status.dto';
import { CreateAssetDto } from './dto/create-asset.dto';
import { CreateFieldDiscoveryDto } from './dto/create-field-discovery.dto';
import { ListFieldDiscoveriesQueryDto } from './dto/list-field-discoveries-query.dto';
import { MatchFieldDiscoveryDto, RejectFieldDiscoveryDto, ReviewFieldDiscoveryDto } from './dto/review-field-discovery.dto';
import { ListAssetsQueryDto } from './dto/list-assets-query.dto';
import { ListAssetTreeQueryDto } from './dto/list-asset-tree-query.dto';
import { ListAssetTreeChildrenQueryDto } from './dto/list-asset-tree-children-query.dto';
import { ParentOptionsQueryDto } from './dto/parent-options-query.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { ChangeAssetContextDto } from './dto/change-asset-context.dto';
@Controller('assets')
export class AssetsController {
constructor(private readonly assets: AssetsService) {}
@Get()
@RequirePermissions('assets.read')
list(@Query() query: ListAssetsQueryDto) {
return this.assets.list(query);
}
@Get('field-discoveries')
@RequirePermissions('assets.read')
listFieldDiscoveries(@Query() query: ListFieldDiscoveriesQueryDto) {
return this.assets.listFieldDiscoveries(query);
}
@Post('field-discoveries')
@RequirePermissions('assets.create')
createFieldDiscovery(
@Body() dto: CreateFieldDiscoveryDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.assets.createFieldDiscovery(dto, principal, request);
}
@Post('field-discoveries/:id/approve')
@RequirePermissions('assets.change_status')
approveFieldDiscovery(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ReviewFieldDiscoveryDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.assets.approveFieldDiscovery(id, dto, principal, request);
}
@Post('field-discoveries/:id/reject')
@RequirePermissions('assets.change_status')
rejectFieldDiscovery(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: RejectFieldDiscoveryDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.assets.rejectFieldDiscovery(id, dto, principal, request);
}
@Post('field-discoveries/:id/match')
@RequirePermissions('assets.change_status')
matchFieldDiscovery(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: MatchFieldDiscoveryDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.assets.matchFieldDiscovery(id, dto, principal, request);
}
@Get('tree')
@RequirePermissions('assets.read')
tree(@Query() query: ListAssetTreeQueryDto) {
return this.assets.tree(query);
}
@Get('tree-children')
@RequirePermissions('assets.read')
treeChildren(@Query() query: ListAssetTreeChildrenQueryDto) {
return this.assets.treeChildren(query);
}
@Get('parent-options')
@RequirePermissions('assets.read')
parentOptions(@Query() query: ParentOptionsQueryDto) {
return this.assets.parentOptions(query);
}
@Post()
@RequirePermissions('assets.create')
create(
@Body() dto: CreateAssetDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.assets.create(dto, principal, request);
}
@Get(':id/lineage')
@RequirePermissions('assets.read')
lineage(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.assets.lineage(id);
}
@Get(':id/dossier')
@RequirePermissions(
'assets.read',
'inspections.read',
'inspection_acts.read',
'inspection_findings.read',
'inspection_evidence.read',
'inspection_communications.read',
)
dossier(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.assets.dossier(id);
}
@Get(':id/context-history')
@RequirePermissions('assets.read_history')
contextHistory(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.assets.contextHistory(id);
}
@Post(':id/context')
@RequirePermissions('assets.manage_context')
changeContext(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ChangeAssetContextDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.assets.changeContext(id, dto, principal, request);
}
@Get(':id')
@RequirePermissions('assets.read')
getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.assets.getById(id);
}
@Patch(':id')
@RequirePermissions('assets.update')
update(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateAssetDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.assets.update(id, dto, principal, request);
}
@Patch(':id/operational-status')
@RequirePermissions('assets.change_operational_status')
changeOperationalStatus(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ChangeAssetOperationalStatusDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.assets.changeOperationalStatus(id, dto, principal, request);
}
@Patch(':id/information-status')
@RequirePermissions('assets.change_status')
changeStatus(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ChangeAssetStatusDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.assets.changeStatus(id, dto, principal, request);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,2 +0,0 @@
import { Transform,Type } from 'class-transformer'; import { IsDateString,IsEnum,IsNumber,IsOptional,IsString,IsUUID,Max,MaxLength,Min } from 'class-validator'; import { AreaLegalRightOrganizationRole } from '../../database/entities';
export class AddAreaLegalRightOrganizationDto { @IsUUID('4') organizationId!:string; @IsEnum(AreaLegalRightOrganizationRole) role!:AreaLegalRightOrganizationRole; @IsOptional() @Type(()=>Number) @IsNumber({maxDecimalPlaces:4}) @Min(0.0001) @Max(100) participationPercent?:number|null; @IsOptional() @IsDateString() validFrom?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(2000) notes?:string|null; }
@@ -1,2 +0,0 @@
import { Transform,Type } from 'class-transformer'; import { IsDateString,IsEnum,IsNumber,IsOptional,IsString,IsUUID,Max,MaxLength,Min } from 'class-validator'; import { OrganizationMembershipRole } from '../../database/entities';
export class AddOrganizationMembershipDto { @IsUUID('4') memberOrganizationId!:string; @IsEnum(OrganizationMembershipRole) role:OrganizationMembershipRole=OrganizationMembershipRole.MEMBER; @IsOptional() @Type(()=>Number) @IsNumber({maxDecimalPlaces:4}) @Min(0.0001) @Max(100) participationPercent?:number|null; @IsOptional() @IsDateString() validFrom?:string|null; @IsOptional() @IsUUID('4') sourceDocumentId?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(2000) notes?:string|null; }
@@ -1,27 +0,0 @@
import { Transform, Type } from 'class-transformer';
import { IsDate, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class ChangeAssetContextDto {
@IsOptional()
@IsUUID('4')
parentId?: string | null;
@IsOptional()
@IsUUID('4')
operationalAreaId?: string | null;
@IsOptional()
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@Type(() => Date)
@IsDate()
effectiveAt?: Date;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(5)
@MaxLength(2000)
reason!: string;
}
@@ -1,3 +0,0 @@
import { IsEnum } from 'class-validator';
import { AssetOperationalStatus } from '../../database/entities';
export class ChangeAssetOperationalStatusDto { @IsEnum(AssetOperationalStatus) status!: AssetOperationalStatus; }
@@ -1,7 +0,0 @@
import { IsEnum } from 'class-validator';
import { AssetInformationStatus } from '../../database/entities';
export class ChangeAssetStatusDto {
@IsEnum(AssetInformationStatus)
informationStatus!: AssetInformationStatus;
}
@@ -1,13 +0,0 @@
import { Transform, Type } from 'class-transformer';
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min, MinLength } from 'class-validator';
import { AreaOrganizationRole } from '../../database/entities';
export class CreateAreaCompanyRelationDto {
@IsUUID('4') areaId!: string;
@IsUUID('4') companyId!: string;
@IsOptional() @IsEnum(AreaOrganizationRole) relationRole: AreaOrganizationRole = AreaOrganizationRole.OPERATOR;
@IsOptional() @Type(() => Number) @IsNumber({ maxDecimalPlaces: 4 }) @Min(0.0001) @Max(100) participationPercent?: number | null;
@IsOptional() @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) @IsString() @MaxLength(240) legalInstrument?: string | null;
@IsOptional() @IsUUID('4') sourceDocumentId?: string | null;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() @MinLength(3) @MaxLength(1000) reason!: string;
}
@@ -1,2 +0,0 @@
import { Transform } from 'class-transformer'; import { IsDateString,IsEnum,IsOptional,IsString,IsUUID,MaxLength,MinLength } from 'class-validator'; import { AreaLegalRightStatus,AreaLegalRightType } from '../../database/entities';
export class CreateAreaLegalRightDto { @IsEnum(AreaLegalRightType) rightType!:AreaLegalRightType; @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(260) name!:string; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(180) instrumentNumber?:string|null; @IsOptional() @IsDateString() validFrom?:string|null; @IsOptional() @IsDateString() validUntil?:string|null; @IsOptional() @IsEnum(AreaLegalRightStatus) status:AreaLegalRightStatus=AreaLegalRightStatus.ACTIVE; @IsOptional() @IsUUID('4') sourceDocumentId?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(4000) notes?:string|null; }
@@ -1,52 +0,0 @@
import { Type } from 'class-transformer';
import {
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
import { AssetMediaKind } from '../../database/entities';
export class CreateAssetMediaDto {
@IsEnum(AssetMediaKind)
kind!: AssetMediaKind;
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
description?: string;
@IsOptional()
@IsISO8601({ strict: true })
capturedAt?: string;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 6 })
@Min(-90)
@Max(90)
latitude?: number;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 6 })
@Min(-180)
@Max(180)
longitude?: number;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 3 })
@Min(0)
@Max(100000)
accuracyM?: number;
}
@@ -1,46 +0,0 @@
import { Transform } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsEnum,
IsString,
IsUUID,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
import { AssetTypeOperationalRole } from '../../database/entities';
export class CreateAssetTypeDto {
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
@IsString()
@MinLength(2)
@MaxLength(80)
@Matches(/^[a-z][a-z0-9_-]+$/)
code!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(160)
name!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MaxLength(2000)
description!: string;
@IsBoolean()
canBeRoot!: boolean;
@IsEnum(AssetTypeOperationalRole)
operationalRole = AssetTypeOperationalRole.GENERIC;
@IsArray()
@ArrayUnique()
@IsUUID('4', { each: true })
allowedParentTypeIds!: string[];
}
@@ -1,67 +0,0 @@
import { Transform } from 'class-transformer';
import {
IsEnum,
IsObject,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
import { AssetInformationStatus } from '../../database/entities';
export class CreateAssetDto {
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toUpperCase() : value,
)
@IsString()
@MinLength(1)
@MaxLength(120)
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
code!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(200)
name!: string;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
)
@IsString()
@MaxLength(200)
commonName?: string | null;
@IsUUID('4')
typeId!: string;
@IsOptional()
@IsUUID('4')
parentId?: string | null;
@IsOptional()
@IsUUID('4')
operationalAreaId?: string | null;
@IsOptional()
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
)
@IsString()
@MaxLength(4000)
description?: string | null;
@IsOptional()
@IsEnum(AssetInformationStatus)
informationStatus = AssetInformationStatus.DRAFT;
@IsObject()
attributes!: Record<string, unknown>;
}
@@ -1,63 +0,0 @@
import { Transform } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
import { AssetAttributeDataType } from '../../database/entities';
export class CreateAttributeDefinitionDto {
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
@IsString()
@MinLength(2)
@MaxLength(80)
@Matches(/^[a-z][a-z0-9_-]+$/)
code!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(160)
name!: string;
@IsEnum(AssetAttributeDataType)
dataType!: AssetAttributeDataType;
@IsBoolean()
isRequired!: boolean;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
)
@IsString()
@MaxLength(40)
unit?: string | null;
@ValidateIf((dto: CreateAttributeDefinitionDto) =>
dto.dataType === AssetAttributeDataType.SELECT,
)
@IsArray()
@ArrayUnique()
@IsString({ each: true })
@MinLength(1, { each: true })
@MaxLength(160, { each: true })
options?: string[];
@IsInt()
@Min(0)
@Max(10_000)
sortOrder!: number;
}
@@ -1,2 +0,0 @@
import { Transform } from 'class-transformer'; import { IsDateString,IsOptional,IsString,IsUUID,Matches,MaxLength,MinLength } from 'class-validator';
export class CreateExternalIdentifierDto { @Transform(({value})=>typeof value==='string'?value.trim().toUpperCase():value) @IsString() @Matches(/^[A-Z0-9][A-Z0-9._/-]{1,79}$/) namespace!:string; @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(1) @MaxLength(180) value!:string; @IsOptional() @IsDateString() validFrom?:string|null; @IsOptional() @IsUUID('4') sourceDocumentId?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(2000) notes?:string|null; }
@@ -1,53 +0,0 @@
import { Transform } from 'class-transformer';
import { IsObject, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator';
export class CreateFieldDiscoveryDto {
@IsUUID('4')
visitId!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value)
@IsString()
@MinLength(1)
@MaxLength(120)
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
code!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(200)
name!: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(200)
commonName?: string | null;
@IsUUID('4')
typeId!: string;
@IsUUID('4')
parentId!: string;
@IsUUID('4')
operationalAreaId!: string;
@IsUUID('4')
operatorCompanyId!: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(4000)
description?: string | null;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(4000)
discoveryNotes?: string | null;
@IsObject()
attributes!: Record<string, unknown>;
}
@@ -1,2 +0,0 @@
import { Transform } from 'class-transformer'; import { IsDateString,IsEnum,IsOptional,IsString,MaxLength,MinLength } from 'class-validator'; import { SourceDocumentType } from '../../database/entities';
export class CreateSourceDocumentDto { @IsEnum(SourceDocumentType) documentType!:SourceDocumentType; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(160) documentNumber?:string|null; @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(300) title!:string; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(240) issuer?:string|null; @IsOptional() @IsDateString() documentDate?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(500) externalReference?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(4000) notes?:string|null; }
@@ -1,10 +0,0 @@
import { Transform } from 'class-transformer';
import { IsString, MaxLength, MinLength } from 'class-validator';
export class EndAreaCompanyRelationDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(3)
@MaxLength(1000)
reason!: string;
}
@@ -1 +0,0 @@
import { Transform } from 'class-transformer'; import { IsDateString,IsOptional,IsString,MaxLength,MinLength } from 'class-validator'; export class EndAreaLegalRightOrganizationDto { @IsOptional() @IsDateString() validUntil?:string|null; @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(1000) reason!:string; }
@@ -1 +0,0 @@
import { Transform } from 'class-transformer'; import { IsString,MaxLength,MinLength } from 'class-validator'; export class EndExternalIdentifierDto { @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(1000) reason!:string; }
@@ -1 +0,0 @@
import { Transform } from 'class-transformer'; import { IsDateString,IsOptional,IsString,MaxLength,MinLength } from 'class-validator'; export class EndOrganizationMembershipDto { @IsOptional() @IsDateString() validUntil?:string|null; @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(1000) reason!:string; }
@@ -1,2 +0,0 @@
import { Transform } from 'class-transformer'; import { IsEnum,IsOptional,IsString,MaxLength } from 'class-validator'; import { AssetSourceDocumentRelationType } from '../../database/entities';
export class LinkAssetSourceDocumentDto { @IsEnum(AssetSourceDocumentRelationType) relationType:AssetSourceDocumentRelationType=AssetSourceDocumentRelationType.SOURCE; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(2000) notes?:string|null; }
@@ -1,17 +0,0 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional, IsUUID } from 'class-validator';
export class ListAreaCompanyRelationsQueryDto {
@IsOptional()
@IsUUID('4')
areaId?: string;
@IsOptional()
@IsUUID('4')
companyId?: string;
@IsOptional()
@Transform(({ value }) => value === true || value === 'true' || value === '1')
@IsBoolean()
includeHistory = false;
}
@@ -1,16 +0,0 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator';
import { ListAssetTreeQueryDto } from './list-asset-tree-query.dto';
export class ListAssetTreeChildrenQueryDto extends ListAssetTreeQueryDto {
@IsOptional()
@IsUUID('4')
parentId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
limit = 100;
}
@@ -1,40 +0,0 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
export class ListAssetTreeQueryDto {
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
@IsOptional()
@IsUUID('4')
typeId?: string;
@IsOptional()
@IsEnum(AssetInformationStatus)
status?: AssetInformationStatus;
@IsOptional()
@IsEnum(AssetOperationalStatus)
operationalStatus?: AssetOperationalStatus;
@IsOptional()
@IsUUID('4')
operationalAreaId?: string;
@IsOptional()
@IsUUID('4')
operatorCompanyId?: string;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
needsValidation?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
hasGeometry?: boolean;
}
@@ -1,62 +0,0 @@
import { Transform, Type } from 'class-transformer';
import {
IsEnum,
IsInt,
IsISO8601,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import {
AssetInformationStatus,
AssetVersionChangeType,
} from '../../database/entities';
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
export class AssetVersionPageQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 25;
}
export class ListAssetVersionsQueryDto extends AssetVersionPageQueryDto {
@Transform(trim)
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
@IsOptional()
@IsUUID('4')
typeId?: string;
@IsOptional()
@IsEnum(AssetInformationStatus)
status?: AssetInformationStatus;
@IsOptional()
@IsEnum(AssetVersionChangeType)
changeType?: AssetVersionChangeType;
@IsOptional()
@IsISO8601({ strict: true })
from?: string;
@IsOptional()
@IsISO8601({ strict: true })
to?: string;
}
@@ -1,68 +0,0 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
export class ListAssetsQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 25;
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
@IsOptional()
@IsUUID('4')
typeId?: string;
@IsOptional()
@IsEnum(AssetInformationStatus)
status?: AssetInformationStatus;
@IsOptional()
@IsEnum(AssetOperationalStatus)
operationalStatus?: AssetOperationalStatus;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
needsValidation?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
hasGeometry?: boolean;
@IsOptional()
@IsUUID('4')
parentId?: string;
@IsOptional()
@IsUUID('4')
operationalAreaId?: string;
@IsOptional()
@IsUUID('4')
operatorCompanyId?: string;
}
@@ -1,26 +0,0 @@
import { Transform } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class ListFieldDiscoveriesQueryDto {
@IsOptional()
@IsIn(['PENDING', 'APPROVED', 'MATCHED', 'REJECTED'])
status?: 'PENDING' | 'APPROVED' | 'MATCHED' | 'REJECTED';
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
search?: string;
@IsOptional()
@Transform(({ value }) => Number(value ?? 1))
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Transform(({ value }) => Number(value ?? 25))
@IsInt()
@Min(1)
@Max(100)
pageSize = 25;
}
@@ -1,7 +0,0 @@
import { IsOptional, IsUUID } from 'class-validator';
export class ListOperationalAreasQueryDto {
@IsOptional()
@IsUUID('4')
parentId?: string;
}
@@ -1,50 +0,0 @@
import { Transform, Type } from 'class-transformer';
import {
IsEnum,
IsInt,
IsISO8601,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import { AssetInformationStatus } from '../../database/entities';
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
export class TemporalAtQueryDto {
@IsISO8601({ strict: true })
at!: string;
}
export class ListTemporalAssetsQueryDto extends TemporalAtQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 25;
@Transform(trim)
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
@IsOptional()
@IsUUID('4')
typeId?: string;
@IsOptional()
@IsEnum(AssetInformationStatus)
status?: AssetInformationStatus;
}
@@ -1,24 +0,0 @@
import { IsEnum, IsOptional, IsString, IsUUID, Matches } from 'class-validator';
import {
AssetGeometryType,
AssetInformationStatus,
} from '../../database/entities';
export class MapAssetsQueryDto {
@IsOptional()
@IsString()
@Matches(/^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/)
bbox?: string;
@IsOptional()
@IsUUID('4')
typeId?: string;
@IsOptional()
@IsEnum(AssetInformationStatus)
status?: AssetInformationStatus;
@IsOptional()
@IsEnum(AssetGeometryType)
geometryType?: AssetGeometryType;
}
@@ -1,15 +0,0 @@
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
export class ParentOptionsQueryDto {
@IsUUID('4')
childTypeId!: string;
@IsOptional()
@IsUUID('4')
assetId?: string;
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
}
@@ -1,29 +0,0 @@
import { Transform } from 'class-transformer';
import { IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class ReviewFieldDiscoveryDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(4000)
notes?: string | null;
}
export class RejectFieldDiscoveryDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(5)
@MaxLength(4000)
reason!: string;
}
export class MatchFieldDiscoveryDto {
@IsUUID('4')
matchedAssetId!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(5)
@MaxLength(4000)
reason!: string;
}
@@ -1,2 +0,0 @@
import { Transform } from 'class-transformer'; import { IsDateString,IsEnum,IsOptional,IsString,IsUUID,MaxLength,MinLength } from 'class-validator'; import { AreaLegalRightStatus } from '../../database/entities';
export class UpdateAreaLegalRightDto { @IsOptional() @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(260) name?:string; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(180) instrumentNumber?:string|null; @IsOptional() @IsDateString() validFrom?:string|null; @IsOptional() @IsDateString() validUntil?:string|null; @IsOptional() @IsEnum(AreaLegalRightStatus) status?:AreaLegalRightStatus; @IsOptional() @IsUUID('4') sourceDocumentId?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(4000) notes?:string|null; @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(1000) reason!:string; }
@@ -1,47 +0,0 @@
import { Type } from 'class-transformer';
import {
IsISO8601,
IsNumber,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export class UpdateAssetMediaDto {
@IsOptional()
@IsString()
@MaxLength(200)
title?: string | null;
@IsOptional()
@IsString()
@MaxLength(4000)
description?: string | null;
@IsOptional()
@IsISO8601({ strict: true })
capturedAt?: string | null;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 6 })
@Min(-90)
@Max(90)
latitude?: number | null;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 6 })
@Min(-180)
@Max(180)
longitude?: number | null;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 3 })
@Min(0)
@Max(100000)
accuracyM?: number | null;
}
@@ -1,39 +0,0 @@
import { Transform } from 'class-transformer';
import {
IsEnum,
IsISO8601,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { AssetDataOrigin } from '../../database/entities';
const nullableTrim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() || null : value;
export class UpdateAssetProvenanceDto {
@IsEnum(AssetDataOrigin)
origin!: AssetDataOrigin;
@IsOptional()
@Transform(nullableTrim)
@IsString()
@MaxLength(160)
sourceName?: string | null;
@IsOptional()
@Transform(nullableTrim)
@IsString()
@MaxLength(255)
sourceReference?: string | null;
@IsOptional()
@IsISO8601({ strict: true })
observedAt?: string | null;
@IsOptional()
@Transform(nullableTrim)
@IsString()
@MaxLength(4000)
notes?: string | null;
}
@@ -1,46 +0,0 @@
import { Transform } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsEnum,
IsOptional,
IsString,
IsUUID,
MaxLength,
MinLength,
} from 'class-validator';
import { AssetTypeOperationalRole } from '../../database/entities';
export class UpdateAssetTypeDto {
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(160)
name?: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MaxLength(2000)
description?: string;
@IsOptional()
@IsBoolean()
canBeRoot?: boolean;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsEnum(AssetTypeOperationalRole)
operationalRole?: AssetTypeOperationalRole;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsUUID('4', { each: true })
allowedParentTypeIds?: string[];
}
@@ -1,65 +0,0 @@
import { Transform } from 'class-transformer';
import {
IsObject,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
export class UpdateAssetDto {
@IsOptional()
@IsUUID('4')
typeId?: string;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toUpperCase() : value,
)
@IsString()
@MinLength(1)
@MaxLength(120)
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
code?: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(200)
name?: string;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
)
@IsString()
@MaxLength(200)
commonName?: string | null;
@IsOptional()
@IsUUID('4')
parentId?: string | null;
@IsOptional()
@IsUUID('4')
operationalAreaId?: string | null;
@IsOptional()
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
)
@IsString()
@MaxLength(4000)
description?: string | null;
@IsOptional()
@IsObject()
attributes?: Record<string, unknown>;
}

Some files were not shown because too many files have changed in this diff Show More