chore: import DH V2 D5.6.4 production baseline

This commit is contained in:
DH V2
2026-09-05 10:12:35 -03:00
commit 82213e72f5
757 changed files with 84218 additions and 0 deletions
@@ -0,0 +1,131 @@
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;
}
@@ -0,0 +1,65 @@
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);
}
}
@@ -0,0 +1,301 @@
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,
};
}
}
@@ -0,0 +1,93 @@
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];
}
@@ -0,0 +1,43 @@
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);
}
}
@@ -0,0 +1,369 @@
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;
}
}
@@ -0,0 +1,405 @@
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');
}
}
@@ -0,0 +1,54 @@
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 {}
@@ -0,0 +1,72 @@
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 };
}
@@ -0,0 +1,106 @@
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();
});
});
}
}
@@ -0,0 +1,402 @@
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,
};
}
}
@@ -0,0 +1,92 @@
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);
}
}
@@ -0,0 +1,369 @@
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,
};
}
}
@@ -0,0 +1,52 @@
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);
}
}
@@ -0,0 +1,195 @@
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;
}
}
@@ -0,0 +1,60 @@
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); }
}
@@ -0,0 +1,252 @@
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'}); }
}
@@ -0,0 +1,33 @@
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);
}
}
@@ -0,0 +1,194 @@
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"`;
}
}
@@ -0,0 +1,118 @@
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,
);
}
}
@@ -0,0 +1,905 @@
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
`;
}
}
@@ -0,0 +1,41 @@
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]),
);
}
@@ -0,0 +1,193 @@
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
@@ -0,0 +1,2 @@
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; }
@@ -0,0 +1,2 @@
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; }
@@ -0,0 +1,27 @@
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;
}
@@ -0,0 +1,3 @@
import { IsEnum } from 'class-validator';
import { AssetOperationalStatus } from '../../database/entities';
export class ChangeAssetOperationalStatusDto { @IsEnum(AssetOperationalStatus) status!: AssetOperationalStatus; }
@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { AssetInformationStatus } from '../../database/entities';
export class ChangeAssetStatusDto {
@IsEnum(AssetInformationStatus)
informationStatus!: AssetInformationStatus;
}
@@ -0,0 +1,13 @@
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;
}
@@ -0,0 +1,2 @@
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; }
@@ -0,0 +1,52 @@
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;
}
@@ -0,0 +1,46 @@
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[];
}
@@ -0,0 +1,67 @@
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>;
}
@@ -0,0 +1,63 @@
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;
}
@@ -0,0 +1,2 @@
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; }
@@ -0,0 +1,53 @@
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>;
}
@@ -0,0 +1,2 @@
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; }
@@ -0,0 +1,10 @@
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;
}
@@ -0,0 +1 @@
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; }
@@ -0,0 +1 @@
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; }
@@ -0,0 +1 @@
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; }
@@ -0,0 +1,2 @@
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; }
@@ -0,0 +1,17 @@
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;
}
@@ -0,0 +1,16 @@
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;
}
@@ -0,0 +1,40 @@
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;
}
@@ -0,0 +1,62 @@
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;
}
@@ -0,0 +1,68 @@
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;
}
@@ -0,0 +1,26 @@
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;
}
@@ -0,0 +1,7 @@
import { IsOptional, IsUUID } from 'class-validator';
export class ListOperationalAreasQueryDto {
@IsOptional()
@IsUUID('4')
parentId?: string;
}
@@ -0,0 +1,50 @@
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;
}
@@ -0,0 +1,24 @@
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;
}
@@ -0,0 +1,15 @@
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;
}
@@ -0,0 +1,29 @@
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;
}
@@ -0,0 +1,2 @@
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; }
@@ -0,0 +1,47 @@
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;
}
@@ -0,0 +1,39 @@
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;
}
@@ -0,0 +1,46 @@
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[];
}
@@ -0,0 +1,65 @@
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>;
}
@@ -0,0 +1,58 @@
import { Transform } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { AssetAttributeDataType } from '../../database/entities';
export class UpdateAttributeDefinitionDto {
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(160)
name?: string;
@IsOptional()
@IsEnum(AssetAttributeDataType)
dataType?: AssetAttributeDataType;
@IsOptional()
@IsBoolean()
isRequired?: boolean;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
)
@IsString()
@MaxLength(40)
unit?: string | null;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
@MinLength(1, { each: true })
@MaxLength(160, { each: true })
options?: string[] | null;
@IsOptional()
@IsInt()
@Min(0)
@Max(10_000)
sortOrder?: number;
}
@@ -0,0 +1,44 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString,
Max,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
import { AssetGeometryType } from '../../database/entities';
export class AssetGeometryPayloadDto {
@IsEnum(AssetGeometryType)
type!: AssetGeometryType;
@IsArray()
coordinates!: unknown[];
}
export class UpsertAssetGeometryDto {
@ValidateNested()
@Type(() => AssetGeometryPayloadDto)
geometry!: AssetGeometryPayloadDto;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 3 })
@Min(0)
@Max(100_000)
accuracyM?: number | null;
@IsOptional()
@IsISO8601({ strict: true })
capturedAt?: string | null;
@IsOptional()
@IsString()
@MaxLength(255)
deviceLabel?: string | null;
}
@@ -0,0 +1,2 @@
import { Transform } from 'class-transformer'; import { IsEmail,IsEnum,IsOptional,IsString,MaxLength } from 'class-validator'; import { OrganizationKind } from '../../database/entities';
export class UpsertOrganizationProfileDto { @IsEnum(OrganizationKind) organizationKind!:OrganizationKind; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(240) legalName?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(32) taxId?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) notificationEmail?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(4000) notes?:string|null; }