fix: restore map geometries and SMTP configuration
DH V2 CI / API · typecheck, tests, build (push) Successful in 38s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m36s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m45s
DH V2 CI / Promote verified main to deploy (push) Successful in 3s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m5s
DH V2 CI / API · typecheck, tests, build (push) Successful in 38s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m36s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m45s
DH V2 CI / Promote verified main to deploy (push) Successful in 3s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m5s
This commit is contained in:
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-12",
|
||||
"version": "0.29.0-13",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class F612AdminReportPermission1790142600000 implements MigrationInterface {
|
||||
name = 'F612AdminReportPermission1790142600000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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'
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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<string>('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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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.
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-8",
|
||||
"version": "0.23.0-9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) : '');
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
+13
-4
@@ -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<AssetGeometry>(`/assets/${assetId}/geometry`, {
|
||||
method: 'PUT', body: JSON.stringify(input),
|
||||
method: 'PUT', body: JSON.stringify({ ...input, geometry: geometryPayload(input.geometry) }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ export function ReportDetailPage() {
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">GEDO</span><h2>Oficialización del Informe</h2><p className="section-copy">No existe una respuesta automática de GEDO. Cuando recibas el identificador IF y el PDF oficial, cargalos manualmente aquí.</p></div></div>
|
||||
{report.status === 'OFFICIALIZED' ? <><div className="responsible-summary"><div><small>Identificador IF</small><strong>{report.gedoIfIdentifier ?? '—'}</strong></div><div><small>Fecha GEDO</small><strong>{formatDate(report.gedoOfficializedAt)}</strong></div><div><small>PDF oficial</small><strong>{report.gedoPdfOriginalName ?? 'Registrado'}</strong></div><div><small>Vencimiento de respuestas</small><strong>{formatDateOnly(report.responseDueOn)}</strong></div></div>{report.gedoPdfOriginalName && <div className="form-actions"><a className="button secondary" href={inspectionReportGedoPdfDownloadUrl(report.id)}>Descargar PDF oficial</a></div>}{report.gedoPdfSha256 && <div className="temporal-notice"><Icon name="check" /><p><strong>PDF GEDO fijado.</strong> SHA-256: <code>{report.gedoPdfSha256}</code></p></div>}</> : canManage && report.status === 'WORKING' ? <form className="form-section" onSubmit={officialize}><div className="form-grid"><label className="field"><span>Identificador IF de GEDO</span><input value={gedoIfIdentifier} onChange={(event) => setGedoIfIdentifier(event.target.value)} required maxLength={255} placeholder="IF-2026-…" /></label><label className="field"><span>Fecha de oficialización</span><input type="datetime-local" value={gedoOfficializedAt} onChange={(event) => setGedoOfficializedAt(event.target.value)} required /></label></div><label className="field"><span>PDF oficial de GEDO</span><input type="file" accept="application/pdf,.pdf" onChange={(event) => setGedoFile(event.target.files?.[0] ?? null)} required /></label><Alert>Esta carga es manual. Registra la referencia institucional del Informe, pero no crea respuestas ni vencimientos automáticamente.</Alert><div className="form-actions"><button className="button primary" disabled={working || !gedoFile || !gedoIfIdentifier.trim()}>{working ? 'Registrando…' : 'Cargar IF y PDF oficial'}</button></div></form> : <Alert type="info">El Informe todavía no está oficializado en GEDO.</Alert>}
|
||||
{report.status === 'OFFICIALIZED' ? <><div className="responsible-summary"><div><small>Identificador IF</small><strong>{report.gedoIfIdentifier ?? '—'}</strong></div><div><small>Fecha GEDO</small><strong>{formatDate(report.gedoOfficializedAt)}</strong></div><div><small>PDF oficial</small><strong>{report.gedoPdfOriginalName ?? 'Registrado'}</strong></div><div><small>Vencimiento de respuestas</small><strong>{formatDateOnly(report.responseDueOn)}</strong></div></div>{report.gedoPdfOriginalName && <div className="form-actions"><a className="button secondary" href={inspectionReportGedoPdfDownloadUrl(report.id)}>Descargar PDF oficial</a></div>}{report.gedoPdfSha256 && <div className="temporal-notice"><Icon name="check" /><p><strong>PDF GEDO fijado.</strong> SHA-256: <code>{report.gedoPdfSha256}</code></p></div>}</> : canManage && report.status === 'WORKING' ? <form className="form-section" onSubmit={officialize}><div className="form-grid"><label className="field"><span>Identificador IF de GEDO</span><input value={gedoIfIdentifier} onChange={(event) => setGedoIfIdentifier(event.target.value)} required maxLength={255} placeholder="IF-2026-…" /></label><label className="field"><span>Fecha de oficialización</span><input type="datetime-local" value={gedoOfficializedAt} onChange={(event) => setGedoOfficializedAt(event.target.value)} required /></label></div><label className="field"><span>PDF oficial de GEDO</span><input type="file" accept="application/pdf,.pdf" onChange={(event) => setGedoFile(event.target.files?.[0] ?? null)} required /></label><Alert>Esta carga es manual. Registra la referencia institucional del Informe, pero no crea respuestas ni vencimientos automáticamente.</Alert><div className="form-actions"><button className="button primary" disabled={working || !gedoFile || !gedoIfIdentifier.trim()}>{working ? 'Registrando…' : 'Cargar IF y PDF oficial'}</button></div></form> : <Alert type="info">El Informe todavía no está oficializado en GEDO.{report.status === 'WORKING' && !canManage ? ' Tu usuario no tiene permiso para gestionar u oficializar Informes.' : ''}</Alert>}
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
|
||||
Reference in New Issue
Block a user