302 lines
9.4 KiB
TypeScript
302 lines
9.4 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|