From da7c1ddb5521249f3983718e6e637e8886370f7f Mon Sep 17 00:00:00 2001 From: DH V2 Date: Tue, 15 Sep 2026 08:55:43 -0300 Subject: [PATCH] fix(actas): move closure to reusable inspector signing --- .github/workflows/android.yml | 4 +- android-app/RELEASE.md | 21 +- android-app/app/build.gradle.kts | 4 +- .../dhinspeccion/ui/MobileActsScreen.kt | 112 ++++------- .../dhinspeccion/ui/ModernMobileActsScreen.kt | 154 ++++++--------- .../dhinspeccion/ui/UiSpanishLabels.kt | 2 +- .../dhinspeccion/ReleaseMetadataTest.kt | 4 +- api-v3/package-lock.json | 4 +- api-v3/package.json | 2 +- .../administration/users/users.controller.ts | 49 +++++ .../src/administration/users/users.service.ts | 95 +++++++++ ...0124600000-f6-7-user-reusable-signature.ts | 49 +++++ .../inspection-closing.service.ts | 187 ++++++++++++++---- api-v3/src/version.ts | 4 +- .../test/unit/f3-2-mobile-multi-acta.test.ts | 3 +- api-v3/test/unit/f4-health-metadata.test.ts | 6 +- .../test/unit/f4-multi-act-lifecycle.test.ts | 5 +- api-v3/test/unit/f5-android-test-cut.test.ts | 4 +- .../f6-1-presentation-ready-contract.test.ts | 2 +- api-v3/test/unit/f6-7-act-signing-ui.test.ts | 50 +++++ web-v2/package-lock.json | 4 +- web-v2/package.json | 2 +- web-v2/src/config/version.ts | 4 +- web-v2/src/lib/myProfileApi.ts | 32 +++ web-v2/src/pages/MyProfilePage.tsx | 62 +++++- web-v2/src/styles.css | 27 +++ 26 files changed, 644 insertions(+), 248 deletions(-) create mode 100644 api-v3/src/database/migrations/1790124600000-f6-7-user-reusable-signature.ts create mode 100644 api-v3/test/unit/f6-7-act-signing-ui.test.ts diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 32993e2..3030b92 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,5 +1,5 @@ name: Android CI / RC -# F6.2 field closing barrier: lint + real tests + debug artifact + release compile. +# F6.7 field closing 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.2" + echo "phase=F6.7" echo "version=$version" echo "versionCode=$code" echo "commit=$GITHUB_SHA" diff --git a/android-app/RELEASE.md b/android-app/RELEASE.md index 436d784..9da78cf 100644 --- a/android-app/RELEASE.md +++ b/android-app/RELEASE.md @@ -1,18 +1,23 @@ -# DH Inspección Android · release final de campo 0.19.8 +# DH Inspección Android · release final de campo 0.19.9 ## Candidata vigente - Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**. -- `versionName`: **0.19.8**. -- `versionCode`: **35**. +- `versionName`: **0.19.9**. +- `versionCode`: **37**. - Application ID release: `com.korexlabs.dhinspeccion`. - Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`. - API: `https://dhv2.korexlabs.com/api/v3/`. -- Servidor compatible de esta candidata: **API 0.29.0-6 / WEB 0.23.0-3**. +- Servidor compatible de esta candidata: **API 0.29.0-7 / WEB 0.23.0-4**. La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica. -### Ajustes 0.19.8 +### Ajustes 0.19.9 + +- Corrige el cierre de Actas evitando `NaN` en el versionado de LOCKED/SEALED. +- El cierre pasa a **Para firmar**; la APK captura sólo la manifestación/firma del acompañante. +- La firma del Inspector se guarda una vez en Mi perfil y se copia de forma inmutable al sellar cada Acta. +- Elimina la tarjeta duplicada del Acta seleccionada y simplifica la jerarquía visual inspirada en la referencia Figma. - El encabezado de **Mis inspecciones** separa las acciones para que **Salir** nunca se comprima ni se corte en pantallas angostas. - El alta de Hallazgos corrige la captura de versión que podía transformar el número en `NaN` después del INSERT y devolver error 500. @@ -20,7 +25,7 @@ La variante debug es independiente de la app productiva y puede instalarse para ## Procedimiento operativo validado -La APK 0.19.8 fija como recorrido principal de campo: +La APK 0.19.9 fija como recorrido principal de campo: 1. **Iniciar Inspección**. Al iniciarla se habilita el circuito de **Actas y Hallazgos**; Inventario no es una acción independiente de la Inspección. 2. **Crear o abrir un Acta**. Puede haber varias Actas dentro de una misma Inspección, pero sólo una puede permanecer en elaboración al mismo tiempo. La urgencia todavía no se define. @@ -29,8 +34,8 @@ La APK 0.19.8 fija como recorrido principal de campo: 5. Si no existe, se da de alta desde campo sin abandonar el Acta. 6. Un elemento nuevo debe completar **GPS + fotografía** antes de poder recibir Hallazgos. 7. Antes del cierre se identifican, para esa Acta, los datos del **Representante de la empresa** que acompañó el recorrido: nombres y apellidos, DNI, cargo/función y email. Si coincide con el Acta anterior, la APK puede precargarlos, pero deben confirmarse nuevamente. -8. Al terminar el contenido del Acta se elige **Urgente / No urgente** y luego se usa **Cerrar Acta y dejar pendiente de firma**. La urgencia pertenece al Acta completa, nunca a cada Hallazgo; desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**. -9. La firma del Inspector y la manifestación del representante completan el Acta: **conformidad**, **disconformidad con motivo obligatorio** o **negativa a firmar con motivo obligatorio**. La firma se registra Acta por Acta aunque sea la misma persona. +8. Al terminar el contenido del Acta se elige **Urgente / No urgente** y luego se usa **Cerrar contenido y pasar a firma**. La urgencia pertenece al Acta completa, nunca a cada Hallazgo; desde ese momento el contenido queda inmutable y el estado visible es **Para firmar**. +9. En **Para firmar**, el acompañante de la empresa registra su manifestación: **conformidad**, **disconformidad con motivo obligatorio**, **negativa a firmar con motivo obligatorio** o **ausencia documentada**. La firma del Inspector no se dibuja en la tablet: se guarda una sola vez desde **Mi perfil** del Dashboard y el servidor copia esa firma de forma inmutable a cada Acta al usar **Aplicar mi firma y cerrar Acta**. 10. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas. La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano. diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 105a587..c4097d8 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.korexlabs.dhinspeccion" minSdk = 26 targetSdk = 36 - versionCode = 36 - versionName = "0.19.8" + versionCode = 37 + versionName = "0.19.9" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt index c8e4432..40e6738 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt @@ -92,26 +92,17 @@ fun MobileActsScreen( LaunchedEffect(visit.id) { model.reloadActs() } - fun signWithGeo(file: File, company: Boolean) { + fun signCompanyWithGeo(file: File) { scope.launch { val geo = runCatching { currentActSignatureGeo(context) }.getOrNull() - if (company) { - model.signSelectedActAsCompany( - png = file, - latitude = geo?.latitude, - longitude = geo?.longitude, - accuracyM = geo?.accuracyM, - manifestation = manifestation, - statement = dissentStatement.takeIf { manifestation == "DISSENT" }, - ) - } else { - model.signSelectedActAsInspector( - png = file, - latitude = geo?.latitude, - longitude = geo?.longitude, - accuracyM = geo?.accuracyM, - ) - } + model.signSelectedActAsCompany( + png = file, + latitude = geo?.latitude, + longitude = geo?.longitude, + accuracyM = geo?.accuracyM, + manifestation = manifestation, + statement = dissentStatement.takeIf { manifestation == "DISSENT" }, + ) } } @@ -188,28 +179,6 @@ fun MobileActsScreen( } if (selected != null) { - HorizontalDivider() - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { - Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text(actStatusLabel(selected.status)) - Text( - when (selected.urgency) { - "URGENT" -> "Urgencia: Urgente" - "NON_URGENT" -> "Urgencia: No urgente" - else -> "Urgencia: se define al cerrar el Acta" - }, - ) - Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario") - selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) } - if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") { - Text("Vencimiento pendiente de fecha GEDO.", style = MaterialTheme.typography.bodySmall) - } - selected.lockedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) } - selected.closureSha256?.let { Text("Hash sellado: $it", style = MaterialTheme.typography.bodySmall) } - } - } - when (selected.status) { "DRAFT" -> { Card( @@ -263,7 +232,7 @@ fun MobileActsScreen( HorizontalDivider() Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text("Al cerrar el Acta, el contenido y los Hallazgos quedan inmutables. Definí ahora una única urgencia para el Acta completa; no para cada Hallazgo.") + Text("Al cerrar el contenido, el Acta queda inmutable y pasa a Para firmar. La firma del inspector se toma de Mi perfil del Dashboard; en la tablet firma el acompañante de la empresa.") Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { AssistChip( onClick = { closingUrgency = "NON_URGENT" }, @@ -278,50 +247,39 @@ fun MobileActsScreen( onClick = { model.prepareSelectedAct(closingUrgency) }, enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(), modifier = Modifier.fillMaxWidth(), - ) { Text("Finalizar y BLOQUEAR Acta") } + ) { Text("Cerrar contenido y pasar a firma") } } "LOCKED" -> { val signatures = closure?.signatures.orEmpty() - val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" } val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" } - val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" + val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" || companyOutcome?.status == "ABSENT" - Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text("El contenido ya es inmutable. Sólo resta resolver firmas y manifestaciones para poder SELLARLA.") - closure?.closure?.preparedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) } + Text("Para firmar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("El contenido ya es inmutable. La firma del inspector está guardada en el Dashboard y se aplicará al cierre final.") HorizontalDivider() - Text("Firma del inspector", fontWeight = FontWeight.Bold) - if (inspectorSigned) { - Text("✓ Firma del inspector registrada", color = MaterialTheme.colorScheme.primary) - } else { - Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall) - SignaturePad( - label = "Firmá como inspector/a", - enabled = !model.busy, - onCaptured = { file -> signWithGeo(file, company = false) }, - ) - } - - HorizontalDivider() - Text("Manifestación de la empresa", fontWeight = FontWeight.Bold) + Text("Acompañante / representante de la empresa", fontWeight = FontWeight.Bold) if (companyOutcome != null && companyResolved) { val detail = when (companyOutcome.status) { - "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disconformidad" else "Firma en conformidad" - "REFUSED" -> "Negativa a firmar" + "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firmó en disconformidad" else "Firmó en conformidad" + "REFUSED" -> "Se negó a firmar" + "ABSENT" -> "Ausencia documentada" else -> companyOutcome.status } Text("✓ $detail", color = MaterialTheme.colorScheme.primary) companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) } companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) } } else if (closure?.responsible?.attendanceStatus == "ABSENT") { - Text( - "El representante fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.", - style = MaterialTheme.typography.bodyMedium, - ) + val recordedAbsence = closure.responsible.absenceReason.orEmpty() + Text("El representante fue registrado como ausente. Confirmá esta ausencia para dejar la constancia documental.") + Text(recordedAbsence, style = MaterialTheme.typography.bodySmall) + OutlinedButton( + onClick = { model.recordCompanyOutcome("ABSENT", recordedAbsence) }, + enabled = !model.busy && recordedAbsence.trim().length >= 10, + modifier = Modifier.fillMaxWidth(), + ) { Text("Confirmar ausencia documentada") } } else { - Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { AssistChip(onClick = { manifestation = "CONFORMITY" }, label = { Text(if (manifestation == "CONFORMITY") "✓ Conforme" else "Conforme") }) AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disconformidad" else "En disconformidad") }) @@ -336,11 +294,10 @@ fun MobileActsScreen( ) } SignaturePad( - label = "Firma del representante de la empresa", - enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10), - onCaptured = { file -> signWithGeo(file, company = true) }, + label = "Firma del representante de la empresa (acompañante)", + enabled = !model.busy && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10), + onCaptured = { file -> signCompanyWithGeo(file) }, ) - Text("Si la persona presente se niega a firmar, asentá el motivo en lugar de dibujar una firma.", style = MaterialTheme.typography.bodySmall) OutlinedTextField( refusalReason, { refusalReason = it }, @@ -350,23 +307,20 @@ fun MobileActsScreen( ) OutlinedButton( onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) }, - enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10, + enabled = !model.busy && refusalReason.trim().length >= 10, modifier = Modifier.fillMaxWidth(), ) { Text("Registrar negativa a firmar") } } HorizontalDivider() - if (inspectorSigned && companyResolved) { + if (companyResolved) { Button( onClick = { model.closeSelectedAct() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth(), - ) { Text("SELLAR Acta definitivamente") } + ) { Text("Aplicar mi firma y cerrar Acta") } } else { - Text( - "Esta Acta bloquea el cierre de la Inspección hasta tener firma de inspector y firma o negativa válida de la empresa.", - style = MaterialTheme.typography.bodySmall, - ) + Text("Completá la firma, disconformidad o negativa del acompañante para habilitar el cierre final.", style = MaterialTheme.typography.bodySmall) } } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt index 14cd573..00c4316 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt @@ -3,7 +3,6 @@ package com.korexlabs.dhinspeccion.ui import android.Manifest import android.content.Context import android.content.pm.PackageManager -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -91,26 +90,17 @@ fun ModernMobileActsScreen( LaunchedEffect(visit.id) { model.reloadActs() } - fun signWithGeo(file: File, company: Boolean) { + fun signCompanyWithGeo(file: File) { scope.launch { val geo = runCatching { currentModernActSignatureGeo(context) }.getOrNull() - if (company) { - model.signSelectedActAsCompany( - png = file, - latitude = geo?.latitude, - longitude = geo?.longitude, - accuracyM = geo?.accuracyM, - manifestation = manifestation, - statement = dissentStatement.takeIf { manifestation == "DISSENT" }, - ) - } else { - model.signSelectedActAsInspector( - png = file, - latitude = geo?.latitude, - longitude = geo?.longitude, - accuracyM = geo?.accuracyM, - ) - } + model.signSelectedActAsCompany( + png = file, + latitude = geo?.latitude, + longitude = geo?.longitude, + accuracyM = geo?.accuracyM, + manifestation = manifestation, + statement = dissentStatement.takeIf { manifestation == "DISSENT" }, + ) } } @@ -172,12 +162,8 @@ fun ModernMobileActsScreen( onClick = { model.selectAct(act.id) }, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, - color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, - tonalElevation = if (active) 1.dp else 0.dp, - border = BorderStroke( - if (active) 2.dp else 1.dp, - if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, - ), + color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 0.dp, ) { Row( Modifier.padding(15.dp), @@ -245,33 +231,6 @@ fun ModernMobileActsScreen( } if (selected != null) { - HorizontalDivider() - Surface( - Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceVariant, - ) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - StatusPill(modernActStatusLabel(selected.status)) - } - Text( - when (selected.urgency) { - "URGENT" -> "Urgencia · Urgente" - "NON_URGENT" -> "Urgencia · No urgente" - else -> "Urgencia · Se define al cerrar el Acta" - }, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text("${selected.findingCount} Hallazgos · ${selected.assetCount} elementos de Inventario", style = MaterialTheme.typography.bodySmall) - selected.deadlineAt?.let { Text("Vencimiento · $it", style = MaterialTheme.typography.bodySmall) } - if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") { - Text("Vencimiento pendiente de fecha GEDO", style = MaterialTheme.typography.bodySmall) - } - } - } - when (selected.status) { "DRAFT" -> { ElevatedCard(Modifier.fillMaxWidth()) { @@ -338,7 +297,7 @@ fun ModernMobileActsScreen( Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) } Text( - "Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. En este momento definí la urgencia del Acta completa. La urgencia no se asigna a cada Hallazgo.", + "Al cerrar el contenido, el Acta queda inmutable y pasa a Para firmar. La firma del inspector se toma de Mi perfil del Dashboard; en la tablet sólo firma el acompañante de la empresa. La urgencia pertenece al Acta completa. La urgencia no se asigna a cada Hallazgo.", color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text("Urgencia del Acta", fontWeight = FontWeight.SemiBold) @@ -367,64 +326,75 @@ fun ModernMobileActsScreen( onClick = { model.prepareSelectedAct(closingUrgency) }, enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(), modifier = Modifier.fillMaxWidth(), - ) { Text("Cerrar Acta y dejar pendiente de firma") } + ) { Text("Cerrar contenido y pasar a firma") } } } } "LOCKED" -> { val signatures = closure?.signatures.orEmpty() - val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" } val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" } - val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" + val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" || companyOutcome?.status == "ABSENT" ElevatedCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.secondary) + Icon(Icons.Filled.EditNote, null, tint = MaterialTheme.colorScheme.primary) Spacer(Modifier.width(8.dp)) - Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Para firmar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) } - Text("El contenido ya está cerrado e inmutable. Completá la firma del inspector y la manifestación de la empresa cuando corresponda.") - closure?.closure?.preparedSha256?.let { Text("Huella de integridad · $it", style = MaterialTheme.typography.bodySmall) } - - HorizontalDivider() - Text("Firma del inspector", fontWeight = FontWeight.Bold) - if (inspectorSigned) { - SuccessLine("Firma del inspector registrada") - } else { - Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall) - SignaturePad( - label = "Firmá como inspector/a", - enabled = !model.busy, - onCaptured = { file -> signWithGeo(file, company = false) }, - ) + Text( + "El contenido ya está cerrado e inmutable. Ahora corresponde la manifestación y firma del acompañante de la empresa.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) + Text( + "Tu firma de inspector está guardada en el Dashboard y se incorporará automáticamente cuando cierres esta Acta.", + Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + ) + } } HorizontalDivider() - Text("Manifestación de la empresa", fontWeight = FontWeight.Bold) + Text("Acompañante / representante de la empresa", fontWeight = FontWeight.Bold) if (companyOutcome != null && companyResolved) { val detail = when (companyOutcome.status) { - "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disconformidad" else "Firma en conformidad" - "REFUSED" -> "Negativa a firmar" + "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firmó en disconformidad" else "Firmó en conformidad" + "REFUSED" -> "Se negó a firmar" + "ABSENT" -> "Ausencia documentada" else -> humanBackendLabelEs(companyOutcome.status) } SuccessLine(detail) companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) } companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) } } else if (closure?.responsible?.attendanceStatus == "ABSENT") { - Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) { - Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error) - Text( - "La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de firmar y cerrar el Acta.", - Modifier.weight(1f), - style = MaterialTheme.typography.bodySmall, - ) + val recordedAbsence = closure.responsible.absenceReason.orEmpty() + Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.secondary) + Text( + "El representante fue registrado como ausente. Confirmá esta ausencia para dejar la constancia documental.", + Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + ) + } + Text(recordedAbsence, style = MaterialTheme.typography.bodySmall) + OutlinedButton( + onClick = { model.recordCompanyOutcome("ABSENT", recordedAbsence) }, + enabled = !model.busy && recordedAbsence.trim().length >= 10, + modifier = Modifier.fillMaxWidth(), + ) { Text("Confirmar ausencia documentada") } } } } else { - Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") }) FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disconformidad") }) @@ -439,11 +409,11 @@ fun ModernMobileActsScreen( ) } SignaturePad( - label = "Firma del representante de la empresa", - enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10), - onCaptured = { file -> signWithGeo(file, company = true) }, + label = "Firma del representante de la empresa (acompañante)", + enabled = !model.busy && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10), + onCaptured = { file -> signCompanyWithGeo(file) }, ) - Text("Si se niega a firmar, registrá el motivo.", style = MaterialTheme.typography.bodySmall) + Text("Si se niega a firmar, asentá el motivo para dejar la constancia.", style = MaterialTheme.typography.bodySmall) OutlinedTextField( refusalReason, { refusalReason = it }, @@ -453,12 +423,12 @@ fun ModernMobileActsScreen( ) OutlinedButton( onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) }, - enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10, + enabled = !model.busy && refusalReason.trim().length >= 10, modifier = Modifier.fillMaxWidth(), ) { Text("Registrar negativa a firmar") } } - if (inspectorSigned && companyResolved) { + if (companyResolved) { Button( onClick = { model.closeSelectedAct() }, enabled = !model.busy, @@ -466,11 +436,11 @@ fun ModernMobileActsScreen( ) { Icon(Icons.Filled.CheckCircle, null) Spacer(Modifier.width(6.dp)) - Text("Firmar y cerrar Acta") + Text("Aplicar mi firma y cerrar Acta") } } else { Text( - "La Inspección no puede cerrarse hasta completar las manifestaciones de esta Acta.", + "Completá la firma, disconformidad o negativa del acompañante para habilitar el cierre final.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt index e84e22d..197165b 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt @@ -13,7 +13,7 @@ internal fun visitStatusLabelEs(status: String?): String = when (status?.upperca internal fun actStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) { "DRAFT" -> "En elaboración" - "LOCKED" -> "Pendiente de firma" + "LOCKED" -> "Para firmar" "SEALED" -> "Firmada y cerrada" "CLOSED" -> "Cerrada" "RECTIFIED" -> "Rectificada" diff --git a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt index a89f30d..dc37aab 100644 --- a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt @@ -8,8 +8,8 @@ class ReleaseMetadataTest { @Test fun debugBuildKeepsSeparateApplicationIdentity() { assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID) - assertEquals(36, BuildConfig.VERSION_CODE) - assertEquals("0.19.8-debug", BuildConfig.VERSION_NAME) + assertEquals(37, BuildConfig.VERSION_CODE) + assertEquals("0.19.9-debug", BuildConfig.VERSION_NAME) } @Test diff --git a/api-v3/package-lock.json b/api-v3/package-lock.json index 5298ae9..a29708d 100644 --- a/api-v3/package-lock.json +++ b/api-v3/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-api", - "version": "0.29.0-6", + "version": "0.29.0-7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-api", - "version": "0.29.0-6", + "version": "0.29.0-7", "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^11.0.0", diff --git a/api-v3/package.json b/api-v3/package.json index 466322c..a174943 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.29.0-6", + "version": "0.29.0-7", "private": true, "license": "UNLICENSED", "scripts": { diff --git a/api-v3/src/administration/users/users.controller.ts b/api-v3/src/administration/users/users.controller.ts index d44ac77..6f2bc23 100644 --- a/api-v3/src/administration/users/users.controller.ts +++ b/api-v3/src/administration/users/users.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, Param, ParseUUIDPipe, @@ -9,7 +10,12 @@ import { Put, Query, Req, + Res, + UploadedFile, + UseInterceptors, } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; import { AuditService } from '../../audit/audit.service'; import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator'; import { CurrentAuth } from '../../auth/decorators/current-auth.decorator'; @@ -19,6 +25,10 @@ import type { } from '../../common/http/request-context'; import { AuditAction } from '../../database/entities'; import { SmtpDeliveryService } from '../../inspection-reports/smtp-delivery.service'; +import { + MAX_INSPECTION_SIGNATURE_BYTES, + type UploadedInspectionSignatureFile, +} from '../../inspection-closing/inspection-signature-file'; import { administrationAuditContext } from '../common/administration-audit'; import { ChangeUserStatusDto } from './dto/change-user-status.dto'; import { CreateUserDto } from './dto/create-user.dto'; @@ -58,6 +68,45 @@ export class UsersController { return this.users.updateSelfProfile(dto, principal, request); } + @Get('self/signature') + selfSignature(@CurrentAuth() principal: AuthPrincipal) { + return this.users.getSelfSignature(principal.userId); + } + + @Get('self/signature/content') + async selfSignatureContent( + @CurrentAuth() principal: AuthPrincipal, + @Res() response: Response, + ): Promise { + const signature = await this.users.getSelfSignatureContent(principal.userId); + response.setHeader('Content-Type', 'image/png'); + response.setHeader('Content-Length', String(signature.buffer.length)); + response.setHeader('Cache-Control', 'private, no-store'); + response.setHeader('ETag', `"${signature.sha256}"`); + response.setHeader('X-Content-Type-Options', 'nosniff'); + response.send(signature.buffer); + } + + @Put('self/signature') + @UseInterceptors(FileInterceptor('file', { + limits: { fileSize: MAX_INSPECTION_SIGNATURE_BYTES, files: 1 }, + })) + updateSelfSignature( + @UploadedFile() file: UploadedInspectionSignatureFile | undefined, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.users.updateSelfSignature(file, principal, request); + } + + @Delete('self/signature') + deleteSelfSignature( + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.users.deleteSelfSignature(principal, request); + } + @Get('self/smtp') selfSmtp(@CurrentAuth() principal: AuthPrincipal) { return this.smtp.publicUserSettings(principal.userId); diff --git a/api-v3/src/administration/users/users.service.ts b/api-v3/src/administration/users/users.service.ts index 0f22cf2..dc7c294 100644 --- a/api-v3/src/administration/users/users.service.ts +++ b/api-v3/src/administration/users/users.service.ts @@ -1,6 +1,8 @@ +import { createHash } from 'node:crypto'; import { BadRequestException, ConflictException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -31,6 +33,10 @@ import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto'; import type { ResetUserPasswordDto } from './dto/reset-user-password.dto'; import type { UpdateSelfProfileDto } from './dto/update-self-profile.dto'; import type { UpdateUserDto } from './dto/update-user.dto'; +import { + inspectInspectionSignatureFile, + type UploadedInspectionSignatureFile, +} from '../../inspection-closing/inspection-signature-file'; export interface UserRoleView { id: string; @@ -38,6 +44,14 @@ export interface UserRoleView { name: string; } +export interface UserReusableSignatureView { + configured: boolean; + mimeType: 'image/png' | null; + sizeBytes: number | null; + imageSha256: string | null; + updatedAt: Date | null; +} + export interface AdministrativeUserView { id: string; username: string; @@ -192,6 +206,87 @@ export class UsersService { return this.getById(userId); } + async getSelfSignature(userId: string): Promise { + const [row] = (await this.dataSource.query(` + SELECT mime_type AS "mimeType", size_bytes AS "sizeBytes", + image_sha256 AS "imageSha256", updated_at AS "updatedAt" + FROM user_signature_profiles WHERE user_id=$1 + `, [userId])) as Array<{ + mimeType: 'image/png'; sizeBytes: number; imageSha256: string; updatedAt: Date; + }>; + return row ? { configured: true, ...row } : { + configured: false, mimeType: null, sizeBytes: null, imageSha256: null, updatedAt: null, + }; + } + + async getSelfSignatureContent(userId: string): Promise<{ buffer: Buffer; sha256: string }> { + const [row] = (await this.dataSource.query(` + SELECT image_data AS buffer, image_sha256 AS sha256 + FROM user_signature_profiles WHERE user_id=$1 + `, [userId])) as Array<{ buffer: Buffer; sha256: string }>; + if (!row) throw new NotFoundException({ + code: 'USER_SIGNATURE_NOT_CONFIGURED', + message: 'Todavía no cargaste tu firma de inspector', + }); + return row; + } + + async updateSelfSignature( + file: UploadedInspectionSignatureFile | undefined, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + if (!principal.roles.includes('inspector')) throw new ForbiddenException({ + code: 'INSPECTOR_SIGNATURE_ROLE_REQUIRED', + message: 'La firma reutilizable está disponible para usuarios con rol Inspector', + }); + const inspected = inspectInspectionSignatureFile(file); + const imageSha256 = createHash('sha256').update(file!.buffer).digest('hex'); + const before = await this.getSelfSignature(principal.userId); + await this.dataSource.transaction(async (manager) => { + await manager.query(` + INSERT INTO user_signature_profiles ( + user_id,original_name,mime_type,size_bytes,image_sha256,image_data,updated_by + ) VALUES ($1,$2,$3,$4,$5,$6,$1) + ON CONFLICT (user_id) DO UPDATE SET + original_name=EXCLUDED.original_name, mime_type=EXCLUDED.mime_type, + size_bytes=EXCLUDED.size_bytes, image_sha256=EXCLUDED.image_sha256, + image_data=EXCLUDED.image_data, updated_by=EXCLUDED.updated_by, + updated_at=CURRENT_TIMESTAMP + `, [principal.userId, inspected.originalName, inspected.mimeType, file!.buffer.length, imageSha256, file!.buffer]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.USER_UPDATED, + entityType: 'user_signature_profile', + entityId: principal.userId, + beforeData: before as unknown as Record, + afterData: { configured: true, imageSha256, sizeBytes: file!.buffer.length }, + metadata: { scope: 'SELF_SIGNATURE', reusableForActs: true }, + }, manager); + }); + return this.getSelfSignature(principal.userId); + } + + async deleteSelfSignature( + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + const before = await this.getSelfSignature(principal.userId); + await this.dataSource.transaction(async (manager) => { + await manager.query('DELETE FROM user_signature_profiles WHERE user_id=$1', [principal.userId]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.USER_UPDATED, + entityType: 'user_signature_profile', + entityId: principal.userId, + beforeData: before as unknown as Record, + afterData: { configured: false }, + metadata: { scope: 'SELF_SIGNATURE', reusableForActs: true }, + }, manager); + }); + return this.getSelfSignature(principal.userId); + } + async updateSelfProfile( dto: UpdateSelfProfileDto, principal: AuthPrincipal, diff --git a/api-v3/src/database/migrations/1790124600000-f6-7-user-reusable-signature.ts b/api-v3/src/database/migrations/1790124600000-f6-7-user-reusable-signature.ts new file mode 100644 index 0000000..8a95d50 --- /dev/null +++ b/api-v3/src/database/migrations/1790124600000-f6-7-user-reusable-signature.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class F67UserReusableSignature1790124600000 implements MigrationInterface { + name = 'F67UserReusableSignature1790124600000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE user_signature_profiles ( + user_id uuid PRIMARY KEY, + original_name varchar(255) NOT NULL, + mime_type varchar(100) NOT NULL, + size_bytes integer NOT NULL, + image_sha256 char(64) NOT NULL, + image_data bytea NOT NULL, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_user_signature_profiles_png CHECK (mime_type='image/png'), + CONSTRAINT chk_user_signature_profiles_size CHECK ( + size_bytes BETWEEN 1 AND 1048576 + AND OCTET_LENGTH(image_data)=size_bytes + ), + CONSTRAINT chk_user_signature_profiles_sha CHECK ( + image_sha256 ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT fk_user_signature_profiles_user FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_user_signature_profiles_updated_by FOREIGN KEY (updated_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(` + GRANT SELECT, INSERT, UPDATE, DELETE + ON TABLE user_signature_profiles + TO ${applicationRole} + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TABLE user_signature_profiles'); + } +} diff --git a/api-v3/src/inspection-closing/inspection-closing.service.ts b/api-v3/src/inspection-closing/inspection-closing.service.ts index 409c3c7..d698f86 100644 --- a/api-v3/src/inspection-closing/inspection-closing.service.ts +++ b/api-v3/src/inspection-closing/inspection-closing.service.ts @@ -103,6 +103,14 @@ interface StoredSignature extends SignatureView { storedName: string | null; } +interface ReusableInspectorSignature { + originalName: string; + mimeType: 'image/png'; + sizeBytes: number; + imageSha256: string; + imageData: Buffer; +} + interface ClosureRecord { actId: string; schemaVersion: string; @@ -306,21 +314,24 @@ export class InspectionClosingService { const lockedAt = new Date(); const [updated] = (await manager.query(` - UPDATE inspection_acts - SET status='LOCKED', - urgency=$2, - deadline_days=$3, - deadline_day_type=$4, - deadline_basis=$5, - deadline_base_at=$6, - deadline_at=$7, - locked_at=$8, - locked_by=$9, - current_version=current_version+1, - updated_by=$9, - updated_at=$8 - WHERE id=$1 - RETURNING current_version AS "versionNumber" + WITH updated AS ( + UPDATE inspection_acts + SET status='LOCKED', + urgency=$2, + deadline_days=$3, + deadline_day_type=$4, + deadline_basis=$5, + deadline_base_at=$6, + deadline_at=$7, + locked_at=$8, + locked_by=$9, + current_version=current_version+1, + updated_by=$9, + updated_at=$8 + WHERE id=$1 + RETURNING current_version + ) + SELECT current_version AS "versionNumber" FROM updated `, [ actId, dto.urgency, @@ -377,7 +388,7 @@ export class InspectionClosingService { afterData: { status: InspectionActStatus.LOCKED, lockedSha256: preparedSha256, - urgency: act.urgency, + urgency: dto.urgency, deadlineDays, deadlineDayType, deadlineBasis, @@ -461,6 +472,13 @@ export class InspectionClosingService { message: 'La negativa a firmar requiere identificar al responsable que se negó', }); } + if (dto.status === InspectionActSignatureStatus.ABSENT + && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.ABSENT) { + throw new BadRequestException({ + code: 'INSPECTION_COMPANY_ABSENCE_REQUIRES_ABSENT_RESPONSIBLE', + message: 'La ausencia sólo puede confirmarse cuando el representante fue registrado como ausente', + }); + } if (await this.hasCompanyOutcome(manager, actId)) { throw new ConflictException({ code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED', @@ -532,22 +550,15 @@ export class InspectionClosingService { request: RequestWithContext, ): Promise { assertMobileInspector(principal); - const sealed = await this.dataSource.transaction(async (manager) => { + let generatedInspectorSignaturePath: string | null = null; + let sealed: InspectionClosureView; + try { + sealed = await this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); this.assertLockedForManifestation(act); await this.assertActorAssigned(manager, visit.id, principal, true); const closure = await this.requireClosure(manager, actId); - const signatures = await this.loadSignatures(manager, actId); - const inspectorSigned = signatures.some((item) => - item.signerType === InspectionActSignerType.INSPECTOR - && item.status === InspectionActSignatureStatus.SIGNED, - ); - if (!inspectorSigned) { - throw new ConflictException({ - code: 'INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED', - message: 'Se requiere la firma del inspector para sellar el acta', - }); - } + let signatures = await this.loadSignatures(manager, actId); const companyOutcomes = signatures.filter((item) => item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE, ); @@ -557,6 +568,17 @@ export class InspectionClosingService { message: 'Debe existir exactamente una firma, disidencia, negativa o ausencia documentada del responsable de la empresa', }); } + const inspectorSigned = signatures.some((item) => + item.signerType === InspectionActSignerType.INSPECTOR + && item.status === InspectionActSignatureStatus.SIGNED, + ); + if (!inspectorSigned) { + const created = await this.createInspectorSignatureFromProfile( + manager, actId, closure.preparedSha256, principal, request, + ); + generatedInspectorSignaturePath = created.filePath; + signatures = await this.loadSignatures(manager, actId); + } const serverSealedAt = new Date(); const deviceSealedAt = new Date(dto.clientClosedAt); @@ -619,18 +641,21 @@ export class InspectionClosingService { WHERE act_id=$1 `, [actId, finalSnapshot, finalSha256, deviceSealedAt, serverSealedAt, dto.uploadMode, principal.userId]); const [updated] = (await manager.query(` - UPDATE inspection_acts - SET status='SEALED', - sealed_at=$2, - sealed_by=$3, - closed_at=$2, - closed_by=$3, - closure_sha256=$4, - current_version=current_version+1, - updated_by=$3, - updated_at=$2 - WHERE id=$1 - RETURNING current_version AS "versionNumber" + WITH updated AS ( + UPDATE inspection_acts + SET status='SEALED', + sealed_at=$2, + sealed_by=$3, + closed_at=$2, + closed_by=$3, + closure_sha256=$4, + current_version=current_version+1, + updated_by=$3, + updated_at=$2 + WHERE id=$1 + RETURNING current_version + ) + SELECT current_version AS "versionNumber" FROM updated `, [actId, serverSealedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>; await manager.query(` INSERT INTO inspection_act_versions ( @@ -664,7 +689,11 @@ export class InspectionClosingService { }, manager); await this.reports.ensureFrozenReport(manager, actId, principal, request); return this.loadView(manager, actId); - }); + }); + } catch (error) { + if (generatedInspectorSignaturePath) await unlink(generatedInspectorSignaturePath).catch(() => undefined); + throw error; + } await this.reports.ensureWordForAct(actId); return sealed; } @@ -1171,6 +1200,82 @@ export class InspectionClosingService { return rows.map(({ storedName: _storedName, originalName: _originalName, ...row }) => row); } + private async requireReusableInspectorSignature( + manager: EntityManager, + userId: string, + ): Promise { + const [row] = await manager.query(` + SELECT original_name AS "originalName", mime_type AS "mimeType", + size_bytes AS "sizeBytes", image_sha256 AS "imageSha256", + image_data AS "imageData" + FROM user_signature_profiles + WHERE user_id=$1 + `, [userId]) as ReusableInspectorSignature[]; + if (!row) { + throw new ConflictException({ + code: 'INSPECTION_INSPECTOR_PROFILE_SIGNATURE_REQUIRED', + message: 'Para cerrar definitivamente el Acta, cargá tu firma de inspector desde Mi perfil en el Dashboard', + }); + } + return row; + } + + private async createInspectorSignatureFromProfile( + manager: EntityManager, + actId: string, + preparedSha256: string, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise<{ filePath: string }> { + const profile = await this.requireReusableInspectorSignature(manager, principal.userId); + const id = randomUUID(); + const storedName = `${id}.png`; + const filePath = resolve(this.signatureRoot, storedName); + await mkdir(this.signatureRoot, { recursive: true, mode: 0o700 }); + await writeFile(filePath, profile.imageData, { flag: 'wx', mode: 0o600 }); + const signedAt = new Date(); + const source = this.signatureSource(principal); + const signerName = `${principal.firstName} ${principal.lastName}`.trim(); + const payload = { + actId, lockedSha256: preparedSha256, signerType: InspectionActSignerType.INSPECTOR, + signerUserId: principal.userId, signerName, position: 'Inspector/a', + status: InspectionActSignatureStatus.SIGNED, imageSha256: profile.imageSha256, + consentText: INSPECTOR_CONSENT, consentVersion: CONSENT_VERSION, + consentAcceptedAt: signedAt.toISOString(), signedAt: signedAt.toISOString(), + source, signatureMode: 'PROFILE_REUSABLE', uploadedBy: principal.userId, + }; + const signaturePayloadSha256 = sha256CanonicalJson(payload); + try { + await manager.query(` + INSERT INTO inspection_act_signatures ( + id,act_id,signer_type,signer_user_id,signer_name,document_type,document_number,position,status, + original_name,stored_name,mime_type,size_bytes,image_sha256,consent_text,consent_version, + consent_accepted_at,client_signed_at,signed_at,latitude,longitude,accuracy_m,device_label,source, + prepared_sha256,signature_payload_sha256,uploaded_by,created_at + ) VALUES ( + $1,$2,'INSPECTOR',$3,$4,NULL,NULL,'Inspector/a','SIGNED', + $5,$6,$7,$8,$9,$10,$11,$12,NULL,$12,NULL,NULL,NULL,$13,$14,$15,$16,$3,$12 + ) + `, [ + id, actId, principal.userId, signerName, profile.originalName, storedName, profile.mimeType, + profile.sizeBytes, profile.imageSha256, INSPECTOR_CONSENT, CONSENT_VERSION, signedAt, + 'Firma guardada en Mi perfil', source, preparedSha256, signaturePayloadSha256, + ]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED, + entityType: 'inspection_act_signature', + entityId: id, + afterData: payload, + metadata: { actId, immutable: true, signatureMode: 'PROFILE_REUSABLE' }, + }, manager); + return { filePath }; + } catch (error) { + await unlink(filePath).catch(() => undefined); + throw error; + } + } + private async loadStoredSignature(manager: EntityManager, id: string): Promise { const [row] = await manager.query(`${this.signatureSelect()} WHERE signature.id=$1`, [id]) as StoredSignature[]; if (!row) throw signatureNotFound(); diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index f71daac..aa84cb9 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -1,2 +1,2 @@ -export const API_VERSION = '0.29.0-6'; -export const API_PHASE = 'F6.2'; +export const API_VERSION = '0.29.0-7'; +export const API_PHASE = 'F6.7'; diff --git a/api-v3/test/unit/f3-2-mobile-multi-acta.test.ts b/api-v3/test/unit/f3-2-mobile-multi-acta.test.ts index 5369484..573cadd 100644 --- a/api-v3/test/unit/f3-2-mobile-multi-acta.test.ts +++ b/api-v3/test/unit/f3-2-mobile-multi-acta.test.ts @@ -28,7 +28,8 @@ test('F3.2/F4 exige seleccionar el Acta explícitamente al crear Hallazgos desde test('F4 conserva el cierre documental inmutable de cada Acta como LOCKED y luego SEALED', () => { const closing = read('src/inspection-closing/inspection-closing.service.ts'); assert.match(closing, /status='LOCKED'/); - assert.match(closing, /INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED/); + assert.match(closing, /INSPECTION_INSPECTOR_PROFILE_SIGNATURE_REQUIRED/); + assert.match(closing, /createInspectorSignatureFromProfile/); assert.match(closing, /INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED/); assert.match(closing, /finalSha256 = sha256CanonicalJson\(finalSnapshot\)/); assert.match(closing, /status='SEALED'/); diff --git a/api-v3/test/unit/f4-health-metadata.test.ts b/api-v3/test/unit/f4-health-metadata.test.ts index bb859fc..8fa0f83 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.1 release', () => { - assert.equal(API_PHASE, 'F6.2'); +test('health metadata reports the current F6.7 release', () => { + assert.equal(API_PHASE, 'F6.7'); 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-6'); + assert.equal(API_VERSION, '0.29.0-7'); }); \ No newline at end of file diff --git a/api-v3/test/unit/f4-multi-act-lifecycle.test.ts b/api-v3/test/unit/f4-multi-act-lifecycle.test.ts index 60ae06e..32ab9e9 100644 --- a/api-v3/test/unit/f4-multi-act-lifecycle.test.ts +++ b/api-v3/test/unit/f4-multi-act-lifecycle.test.ts @@ -80,9 +80,10 @@ test('F4 field finding flow only targets a draft act of the same in-progress ins assert.match(draftGuard, /WHERE visit_id = \$1::uuid[\s\S]*AND status = 'DRAFT'/); }); -test('F4 sealing requires inspector signature and exactly one terminal company outcome', () => { +test('F6.7 sealing copies the stored inspector signature and requires exactly one terminal company outcome', () => { const closeBody = closing.slice(closing.indexOf(' async close('), closing.indexOf(' async signatureContent(')); - assert.match(closeBody, /INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED/); + assert.match(closeBody, /createInspectorSignatureFromProfile/); + assert.match(closing, /INSPECTION_INSPECTOR_PROFILE_SIGNATURE_REQUIRED/); assert.match(closeBody, /companyOutcomes\.length !== 1/); assert.match(closeBody, /INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED/); assert.match(closeBody, /ausencia documentada/); diff --git a/api-v3/test/unit/f5-android-test-cut.test.ts b/api-v3/test/unit/f5-android-test-cut.test.ts index f0c089f..2807f8c 100644 --- a/api-v3/test/unit/f5-android-test-cut.test.ts +++ b/api-v3/test/unit/f5-android-test-cut.test.ts @@ -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 = 36/); - assert.match(gradle, /versionName = "0\.19\.8"/); + assert.match(gradle, /versionCode = 37/); + assert.match(gradle, /versionName = "0\.19\.9"/); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /applicationIdSuffix = "\.debug"/); }); 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 0ac1e7e..cf0a4cf 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\.2 · Firma por Acta y correo de usuario'/); + assert.match(version, /APP_PHASE\s*=\s*'F6\.7 · Cierre y firma por Acta'/); }); test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => { diff --git a/api-v3/test/unit/f6-7-act-signing-ui.test.ts b/api-v3/test/unit/f6-7-act-signing-ui.test.ts new file mode 100644 index 0000000..77b32ee --- /dev/null +++ b/api-v3/test/unit/f6-7-act-signing-ui.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +function source(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +const closing = source('src/inspection-closing/inspection-closing.service.ts'); +const users = source('src/administration/users/users.controller.ts'); +const userService = source('src/administration/users/users.service.ts'); +const migration = source('src/database/migrations/1790124600000-f6-7-user-reusable-signature.ts'); +const android = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt'); +const labels = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt'); +const profile = source('../web-v2/src/pages/MyProfilePage.tsx'); + +test('F6.7 Acta lock and seal use SELECT-shaped version CTEs', () => { + assert.doesNotMatch(closing, /RETURNING current_version AS "versionNumber"/); + assert.equal((closing.match(/WITH updated AS \(/g) ?? []).length >= 2, true); + assert.equal((closing.match(/SELECT current_version AS "versionNumber" FROM updated/g) ?? []).length >= 2, true); +}); +test('F6.7 inspector signature is stored once in profile and copied into each sealed Acta', () => { + assert.match(migration, /CREATE TABLE user_signature_profiles/); + assert.match(migration, /image_data bytea NOT NULL/); + assert.match(migration, /image_sha256 char\(64\) NOT NULL/); + assert.match(users, /@Put\('self\/signature'\)/); + assert.match(users, /@Get\('self\/signature\/content'\)/); + assert.match(userService, /inspectInspectionSignatureFile/); + assert.match(closing, /requireReusableInspectorSignature/); + assert.match(closing, /createInspectorSignatureFromProfile/); + assert.match(closing, /signatureMode: 'PROFILE_REUSABLE'/); +}); + +test('F6.7 Android goes Draft to Para firmar to company manifestation without inspector drawing', () => { + assert.match(labels, /"LOCKED" -> "Para firmar"/); + assert.match(android, /Cerrar contenido y pasar a firma/); + assert.match(android, /Firma del representante de la empresa \(acompañante\)/); + assert.match(android, /Aplicar mi firma y cerrar Acta/); + assert.doesNotMatch(android, /Firmá como inspector\/a/); + assert.doesNotMatch(android, /inspectorSigned/); + assert.doesNotMatch(android, /Text\(selected\.code/); + assert.match(android, /companyOutcome\?\.status == "ABSENT"/); +}); +test('F6.7 Dashboard exposes the reusable inspector signature clearly', () => { + assert.match(profile, /FIRMA DEL INSPECTOR/); + assert.match(profile, /Firma guardada para Actas/); + assert.match(profile, /saveSelfSignature/); + assert.match(profile, /Cada Acta conserva una copia propia de la firma/); +}); diff --git a/web-v2/package-lock.json b/web-v2/package-lock.json index 1917d9e..b04bd8f 100644 --- a/web-v2/package-lock.json +++ b/web-v2/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-web", - "version": "0.23.0-3", + "version": "0.23.0-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-web", - "version": "0.23.0-3", + "version": "0.23.0-4", "dependencies": { "maplibre-gl": "6.4.1", "react": "^19.0.0", diff --git a/web-v2/package.json b/web-v2/package.json index 57e18ab..b6af6a8 100644 --- a/web-v2/package.json +++ b/web-v2/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-web", - "version": "0.23.0-3", + "version": "0.23.0-4", "private": true, "type": "module", "engines": { diff --git a/web-v2/src/config/version.ts b/web-v2/src/config/version.ts index fa58f0a..300e516 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-3'; -export const APP_PHASE = 'F6.2 · Firma por Acta y correo de usuario'; +export const APP_VERSION = '0.23.0-4'; +export const APP_PHASE = 'F6.7 · Cierre y firma por Acta'; diff --git a/web-v2/src/lib/myProfileApi.ts b/web-v2/src/lib/myProfileApi.ts index 8c9f2d8..f114ce4 100644 --- a/web-v2/src/lib/myProfileApi.ts +++ b/web-v2/src/lib/myProfileApi.ts @@ -23,6 +23,15 @@ export interface UserSmtpSettings { custom: UserSmtpCustomSettings | null; } + +export interface UserReusableSignature { + configured: boolean; + mimeType: 'image/png' | null; + sizeBytes: number | null; + imageSha256: string | null; + updatedAt: string | null; +} + export interface UserSmtpSettingsInput { mode: UserSmtpMode; host?: string; @@ -65,3 +74,26 @@ export function testSelfSmtpSettings() { method: 'POST', }); } + + +export function getSelfSignature() { + return apiRequest('/users/self/signature'); +} + +export function saveSelfSignature(file: File) { + const body = new FormData(); + body.append('file', file); + return apiRequest('/users/self/signature', { + method: 'PUT', + body, + }); +} + +export function deleteSelfSignature() { + return apiRequest('/users/self/signature', { method: 'DELETE' }); +} + +export function selfSignatureContentUrl(signature: UserReusableSignature) { + const version = signature.imageSha256 ? `?v=${encodeURIComponent(signature.imageSha256)}` : ''; + return `/api/v3/users/self/signature/content${version}`; +} diff --git a/web-v2/src/pages/MyProfilePage.tsx b/web-v2/src/pages/MyProfilePage.tsx index 838cc48..c5d9541 100644 --- a/web-v2/src/pages/MyProfilePage.tsx +++ b/web-v2/src/pages/MyProfilePage.tsx @@ -4,13 +4,18 @@ import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { useAuth } from '../auth/AuthContext'; import { initials } from '../lib/format'; import { + deleteSelfSignature, getSelfProfile, + getSelfSignature, getSelfSmtpSettings, + saveSelfSignature, saveSelfSmtpSettings, + selfSignatureContentUrl, testSelfSmtpSettings, updateSelfProfile, } from '../lib/myProfileApi'; import type { + UserReusableSignature, UserSmtpMode, UserSmtpSecurityMode, UserSmtpSettings, @@ -21,6 +26,8 @@ export function MyProfilePage() { const { user } = useAuth(); const [profile, setProfile] = useState(null); const [smtp, setSmtp] = useState(null); + const [signature, setSignature] = useState(null); + const [signatureFile, setSignatureFile] = useState(null); const [mode, setMode] = useState('SYSTEM'); const [host, setHost] = useState(''); const [port, setPort] = useState(587); @@ -47,12 +54,14 @@ export function MyProfilePage() { const load = async () => { setLoading(true); try { - const [loadedProfile, loadedSmtp] = await Promise.all([ + const [loadedProfile, loadedSmtp, loadedSignature] = await Promise.all([ getSelfProfile(), getSelfSmtpSettings(), + getSelfSignature(), ]); setProfile(loadedProfile); applySmtp(loadedSmtp); + setSignature(loadedSignature); } catch (requestError) { setError(errorMessage(requestError)); } finally { @@ -82,6 +91,35 @@ export function MyProfilePage() { } }; + const saveSignature = async () => { + if (!signatureFile) return; + setError(''); setSuccess(''); setSaving('signature'); + try { + const updated = await saveSelfSignature(signatureFile); + setSignature(updated); + setSignatureFile(null); + setSuccess('Firma del inspector guardada. Se aplicará automáticamente al cerrar cada Acta.'); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setSaving(''); + } + }; + + const clearSignature = async () => { + setError(''); setSuccess(''); setSaving('signature-delete'); + try { + const updated = await deleteSelfSignature(); + setSignature(updated); + setSignatureFile(null); + setSuccess('Firma guardada eliminada.'); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setSaving(''); + } + }; + const saveSmtp = async () => { setError(''); setSuccess(''); setSaving('smtp'); try { @@ -116,7 +154,9 @@ export function MyProfilePage() { }; if (loading) return ; - if (!profile || !smtp) return {error || 'No se pudo cargar el perfil.'}; + if (!profile || !smtp || !signature) return {error || 'No se pudo cargar el perfil.'}; + + const isInspector = profile.roles.some((role) => role.code === 'inspector'); return
@@ -184,6 +224,24 @@ export function MyProfilePage() {
+ {isInspector &&
+
+
FIRMA DEL INSPECTOR

Firma guardada para Actas

+
+

Cargala una sola vez. Cuando cierres un Acta desde la APK, el contenido quedará en estado Para firmar; después firma el acompañante y, al finalizar, el sistema incorpora automáticamente esta firma del inspector al ejemplar inmutable.

+ {signature.configured &&
+ Firma guardada del inspector +
Firma configuradaPNG · {Math.max(1, Math.round((signature.sizeBytes ?? 0) / 1024))} KB
+
} +
+ +
+

La imagen debe ser PNG y pesar como máximo 1 MB. Cada Acta conserva una copia propia de la firma y su SHA-256; cambiar la firma del perfil no altera Actas ya cerradas.

+
+ + {signature.configured && } +
+
}
SEGURIDAD

Cuenta

Tu email es obligatorio para operar en DH Inspección. La contraseña SMTP nunca se muestra ni se incluye en auditorías.

diff --git a/web-v2/src/styles.css b/web-v2/src/styles.css index d84b876..df4e3a1 100644 --- a/web-v2/src/styles.css +++ b/web-v2/src/styles.css @@ -1350,3 +1350,30 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; } cursor: default; opacity: .65; } + +/* F6.7 · firma reutilizable del inspector */ +.signature-profile-preview { + display: flex; + align-items: center; + gap: 16px; + max-width: 620px; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: 14px; + background: #f7faf9; +} +.signature-profile-preview img { + width: min(240px, 46%); + height: 88px; + object-fit: contain; + border-radius: 10px; + background: #fff; + border: 1px solid #e3e7e5; +} +.signature-profile-preview strong, +.signature-profile-preview small { display: block; } +.signature-profile-preview small { margin-top: 4px; color: var(--muted); } +@media (max-width: 640px) { + .signature-profile-preview { align-items: flex-start; flex-direction: column; } + .signature-profile-preview img { width: 100%; } +}