Compare commits

..
Author SHA1 Message Date
admin 29208f4e1f release(android): prepare client demo 0.19.12
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / API · typecheck, tests, build (push) Successful in 36s
Production dependency audit / API · production dependencies (push) Successful in 9s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m33s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m3s
DH V2 CI / Promote verified main to deploy (push) Successful in 2s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m5s
2026-09-16 00:42:29 -03:00
admin 4523aef932 fix(map): serve MapLibre module worker as JavaScript
DH V2 CI / WEB · typecheck, build (push) Successful in 23s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
Production dependency audit / API · production dependencies (push) Successful in 49s
DH V2 CI / Docker / migrations / production images (push) Successful in 50s
DH V2 CI / API · typecheck, tests, build (push) Successful in 1m9s
DH V2 CI / Promote verified main to deploy (push) Successful in 2s
2026-09-16 00:32:03 -03:00
admin 011b3e7fcb fix(web): include map worker build script in image
Production dependency audit / API · production dependencies (push) Successful in 48s
DH V2 CI / API · typecheck, tests, build (push) Successful in 1m10s
DH V2 CI / Docker / migrations / production images (push) Successful in 59s
DH V2 CI / Promote verified main to deploy (push) Successful in 2s
DH V2 CI / WEB · typecheck, build (push) Successful in 23s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
2026-09-16 00:23:57 -03:00
admin 9b7d67bea2 test(web): align F6.13 presentation phase
DH V2 CI / WEB · typecheck, build (push) Successful in 23s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
Production dependency audit / API · production dependencies (push) Successful in 49s
DH V2 CI / API · typecheck, tests, build (push) Successful in 1m11s
DH V2 CI / Docker / migrations / production images (push) Failing after 1m1s
DH V2 CI / Promote verified main to deploy (push) Skipped
2026-09-16 00:18:53 -03:00
admin f0b92c00e1 fix(map): publish MapLibre worker
DH V2 CI / WEB · typecheck, build (push) Successful in 21s
DH V2 CI / API · typecheck, tests, build (push) Failing after 46s
DH V2 CI / Docker / migrations / production images (push) Skipped
DH V2 CI / Promote verified main to deploy (push) Skipped
2026-09-16 00:14:17 -03:00
admin cab9cd7c01 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
2026-09-15 23:58:49 -03:00
24 changed files with 136 additions and 44 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
name: Android CI / RC
# F6.8 offline/document barrier: lint + real tests + debug artifact + release compile.
# F6.13 client-demo barrier: lint + real tests + debug artifact + release compile.
on:
push:
@@ -111,7 +111,7 @@ jobs:
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
sha256sum "$apk" > "${apk}.sha256"
{
echo "phase=F6.8"
echo "phase=F6.13"
echo "version=$version"
echo "versionCode=$code"
echo "commit=$GITHUB_SHA"
+3 -3
View File
@@ -1,9 +1,9 @@
# DH Inspección Android · release de campo 0.19.11
# DH Inspección Android · release de campo 0.19.12
## Candidata vigente
- `versionName`: **0.19.11**; `versionCode`: **39**.
- API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-9 / WEB 0.23.0-6.
- `versionName`: **0.19.12**; `versionCode`: **40**.
- API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-13 / WEB 0.23.0-10.
- Antes de cerrar el contenido, el inspector escribe una descripción real de lo actuado.
- La descripción se sincroniza antes del bloqueo, también cuando se trabajó sin conexión.
- Un acta anterior, ya sellada, conserva el texto originalmente registrado.
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 39
versionName = "0.19.11"
versionCode = 40
versionName = "0.19.12"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(39, BuildConfig.VERSION_CODE)
assertEquals("0.19.11-debug", BuildConfig.VERSION_NAME)
assertEquals(40, BuildConfig.VERSION_CODE)
assertEquals("0.19.12-debug", BuildConfig.VERSION_NAME)
}
@Test
+2 -2
View File
@@ -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
View File
@@ -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;
}
+2 -2
View File
@@ -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';
+3 -3
View File
@@ -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');
});
+2 -2
View File
@@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string {
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 39/);
assert.match(gradle, /versionName = "0\.19\.11"/);
assert.match(gradle, /versionCode = 40/);
assert.match(gradle, /versionName = "0\.19\.12"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
@@ -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\.13/);
});
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/);
});
+7
View File
@@ -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.
+1
View File
@@ -4,6 +4,7 @@ COPY package*.json ./
RUN npm ci
COPY tsconfig*.json vite.config.ts index.html ./
COPY public ./public
COPY scripts ./scripts
COPY src ./src
RUN npm run build
+5
View File
@@ -4,6 +4,11 @@ server {
root /usr/share/nginx/html;
index index.html;
location = /assets/maplibre-gl-worker.mjs {
default_type application/javascript;
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "dhv2-web",
"version": "0.23.0-8",
"version": "0.23.0-10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dhv2-web",
"version": "0.23.0-8",
"version": "0.23.0-10",
"dependencies": {
"maplibre-gl": "6.4.1",
"react": "^19.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-web",
"version": "0.23.0-8",
"version": "0.23.0-10",
"private": true,
"type": "module",
"engines": {
@@ -8,7 +8,7 @@
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build": "tsc -b && vite build && node scripts/copy-maplibre-worker.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
+11
View File
@@ -0,0 +1,11 @@
import { chmodSync, copyFileSync, mkdirSync, statSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
const source = resolve('node_modules/maplibre-gl/dist/maplibre-gl-worker.mjs');
const target = resolve('dist/assets/maplibre-gl-worker.mjs');
mkdirSync(dirname(target), { recursive: true });
copyFileSync(source, target);
chmodSync(target, 0o644);
const bytes = statSync(target).size;
if (bytes < 1000) throw new Error(`MapLibre worker inválido: ${bytes} bytes`);
console.log(`MapLibre worker: ${target} (${bytes} bytes)`);
+2 -2
View File
@@ -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-10';
export const APP_PHASE = 'F6.13 · Mapa operativo';
@@ -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) : '');
+3 -3
View File
@@ -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
View File
@@ -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) }),
});
}
+1 -1
View File
@@ -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">