diff --git a/api-v3/package-lock.json b/api-v3/package-lock.json index b646329..0d4c2f6 100644 --- a/api-v3/package-lock.json +++ b/api-v3/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-api", - "version": "0.29.0-12", + "version": "0.29.0-13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-api", - "version": "0.29.0-12", + "version": "0.29.0-13", "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^11.0.0", diff --git a/api-v3/package.json b/api-v3/package.json index c930414..80287b4 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.29.0-12", + "version": "0.29.0-13", "private": true, "license": "UNLICENSED", "scripts": { diff --git a/api-v3/src/database/migrations/1790142600000-f6-12-admin-report-permission.ts b/api-v3/src/database/migrations/1790142600000-f6-12-admin-report-permission.ts new file mode 100644 index 0000000..409dbdc --- /dev/null +++ b/api-v3/src/database/migrations/1790142600000-f6-12-admin-report-permission.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F612AdminReportPermission1790142600000 implements MigrationInterface { + name = 'F612AdminReportPermission1790142600000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO role_permissions(role_id, permission_id) + SELECT role.id, permission.id + FROM roles role + JOIN permissions permission ON permission.code='inspection_reports.generate' + WHERE role.code='admin' + ON CONFLICT(role_id, permission_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM role_permissions role_permission + USING roles role, permissions permission + WHERE role_permission.role_id=role.id + AND role_permission.permission_id=permission.id + AND role.code='admin' + AND permission.code='inspection_reports.generate' + `); + } +} diff --git a/api-v3/src/inspection-reports/smtp-delivery.service.ts b/api-v3/src/inspection-reports/smtp-delivery.service.ts index bc61968..992855b 100644 --- a/api-v3/src/inspection-reports/smtp-delivery.service.ts +++ b/api-v3/src/inspection-reports/smtp-delivery.service.ts @@ -1,7 +1,7 @@ import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto'; import { connect as connectNet, Socket } from 'node:net'; import { connect as connectTls, TLSSocket } from 'node:tls'; -import { Injectable } from '@nestjs/common'; +import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DataSource } from 'typeorm'; import { SmtpSecurityMode } from '../database/entities'; @@ -250,9 +250,9 @@ export class SmtpDeliveryService { private encryptionKey():Buffer{ const raw=this.config.get('SMTP_SETTINGS_MASTER_KEY'); - if(!raw)throw new Error('SMTP_SETTINGS_MASTER_KEY no configurada'); + if(!raw)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_NOT_CONFIGURED', message:'La clave maestra para proteger credenciales SMTP no está configurada en el servidor' }); const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64'); - if(key.length!==32)throw new Error('SMTP_SETTINGS_MASTER_KEY debe contener exactamente 32 bytes'); + if(key.length!==32)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_INVALID', message:'La clave maestra SMTP del servidor tiene un formato inválido' }); return key; } diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index 4bd1bea..f87a9d7 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -1,2 +1,2 @@ -export const API_VERSION = '0.29.0-12'; -export const API_PHASE = 'F6.11'; +export const API_VERSION = '0.29.0-13'; +export const API_PHASE = 'F6.12'; diff --git a/api-v3/test/unit/f4-health-metadata.test.ts b/api-v3/test/unit/f4-health-metadata.test.ts index a6eb118..25e8061 100644 --- a/api-v3/test/unit/f4-health-metadata.test.ts +++ b/api-v3/test/unit/f4-health-metadata.test.ts @@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { API_PHASE, API_VERSION } from '../../src/version'; -test('health metadata reports the current F6.11 release', () => { - assert.equal(API_PHASE, 'F6.11'); +test('health metadata reports the current F6.12 release', () => { + assert.equal(API_PHASE, 'F6.12'); const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string }; assert.equal(API_VERSION, pkg.version); - assert.equal(API_VERSION, '0.29.0-12'); + assert.equal(API_VERSION, '0.29.0-13'); }); diff --git a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts index b2cac9a..c1b07af 100644 --- a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts +++ b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts @@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1]; assert.equal(visibleVersion, pkg.version); - assert.match(version, /APP_PHASE\s*=\s*'F6\.11/); + assert.match(version, /APP_PHASE\s*=\s*'F6\.12/); }); test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => { diff --git a/api-v3/test/unit/f6-12-map-smtp-admin-fixes.test.ts b/api-v3/test/unit/f6-12-map-smtp-admin-fixes.test.ts new file mode 100644 index 0000000..89cb078 --- /dev/null +++ b/api-v3/test/unit/f6-12-map-smtp-admin-fixes.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); +const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8'); + +test('F6.12 uses standard GeoJSON names in WEB and converts only at the API boundary', () => { + const client = web('src/lib/api.ts'); + const map = web('src/features/map/DhMap.tsx'); + const editor = web('src/features/map/AssetGeometryEditor.tsx'); + assert.match(client, /type: 'Point'/); + assert.match(client, /type: 'LineString'/); + assert.match(client, /type: 'Polygon'/); + assert.match(client, /geometryPayload/); + assert.match(map, /feature\.geometry\.type === 'Point'/); + assert.match(editor, /setType\(value\.geometryType\)/); +}); + +test('F6.12 reports missing SMTP encryption configuration explicitly', () => { + const smtp = api('src/inspection-reports/smtp-delivery.service.ts'); + assert.match(smtp, /SMTP_SETTINGS_MASTER_KEY_NOT_CONFIGURED/); + assert.match(smtp, /ServiceUnavailableException/); +}); + +test('F6.12 grants report management to the system administrator role', () => { + const migration = api('src/database/migrations/1790142600000-f6-12-admin-report-permission.ts'); + assert.match(migration, /role\.code='admin'/); + assert.match(migration, /inspection_reports\.generate/); + assert.match(migration, /ON CONFLICT\(role_id, permission_id\) DO NOTHING/); +}); diff --git a/docs/PHASE_F6_12_MAP_SMTP_FIXES.md b/docs/PHASE_F6_12_MAP_SMTP_FIXES.md new file mode 100644 index 0000000..c64061c --- /dev/null +++ b/docs/PHASE_F6_12_MAP_SMTP_FIXES.md @@ -0,0 +1,7 @@ +# F6.12 · Geometrías y correo SMTP + +- Corrige el contrato GeoJSON entre PostGIS, WEB y MapLibre: `Point`, `LineString` y `Polygon` se usan para render; `POINT`, `LINESTRING` y `POLYGON` quedan como tipos técnicos al persistir. +- El editor de geometrías vuelve a cargar correctamente geometrías existentes y conserva el tipo técnico separado del GeoJSON. +- La vista general del mapa vuelve a calcular bounds sobre GeoJSON real. +- El SMTP administrable requiere una clave maestra AES-256-GCM persistente; si falta o es inválida, el API devuelve un error de configuración explícito en vez de un 500 genérico. +- El rol Administrador recibe `inspection_reports.generate`, coherente con su definición de administración total. diff --git a/web-v2/package-lock.json b/web-v2/package-lock.json index 3cf144f..2ddf213 100644 --- a/web-v2/package-lock.json +++ b/web-v2/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-web", - "version": "0.23.0-8", + "version": "0.23.0-9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-web", - "version": "0.23.0-8", + "version": "0.23.0-9", "dependencies": { "maplibre-gl": "6.4.1", "react": "^19.0.0", diff --git a/web-v2/package.json b/web-v2/package.json index c6aa78f..ad8295e 100644 --- a/web-v2/package.json +++ b/web-v2/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-web", - "version": "0.23.0-8", + "version": "0.23.0-9", "private": true, "type": "module", "engines": { diff --git a/web-v2/src/config/version.ts b/web-v2/src/config/version.ts index d2c5afd..b2d7fb3 100644 --- a/web-v2/src/config/version.ts +++ b/web-v2/src/config/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.23.0-8'; -export const APP_PHASE = 'F6.11 · GEDO y respuestas de informes'; +export const APP_VERSION = '0.23.0-9'; +export const APP_PHASE = 'F6.12 · Geometrías y correo SMTP'; diff --git a/web-v2/src/features/map/AssetGeometryEditor.tsx b/web-v2/src/features/map/AssetGeometryEditor.tsx index 8cc2b08..5815086 100644 --- a/web-v2/src/features/map/AssetGeometryEditor.tsx +++ b/web-v2/src/features/map/AssetGeometryEditor.tsx @@ -41,22 +41,22 @@ function localDateTime(value: string | number | Date): string { function geometryVertices(geometry: GeoJsonGeometry | null): Position[] { if (!geometry) return []; - if (geometry.type === 'POINT') return [geometry.coordinates]; - if (geometry.type === 'LINESTRING') return geometry.coordinates; + if (geometry.type === 'Point') return [geometry.coordinates]; + if (geometry.type === 'LineString') return geometry.coordinates; return geometry.coordinates[0]?.slice(0, -1) ?? []; } function draftGeometry(type: AssetGeometryType, vertices: Position[]): GeoJsonGeometry | null { - if (type === 'POINT') return vertices[0] ? { type, coordinates: vertices[0] } : null; - if (type === 'LINESTRING') return vertices.length >= 2 ? { type, coordinates: vertices } : null; + if (type === 'POINT') return vertices[0] ? { type: 'Point', coordinates: vertices[0] } : null; + if (type === 'LINESTRING') return vertices.length >= 2 ? { type: 'LineString', coordinates: vertices } : null; if (vertices.length < 3) return null; - return { type, coordinates: [[...vertices, vertices[0]!]] }; + return { type: 'Polygon', coordinates: [[...vertices, vertices[0]!]] }; } function drawingCollection(geometry: GeoJsonGeometry | null, vertices: Position[]) { const features: unknown[] = []; if (geometry) features.push({ type: 'Feature', properties: { kind: 'shape' }, geometry }); - if (geometry?.type !== 'POINT') { + if (geometry?.type !== 'Point') { vertices.forEach((coordinates, index) => features.push({ type: 'Feature', properties: { kind: 'vertex', index: index + 1 }, geometry: { type: 'Point', coordinates }, @@ -187,7 +187,7 @@ export function AssetGeometryEditor({ const applyStored = (value: AssetGeometry | null) => { setStored(value); if (value) { - setType(value.geometry.type); + setType(value.geometryType); setVertices(geometryVertices(value.geometry)); setAccuracyM(value.accuracyM == null ? '' : String(value.accuracyM)); setCapturedAt(value.capturedAt ? localDateTime(value.capturedAt) : ''); diff --git a/web-v2/src/features/map/DhMap.tsx b/web-v2/src/features/map/DhMap.tsx index 5aaa3ab..5248abc 100644 --- a/web-v2/src/features/map/DhMap.tsx +++ b/web-v2/src/features/map/DhMap.tsx @@ -25,9 +25,9 @@ const interactiveLayers = ['assets-points', 'assets-lines', 'assets-polygons']; function boundsFromFeatures(collection: MapAssetFeatureCollection) { const positions: Array<[number, number]> = []; collection.features.forEach((feature) => { - if (feature.geometry.type === 'POINT') positions.push(feature.geometry.coordinates); - if (feature.geometry.type === 'LINESTRING') positions.push(...feature.geometry.coordinates); - if (feature.geometry.type === 'POLYGON') feature.geometry.coordinates.forEach((ring) => positions.push(...ring)); + if (feature.geometry.type === 'Point') positions.push(feature.geometry.coordinates); + if (feature.geometry.type === 'LineString') positions.push(...feature.geometry.coordinates); + if (feature.geometry.type === 'Polygon') feature.geometry.coordinates.forEach((ring) => positions.push(...ring)); }); if (!positions.length) return null; return positions.reduce<[number, number, number, number]>((result, point) => [ diff --git a/web-v2/src/lib/api.ts b/web-v2/src/lib/api.ts index 6483d10..433864a 100644 --- a/web-v2/src/lib/api.ts +++ b/web-v2/src/lib/api.ts @@ -536,9 +536,9 @@ export type Position = [number, number]; export type AssetGeometryType = 'POINT' | 'LINESTRING' | 'POLYGON'; export type GeoJsonGeometry = - | { type: 'POINT'; coordinates: Position } - | { type: 'LINESTRING'; coordinates: Position[] } - | { type: 'POLYGON'; coordinates: Position[][] }; + | { type: 'Point'; coordinates: Position } + | { type: 'LineString'; coordinates: Position[] } + | { type: 'Polygon'; coordinates: Position[][] }; export interface AssetGeometry { assetId: string; @@ -2162,6 +2162,15 @@ export async function getAssetGeometry(assetId: string) { return (await apiRequest<{ data: AssetGeometry | null }>(`/assets/${assetId}/geometry`)).data; } +function geometryPayload(geometry: GeoJsonGeometry) { + const type: AssetGeometryType = geometry.type === 'Point' + ? 'POINT' + : geometry.type === 'LineString' + ? 'LINESTRING' + : 'POLYGON'; + return { type, coordinates: geometry.coordinates }; +} + export function upsertAssetGeometry(assetId: string, input: { geometry: GeoJsonGeometry; accuracyM?: number | null; @@ -2169,7 +2178,7 @@ export function upsertAssetGeometry(assetId: string, input: { deviceLabel?: string | null; }) { return apiRequest(`/assets/${assetId}/geometry`, { - method: 'PUT', body: JSON.stringify(input), + method: 'PUT', body: JSON.stringify({ ...input, geometry: geometryPayload(input.geometry) }), }); } diff --git a/web-v2/src/pages/ReportDetailPage.tsx b/web-v2/src/pages/ReportDetailPage.tsx index b5e3a53..b9cbdcd 100644 --- a/web-v2/src/pages/ReportDetailPage.tsx +++ b/web-v2/src/pages/ReportDetailPage.tsx @@ -234,7 +234,7 @@ export function ReportDetailPage() {
GEDO

Oficialización del Informe

No existe una respuesta automática de GEDO. Cuando recibas el identificador IF y el PDF oficial, cargalos manualmente aquí.

- {report.status === 'OFFICIALIZED' ? <>
Identificador IF{report.gedoIfIdentifier ?? '—'}
Fecha GEDO{formatDate(report.gedoOfficializedAt)}
PDF oficial{report.gedoPdfOriginalName ?? 'Registrado'}
Vencimiento de respuestas{formatDateOnly(report.responseDueOn)}
{report.gedoPdfOriginalName && }{report.gedoPdfSha256 &&

PDF GEDO fijado. SHA-256: {report.gedoPdfSha256}

} : canManage && report.status === 'WORKING' ?
Esta carga es manual. Registra la referencia institucional del Informe, pero no crea respuestas ni vencimientos automáticamente.
: El Informe todavía no está oficializado en GEDO.} + {report.status === 'OFFICIALIZED' ? <>
Identificador IF{report.gedoIfIdentifier ?? '—'}
Fecha GEDO{formatDate(report.gedoOfficializedAt)}
PDF oficial{report.gedoPdfOriginalName ?? 'Registrado'}
Vencimiento de respuestas{formatDateOnly(report.responseDueOn)}
{report.gedoPdfOriginalName && }{report.gedoPdfSha256 &&

PDF GEDO fijado. SHA-256: {report.gedoPdfSha256}

} : canManage && report.status === 'WORKING' ?
Esta carga es manual. Registra la referencia institucional del Informe, pero no crea respuestas ni vencimientos automáticamente.
: El Informe todavía no está oficializado en GEDO.{report.status === 'WORKING' && !canManage ? ' Tu usuario no tiene permiso para gestionar u oficializar Informes.' : ''}}