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 { 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 { 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 { 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 { return { geometry: view.geometry, geometryType: view.geometryType, source: view.source, accuracyM: view.accuracyM, capturedAt: view.capturedAt, deviceLabel: view.deviceLabel, }; } }